Skip to content

Chapter 5: Hooks — 让 Claude 的每个操作都受你控制 ​

学习目标 ​

完成本章后,你将能够:

  1. 解释 Hook 生命周期,以及 PreToolUse、PostToolUse、SessionStart 和 Stop 事件如何映射到 Claude 的工具调用管线
  2. 编写 Hook 脚本来强制 commit 质量、拦截危险命令、注入会话上下文、观察工具使用情况
  3. 正确使用 exit code:0(放行)、1(错误 -- 操作仍然继续)、2(主动拦截)
  4. 在 settings.json 中配置 Hook,包括 matcher、handler 类型和事件绑定
  5. 调试常见的 Hook 故障:缺少 shebang、错误的 exit code、脚本没有执行权限

你将构建什么 ​

四个解决真实问题的生产级 Hook:

  1. Commit 质量门禁 — 强制 commit message 格式、运行 linter、在代码入库前捕获泄露的密钥
  2. 危险命令拦截器 — 阻止 --no-verify、DROP TABLE、强制推送到 main 分支
  3. 会话上下文注入器 — 每次启动时向 Claude 注入 git 分支、近期 PR、CI 状态信息
  4. 持续学习观察器 — 捕获工具使用模式,帮你优化工作流

这些不是玩具示例,而是从 Everything Claude Code(148k 星)和 gstack(68k 星)中提取的实战模式。

Hook 工作原理 ​

每次 Claude 使用工具时,都会经过一个生命周期。Hook 让你在特定节点拦截这个周期,运行自己的代码。

你说:"提交这些修改"
  |
  v
Claude 准备执行: Bash(command="git commit -m '...'")
  |
  v
PreToolUse hook 触发 --> 你的脚本检查 commit message 格式
  |                       你的脚本扫描暂存文件中的密钥
  |                       你的脚本拦截 --no-verify 标志
  |
  v (所有检查通过, exit 0)
Claude 执行命令
  |
  v
PostToolUse hook 触发 --> 你的脚本记录工具使用日志
  |
  v
完成

关键点:PreToolUse hook 可以通过返回 exit code 2 来 阻止 工具执行。其他 hook 都是观察性的。

Hook 事件 ​

事件触发时机能阻止?实际用途
PreToolUse工具执行前能 (exit 2)拦截 --no-verify、强制 commit 格式
PostToolUse工具执行后不能运行 linter、记录使用日志、格式化代码
SessionStart会话开始时不能注入 git 上下文、CI 状态、PR 信息
StopClaude 完成时不能通知提示、使用统计、会话摘要
NotificationClaude 发通知时不能转发到 Slack、手机推送

处理器类型 ​

类型作用使用场景
command运行 shell 脚本90% 的场景都用这个
httpPOST 到 webhookSlack 通知、遥测端点
promptClaude 评估条件"这个修改会不会破坏公开 API?"
agent启动子 Agent提交前进行深度代码审查

Exit Code 协议 ​

Exit Code含义对工具执行的影响
0放行 — hook 通过操作正常继续
1hook 脚本出错操作 仍然继续(hook 故障不阻塞)
2主动拦截工具执行被 阻止

为什么用 Exit Code 2 拦截(而不是 1)?

这是深思熟虑的设计,不是随意选择。在 Unix 惯例中,exit code 1 表示"一般错误" -- 脚本本身出了问题(语法错误、缺少依赖、jq 解析失败)。如果 hook 错误就拦截操作,那 hook 脚本里一个拼写错误就会让 Claude 完全无法工作。

Exit code 2 表示"有意拒绝"。你的脚本正常运行了,评估了情况,做出了主动拦截的决定。这种分离确保 坏掉的 hook 优雅降级(Claude 继续工作),而 有意的策略被可靠执行(被拦截的操作保持拦截状态)。

这和 HTTP 状态码的设计一样:500(服务器错误)vs 403(禁止)。服务器可能坏了,也可能在主动拒绝你 -- 这是完全不同的情况。

配置方式 ​

Hook 配置在 settings.json 中。可以在项目级(.claude/settings.json)或用户级(~/.claude/settings.json)配置。

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/my-hook.sh"
          }
        ]
      }
    ]
  }
}

Matcher 使用正则表达式。"Bash" 匹配 Bash 工具。"Write|Edit" 匹配两者。"mcp__github__.*" 匹配所有 GitHub MCP 工具。


Demo 11: Commit 质量门禁 ​

11
Commit Quality Gate
Intermediate~10 min

