Skip to content

Latest commit

 

History

History
250 lines (171 loc) · 9.24 KB

File metadata and controls

250 lines (171 loc) · 9.24 KB

hooks

this repo has 9 hooks you can use. here's how they work and how to build your own.

hooks are the difference between "claude code does what i want" and "claude code does whatever it feels like." CLAUDE.md gives guidance. hooks give enforcement. one is a suggestion, the other is a wall.


what hooks actually are

hooks are shell scripts (or LLM prompts) that fire on claude code lifecycle events. they intercept, validate, block, or extend what claude does -- from session start to tool execution to context compaction to shutdown.

think of them as git hooks, but for your AI coding agent.

a hook:

  1. receives JSON on stdin -- session ID, tool name, tool input, transcript path
  2. runs your logic -- inspect input, check conditions, log, call APIs
  3. returns via exit code:
    • exit 0 -- allow (proceed normally)
    • exit 2 -- block (stop this action, stderr shown to claude)
    • anything else -- non-blocking error (logged, execution continues)

the hooks i can't live without

hook event what it does
safety-guard PreToolUse blocks 6 categories of destructive bash commands
no-squash PreToolUse blocks squash merges -- preserves commit history
context-save PreCompact saves session state before context compression
panopticon PostToolUse logs every tool call to sqlite for later analysis
commit-nudge PostToolUse soft reminder after 8+ edits without a commit
md-lint-fix PostToolUse auto-runs markdownlint-fix on saved .md files
version-stamp SessionEnd updates "tested with" version stamps in changed files
stale-branch SessionStart warns about local branches with deleted remotes
notify Notification routes claude code alerts to macOS notifications

hook fire frequency is driven by tool usage. from real session data:

tool event fires what triggers hooks
Bash (10,153) most hook-triggering safety-guard, no-squash, commit-nudge all fire on Bash
Read (9,187) panopticon logs these panopticon tracks all read operations
Edit (5,010) panopticon tracks md-lint-fix fires on .md edits, commit-nudge counts edits
Write (1,696) panopticon tracks version-stamp checks written files at SessionEnd

PreToolUse hooks (safety-guard, no-squash) fire on every Bash call -- 10K+ times across all sessions. that's why they need to be fast (< 50ms).


safety prompts for sensitive file writes (v2.1.160+)

v2.1.160 added confirmation prompts before writing to shell startup files (.zshenv, .zlogin, .bash_login), git config (~/.config/git/), and build-tool config files (.npmrc, .yarnrc*, bunfig.toml, .bazelrc, .pre-commit-config.yaml, .devcontainer/). these prompts apply in acceptEdits mode and provide a second safety layer alongside safety-guard.sh hooks.

hook performance notes (v2.1.140+)

PreToolUse hooks on Bash commands need to complete in <50ms to avoid user-facing latency. the hooks listed above are optimized for speed -- they use jq for JSON parsing and regex for pattern matching rather than spawning subprocesses. profile your custom hooks with time if you add new ones.

what hooks actually prevent

three categories of damage:

destructive commands -- safety-guard.sh blocks force-pushes to main, rm -rf /, DROP TABLE, chmod 777 on sensitive paths, and curl | bash remote execution. exit code 2 = hard block, no override.

bad merges -- no-squash.sh blocks --squash on any merge. one CLAUDE.md rule saying "don't squash" gets ignored eventually. a hook that exits 2 never does.

context loss -- context-save.sh fires on PreCompact and writes a handoff markdown before compression. without it, every /compact wipes your plan. with it, claude reads the handoff and picks up where it left off.


hooks vs CLAUDE.md rules

use CLAUDE.md when you want to guide behavior -- coding style, naming conventions, preferred patterns. claude reads it, usually follows it, occasionally forgets.

use hooks when you want to enforce behavior -- things that must never happen, things that must always happen. hooks don't forget. they don't get creative. they run every time.

rule of thumb: if you'd be angry when it's violated, make it a hook. if you'd be mildly annoyed, put it in CLAUDE.md.

CLAUDE.md:  "prefer conventional commits"     -- guidance
hook:       block force-push to main           -- enforcement

how to set up hooks

hooks live in JSON settings files at three levels:

location scope shareable
~/.claude/settings.json all your projects no (local)
.claude/settings.json single project yes (commit it)
.claude/settings.local.json single project no (gitignored)

basic config structure

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/your-script.sh"
          }
        ]
      }
    ]
  }
}

matcher syntax

the matcher field is a regex that filters when hooks fire:

events what matcher filters examples
PreToolUse, PostToolUse tool name Bash, Edit|Write, mcp__memory__.*
SessionStart how session started startup, resume, clear, compact
SessionEnd why session ended clear, logout, prompt_input_exit
Notification notification type permission_prompt, idle_prompt
SubagentStart, SubagentStop agent type Bash, Explore, Plan
PreCompact what triggered compaction manual, auto
UserPromptSubmit, Stop no matcher support always fires

use "*", "", or omit matcher entirely to match all.

handler types

type description default timeout
command shell command, receives JSON on stdin 600s
prompt single-turn LLM evaluation 30s
agent subagent with Read, Grep, Glob access 60s
http POST JSON to a URL 30s

prompt and agent hooks only work on: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, Stop, SubagentStop, TaskCompleted, UserPromptSubmit. everything else is command only.


hook events quick reference

SessionStart -- fires on session begin/resume/clear/compact. stdout becomes conversation context. use for loading git status, TODOs, project state. can set env vars via CLAUDE_ENV_FILE.

UserPromptSubmit -- fires before claude processes each prompt. stdout becomes context. can block with exit 2.

PreToolUse -- fires before every tool call. this is your safety layer. can block (exit 2), allow, or modify tool inputs via updatedInput JSON.

PermissionRequest -- fires only when user would see a permission dialog. use for auto-approving safe commands (npm test) or auto-denying risky ones.

PostToolUse -- fires after successful tool execution. use for formatting, logging, linting. can't block (tool already ran).

PreCompact -- fires before context compression. use for saving session state.

Stop -- fires when claude finishes responding. use for post-response validation.

Notification -- fires on permission prompts, idle alerts. use for custom notification routing.

SessionEnd -- fires on session close. use for cleanup, version stamps.

official hooks docs -- full event reference, input schemas, advanced patterns


writing a hook script

every hook script in this repo follows the same pattern:

#!/usr/bin/env bash
set -euo pipefail

INPUT=$(cat)

# extract what you need from the JSON
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# your logic here
if echo "$COMMAND" | grep -q "git push.*--force.*main"; then
  echo "blocked: force-push to main is not allowed" >&2
  exit 2  # hard block
fi

exit 0  # allow

key patterns:

  • always set -euo pipefail at the top
  • read all of stdin into a variable (hooks get JSON on stdin)
  • use jq to extract fields
  • exit 2 to block, exit 0 to allow
  • stderr on exit 2 is shown to claude as the reason

advanced: JSON output

instead of just exit codes, you can print JSON to stdout for finer control:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Force-push to main is not allowed"
  }
}

three decisions: "allow" (bypass permission system), "deny" (block), "ask" (prompt user).

you can also modify tool inputs before execution:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": {
      "command": "npm test -- --coverage"
    }
  }
}

my philosophy

hooks should be:

  • invisible when things go right -- you shouldn't notice them firing
  • loud when things go wrong -- exit 2 with a clear reason
  • cheap to run -- bash + jq, not python + imports
  • standalone -- each hook is one script, no shared libraries

further reading