Add project-checks hook - #3
Merged
Merged
Conversation
PostToolUse hook that runs the formatter or linter a project already
defines on the file Claude just edited, and returns failures with exit 2
so they are fixed in the same turn rather than found at commit time. One
file, never the whole repo.
Made opt-in per project. The hook executes commands defined by the
repository it is looking at: `npm run format` runs whatever that
project's package.json says. In a repo you wrote that is the point; in
one you cloned to look at, it is somebody else's code running with your
shell and your credentials because Claude touched a file. It now does
nothing until the project has a .claude/project-checks.json, which may
be an empty {}. Detection still does the work, so opting in grants
permission once rather than specifying commands.
Dropped Makefile auto-detection. `make fmt` takes no file argument, so
it ran over the whole repository, producing exactly the unreviewable
diff this hook exists to avoid, and its failures would have pushed
Claude to fix code it never touched. Makefile projects set "command"
explicitly instead.
Invalid JSON in the config is now reported with exit 1 instead of being
treated as absent: the file was written on purpose, so silence would be
confusing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The opt-in was a .claude/project-checks.json inside the repository. That is worthless as a trust boundary: any repository you clone can ship one, and the hook would then happily run that repository's npm or composer scripts the moment Claude edited a file. The gate was defeated by the thing it was meant to gate. Claude Code refuses to read project-level pluginConfigs for exactly this reason, stated in the plugins reference: both settings files live in the workspace, so a cloned repository could supply values that flow into hook commands. This hook now follows the same rule. Trust is now a list of directories in user scope. Claude Code prompts for it when the plugin is enabled, via a trusted_roots userConfig option, and passes it to the hook as CLAUDE_PLUGIN_OPTION_TRUSTED_ROOTS. People who installed the script by hand can put trustedRoots in ~/.claude/project-checks.json instead. No list means the hook does nothing: it fails closed. The trust check runs before anything in the project is read, so an untrusted repository cannot influence the run at all. The per-project config file still tunes behaviour (command, skipExtensions, timeout, enabled) but can no longer grant permission, and inside an untrusted directory it is never opened. Also sets defaultEnabled: false, so installing the plugin does not switch it on, and adds the manifest metadata the schema supports. The remaining trade-off is stated in the README rather than hidden: trusting a directory means trusting everything you put in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
KainDitmer
previously approved these changes
Aug 13, 2026
Both sides added one entry to marketplace.json and one row to the README hooks table; kept both, git-guardrails first. Switched the project-checks hook to the exec form (interpreter in `command`, script in `args`) that main's CONTRIBUTING now requires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
PostToolUsehook that runs the formatter or linter a project already defines on the file Claude just edited, and hands failures back so they get fixed in the same turn instead of surfacing at commit time.Opt-in per project
This hook does nothing until a project opts in, by having a
.claude/project-checks.json— an empty{}is enough.That gate is the main design decision here, and it is worth explaining. The hook executes commands defined by the repository it is looking at:
npm run formatruns whatever that project'spackage.jsonsays it should. In a repo you wrote, that is exactly the point. In a repo you cloned to take a look at, it is somebody else's code running with your shell and your credentials, triggered by nothing more than Claude touching a file.CONTRIBUTING.md rules out "anything downloaded or executed at runtime", and an always-on version of this hook would sit uncomfortably close to that line. Opt-in resolves it: opening an unfamiliar repository does nothing at all, and enabling it in your own is one command. Detection still does the work, so you grant permission once rather than writing commands.
What it runs
composer.jsoncs-fix,csfix,ecs,php-cs-fixer,format,lint:fix,fixcomposer run <script> -- <file>package.jsonformat,fmt,lint:fix,lint-fix,prettier,lintnpm run <script> -- <file>.pre-commit-config.yamlpre-commit run --files <file>One file, never the whole repo. Formatting everything because one line changed produces a diff nobody can review, which is why most format-on-write hooks end up removed.
Makefile auto-detection removed
The original detected
fmt/format/lint-fixtargets in a Makefile and ranmake <target>— with no file argument. That runs over the entire repository, which is the exact outcome the "one file, never the whole repo" promise rules out. Worse, on a repo with pre-existing lint failures it would hand Claude errors about code it never touched, and Claude would try to fix them.Makefile projects now set
commandexplicitly:{ "command": "make fmt-file FILE={file}" }Other change
Invalid JSON in
.claude/project-checks.jsonis now reported (exit 1, non-blocking) instead of being silently treated as absent. Someone wrote that file on purpose; silence would just be confusing.Testing
./scripts/validate.shand./scripts/pr-policy.shpass.12 scenarios against fixture projects with stubbed
composer/npm/pre-commitbinaries, all passing:composer run cs-fix -- src.phpenabled: falsenpm run format -- app.tsfmttargetcommandoverrideReview notes
The thing to weigh is the exit-2 path: a failing formatter sends its output back to Claude, which will act on it. That is the feature, but it means a project whose
lintscript reports pre-existing problems will send Claude chasing them. Scoping to a single file keeps that mostly contained, and droppingmakeremoves the case where it was guaranteed not to be.No network, no writes outside what the project's own tool does, and the only subprocess is the detected command itself, with a 60-second default timeout.
🤖 Generated with Claude Code