灵感来自 ECC 的 pre-bash-commit-quality.js -- 在坏提交进入仓库前拦截它。

问题 ​

你的团队使用 Conventional Commits。但 Claude 有时会生成 "update stuff" 这样的 commit message,或者用 --no-verify 跳过 pre-commit hook。更糟的是,暂存文件中偶尔包含硬编码的 API 密钥。

构建 ​

1. 项目准备 ​

bash
mkdir -p ~/claude-demos/demo-11 && cd ~/claude-demos/demo-11
git init && git branch -M main
mkdir -p .claude/hooks src

cat > CLAUDE.md << 'EOF'
# Commit Quality Demo
- Use Conventional Commits format: type(scope): description
- Never skip pre-commit hooks
- Never commit secrets or API keys
EOF

cat > src/app.js << 'EOF'
export function processPayment(amount, currency) {
  if (amount <= 0) throw new Error('Invalid amount');
  return { status: 'processed', amount, currency };
}
EOF

git add -A && git commit -m "feat: initial project setup"

2. 创建 Hook 脚本 ​

这个 hook 对每个 git commit 命令检查三件事:

bash
cat > .claude/hooks/commit-quality-gate.sh << 'HOOKEOF'
#!/bin/bash
# PreToolUse Hook: Commit quality gate
# Blocks commits that fail format, secret, or hygiene checks

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
command=$(echo "$input" | jq -r '.tool_input.command // empty')

# Only check Bash commands that look like git commits
if [ "$tool_name" != "Bash" ]; then
  exit 0
fi

if ! echo "$command" | grep -q "git commit"; then
  exit 0
fi

# === CHECK 1: Block --no-verify ===
# This is from ECC's hook set — never let Claude skip pre-commit hooks
if echo "$command" | grep -q "\-\-no-verify"; then
  echo "BLOCKED: --no-verify flag detected"
  echo ""
  echo "Commits must pass pre-commit hooks. Remove --no-verify and fix"
  echo "whatever issue the hooks are catching."
  exit 2
fi

# === CHECK 2: Enforce Conventional Commits format ===
# Extract the commit message from -m "..." or -m '...'
msg=$(echo "$command" | sed -n 's/.*-m ["\x27]\([^"\x27]*\)["\x27].*/\1/p')
if [ -n "$msg" ]; then
  # Check format: type(scope): description  OR  type: description
  if ! echo "$msg" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\([a-zA-Z0-9_-]+\))?: .+'; then
    echo "BLOCKED: Commit message doesn't follow Conventional Commits"
    echo ""
    echo "Expected format: type(scope): description"
    echo "  Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert"
    echo "  Example: feat(auth): add password reset flow"
    echo ""
    echo "Got: $msg"
    exit 2
  fi

  # Subject line max 72 chars
  subject=$(echo "$msg" | head -1)
  if [ ${#subject} -gt 72 ]; then
    echo "BLOCKED: Commit subject line is ${#subject} chars (max 72)"
    echo "Shorten it: $subject"
    exit 2
  fi
fi

# === CHECK 3: Scan staged files for secrets ===
# Check if there are staged files with potential secrets
staged_files=$(git diff --cached --name-only 2>/dev/null)
if [ -n "$staged_files" ]; then
  for file in $staged_files; do
    if [ -f "$file" ]; then
      # Check for common secret patterns
      if grep -qiE '(api_key|api_secret|aws_secret|private_key|password)\s*[=:]\s*["\x27][^\s]+["\x27]' "$file" 2>/dev/null; then
        echo "BLOCKED: Potential secret detected in staged file: $file"
        echo ""
        echo "Found what looks like a hardcoded credential. Use environment"
        echo "variables instead, or add the file to .gitignore."
        exit 2
      fi
      # Check for .env files
      if echo "$file" | grep -qE '\.env$|\.env\.local$|\.env\.production$'; then
        echo "BLOCKED: Environment file staged for commit: $file"
        echo "Run: git reset HEAD $file"
        exit 2
      fi
    fi
  done
fi

# All checks passed
exit 0
HOOKEOF

chmod +x .claude/hooks/commit-quality-gate.sh

3. 配置 ​

bash
cat > .claude/settings.json << 'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/commit-quality-gate.sh"
          }
        ]
      }
    ]
  }
}
EOF

4. 测试 ​

bash
claude

测试格式检查:

Commit the current changes with message "update stuff"

当 hook 拦截不合格的 commit message 时,你会在终端看到这样的输出:

terminal
$ claude
> Commit the current changes with message "update stuff"

  Claude wants to run: Bash
    git commit -m "update stuff"

  Hook: commit-quality-gate.sh
  BLOCKED: Commit message doesn't follow Conventional Commits

  Expected format: type(scope): description
    Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert
    Example: feat(auth): add password reset flow

  Got: update stuff

  I'll retry with a proper format.

  Claude wants to run: Bash
    git commit -m "chore: update project configuration"

  Allow? [y/n/a]: y
  [main abc1234] chore: update project configuration
   1 file changed, 3 insertions(+)

测试 --no-verify 拦截:

Run git commit --no-verify -m "fix: bypass hooks"
terminal
> Run git commit --no-verify -m "fix: bypass hooks"

  Claude wants to run: Bash
    git commit --no-verify -m "fix: bypass hooks"

  Hook: commit-quality-gate.sh
  BLOCKED: --no-verify flag detected

  Commits must pass pre-commit hooks. Remove --no-verify and fix
  whatever issue the hooks are catching.

  I understand. I'll commit without --no-verify and address any
  pre-commit hook failures directly.

测试正确的 commit 能通过:

Make a small change to src/app.js, then commit with a proper conventional commit message

预期结果:Hook 放行,commit 成功。

刚才发生了什么? ​

1
Bash
git commit -m 'update stuff'
↓
2
Bash
git commit -m 'chore: update project configuration'
↓
3
Bash
git commit --no-verify -m 'fix: bypass hooks'

关键洞察:hook 在命令 执行之前 运行。Claude 在 hook 确认安全之前不会真正执行 git commit。被拦截时,Claude 读取 hook 的 stderr 输出并自我修正 -- 通常在下一次尝试就会成功。

为什么限制 72 个字符? ​

自 2005 年以来的 Git 惯例

72 个字符的限制不是随意规定的,而是来自 Linux 内核项目的 commit message 标准,此后被几乎所有主要开源项目采纳。

实际原因:

  • git log --oneline 按终端宽度截断。大多数终端默认 80 列,减去 7 字符的 commit hash 前缀 + 空格,留给消息的就是 72 个字符。
  • git shortlog(用于发布说明)在长标题时换行效果很差。
  • GitHub 的 commit 列表 UI 在 ~72 个字符后用省略号截断。
  • Patch email 格式(Git 最初的工作流)假设 72 字符行宽。

如果你的团队使用不同的限制(比如标题用 50 字符),修改 hook 脚本中的 72 即可。

为什么用 Conventional Commits 格式? ​

结构化消息赋能自动化

Conventional Commits 使用 type(scope): description 格式是有原因的:

  • 自动生成变更日志:standard-version 和 semantic-release 等工具解析类型前缀来自动生成 CHANGELOG.md。
  • 语义化版本控制:feat: 触发次版本号升级,fix: 触发修订版本号升级,feat!: 或任何带 BREAKING CHANGE 注脚的类型触发主版本号升级。
  • 可搜索的历史:git log --grep="^feat" 立即显示所有功能新增。用自由格式消息试试看?
  • PR 分类:GitHub Actions 可以根据 commit 类型自动给 PR 打标签。

格式是 type(optional-scope): description。类型(feat、fix、docs、style、refactor、test、chore、perf、ci、build、revert)覆盖了所有变更类别。


Demo 12: 危险命令拦截器 ​

12
Dangerous Command Blocker
Intermediate~10 min

改编自 ECC 的安全 hook -- 在破坏性命令执行前拦截它。

问题 ​

Claude 通常比较谨慎,但它可能被提示执行破坏性命令。强制推送到 main、删除数据库、rm -rf 重要路径 -- 这些都可能因为一个坏的提示而发生。

构建 ​

1. 创建 Hook ​

bash
cd ~/claude-demos/demo-11

cat > .claude/hooks/block-dangerous.sh << 'HOOKEOF'
#!/bin/bash
# PreToolUse Hook: Block destructive Bash commands
# Covers the patterns that actually cause incidents in production teams

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')

if [ "$tool_name" != "Bash" ]; then
  exit 0
fi

command=$(echo "$input" | jq -r '.tool_input.command // empty')

# --- Destructive file operations ---
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+).*(/|~|\*|\.)'; then
  echo "BLOCKED: Destructive rm command"
  echo "Command: $command"
  echo ""
  echo "If you need to delete files, be specific about the path."
  exit 2
fi

# --- Git force push to protected branches ---
if echo "$command" | grep -qiE 'git\s+push\s+.*--force.*\s+(main|master|production|release)'; then
  echo "BLOCKED: Force push to protected branch"
  echo "Command: $command"
  echo ""
  echo "Force pushing to main/master/production is never safe."
  echo "Use a feature branch and create a PR instead."
  exit 2
