Hooks:把规矩写成必然发生的动作
CLAUDE.md 里的要求 Claude 偶尔会忘,Hooks 不会。学会在生命周期事件上挂脚本,用 matcher 精确触发,用退出码和 JSON 控制结果,做出自动格式化、高危命令拦截和完成通知三个实用 Hook。
学完这一课,你能
- 能说出常用的 Hook 事件各在什么时候触发,以及哪些事件能阻止动作
- 能写出一段结构正确的 hooks 配置,并用 matcher 和 if 精确控制触发范围
- 能用退出码 0 / 2 和 JSON 输出控制 Hook 的结果
- 能独立配好编辑后格式化、拦截危险命令、完成时桌面通知三个 Hook
你在 CLAUDE.md 里写了「每次改完代码都要跑 Prettier」,Claude 大多数时候会照做,但总有那么一两次忘了,提交时才发现格式全乱。你又写了「绝对不要 force push」,可它到底会不会遵守,你心里没底。
问题在于:CLAUDE.md 是建议,模型会尽量遵守,但不保证。Hooks 是规则:在 Claude Code 生命周期的固定时刻,由程序(而不是模型)去执行你写好的命令,每次必然发生。这一课就把「希望它记得」变成「一定会发生」。
Hook 由三层组成
一个 Hook 配置有三层嵌套:
- 事件(hook event):什么时刻触发,比如「工具调用之前」
PreToolUse、「Claude 回复完毕」Stop; - 匹配器(matcher):在这个事件里再筛一道,比如「只针对 Bash 工具」;
- 处理器(hook handler):真正执行的东西。最常用的是
command(跑一条 shell 命令),另外还有http(把事件 POST 到一个地址)、mcp_tool(调用某个 MCP 工具)、prompt(让模型做一次判断)和仍属实验性质的agent。
事件触发时,Claude Code 会把这次事件的上下文以 JSON 形式通过 stdin 交给你的命令,比如 PreToolUse 会收到 tool_name、tool_input、cwd、session_id 等字段。你的脚本读它、做判断、用退出码或输出告诉 Claude Code 结果。
生命周期事件一览
官方当前列出了三十多个事件,下面按用途分组。能阻止动作的事件意味着你的 Hook 可以说「不行」:
| 分组 | 事件 | 什么时候触发 | 能阻止吗 |
|---|---|---|---|
| 会话 | SessionStart / SessionEnd |
会话开始或恢复 / 会话结束 | 否 |
| 会话 | Setup |
用 --init-only 等参数做一次性初始化时,多用于 CI |
否 |
| 每轮对话 | UserPromptSubmit |
你提交提示词、Claude 处理之前 | 能 |
| 每轮对话 | UserPromptExpansion |
斜杠命令展开成提示词之前 | 能 |
| 每轮对话 | Stop / StopFailure |
Claude 回复结束 / 因 API 错误结束 | Stop 能(让它继续干) |
| 工具调用 | PreToolUse |
工具执行之前 | 能 |
| 工具调用 | PermissionRequest / PermissionDenied |
需要权限决定时 / auto 模式拒绝时 | 不认退出码 2,用 JSON 决定 |
| 工具调用 | PostToolUse / PostToolUseFailure |
工具成功 / 失败之后 | 否(已经执行了) |
| 工具调用 | PostToolBatch |
一批并行工具调用全部结束后 | 能 |
| 子智能体与任务 | SubagentStart / SubagentStop、TaskCreated / TaskCompleted、TeammateIdle |
子智能体、任务、团队成员状态变化时 | 部分能 |
| 上下文 | PreCompact / PostCompact、InstructionsLoaded |
压缩上下文前后、加载 CLAUDE.md 时 | PreCompact 能 |
| 环境变化 | CwdChanged、FileChanged、DirectoryAdded、ConfigChange |
目录、被监视的文件、配置变化时 | ConfigChange 能 |
| 其他 | Notification、WorktreeCreate / WorktreeRemove、PreModelSwitch / PostModelSwitch、Elicitation / ElicitationResult、MessageDisplay |
通知、worktree、切换模型、MCP 请求输入、显示消息时 | 视事件而定 |
日常最常用的是 PreToolUse(拦截)、PostToolUse(善后)、Stop 和 Notification(提醒),先把这四个用熟。事件清单还在持续增加,用到冷门事件时以官方参考为准。
配置写在哪、怎么写
Hooks 写在 settings 文件的 hooks 字段里。放在哪个文件,决定了它的作用范围:
| 文件 | 作用范围 | 能否提交共享 |
|---|---|---|
~/.claude/settings.json |
你的所有项目 | 否,只在本机 |
.claude/settings.json |
当前项目 | 能,团队共用 |
.claude/settings.local.json |
当前项目 | 否,个人覆盖用 |
此外,插件、Skill 和子智能体的 frontmatter 里也能定义 Hooks,组织管理员还能下发托管策略。多个层级的 Hooks 是叠加的,不会互相覆盖。
matcher 的规则:只含字母、数字、下划线、| 这类字符时按精确名称匹配,所以 Bash 只匹配 Bash 工具,Edit|Write 匹配两者之一;含有其他字符时按正则匹配,比如 mcp__github__.* 匹配 GitHub 服务器的所有工具。matcher 区分大小写,写成 bash 不会触发。"*"、空字符串或不写,都表示全部匹配。
想更精细,可以在处理器上加 if 字段,它用和权限规则一样的语法同时匹配工具名和参数,比如 "Bash(git push *)" 只在执行 git push 时才运行脚本,省得每条命令都启动一次。
配完后在会话里输入 /hooks,可以看到一份只读清单,标明每个 Hook 来自哪个文件。
退出码与输出控制
你的脚本怎么「回话」,决定了 Hook 的效果:
| 脚本的结局 | 含义 | 效果 |
|---|---|---|
| 退出码 0 | 成功 | 动作照常进行;如果 stdout 打印了合法 JSON,按 JSON 字段处理 |
| 退出码 2 | 阻塞错误 | 在能阻止的事件上拦下动作,stderr 的内容作为理由反馈给 Claude |
| 其他退出码(包括 1) | 非阻塞错误 | 动作照常进行,记录里出现一条 hook error 提示 |
最大的坑就在第三行:Unix 习惯用 exit 1 表示失败,但在 Hooks 里 exit 1 拦不住任何东西。要拦截,必须 exit 2。
需要更细的控制时,退出 0 并在 stdout 打印一个 JSON 对象。常用字段:
continue: false加stopReason:让 Claude 整个停下来;systemMessage:给你(用户)看的提示;- 顶层
decision: "block"加reason:用于Stop、PostToolUse等事件,比如让 Stop 不许结束、继续去跑测试; hookSpecificOutput:事件专属字段,必须带hookEventName。PreToolUse用其中的permissionDecision(allow/deny/ask/defer)决定放行、拒绝还是交给你确认,用additionalContext给 Claude 补充信息。
JSON 前面混进任何别的文字(比如你的 .zshrc 里无条件 echo 了一句欢迎语),整段就会被当成普通文本而失效。
三个实用 Hook
下面三个例子都假设你装了 jq(macOS 用 brew install jq),脚本放在 .claude/hooks/ 下,并且执行过 chmod +x。
1. 编辑后自动格式化(项目级)
#!/bin/bash
# .claude/hooks/format.sh:Claude 写入或编辑文件后自动格式化
FILE=$(jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
PRETTIER="$CLAUDE_PROJECT_DIR/node_modules/.bin/prettier"
case "$FILE" in
*.ts|*.tsx|*.js|*.vue|*.css|*.json)
[ -x "$PRETTIER" ] && "$PRETTIER" --write "$FILE" >/dev/null 2>&1 ;;
*.py)
command -v ruff >/dev/null && ruff format "$FILE" >/dev/null 2>&1 ;;
esac
exit 0
2. 拦截危险命令(项目级)
#!/bin/bash
# .claude/hooks/guard-bash.sh:在 Bash 命令执行前检查
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eiq 'rm -rf /|git push (-f|--force)|git reset --hard|drop (table|database)'; then
echo "已拦截高危命令:$CMD。如确有需要,请让用户在终端里手动执行。" >&2
exit 2
fi
if echo "$CMD" | grep -q '^git push'; then
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "ask", permissionDecisionReason: "推送代码前请人工确认"}}'
fi
exit 0
这个脚本演示了两种回话方式:高危命令直接 exit 2 拦死,普通 git push 则用 JSON 的 ask 交给你确认。
把两个脚本注册到 .claude/settings.json(这里用了 args: [] 的 exec 形式,直接启动脚本,不经过 shell,路径里有空格也不怕):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format.sh", "args": [], "timeout": 30 }
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh", "args": [] }
]
}
]
}
}
3. 完成时桌面通知(个人级)
通知是个人偏好,放进 ~/.claude/settings.json。Stop 在 Claude 每次回复完时触发,Notification 在它等你批准权限(permission_prompt)或闲置等你输入(idle_prompt)时触发:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"这一轮干完了,回来看看\" with title \"Claude Code\"'" }
]
}
],
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"在等你拍板\" with title \"Claude Code\"'" }
]
}
]
}
}
Linux 把 osascript ... 换成 notify-send 'Claude Code' '这一轮干完了'。macOS 上如果通知不出现,多半是「脚本编辑器」没有通知权限:先在终端运行一次 osascript -e 'display notification "test"',再到「系统设置 → 通知」里找到「脚本编辑器」打开允许。
> 帮我把本地分支强制推到远程,覆盖掉同事的提交⏺ Bash git push --force origin main PreToolUse:Bash hook error: [...guard-bash.sh]: 已拦截高危命令:git push --force origin main。 如确有需要,请让用户在终端里手动执行。⏺ 这个操作被项目的安全规则拦下了。强制推送会覆盖同事的提交, 建议先 git pull --rebase 合并远程改动,再正常推送。需要我这样做吗?# 拦截理由会反馈给 Claude,它会据此换一种做法 常见坑
- Hook 没触发:先看
/hooks里有没有它;再检查 matcher 大小写、事件选对没有(改文件前后分别是 Pre 和 Post)、脚本有没有执行权限。手动测试可以直接喂一段 JSON:echo '{"tool_input":{"command":"git push -f"}}' | .claude/hooks/guard-bash.sh; echo $?。 - Stop Hook 死循环:用
decision: "block"让 Claude 继续干活时,要读输入里的stop_hook_active,为true时直接exit 0放行。官方也设了上限,连续拦截 8 次后会强制结束这一轮。 - 把 Hook 当唯一防线:
if和字符串匹配都是尽力而为,rm -rf换个写法就可能绕过。真正的硬性禁止交给权限规则的 deny(见第 3 课),Hook 作为第二道防线。好消息是:PreToolUse 返回的deny在任何权限模式下都有效,包括 bypassPermissions。 - Hook 以你的身份运行:它能做你在终端里能做的一切。克隆陌生仓库时,先看一眼
.claude/settings.json里的 hooks 再信任这个目录。
你写了一个 PreToolUse 脚本,检测到 rm -rf 时执行 echo 「禁止」 >&2 然后 exit 1。实际效果是?
你希望 Claude 每次改完 .vue 文件后都自动跑一遍 ESLint 修复,最合适的配置是?
让 Claude 帮你写 Hook
手写 JSON 容易漏逗号。你可以把需求描述清楚,让 Claude 来写,再自己审一遍:
请帮我为这个项目配置 Claude Code Hooks,要求如下:
- 编辑或写入文件后:.ts/.vue/.css 文件用项目里 node_modules 下的 prettier 格式化,其他文件不处理;
- 执行 Bash 命令前:遇到 rm -rf、git push –force、git reset –hard 直接拦截(exit 2,并用中文写清拦截原因);普通 git push 改为需要我确认;
- 脚本放在 .claude/hooks/ 下并加执行权限,配置写进 .claude/settings.json;如果文件里已有 hooks,合并而不是覆盖;
- 写完后用 echo 一段模拟的 JSON 输入分别测试两个脚本,把退出码给我看;
- 不要修改 hooks 以外的任何配置。
动手清单
0/5参考来源
工具更新很快,若与官方文档不一致,以官方为准。
学完了吗?
标记完成会记录在本机浏览器,计入你的学习进度和连续天数。