Use PostToolUse Hooks to auto-trigger linting after Claude writes files. Exit code 2 feeds errors back to Claude for automatic fixing — a write→check→fix loop with no manual steps.
Claude Code writes code fast — but it doesn't always remember to run lint. You ask it to "check things while you're at it," and sometimes it does, sometimes it doesn't. Hooks solve this by shifting code checking from "Claude's responsibility" to "the system's responsibility."
This article focuses on one thing: using PostToolUse Hooks to automatically trigger checks after Claude writes code, so every file change meets your coding standards.
The logic for a code-checking Hook is straightforward:
Write or Edit to write a fileExit code 2 is the key — it makes Claude receive the error output and automatically fix it, creating a "write → check → fix" loop.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.rb ]] && rubocop -A \"$file\" --no-color -q || true'"
}
]
}
]
}
}
The -A flag tells RuboCop to auto-fix whatever it can. Issues it can't fix are written to stderr with a non-zero exit code — Claude receives them and handles them.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" =~ \\.(js|ts|jsx|tsx)$ ]] && npx eslint --fix \"$file\" || true'"
}
]
}
]
}
}
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.py ]] && ruff check --fix \"$file\" && ruff format \"$file\" || true'"
}
]
}
]
}
}
Real projects often mix languages. Moving the check logic into a standalone script is easier to maintain than cramming everything into a JSON string.
Create .claude/hooks/lint.sh:
#!/bin/bash
set -e
# Read tool call info from stdin
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')
# No file path — exit
[[ -z "$file" ]] && exit 0
# File doesn't exist (maybe a delete) — exit
[[ ! -f "$file" ]] && exit 0
ext="${file##*.}"
case "$ext" in
rb)
rubocop -A "$file" --no-color -q
;;
js|ts|jsx|tsx)
npx eslint --fix "$file" --quiet
;;
py)
ruff check --fix "$file"
ruff format "$file"
;;
go)
gofmt -w "$file"
golangci-lint run "$file" 2>&1
;;
*)
exit 0
;;
esac
Reference the script in settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/lint.sh"
}
]
}
]
}
}
Scripts can be commented, tested independently, and committed to git for the whole team.
Exit code 2 + stderr output is what triggers Claude's auto-fix behavior. By default, a linter failing with a non-zero exit code will surface the error — but using exactly 2 signals Claude to act on it immediately.
#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')
[[ -z "$file" || ! -f "$file" ]] && exit 0
ext="${file##*.}"
run_lint() {
local output exit_code
output=$(rubocop "$file" --no-color 2>&1)
exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo "$output" >&2
exit 2 # Use 2, not the original code, to ensure Claude acts on it
fi
rubocop -A "$file" --no-color -q
}
[[ "$ext" == "rb" ]] && run_lint
Claude sees the exact error output and fixes it in the next turn.
Type checks are usually slower than linting — better to keep them separate so small edits don't wait on a full type check run.
TypeScript type checking (.ts files only):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'file=$(echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path 2>/dev/null); [[ \"$file\" == *.ts ]] && npx tsc --noEmit --skipLibCheck 2>&1 | head -20 || true'"
}
]
}
]
}
}
head -20 caps the output — tsc errors can be enormous, and feeding Claude a wall of text hurts more than it helps.
Some files shouldn't be linted: auto-generated code, vendor directories, test fixtures.
#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // empty')
[[ -z "$file" || ! -f "$file" ]] && exit 0
skip_patterns=(
"vendor/"
"node_modules/"
"db/schema.rb"
"db/queue_schema.rb"
".min.js"
"_test.go"
)
for pattern in "${skip_patterns[@]}"; do
[[ "$file" == *"$pattern"* ]] && exit 0
done
ext="${file##*.}"
[[ "$ext" == "rb" ]] && rubocop -A "$file" --no-color -q
With this set up, the workflow becomes:
Write toolrubocop -A automatically — finds 3 issuesYou never manually run a check command.
Global (~/.claude/settings.json): Good when you use the same standards across all projects — personal projects with a consistent ESLint + RuboCop setup.
Project-level (.claude/settings.json committed to git): Recommended for team projects. Anyone who opens the repo gets the right hooks automatically, no setup required.
Both can coexist — project-level hooks merge with global ones and both run.
Automatic code checking with Hooks comes down to three steps:
PostToolUse + "Write|Edit" matcher to intercept file writesStart with one language. Once it's running, add more. Commit the scripts to .claude/hooks/ so the whole team benefits without any extra setup.