Appendix C: Hooks Library
The pattern for every hook below: register one script per event in .claude/settings.json, read the event JSON from standard input with jq, exit 0 to continue, and exit 2 with a short message on standard error to make Claude fix the problem. Keep per-edit hooks fast and quiet; put slow checks on Stop.
C.1 Settings skeleton
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-paths.sh" }] },
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-lint.sh" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-on-stop.sh" }] }
]
}
}
Make every script executable with chmod +x .claude/hooks/*.sh.
C.2 Format and lint per edit (multi-language)
#!/usr/bin/env bash
# .claude/hooks/format-lint.sh
set -uo pipefail
f=$(jq -r '.tool_input.file_path // empty'); [[ -z "$f" || ! -f "$f" ]] && exit 0
fail() { echo "$1" | head -30 >&2; exit 2; }
case "$f" in
*.ts|*.tsx|*.js|*.jsx)
npx prettier --write "$f" >/dev/null 2>&1
out=$(npx eslint --fix "$f" 2>&1) || fail "$out" ;;
*.py)
ruff format "$f" >/dev/null 2>&1
out=$(ruff check --fix "$f" 2>&1) || fail "$out" ;;
*.go)
gofmt -w "$f"
out=$(go vet "./$(dirname "$f")" 2>&1) || fail "$out" ;;
*.rs)
rustfmt "$f" 2>/dev/null ;;
*.tf)
terraform fmt "$f" >/dev/null ;;
esac
exit 0
C.3 Typecheck after edits (TypeScript)
Whole-project typechecks are slow. Run them on Stop for large projects, or per edit only for small ones.
#!/usr/bin/env bash
# .claude/hooks/typecheck.sh
out=$(npx tsc --noEmit -p . 2>&1) || { echo "$out" | head -40 >&2; exit 2; }
exit 0
For Python use mypy src; for Rust use cargo check --quiet.
C.4 Tests on Stop
#!/usr/bin/env bash
# .claude/hooks/test-on-stop.sh
# Avoid loops: if a Stop hook already blocked once, let Claude finish.
[[ "$(jq -r '.stop_hook_active // false')" == "true" ]] && exit 0
# Only run when there are uncommitted changes to source files.
git diff --quiet -- src tests 2>/dev/null && exit 0
out=$(npm test --silent 2>&1) || { echo "Tests failing:"$'\n'"$(echo "$out" | tail -40)" >&2; exit 2; }
exit 0
Replace npm test with pytest -q, go test ./..., or cargo test. The stop_hook_active check prevents an endless loop when tests cannot be fixed.
C.5 Protect paths
#!/usr/bin/env bash
# .claude/hooks/protect-paths.sh
f=$(jq -r '.tool_input.file_path // empty')
case "$f" in
*.env|*.env.*|*/secrets/*|*/migrations/*|*/generated/*|*.lock|*lock.json|*/envs/prod/*)
echo "Protected path: $f. Ask the user before editing." >&2; exit 2 ;;
esac
exit 0
C.6 Guard dangerous shell commands
#!/usr/bin/env bash
# .claude/hooks/guard-bash.sh
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -Eq 'rm -rf /|git push (-f|--force)|terraform (apply|destroy)|kubectl (apply|delete)|DROP (TABLE|DATABASE)|curl[^|]*\| *(ba)?sh'; then
echo "Blocked command: $cmd. Ask the user to run it manually if intended." >&2
exit 2
fi
exit 0
This complements the permission denylist. Deny rules stop exact patterns; the script catches variations.
C.7 Secret scan before commit
#!/usr/bin/env bash
# .claude/hooks/secret-scan.sh (register under PreToolUse, matcher Bash)
cmd=$(jq -r '.tool_input.command // empty')
[[ "$cmd" != *"git commit"* ]] && exit 0
if ! gitleaks git --pre-commit --staged --redact --no-banner >/tmp/gl.out 2>&1; then
echo "Secret detected in staged changes:" >&2; head -20 /tmp/gl.out >&2; exit 2
fi
exit 0
C.8 Session context on start
#!/usr/bin/env bash
# .claude/hooks/session-context.sh (register under SessionStart)
# Text printed on stdout is added to Claude's context.
echo "Date: $(date +%Y-%m-%d). Branch: $(git branch --show-current)."
echo "Uncommitted files: $(git status --porcelain | wc -l | tr -d ' ')."
[[ -f docs/plans/current.md ]] && echo "Active plan: docs/plans/current.md (read it before starting)."
exit 0
C.9 Desktop notification when Claude needs you
{
"hooks": {
"Notification": [
{ "hooks": [{ "type": "command",
"command": "osascript -e 'display notification \"Claude needs input\" with title \"Claude Code\"'" }] }
]
}
}
On Linux, replace the command with notify-send "Claude Code" "Claude needs input".
Cost guidance
| Hook | Event | Typical cost |
|---|---|---|
| Format single file | PostToolUse | Milliseconds, negligible context |
| Lint single file | PostToolUse | Under a second; context only on failure |
| Full typecheck | Stop (large repos) | Seconds; context only on failure |
| Test suite | Stop | Seconds to minutes; cap output with tail |
| Path and command guards | PreToolUse | Milliseconds |
| Secret scan | PreToolUse on commit | About a second |
The rule: hooks should be silent on success. Every line they print on success costs context in every turn.