fi

# The reverse pattern too: branch name before --force
if echo "$command" | grep -qiE 'git\s+push\s+\S+\s+(main|master|production|release)\s+--force'; then
  echo "BLOCKED: Force push to protected branch"
  exit 2
fi

# --- Database destruction ---
if echo "$command" | grep -qiE '(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\S+\s*;?\s*$)'; then
  echo "BLOCKED: Destructive database command"
  echo "Command: $command"
  echo ""
  echo "Use migrations for schema changes. DELETE requires a WHERE clause."
  exit 2
fi

# --- Disk operations ---
if echo "$command" | grep -qE '(mkfs\.|dd\s+if=|wipefs)'; then
  echo "BLOCKED: Disk-level destructive command"
  exit 2
fi

# --- chmod 777 on root ---
if echo "$command" | grep -qE 'chmod\s+(-R\s+)?777\s+/'; then
  echo "BLOCKED: chmod 777 on root filesystem"
  exit 2
fi

exit 0
HOOKEOF

chmod +x .claude/hooks/block-dangerous.sh

2. 更新配置 ​

bash
cat > .claude/settings.json << 'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/commit-quality-gate.sh"
          },
          {
            "type": "command",
            "command": "bash .claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ]
  }
}
EOF

3. 测试 ​

bash
claude

尝试以下命令 -- 都应该被拦截:

Run rm -rf /tmp/important-data
terminal
> Run rm -rf /tmp/important-data

  Claude wants to run: Bash
    rm -rf /tmp/important-data

  Hook: block-dangerous.sh
  BLOCKED: Destructive rm command
  Command: rm -rf /tmp/important-data

  If you need to delete files, be specific about the path.

  I can't run that command because it matches a destructive pattern.
  If you need to remove specific files, I can list them first and
  delete them individually.
Force push to main branch
terminal
> Force push to main branch

  Claude wants to run: Bash
    git push origin main --force

  Hook: block-dangerous.sh
  BLOCKED: Force push to protected branch
  Command: git push origin main --force

  Force pushing to main/master/production is never safe.
  Use a feature branch and create a PR instead.

  I can't force push to main. Instead, I'll create a feature branch
  and push there.
Run this SQL: DROP TABLE users;
terminal
> Run this SQL: DROP TABLE users;

  Claude wants to run: Bash
    psql -c "DROP TABLE users;"

  Hook: block-dangerous.sh
  BLOCKED: Destructive database command
  Command: psql -c "DROP TABLE users;"

  Use migrations for schema changes. DELETE requires a WHERE clause.

而这个应该正常通过:

Run ls -la src/

刚才发生了什么? ​

1
Bash
rm -rf /tmp/important-data
↓
2
Bash
git push origin main --force
↓
3
Bash
psql -c 'DROP TABLE users;'
↓
4
Bash
ls -la src/

注意 hook 按顺序运行多个正则检查。命令只需要匹配一个模式就会被拦截。安全的命令通过所有检查后以 exit 0 退出。

核心理念 ​

gstack 的理念在这里同样适用:精确拦截真正导致事故的命令模式。不要试图捕获所有可能的破坏性命令 -- 聚焦于实际引发事故的模式。上面的列表覆盖了 95% 的真实生产事故。


Demo 13: 会话上下文注入器 ​

13
Session Context Injector
Intermediate~10 min

基于 ECC 的会话初始化模式和 gstack 的 "re-ground"(重新接地)原则。

问题 ​

每次在项目中启动 Claude,它对你刚才在做什么一无所知。不知道当前分支、最近的 PR、CI 状态、上次会话后的变更。你得花前几条消息来给 Claude 补课。

构建 ​

1. 创建注入器 ​

bash
cd ~/claude-demos/demo-11

cat > .claude/hooks/inject-context.sh << 'HOOKEOF'
#!/bin/bash
# SessionStart Hook: Give Claude full project context on every startup
# Inspired by gstack's "re-ground" principle — always start with reality

echo "=== SESSION CONTEXT ==="

# --- Git state ---
if git rev-parse --git-dir > /dev/null 2>&1; then
  branch=$(git branch --show-current 2>/dev/null)
  echo "Branch: $branch"

  # Recent commits on this branch
  echo ""
  echo "Recent commits:"
  git log --oneline -5 2>/dev/null | sed 's/^/  /'

  # Uncommitted changes
  changes=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
  if [ "$changes" -gt 0 ]; then
    echo ""
    echo "Uncommitted changes ($changes files):"
    git status --short 2>/dev/null | head -10 | sed 's/^/  /'
    if [ "$changes" -gt 10 ]; then
      echo "  ... and $((changes - 10)) more"
    fi
  fi

  # Check if branch is behind remote
  behind=$(git rev-list --count HEAD..@{u} 2>/dev/null)
  ahead=$(git rev-list --count @{u}..HEAD 2>/dev/null)
  if [ -n "$behind" ] && [ "$behind" -gt 0 ]; then
    echo ""
    echo "WARNING: Branch is $behind commits behind remote"
  fi
  if [ -n "$ahead" ] && [ "$ahead" -gt 0 ]; then
    echo "Branch is $ahead commits ahead of remote (unpushed)"
  fi
fi

# --- CI status (if gh CLI is available) ---
if command -v gh > /dev/null 2>&1; then
  # Get the most recent PR for this branch
  pr_info=$(gh pr view --json number,title,state,statusCheckRollup 2>/dev/null)
  if [ -n "$pr_info" ]; then
    pr_num=$(echo "$pr_info" | jq -r '.number')
    pr_title=$(echo "$pr_info" | jq -r '.title')
    pr_state=$(echo "$pr_info" | jq -r '.state')
    echo ""
    echo "Active PR: #$pr_num - $pr_title ($pr_state)"

    # CI check status
    failing=$(echo "$pr_info" | jq -r '.statusCheckRollup[]? | select(.conclusion == "FAILURE") | .name' 2>/dev/null)
    if [ -n "$failing" ]; then
      echo "FAILING CI checks:"
      echo "$failing" | sed 's/^/  - /'
    fi
  fi
fi

# --- Active TODO/FIXME count ---
todo_count=$(grep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.js" --include="*.ts" --include="*.py" . 2>/dev/null | grep -v node_modules | grep -v ".git" | wc -l | tr -d ' ')
if [ "$todo_count" -gt 0 ]; then
  echo ""
  echo "Open TODOs/FIXMEs: $todo_count"
fi

echo ""
echo "=== END CONTEXT ==="
HOOKEOF

chmod +x .claude/hooks/inject-context.sh

2. 添加到配置 ​

bash
cat > .claude/settings.json << 'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/commit-quality-gate.sh"
          },
          {
            "type": "command",
            "command": "bash .claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/inject-context.sh"
          }
        ]
      }
    ]
  }
}
EOF

3. 测试 ​

bash
# Make some changes to create interesting context
echo "// TODO: add input validation" >> src/app.js
git add src/app.js

# Start a new session
claude

会话启动时注入上下文的效果如下:

terminal
$ claude

  Hook: inject-context.sh
  === SESSION CONTEXT ===
  Branch: main

  Recent commits:
    abc1234 feat: initial project setup

  Uncommitted changes (1 files):
    M  src/app.js

  Open TODOs/FIXMEs: 1

  === END CONTEXT ===

  Claude Code v1.x.x

  I can see you're on the main branch with 1 uncommitted change to
  src/app.js and 1 open TODO. What would you like to work on?

>

Claude 现在每次开始会话都知道你的分支、近期提交、未提交变更、CI 状态和待处理的 TODO。不用再浪费前 3 条消息在"我在哪个分支上?"

刚才发生了什么? ​

1
SessionStart
inject-context.sh
↓
2
Bash
git branch --show-current
↓
3
Bash
git status --short
↓
4
Bash
grep -rn TODO

SessionStart hook 在 Claude 开口之前 运行。输出成为 Claude 初始上下文的一部分,所以它从对话一开始就掌握了信息。gstack 称此为 "re-grounding"(重新接地)-- 始终从现实出发,而不是基于假设。


Demo 14: 持续学习观察器 ​

14
Continuous Learning Observer
Intermediate~10 min

灵感来自 ECC 的 observe.sh -- 一个 PostToolUse hook,观察 Claude 的行为并从中学习。

问题 ​

你用了 Claude 一个月。它最常用哪些工具?多久读一次文件 vs 写一次?哪些命令反复执行?没有数据,你无法优化工作流或写出更好的 CLAUDE.md 指令。

构建 ​

1. 创建观察器 ​

bash
cd ~/claude-demos/demo-11

cat > .claude/hooks/observe.sh << 'HOOKEOF'
#!/bin/bash
# PostToolUse Hook: Log every tool call for analysis
# Inspired by ECC's continuous learning pattern

