Skip to content

Add project-checks hook - #3

Merged
jackyraimond merged 3 commits into
mainfrom
add-project-checks
Aug 13, 2026
Merged

Add project-checks hook#3
jackyraimond merged 3 commits into
mainfrom
add-project-checks

Conversation

@jackyraimond

Copy link
Copy Markdown
Collaborator

Adds a PostToolUse hook 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 format runs whatever that project's package.json says 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

Source Looks for Runs
composer.json cs-fix, csfix, ecs, php-cs-fixer, format, lint:fix, fix composer run <script> -- <file>
package.json format, fmt, lint:fix, lint-fix, prettier, lint npm run <script> -- <file>
.pre-commit-config.yaml exists pre-commit run --files <file>
nothing exits silently

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-fix targets in a Makefile and ran make <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 command explicitly:

{ "command": "make fmt-file FILE={file}" }

Other change

Invalid JSON in .claude/project-checks.json is 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.sh and ./scripts/pr-policy.sh pass.

12 scenarios against fixture projects with stubbed composer / npm / pre-commit binaries, all passing:

Scenario Expected Result
Opted-in PHP project composer run cs-fix -- src.php
Same project, no config file nothing
enabled: false nothing
Invalid JSON config error message, exit 1
Opted-in JS project npm run format -- app.ts
Makefile with an fmt target nothing (no auto-detect)
command override wins over detection
Markdown file skipped
File that no longer exists skipped
Tool exits non-zero exit 2, output on stderr
Tool binary not installed silent, exit 0
Malformed hook payload exit 0

Review 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 lint script reports pre-existing problems will send Claude chasing them. Scoping to a single file keeps that mostly contained, and dropping make removes 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

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
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>
@jackyraimond
jackyraimond merged commit 6ba1af2 into main Aug 13, 2026
5 checks passed
@jackyraimond
jackyraimond deleted the add-project-checks branch August 13, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants