生命不息,折腾不止。上一课给 Claude Code 装好了「手脚」和「习惯」,这一课再给它立「规矩」——用 Hooks 在它每次动手前后自动挂上检查,改代码先跑测试、提交前先过 lint,把「人盯着才放心」变成「代码自己把关」。

上篇结尾我把话说在前头了:第一课聊 MCP 和自定义命令,第二课聊 Hooks。今天就来兑现。这玩意儿是 Claude Code 里最容易被忽略、又最能让它「上生产」的能力——不靠模型记性,靠写死的代码,每次触发、无一例外。

一、为啥要 Hooks:那 5% 的翻车,靠「记得提醒」防不住

Claude Code 平时是真能干,问题是它偶尔会自信地犯错:把改动推到 main、跳过格式化、提交没过 lint 的代码、顺手 rm -rf 掉不该删的目录。这类事故概率不高,可一旦发生就伤筋动骨。

有人觉得「我在 system prompt 里写一句『提交前先跑测试』不就完了吗」。现实是这一句不靠谱

  • 模型会忘,prompt 越写越长,它抓不住重点;
  • 模型会糊弄,觉得「这次改动很小不用测」;
  • 换个人、换个模型,这句提示词就跟着换,约束力全看运气。

Hooks 解决的就是这个「运气象」。 它是确定性门控:把规则写成 shell 脚本,挂在 Claude Code 的生命周期节点上,每次到点必触发,跟模型当时怎么想一毛钱关系都没有。一句话——你现在手动盯着它干的那些「检查」,全可以让脚本替你干。

二、Hooks 是什么:挂在生命周期节点上的「自动开关」

Hooks 说白了就是一段 shell 命令,Claude Code 在特定节点会自动执行它。比如「工具调用之前」「文件被编辑之后」「这一轮回答结束」。你写的脚本从 stdin 读一段 JSON(Claude 把上下文塞在里面),干完活之后用退出码说话。

最关键的两个事件先记住:

  • PreToolUse:某个工具执行前触发,能拦住它。这是安全门禁的核心。
  • PostToolUse:某个工具执行成功后触发,做「事后检查」,比如格式化、跑测试。

此外官方还有一长串事件,常用的有这么几个:

事件 什么时候触发 能干吗
SessionStart / SessionEnd 会话开始/结束 初始化、清理、压缩后重注入上下文
UserPromptSubmit 你提交提示词的瞬间 校验或给提示词补上下文
PostToolUse 工具执行成功后 格式化、跑测试、记日志
Stop Claude 这一轮回答结束 收尾检查、提醒你 review
Notification Claude 发通知时 转发到 Slack、桌面弹窗
SubagentStop 子代理跑完时 监控子代理、抓摘要
PreCompact / PostCompact 上下文压缩前后 压缩前存草稿、压缩后补关键记忆

配置就一个文件:.claude/settings.json(项目级,放仓库根目录,可 git 提交给全团队共用);个人全局的放 ~/.claude/settings.json。结构是三层嵌套:事件 → 匹配器(matcher)→ 具体 hook。骨架长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
}
]
}
]
}
}

几点说清楚:

  • matcher 是个正则,匹配的是工具名(tool_name)。Bash 匹配所有命令行、Edit|Write 匹配改文件、Read / Glob / Grep 匹配读和搜、mcp__github__.* 匹配某个 MCP 工具。不写 matcher(或写成空)就每次都触发。
  • 数据怎么拿:脚本从 stdin 读 JSON,里面有 tool_nametool_inputBash 的命令在 .tool_input.command,文件类工具的路径在 .tool_input.file_path
  • 结果怎么回:退出码说话——0 放行、2 拦截(stderr 会回传给 Claude 当理由)、1 只记条警告不拦。
  • 配置里可以用 ${CLAUDE_PROJECT_DIR} 这个环境变量指到项目根目录,团队共享时别写死本机路径。

三、动手写第一个 Hook:危险命令「一票否决」

第一个 hook 做最有价值的事——拦住 rm -rfgit push -fgit reset --hard 这类危险命令。在项目里建个脚本 .claude/hooks/block-rm.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env bash
set -euo pipefail

# Claude Code 会把上下文以 JSON 塞进 stdin,先整个读出来
payload=$(cat)
command=$(echo "$payload" | jq -r '.tool_input.command // ""')

# 命中危险模式就拦下
if echo "$command" | grep -Eq 'rm[[:space:]]+-[a-z]*r[a-z]*f|git[[:space:]]+(push[[:space:]]+-f|reset[[:space:]]+--hard)'; then
echo "被钩子拦下:$command 看着像危险操作,请人工确认后再跑。" >&2
exit 2
fi

exit 0

这一步很关键:脚本必须给执行权限,否则 Claude Code 不会跑它:

1
chmod +x .claude/hooks/block-rm.sh

再在 .claude/settings.json 里挂上 PreToolUse + Bash 匹配器(就是上面那个骨架)。现在 Claude 每次想跑 bash 命令,这个脚本都会先过一遍。

这里有个新手最容易踩的坑:退出码。exit 2 才是「拦截」,exit 1 只算「非阻塞错误」——命令照样会执行,你的安全门禁形同虚设。所以安全类 hook 必须 exit 2,别图省事写 1。手动测一下你就明白了:

1
2
echo '{"tool_input":{"command":"rm -rf /opt/old"}}' | bash .claude/hooks/block-rm.sh
# 应该输出一句 "被钩子拦下……",然后 echo $? 返回 2

四、进阶:提交前自动跑 lint,过不了就别想 commit

光拦危险命令还不够,更常见的翻车是「提交了没过 lint 的代码」。用同样思路,在 git commit 之前插一道 PreToolUse 门禁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env bash
# .claude/hooks/commit-guard.sh
set -euo pipefail

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

# 只在 git commit 时激活
if echo "$command" | grep -Eq '^git[[:space:]]+commit'; then
if ! out=$(npm run lint 2>&1); then
echo "lint 没过,先把下面的问题修掉再提交:" >&2
echo "$out" >&2
exit 2
fi
fi

exit 0

chmod +x 后,在 settings.jsonPreToolUse 里再加一条指向它。以后 Claude 想 git commit,会先跑一遍 npm run lint;不过关就 exit 2,stderr 里的报错会直接甩回给 Claude,逼它先修再提。

PreToolUse 管「动手前」,它兄弟 PostToolUse 管「动手后」。比如每次 Claude 改完文件,自动给你格式化回来,用的是 Edit|Write 匹配器 + jq 提取文件路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}

跑测试也一个道理:改到 Python 文件就自动 pytest 对应测试,改到 JS 就自动跑对应的单测。把这些组合起来,你就有了一个「改完自动格式化、提交自动过 lint、危险命令自动拦」的 Claude Code。

五、装完怎么验,以及四条保命提醒

配好之后先验证,别急着上生产:

  1. 在 Claude Code 会话里敲 /hooks,会打开一个 hooks 浏览器,能看到每个事件挂了几个 hook、状态对不对。
  2. 独立测脚本:手动把示例 JSON 喂进去(就是第三小节那种 echo ... | bash xxx.sh)。脚本在外面就报错,进去必然也报错。
  3. 调试信息写 stderr:hook 写进 stderr 的内容会出现在 Claude 的上下文里,开发阶段 echo "DEBUG: matched $cmd" >&2 能帮你定位,稳定了再删。

最后四条提醒,都是血泪教训:

  • hook 要快。它是同步执行的,一个 5 秒的 hook 就会让每次匹配的工具调用都多等 5 秒。尽量控制在 2 秒内,理想是半秒内搞定;重的活(全量测试)留给 CI。
  • jq 路径写错会静默返回 null,条件就永远不匹配,hook 看似在跑其实没生效。所以先手动喂真实 JSON 测 jq 表达式。
  • 别把密钥写进可共享的 .claude/settings.json。团队共享时用绝对路径 + 环境变量,token 走 ${VAR:-默认值} 占位,别把真实 key 提交进仓库。
  • Hooks 不是 CI、也不是 code review 的替代品。它是本地第一道防线,PR 上的完整流水线该跑还得跑。

到这,Claude Code 第二课就完了。回头看,你的 Claude Code 已经有了「手脚」(MCP)、「习惯」(自定义命令)、「规矩」(Hooks)三层,从「会聊天的助手」正式进化成了「能自动把关的工程师」。

生命不息,折腾不止。下一篇《Claude Code 实战第三课:CLAUDE.md + 权限模式,把项目约定和安全边界沉淀成文件》——Hooks 管的是「动作」,第三课管的是「记性和底线」:怎么写 CLAUDE.md 让 Claude 读项目、守住约定,怎么用 permission 规则把「哪些操作自动放行、哪些必须问你」写进 settings.json,让 Claude Code 既有脑子又懂分寸。下篇见。