input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // "unknown"')
session_id="${CLAUDE_SESSION_ID:-unknown}"

# Create log directory
log_dir="$HOME/.claude/tool-logs"
mkdir -p "$log_dir"

# Log file per day
log_file="$log_dir/$(date +%Y-%m-%d).jsonl"

# Extract useful context based on tool type
case "$tool_name" in
  "Bash")
    detail=$(echo "$input" | jq -r '.tool_input.command // ""' | head -1 | cut -c1-100)
    ;;
  "Read")
    detail=$(echo "$input" | jq -r '.tool_input.file_path // ""')
    ;;
  "Write"|"Edit")
    detail=$(echo "$input" | jq -r '.tool_input.file_path // ""')
    ;;
  "Glob"|"Grep")
    detail=$(echo "$input" | jq -r '.tool_input.pattern // ""')
    ;;
  *)
    detail=""
    ;;
esac

# Write structured log entry (JSONL format)
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"session\":\"$session_id\",\"tool\":\"$tool_name\",\"detail\":\"$detail\"}" >> "$log_file"

exit 0
HOOKEOF

chmod +x .claude/hooks/observe.sh

2. 创建分析脚本 ​

这是你在一天结束时运行来查看使用模式的脚本:

bash
cat > .claude/hooks/analyze-usage.sh << 'HOOKEOF'
#!/bin/bash
# Analyze tool usage patterns from observer logs
# Run manually: bash .claude/hooks/analyze-usage.sh

log_dir="$HOME/.claude/tool-logs"

if [ ! -d "$log_dir" ]; then
  echo "No tool logs found. Use Claude for a while first."
  exit 0
fi

echo "=== Claude Code Usage Report ==="
echo "Period: $(ls "$log_dir"/*.jsonl 2>/dev/null | head -1 | xargs basename .jsonl 2>/dev/null) to $(ls "$log_dir"/*.jsonl 2>/dev/null | tail -1 | xargs basename .jsonl 2>/dev/null)"
echo ""

echo "--- Tool Usage Frequency ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r '.tool' | sort | uniq -c | sort -rn
echo ""

echo "--- Most Read Files ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r 'select(.tool=="Read") | .detail' | sort | uniq -c | sort -rn | head -10
echo ""

echo "--- Most Common Bash Commands ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r 'select(.tool=="Bash") | .detail' | sort | uniq -c | sort -rn | head -10
echo ""

