How to Make Claude Code Automatically Format and Lint Files
Every file Claude Code writes is a file your linter hasn’t seen yet. I kept catching formatting and linting violations that CI would have flagged anyway. You can set up PostToolUse hooks that run the formatter and linter after every file write. When the linter fails, the hook returns a block decision with the output, and Claude fixes the issues on its next turn automatically.
Here’s how to set it up for PHP, CSS/JavaScript/TypeScript, and Go (the primary languages I’ve been working with), based on the tools I use.
Why a shell script
You could inline the command directly in settings.json, but it turns into escaping hell fast. Hook commands are JSON strings, so any shell logic has to survive JSON escaping → shell escaping → nested quoting. The inline version of the PHP example looks like:
"command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r f; case \"$f\" in *.php) mago format \"$f\" >/dev/null 2>&1; if ! out=$(mago lint --minimum-fail-level=warning \"$f\" 2>&1); then printf '{\"decision\":\"block\",\"reason\":%s}' \"$(printf '%s' \"$out\" | jq -Rs .)\"; fi ;; esac; } || true"
Versus a script you can read, comment, shellcheck, test standalone with echo '{"tool_input":{"file_path":"app/Foo.php"}}' | .claude/hooks/format-lint.sh, and extend without turning the command into an illegible string. The settings.json entry stays a one-liner reference: "$CLAUDE_PROJECT_DIR"/.claude/hooks/format-lint.sh.
Setup
Every example uses the same .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-lint.sh"
}
]
}
]
}
}
And the same script structure in .claude/hooks/format-lint.sh. The only thing that changes is the case block. Make the script executable with chmod +x .claude/hooks/format-lint.sh.
PHP — mago
Mago handles both formatting and linting for PHP.
#!/usr/bin/env bash
set -euo pipefail
f=$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')
[ -n "$f" ] || exit 0
block() {
printf '{"decision":"block","reason":%s}' "$(printf '%s' "$1" | jq -Rs .)"
exit 0
}
case "$f" in
*.php)
mago format "$f" >/dev/null 2>&1 || true
if ! out=$(mago lint --minimum-fail-level=warning "$f" 2>&1); then
block "$out"
fi
;;
esac
The rest of the script is always the same — drop a different case block inside the case "$f" in ... esac from above.
JavaScript/TypeScript/CSS — oxc
oxc provides oxlint for linting and oxfmt for formatting.
*.js|*.jsx|*.ts|*.tsx|*.css)
npx --no-install oxfmt "$f" >/dev/null 2>&1 || true
if ! out=$(npx --no-install oxlint --fix --deny-warnings "$f" 2>&1); then
block "$out"
fi
;;
Go — gofmt + golangci-lint
Note: golangci-lint needs a package path, not a file path — that’s why this does dir=$(dirname "$f") and passes "$dir/..." instead of just "$f".
*.go)
gofmt -w "$f" 2>/dev/null || true
dir=$(dirname "$f")
if ! out=$(golangci-lint run --fix "$dir/..." 2>&1); then
block "$out"
fi
;;
Multiple languages
Combine the case blocks into one script:
case "$f" in
*.php)
mago format "$f" >/dev/null 2>&1 || true
if ! out=$(mago lint --minimum-fail-level=warning "$f" 2>&1); then
block "$out"
fi
;;
*.js|*.jsx|*.ts|*.tsx|*.css)
npx --no-install oxfmt "$f" >/dev/null 2>&1 || true
if ! out=$(npx --no-install oxlint --fix --deny-warnings "$f" 2>&1); then
block "$out"
fi
;;
esac
Quick setup
Want Claude Code to set this up for you?
Copy this prompt
Read https://benword.com/how-to-make-claude-code-automatically-format-and-lint-files and set up the PostToolUse format/lint hook for this project. Use whatever linter and formatter this project already uses.