Claude Code has a hooks system that lets you run scripts after every file edit. The killer use case: run your linter automatically and force Claude to fix issues before moving on. No more "can you run rubocop" after every change — it just happens.
The catch? Getting Claude to actually act on hook output requires a specific JSON format that isn't obvious from the docs. Here's what works.
The problem
Out of the box, Claude Code ignores hook output. You can pipe all the lint errors you want to stdout — Claude won't see them. I spent an embarrassing amount of time thinking my hook was broken before finding this GitHub issue where an Anthropic engineer confirmed the fix.
The fix: "decision": "block"
Your hook script must output this JSON on stdout:
{
"decision": "block",
"reason": "Your lint output here"
}
Without "decision": "block", the reason field is silently discarded. Claude never sees it. That's it — that's the whole secret.
Full setup
1. Create the hook script
Save this as .claude/hooks/lint-ruby.sh in your project:
#!/bin/bash
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty')
# Only lint Ruby files
if [[ ! "$file_path" =~ \.rb$ ]]; then
exit 0
fi
# Only lint if file exists (wasn't deleted)
if [[ ! -f "$file_path" ]]; then
exit 0
fi
output=""
# Run rubocop
rubocop_out=$(bundle exec rubocop --format simple "$file_path" 2>&1)
if [[ $? -ne 0 ]]; then
output+="RuboCop issues:\n$rubocop_out\n\n"
fi
# Run reek
reek_out=$(bundle exec reek "$file_path" 2>&1)
if [[ $? -ne 0 ]]; then
output+="Reek issues:\n$reek_out\n\n"
fi
if [[ -n "$output" ]]; then
jq -n --arg reason "$output" '{"decision": "block", "reason": $reason}'
exit 0
fi
Make it executable:
chmod +x .claude/hooks/lint-ruby.sh
2. Configure the hook
Add this to .claude/settings.json (project-level) or ~/.claude/settings.json (global):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/lint-ruby.sh",
"timeout": 30
}
]
}
]
}
}
The matcher field is a regex against tool names — Write|Edit catches both file creation and modification.
3. That's it
Next time Claude edits a Ruby file, the hook runs RuboCop and Reek automatically. If either finds issues, Claude sees them immediately and fixes them before continuing. No prompting needed.
Adapting for other languages
The pattern works for any linter. Swap the file extension check and linter commands:
ESLint (JavaScript/TypeScript):
if [[ ! "$file_path" =~ \.(js|ts|jsx|tsx)$ ]]; then exit 0; fi
lint_out=$(npx eslint "$file_path" 2>&1)
Ruff (Python):
if [[ ! "$file_path" =~ \.py$ ]]; then exit 0; fi
lint_out=$(ruff check "$file_path" 2>&1)
Go:
if [[ ! "$file_path" =~ \.go$ ]]; then exit 0; fi
lint_out=$(golangci-lint run "$file_path" 2>&1)
The structure is always the same: filter by extension, run your linter, and wrap any output in {"decision": "block", "reason": "..."}.
Gotchas
jqis required — the hook uses it to parse input and build JSON output. Install withbrew install jqor your package manager.$BASH_ENVcan break hooks — the hook runs as a non-interactive, non-login bash script, so~/.bashrcand~/.zshrcare not loaded. But if$BASH_ENVis set to a file that prints to stdout, it'll corrupt the JSON output. This is rare but worth checking if your hook silently fails.- The edit already happened — PostToolUse hooks run after the file is written. The
"block"decision doesn't prevent the edit; it tells Claude "something's wrong, fix it." Claude will then make another edit to address the lint errors, which triggers the hook again — the loop continues until lint passes. - Timeout matters — set a reasonable timeout (30s works for most linters). If your linter is slow on first run (loading gems, compiling), you may need to bump it.
- Exit code 0 always — the hook communicates via JSON, not exit codes. Exit 0 whether lint passes or fails.
This post was generated with EARL (via Opus 4.6), but reviewed by me.