第 7 章 / 共 10 章
产物三·钩子:唯一不靠模型判断的那一档
7.1 建议和强制的区别
前三章做的技能和子代理,有一个共同点:用不用,最后都是模型说了算。description 写得再好,也只是提高模型想起它的概率。这对大多数事情够用了。
但有些事不能是概率。“每次编辑后格式化""提交前必须跑 lint""绝不许写 .env”——这些是纪律,漏一次就出事。对纪律,你需要的不是”建议模型这么做”,而是”不管模型怎么想,这件事都会发生”。
钩子就是那唯一确定性的一档。它挂在生命周期的特定事件上,由事件触发,不经过模型判断。
7.2 写第一个 PostToolUse:编辑后自动格式化
我们要的第一条纪律是:只要 Claude 改了文件,就自动跑一遍格式化。对应的事件叫 PostToolUse——工具成功执行之后触发。
钩子配置写在 settings 文件里。项目级的放 .claude/settings.json(可提交,全队共享):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -r npx prettier --write"
}
]
}
]
}
}
拆开看:PostToolUse 是事件名;matcher 用 Edit|Write 只在这两种工具后触发;hooks 数组里 type: command 表示跑一条 shell 命令。命令本身从标准输入拿到本次事件的 JSON,用 jq 取出被改文件的路径,交给 prettier。
7.3 钩子从 stdin 拿到什么
钩子脚本不是凭空运行的——它从标准输入收到一段描述本次事件的 JSON。核心字段有:
{
"session_id": "abc123",
"hook_event_name": "PostToolUse",
"cwd": "/home/you/project",
"tool_name": "Edit",
"tool_input": { "file_path": "src/app.ts", "old_string": "...", "new_string": "..." }
}
你要的文件路径在 tool_input.file_path 里,所以上面用 jq -r '.tool_input.file_path' 把它取出来。
这里要纠正一个常见的旧习惯:从 stdin 的 JSON 取字段,别依赖某个环境变量去拿文件路径。stdin 的 JSON 是稳定的取值方式,字段结构清楚,比记一堆环境变量名靠谱。

7.4 退出码:0 放行,2 拦截
钩子脚本用退出码跟 Claude Code 通信,约定很简单:
- 退出 0:成功。一切照常继续。
- 退出 2:拦截。在可拦截的事件上,这次操作被挡下,脚本打到标准错误的内容会成为拦截理由反馈给模型。
- 其他非零:非阻断错误,操作照常,但会显示一条错误提示。
PostToolUse 发生在工具已经执行之后,所以它拦不住”已经发生的编辑”,退出 2 在这里不阻断操作。真正用退出 2 来拦下动作的,是 PreToolUse——在工具执行之前触发的那个事件,正是下一章守门的主角。