Chapter 5: Hooks — 让 Claude 的每个操作都受你控制
学习目标
完成本章后,你将能够:
- 解释 Hook 生命周期,以及 PreToolUse、PostToolUse、SessionStart 和 Stop 事件如何映射到 Claude 的工具调用管线
- 编写 Hook 脚本来强制 commit 质量、拦截危险命令、注入会话上下文、观察工具使用情况
- 正确使用 exit code:0(放行)、1(错误 -- 操作仍然继续)、2(主动拦截)
- 在
settings.json中配置 Hook,包括 matcher、handler 类型和事件绑定 - 调试常见的 Hook 故障:缺少 shebang、错误的 exit code、脚本没有执行权限
你将构建什么
四个解决真实问题的生产级 Hook:
- Commit 质量门禁 — 强制 commit message 格式、运行 linter、在代码入库前捕获泄露的密钥
- 危险命令拦截器 — 阻止
--no-verify、DROP TABLE、强制推送到 main 分支 - 会话上下文注入器 — 每次启动时向 Claude 注入 git 分支、近期 PR、CI 状态信息
- 持续学习观察器 — 捕获工具使用模式,帮你优化工作流
这些不是玩具示例,而是从 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 信息 |
| Stop | Claude 完成时 | 不能 | 通知提示、使用统计、会话摘要 |
| Notification | Claude 发通知时 | 不能 | 转发到 Slack、手机推送 |
处理器类型
| 类型 | 作用 | 使用场景 |
|---|---|---|
| command | 运行 shell 脚本 | 90% 的场景都用这个 |
| http | POST 到 webhook | Slack 通知、遥测端点 |
| prompt | Claude 评估条件 | "这个修改会不会破坏公开 API?" |
| agent | 启动子 Agent | 提交前进行深度代码审查 |
Exit Code 协议
| Exit Code | 含义 | 对工具执行的影响 |
|---|---|---|
| 0 | 放行 — hook 通过 | 操作正常继续 |
| 1 | hook 脚本出错 | 操作 仍然继续(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)配置。
{
"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 质量门禁
灵感来自 ECC 的
pre-bash-commit-quality.js-- 在坏提交进入仓库前拦截它。
问题
你的团队使用 Conventional Commits。但 Claude 有时会生成 "update stuff" 这样的 commit message,或者用 --no-verify 跳过 pre-commit hook。更糟的是,暂存文件中偶尔包含硬编码的 API 密钥。
构建
1. 项目准备
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 命令检查三件事:
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.sh3. 配置
cat > .claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/commit-quality-gate.sh"
}
]
}
]
}
}
EOF4. 测试
claude测试格式检查:
Commit the current changes with message "update stuff"当 hook 拦截不合格的 commit message 时,你会在终端看到这样的输出:
$ 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"> 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 成功。
刚才发生了什么?
关键洞察: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: 危险命令拦截器
改编自 ECC 的安全 hook -- 在破坏性命令执行前拦截它。
问题
Claude 通常比较谨慎,但它可能被提示执行破坏性命令。强制推送到 main、删除数据库、rm -rf 重要路径 -- 这些都可能因为一个坏的提示而发生。
构建
1. 创建 Hook
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.sh2. 更新配置
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"
}
]
}
]
}
}
EOF3. 测试
claude尝试以下命令 -- 都应该被拦截:
Run rm -rf /tmp/important-data> 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> 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;> 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/刚才发生了什么?
注意 hook 按顺序运行多个正则检查。命令只需要匹配一个模式就会被拦截。安全的命令通过所有检查后以 exit 0 退出。
核心理念
gstack 的理念在这里同样适用:精确拦截真正导致事故的命令模式。不要试图捕获所有可能的破坏性命令 -- 聚焦于实际引发事故的模式。上面的列表覆盖了 95% 的真实生产事故。
Demo 13: 会话上下文注入器
基于 ECC 的会话初始化模式和 gstack 的 "re-ground"(重新接地)原则。
问题
每次在项目中启动 Claude,它对你刚才在做什么一无所知。不知道当前分支、最近的 PR、CI 状态、上次会话后的变更。你得花前几条消息来给 Claude 补课。
构建
1. 创建注入器
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.sh2. 添加到配置
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"
}
]
}
]
}
}
EOF3. 测试
# 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会话启动时注入上下文的效果如下:
$ 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 条消息在"我在哪个分支上?"
刚才发生了什么?
SessionStart hook 在 Claude 开口之前 运行。输出成为 Claude 初始上下文的一部分,所以它从对话一开始就掌握了信息。gstack 称此为 "re-grounding"(重新接地)-- 始终从现实出发,而不是基于假设。
Demo 14: 持续学习观察器
灵感来自 ECC 的
observe.sh-- 一个 PostToolUse hook,观察 Claude 的行为并从中学习。
问题
你用了 Claude 一个月。它最常用哪些工具?多久读一次文件 vs 写一次?哪些命令反复执行?没有数据,你无法优化工作流或写出更好的 CLAUDE.md 指令。
构建
1. 创建观察器
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.sh2. 创建分析脚本
这是你在一天结束时运行来查看使用模式的脚本:
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.sh3. 添加到配置
将 PostToolUse 添加到你的 settings 中:
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"
}
]
}
]
}
}
EOF4. 使用
正常使用 Claude 一段时间,然后查看数据:
bash .claude/hooks/analyze-usage.sh示例输出:
$ 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刚才发生了什么?
这是 gstack 遥测模式的个人版本。使用一周后,你就能确切知道 Claude 最常读取哪些文件(把它们放进 CLAUDE.md)、哪些命令反复执行(做成 skill)、以及时间花在哪里。
常见问题排查
Hook 就是 shell 脚本,而 shell 脚本的故障是有规律可循的。以下是四种最常见的故障及其修复方法。
问题 1: 缺少 Shebang 行
你写了一个 hook 脚本但忘了在顶部加 #!/bin/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 开头:
#!/bin/bash
# 正确: 明确指定 bash 解释器
input=$(cat)问题 2: Exit Code 混淆(1 vs 2)
这是最常见的 hook bug。你想拦截操作,但用了 exit 1 而不是 exit 2:
# 错误: 用 exit 1 来"拦截"
if echo "$command" | grep -q "dangerous"; then
echo "This is dangerous!"
exit 1 # 错误: 这意味着"hook 出错" -- 操作仍然继续
fi会发生什么:Claude 看到 exit code 1,将其解释为"hook 脚本出错",然后 照常执行操作。你的"安全门禁"在静默地做无用功。
修复:拦截操作始终使用 exit 2:
# 正确: 用 exit 2 拦截
if echo "$command" | grep -q "dangerous"; then
echo "BLOCKED: This is dangerous!"
exit 2 # 正确: 这意味着"主动拦截" -- 操作被阻止
fiExit 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:
> 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...修复:让模式更精确。为已知安全的操作添加白名单:
# 更好: 允许特定的安全 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:
$ 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:
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 中加一条检查:
# Hook Maintenance
After creating or modifying any hook script in .claude/hooks/,
always run: chmod +x .claude/hooks/*.sh参考:完整 Hook 事件数据
| 事件 | stdin 字段 | 可阻止 | 最佳用途 |
|---|---|---|---|
| PreToolUse | tool_name, tool_input | 是 (exit 2) | 安全门禁、格式强制 |
| PostToolUse | tool_name, tool_input, tool_output | 否 | 日志、格式化、lint |
| SessionStart | session_id, model | 否 | 上下文注入、环境检查 |
| Stop | (无) | 否 | 通知、清理、使用报告 |
| Notification | message | 否 | 转发到 Slack/手机 |
Hook 中可用的环境变量
| 变量 | 值 |
|---|---|
CLAUDE_PROJECT_DIR | 项目根路径 |
CLAUDE_SESSION_ID | 当前会话标识 |
CLAUDE_MODEL | 使用的模型 |
练习:构建你的 Hook 套件
把本章四个 hook 部署到你正在做的真实项目上,然后扩展它们:
- 扩展 commit 门禁:添加检查 -- 阻止生产代码中包含
console.log(测试文件除外) - 扩展危险拦截器:添加你技术栈特有的命令模式(如数据库项目的
DROP INDEX、k8s 项目的kubectl delete namespace) - 扩展上下文注入器:添加部署状态(如 staging 和 production 是否在同一个 commit 上)
- 扩展观察器:添加耗时数据 -- 每次工具调用花了多长时间?哪些工具最慢?
成功标准
- [ ] 四个 hook 都配置在
.claude/settings.json中 - [ ] Commit 质量门禁拦截坏消息和
--no-verify - [ ] 危险命令拦截器捕获强制推送和破坏性 SQL
- [ ] 每次会话启动时有完整的项目上下文(分支、变更、CI、TODO)
- [ ] 观察器日志在生成,
analyze-usage.sh能产出报告
知识检测
用以下问题检验你对 Hook 的理解。
深入探索: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。