Claude Code Ruff Hook: Exit Code 2 and --color never
A Claude Code Ruff hook that exits 1 sends Claude nothing. A logged test on Claude Code 2.1.295 shows why you need exit code 2 and --color never.

7 min read · Last updated: October 24, 2026
A Claude Code Ruff hook only reaches Claude if it exits with code 2. If it exits with 1, or with the 123 that xargs returns, the lint report goes to a debug log and Claude sees nothing. On ruff 0.16.10 the report also arrives full of ANSI colour codes unless every ruff call passes --color never.
Came here from Instagram, LinkedIn, X or YouTube? Here is the full source list you came for. Everything below is built from these, nothing else.
The test
The docs
- Claude Code hooks reference: exit codes and PostToolUse (read Oct 24 2026)
- Ruff linter docs: exit codes and --fix (read Oct 24 2026)
- Ruff configuration docs: the --color option (read Oct 24 2026)
The receipts
Key takeaways
- In a logged test, a naive Claude Code Ruff hook changed nothing about what the model received. The model got exactly what it would have got with no hook at all.
- A PostToolUse hook's stderr reaches Claude only on exit code 2. Exit 1, and xargs' 123, are non-blocking errors that Claude never sees.
- Claude Code ran hooks with FORCE_COLOR=0, and ruff 0.16.10 still printed colour. Setting NO_COLOR=1 did not fix it. Only --color never did.
- The working hook runs
ruff check --fixandruff formatfirst, then reports only what is left, so a fully fixable file costs the model zero lint text. - The test used Claude Code 2.1.295, ruff 0.16.10, jq 1.7 and Debian 13, run on October 9, 2026.
What happened?
On October 24, 2026, developer Nandan Priyadarshi published a test of the most common Claude Code hook there is: run Ruff, the Python linter and formatter, every time Claude writes a file. Instead of trusting the setup, he logged what Claude Code actually sent back to the model. He pointed Claude Code 2.1.295 at a local, scripted fake API on 127.0.0.1 with a dummy key, ran it headless with claude -p ... --permission-mode acceptEdits, and had the fake model call Write once to create app.py.
The result: the naive hook did nothing visible to the model. Ruff found three problems, and the tool result Claude received was word for word the same as a run with no hook installed. Two separate things were wrong: the exit code, and colour codes in the output.
Why does my Claude Code Ruff hook not show errors to Claude?
Because your hook probably exits with 1, not 2. The Claude Code hooks reference says a PostToolUse hook that exits 2 "shows stderr to Claude". Any other non-zero code is a non-blocking error, and "Claude never sees stderr from a hook that exits 0." Ruff itself exits 1 when it finds violations.
Here is the naive hook from the test, registered on PostToolUse with matcher Edit|Write:
jq -r '.tool_input.file_path' | xargs ruff check
It looks right. Ruff runs, finds problems, exits 1. But xargs does not pass that 1 through. According to the xargs man page, xargs exits with 123 "if any invocation of the command exited with status other than 0 or 255". So the hook exits 123.
The model received this, identical to the no-hook run:
[tool_result] File created successfully at: ./app.py (file state is current in your context ...)
The lint report only showed up in Claude Code's debug log:
[ERROR] "Hook PostToolUse:Write (PostToolUse) error: status code 123
stdout:
...
You would only notice if you went looking. In normal use the hook looks installed and the model keeps writing unused imports.
What do Claude Code hook exit codes mean?
Exit 0 means success, exit 2 means a blocking error, and anything else means a non-blocking error. For PostToolUse, "blocking" cannot undo the edit, because the tool already ran. What exit 2 does is route your stderr to Claude so it can fix the file on its next step.
| Exit code | What the hooks docs call it | What Claude sees after PostToolUse |
|---|---|---|
| 0 | Success | Nothing from stderr. JSON output fields are applied |
| 2 | Blocking error | Your stderr, shown next to the tool result |
| 1, 123 or any other | Non-blocking error | Nothing. Output goes to the debug log |
Ruff's own codes do not line up with this. The Ruff linter docs say ruff check exits 0 when there are no violations (or all were fixed), 1 when violations were found, and 2 only when Ruff "terminates abnormally due to invalid configuration, invalid CLI options, or an internal error". So you cannot just pass ruff's exit code through. Your script has to translate "ruff found something" into its own exit 2.
Why does ruff output show escape codes in Claude Code?
In this test, Claude Code ran hooks with FORCE_COLOR=0, NO_COLOR=1 and TERM=dumb, and ruff 0.16.10 treated FORCE_COLOR=0 as a request for colour. The report reached the model wrapped in ANSI escape sequences. Passing --color never to every ruff call was the only thing that produced plain text.
Once the author fixed the exit code (version 2 of the hook, writing to stderr and exiting 2), the model finally got an extra block that began:
PostToolUse:Write hook blocking error from command:
It listed I001 (unsorted imports), F401 for os, F401 for sys, and "Found 3 errors." But viewed with cat -v, the text was littered with sequences like ^[[1m, ^[[36m:^[[0m and ^[[1m^[[31mI001^[[0m.
That is noise in the model's context on every single edit. The model can usually read past it, but you are paying for those characters and making the file path and rule codes harder to parse.
The Ruff configuration docs list --color with three values: auto, always and never. Use never in hooks. Do not rely on environment variables to do it for you.
What changes in practice?
Your hook needs three changes: fix what Ruff can fix silently, report only what is left, and exit 2 with plain text on stderr. The final version from the test does exactly that, and a fully fixable file produced no lint report at all.
Here is how the three versions compare:
| Version | What it does | Exit code | What the model got |
|---|---|---|---|
| 1. Naive | jq ... | xargs ruff check | 123 | Same as no hook |
| 2. Exit 2 | Concise report on stderr, then exit 2 | 2 | The 3 errors, wrapped in colour codes |
| 3. Final | Fix, format, report leftovers with --color never | 0 or 2 | Nothing if clean, plain-text errors if not |
The test file was small on purpose: import os, sys, then a total(xs) function with l = 0 and a one-line for loop. The author expected E741 and E701 as well, but they did not fire with Ruff's default rules, which is a reminder to check your select list rather than assume.
In version 3, --fix removed the unused imports and sorted them, ruff format cleaned the layout, the hook exited 0, and the model only got a note that the hook had modified ./app.py after the edit. Then the author broke the file on purpose with return totl. The model received:
app.py:5:12: F821 Undefined name `totl`
Found 1 error.
Plain text, one line per problem, nothing Ruff could have fixed by itself. That is what a lint hook should send.
How do I set up a Ruff hook in Claude Code this week?
Save the script, make it executable, and register it under PostToolUse with matcher Edit|Write in .claude/settings.json. The script below is assembled from the key lines published in the test, plus a check that skips non-Python files.
- Create
.claude/hooks/ruff.sh:
#!/usr/bin/env bash
f=$(jq -r '.tool_input.file_path')
[[ "$f" == *.py ]] || exit 0
ruff check --fix --quiet --color never "$f" >/dev/null 2>&1
ruff format --quiet "$f"
out=$(ruff check --color never --output-format concise "$f" 2>&1) && exit 0
echo "ruff found problems in $f:" >&2
echo "$out" >&2
exit 2
-
Make it executable with
chmod +x .claude/hooks/ruff.sh. -
Register it in
.claude/settings.json, using the${CLAUDE_PROJECT_DIR}placeholder the hooks docs recommend:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/ruff.sh"
}
]
}
]
}
}
-
Test it on a file with a real, unfixable error like an undefined name. If Claude does not mention the error on its next turn, the hook is not reaching it.
-
Audit your other hooks. Any PostToolUse hook that pipes through
xargs, or lets a tool's exit 1 fall through, has the same silent failure. Mypy, ESLint, pytest wrappers: check them all.
The docs also offer a JSON route. A PostToolUse hook can exit 0 and print {"decision": "block", "reason": "..."}, and Claude Code adds the reason next to the tool result. Exit 2 with stderr is simpler for a shell script, so I would start there.
Why this matters beyond Ruff: a hook is a quality gate you set up once and then stop thinking about. If it fails silently, you get the confidence of a linter with none of the effect. If you are still building your review habits around agent output, our guide on how to review AI-generated code covers what to check by hand.
What did the test not cover?
The author lists the limits plainly. The model was scripted, so this shows what Claude Code sends, not how a real Claude model reacts. Only headless mode and the Write tool were tested, only on Linux, and the colour behaviour is specific to ruff 0.16.10 with Claude Code 2.1.295.
The exit-code rule is different. It comes from the Claude Code hooks reference itself, so it applies to every PostToolUse hook you write, in any language. The colour issue is version-specific, but --color never costs nothing, so add it anyway.
FAQ
Does exit code 2 in a PostToolUse hook undo Claude's edit?
No. The hooks reference says PostToolUse cannot block, because the tool has already run. Exit 2 shows your stderr to Claude next to the tool result, so the model can fix the file on its next step. To stop an edit before it happens, you need a PreToolUse hook.
Why not just set NO_COLOR=1 in the hook?
In this test it did not work. Claude Code already set NO_COLOR=1, along with FORCE_COLOR=0 and TERM=dumb, and ruff 0.16.10 still printed colour because of FORCE_COLOR=0. Passing --color never on every ruff call was the only fix that produced plain text.
Why does my hook exit 123 instead of 1?
Because of xargs. According to its man page, xargs exits 123 if any command it ran exited with a status other than 0 or 255. Claude Code treats 123 as a non-blocking error, so the output never reaches Claude. Read the file path into a variable instead, as in the script above.
Should the hook run ruff --fix automatically?
Yes, for safe fixes. The Ruff docs say it applies only safe fixes by default. Fixing first means the model never spends a turn on sorted imports or unused imports, and only sees problems that need its judgement, like an undefined name.
Open your .claude/settings.json today and check what every PostToolUse hook exits with. If you are new to Claude Code, start with our beginner's guide to building an app with Claude Code, or compare it with Cursor in our Cursor vs Claude Code breakdown. For everything else, the free tools on Art of Code are a click away.
Written by Adithya Guttha, Founder of Art of Code.
Sign in to like and save posts.
Keep reading


Discussion
Comments (0)
Want to join the conversation?
Sign in to commentNo comments yet. Be the first to comment!