Skip to content

Chapter 17 of 204 min readBy Deepak GuptaPublished

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.