Claude Code Hooks 101

CLAUDE.md is a suggestion. Claude reads it and follows it about 80% of the time. Hooks are different. They’re shell commands that fire automatically every time Claude does something, regardless of what the prompt says.

Two hook types cover most workflows:

  • PreToolUse runs before the tool executes. Exit code 2 blocks the action and sends your error message back to Claude so it can try a safer approach. Think of it as a bouncer.
  • PostToolUse runs after the tool completes. Good for formatting, linting, logging. Think of it as quality control on the assembly line.

Hooks live in one of 3 places:

.claude/settings.json         # project-level, committed to git
~/.claude/settings.json       # user-level, all projects
.claude/settings.local.json   # local only, not committed

Block dangerous commands

Claude is powerful enough to run rm -rf, git reset --hard, or curl … | bash. It probably won’t, but “probably” isn’t good enough.

.claude/hooks/block-dangerous.sh
#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command // ""')

dangerous_patterns=(
  "rm -rf"
  "git reset --hard"
  "git push.*--force"
  "DROP TABLE"
  "DROP DATABASE"
  "curl.*|.*sh"
  "wget.*|.*bash"
)

for pattern in "${dangerous_patterns[@]}"; do
  if echo "$cmd" | grep -qiE "$pattern"; then
    echo "Blocked: '$cmd' matches dangerous pattern '$pattern'. Propose a safer alternative." >&2
    exit 2
  fi
done
exit 0

Exit code 2 is the key. It cancels the action and the error message goes back to Claude so it knows to try something safer. Exit 0 means go ahead. Anything else logs a warning but doesn’t block.

Log every command

Appends every Bash command Claude runs to a timestamped audit trail.

.claude/hooks/log-commands.sh
#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command // ""')
printf '%s %s\n' "$(date -Is)" "$cmd" >> .claude/command-log.txt
exit 0

Add .claude/command-log.txt to .gitignore. When something breaks, you have an exact record of what ran and when.

Auto-format and lint after every edit

Rather than telling Claude “always run Prettier” in CLAUDE.md and hoping it remembers, a PostToolUse hook makes it unconditional.

The hook receives the tool call as JSON on stdin, so jq can pull out the file path:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-dangerous.sh" },
          { "type": "command", "command": ".claude/hooks/log-commands.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null; exit 0"
          },
          {
            "type": "command",
            "command": "npx eslint --fix $(jq -r '.tool_input.file_path') 2>&1 | tail -10; exit 0"
          },
          {
            "type": "command",
            "command": "npm run test --silent 2>&1 | tail -5; exit 0"
          }
        ]
      }
    ]
  }
}

Prettier runs first, then ESLint with --fix, then the test suite. The tail -5 keeps test output short so Claude sees “3 tests failed” rather than 200 lines. The ; exit 0 on all three means failures are reported but never block the session. By the time you look at the code, it’s formatted, lint-clean, and you already know if tests are passing.