echo "--- Sessions per Day ---"
cat "$log_dir"/*.jsonl 2>/dev/null | jq -r '.ts[:10] + " " + .session' | sort -u | cut -d' ' -f1 | uniq -c
HOOKEOF

chmod +x .claude/hooks/analyze-usage.sh

3. 添加到配置 ​

将 PostToolUse 添加到你的 settings 中:

bash
cat > .claude/settings.json << 'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/commit-quality-gate.sh"
          },
          {
            "type": "command",
            "command": "bash .claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/observe.sh"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/inject-context.sh"
          }
        ]
      }
    ]
  }
}
EOF

4. 使用 ​

正常使用 Claude 一段时间,然后查看数据:

bash
bash .claude/hooks/analyze-usage.sh

示例输出:

terminal
$ bash .claude/hooks/analyze-usage.sh

=== Claude Code Usage Report ===
Period: 2026-04-10 to 2026-04-10

--- Tool Usage Frequency ---
  47 Read
  23 Bash
  15 Edit
   8 Write
   6 Grep
   3 Glob

--- Most Read Files ---
   8 src/app.js
   5 package.json
   4 CLAUDE.md
   3 src/utils.js

--- Most Common Bash Commands ---
   5 npm test
   4 git status
   3 git diff --cached
   2 npx eslint src/

--- Sessions per Day ---
   3 2026-04-10

刚才发生了什么? ​

1
PostToolUse
observe.sh
↓
2
Bash
jq -r .tool_name
↓
3
Bash
echo >> $log_file
↓
4
Bash
analyze-usage.sh

这是 gstack 遥测模式的个人版本。使用一周后,你就能确切知道 Claude 最常读取哪些文件(把它们放进 CLAUDE.md)、哪些命令反复执行(做成 skill)、以及时间花在哪里。


常见问题排查 ​

Hook 就是 shell 脚本,而 shell 脚本的故障是有规律可循的。以下是四种最常见的故障及其修复方法。

问题 1: 缺少 Shebang 行 ​

你写了一个 hook 脚本但忘了在顶部加 #!/bin/bash:

bash
# 错误: 没有 shebang 行
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
# ...

会发生什么:系统尝试用默认 shell 执行脚本,可能是 sh(不是 bash)。Bash 特有的功能如 [[ ]] 测试、$(( )) 算术或 {1..10} 大括号展开会静默失败或产生错误结果。脚本以 exit code 1(错误)退出,操作仍然继续,因为 exit 1 意味着"hook 出错"而不是"拦截"。

修复:每个 hook 脚本始终以 #!/bin/bash 开头:

bash
#!/bin/bash
# 正确: 明确指定 bash 解释器
input=$(cat)

问题 2: Exit Code 混淆(1 vs 2) ​

这是最常见的 hook bug。你想拦截操作,但用了 exit 1 而不是 exit 2:

bash
# 错误: 用 exit 1 来"拦截"
if echo "$command" | grep -q "dangerous"; then
  echo "This is dangerous!"
  exit 1  # 错误: 这意味着"hook 出错" -- 操作仍然继续
fi

会发生什么:Claude 看到 exit code 1,将其解释为"hook 脚本出错",然后 照常执行操作。你的"安全门禁"在静默地做无用功。

修复:拦截操作始终使用 exit 2:

bash
# 正确: 用 exit 2 拦截
if echo "$command" | grep -q "dangerous"; then
  echo "BLOCKED: This is dangerous!"
  exit 2  # 正确: 这意味着"主动拦截" -- 操作被阻止
fi

Exit Code 速查表

  • exit 0 -- "一切正常,继续"
  • exit 1 -- "我的脚本坏了,但让 Claude 继续"(开放式失败)
  • exit 2 -- "我在主动拦截这个操作"(封闭式失败)

如果本章你只记住一件事:exit 2 拦截,exit 1 不拦截。

问题 3: Hook 误拦正常操作 ​

你的 hook 太激进了。你写了一个拦截 rm -rf 的模式,但它同时也捕获了 rm -rf node_modules/(完全正常的清理操作),或者你的 commit 格式检查拒绝了 Git 自动生成的 merge commit:

terminal
> Clean up the project by removing node_modules

  Claude wants to run: Bash
    rm -rf node_modules/

  Hook: block-dangerous.sh
  BLOCKED: Destructive rm command

  But this is a legitimate operation...

修复:让模式更精确。为已知安全的操作添加白名单:

bash
# 更好: 允许特定的安全 rm 目标
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)'; then
  # 白名单: 这些删除是安全的
  if echo "$command" | grep -qE 'rm\s+-rf\s+(node_modules|\.cache|dist|build|__pycache__|\.pytest_cache)\s*$'; then
    exit 0  # 已知安全的清理目标
  fi
  # 其他一律拦截
  echo "BLOCKED: Destructive rm command"
  exit 2
fi

通用原则:先严格,遇到误拦时再添加特定例外。永远不要反过来(先宽松,再试图拦截所有坏的东西)。

问题 4: Hook 脚本没有执行权限 ​

你创建了脚本但忘了 chmod +x:

terminal
$ claude
> Commit these changes

  Hook error: Permission denied: .claude/hooks/commit-quality-gate.sh
  (Hook returned exit code 1, proceeding with operation)

  Claude wants to run: Bash
    git commit -m "update stuff"

  # 坏的 commit 通过了,因为 hook 出错了(exit 1),
  # 而错误是不阻塞的!

会发生什么:操作系统无法执行脚本。Hook 系统捕获权限错误并返回 exit code 1(错误)。由于设计上错误是不阻塞的,操作继续执行 -- 你的安全门禁被完全绕过了。

修复:创建 hook 脚本后始终运行 chmod +x:

bash
chmod +x .claude/hooks/commit-quality-gate.sh
chmod +x .claude/hooks/block-dangerous.sh
chmod +x .claude/hooks/inject-context.sh
chmod +x .claude/hooks/observe.sh

小技巧:在 CLAUDE.md 中加一条检查:

markdown
# Hook Maintenance
After creating or modifying any hook script in .claude/hooks/,
always run: chmod +x .claude/hooks/*.sh

参考:完整 Hook 事件数据 ​

事件stdin 字段可阻止最佳用途
PreToolUsetool_name, tool_input是 (exit 2)安全门禁、格式强制
PostToolUsetool_name, tool_input, tool_output否日志、格式化、lint
SessionStartsession_id, model否上下文注入、环境检查
Stop(无)否通知、清理、使用报告
Notificationmessage否转发到 Slack/手机

Hook 中可用的环境变量 ​

变量值
CLAUDE_PROJECT_DIR项目根路径
CLAUDE_SESSION_ID当前会话标识
CLAUDE_MODEL使用的模型

练习:构建你的 Hook 套件 ​

把本章四个 hook 部署到你正在做的真实项目上,然后扩展它们:

  1. 扩展 commit 门禁:添加检查 -- 阻止生产代码中包含 console.log(测试文件除外)
  2. 扩展危险拦截器:添加你技术栈特有的命令模式(如数据库项目的 DROP INDEX、k8s 项目的 kubectl delete namespace)
  3. 扩展上下文注入器:添加部署状态(如 staging 和 production 是否在同一个 commit 上)
  4. 扩展观察器:添加耗时数据 -- 每次工具调用花了多长时间?哪些工具最慢?

成功标准 ​

  • [ ] 四个 hook 都配置在 .claude/settings.json 中
  • [ ] Commit 质量门禁拦截坏消息和 --no-verify
  • [ ] 危险命令拦截器捕获强制推送和破坏性 SQL
  • [ ] 每次会话启动时有完整的项目上下文(分支、变更、CI、TODO)
  • [ ] 观察器日志在生成,analyze-usage.sh 能产出报告

知识检测 ​

用以下问题检验你对 Hook 的理解。

你的 PreToolUse hook 脚本有一个拼写错误导致 bash 语法错误。Claude 正在尝试的工具调用会怎样?
工具调用被拦截 -- 错误被当作拒绝处理
工具调用继续执行 -- exit code 1(错误)不会阻塞
Claude 自动重试 hook
整个 Claude 会话崩溃
你想同时拦截 Claude 使用 Write 和 Edit 工具修改 /config 目录的文件。在 settings.json 中应该用什么 matcher 模式?
Write, Edit
Write|Edit
Write&&Edit
需要两个独立的 matcher 条目
哪个 Hook 事件在 Claude 执行工具之前触发,并且能阻止执行?
PostToolUse
SessionStart
PreToolUse
Stop
你配置了一个 SessionStart hook,输出你的 git 分支和近期提交。这些输出去哪了?
打印到终端供用户阅读
注入到 Claude 上下文中供 Claude 引用
保存到日志文件
替换该会话的 CLAUDE.md 内容
你在 Bash matcher 上配置了三个 PreToolUse hook。Hook A 返回 exit 0,Hook B 返回 exit 2,Hook C 返回 exit 0。会发生什么?
三个 hook 都运行;工具被拦截因为 Hook B 返回了 exit 2
只有 Hook A 运行;hook 在第一个之后就停止了
工具继续执行因为三个中有两个允许了(多数胜出)
Hook A 和 B 运行;因为 B 已经拦截了所以跳过 Hook C

深入探索:Hook 如何与工具调用集成 ​

Hook 位于 Claude Code 工具调用管线的特定位置。关于 Claude 如何选择工具、构造参数和处理结果的完整图景,参见 附录 A05:工具调用内部机制。

A05 中与 Hook 相关的关键概念:

  • 工具选择:Claude 根据任务选择工具。PreToolUse hook 在工具选择 之后 但执行 之前 触发 -- 你能看到 Claude 选择了什么,但可以否决这个决定。
  • JSON Schema 匹配:你的 hook 接收到的 tool_input 正是 Claude 为工具调用构造的 JSON 对象。理解 schema 有助于你编写精确的检查。
  • 权限门控:Hook 与 Claude Code 内置的权限系统并行运行(而非替代)。工具调用必须同时通过权限检查和所有 PreToolUse hook。

总结 ​

Hook 是让 Claude Code 默认安全且智能的自动化层。本章的四个模式 -- 质量门禁、安全拦截、上下文注入、使用观察 -- 区分了普通用户和把 Claude 当作正式团队成员使用的人。

要点:

  • PreToolUse + exit code 2 = 以编程方式阻止 Claude 做某事的唯一方法
  • Exit code 1 意味着"hook 出错"(不阻塞);exit code 2 意味着"主动拦截"(阻塞)-- 不要搞混
  • SessionStart 上下文注入消除了每次会话的"冷启动"问题
  • PostToolUse 观察器把使用模式转化为优化数据
  • 同一事件上可以叠加多个 hook -- 按顺序运行,任何一个都可以阻止
  • 每个 hook 脚本始终加 #!/bin/bash 并运行 chmod +x

下一章:Chapter 6: 自定义 Skills 与斜杠命令 -- 构建可复用的工作流,如 /deep-research、/review、/investigate。

基于 MIT 许可发布