Claude Code 钩子教程:从零构建 5 个生产级钩子
绝大多数情况下,Claude Code 都会执行正确的操作。剩下的少数边缘情况才是问题:强制推送到 main、跳过格式化工具、提交无法通过 lint 的代码。钩子在 Claude 工作流的 31 个生命周期节点(截至 2026 年 8 月)上设置确定性的关卡,从而消除这些边缘情况。1 本教程是我关于构建生产级智能体系统的 AI 工程系列文章之一。钩子每次都会触发,绝无例外,与提示词怎么写、模型如何表现都无关。
TL;DR: 钩子是由 Claude Code 生命周期事件触发的 shell 命令。1 PreToolUse 钩子负责检查并拦截操作(退出码 2 = 拦截,退出码 0 = 放行)。2 PostToolUse 钩子则在事后做校验与格式化。配置写在 .claude/settings.json 里,包含一个 matcher(精确的工具名、以 | 分隔的列表,或一个正则表达式)和一个嵌套的 hooks 数组。3 下面的教程会构建五个生产级钩子:自动格式化、安全网关、测试运行、通知提醒,以及提交前的质量检查。
要点速览
- 独立开发者: 先从自动格式化(钩子 1)和安全网关(钩子 2)入手。这两个钩子能挡住 Claude Code 最常见的失误,而且后续无需维护。
- 技术负责人: 把钩子提交到仓库的
.claude/settings.json中。团队每位成员都会自动获得同样的安全关卡和质量检查。 - 安全工程师: 真正拦截操作的是退出码 2。2 退出码 1 只会记录一条警告。每个 PreToolUse 安全钩子都必须使用
exit 2,否则形同虚设。
什么是钩子
钩子是在 Claude Code 会话期间、于特定生命周期事件处执行的 shell 命令。它们运行在 LLM 之外,是由 Claude 的操作触发的普通脚本,而不是交给模型解读的提示词。
四大类事件覆盖了最常见的使用场景(截至 2026 年 8 月,Claude Code 已记录 31 种事件类型)。1
- 会话类事件:
SessionStart在会话开始时触发,SessionEnd在会话关闭时触发,Stop则在 Claude 每次回答结束时触发(不只是会话结束时)。适合用来做初始化、收尾和通知。 - 工具类事件:
PreToolUse和PostToolUse分别在 Claude 使用工具前后触发(写文件、执行 bash 命令、检索代码等)。它们能检查并拦截具体操作,因而是最强大的钩子。 - 通知类事件:
Notification在 Claude 生成通知时触发。适合把告警转发到 Slack、桌面通知或日志系统。 - 子代理类事件:
SubagentStop在通过 Agent 工具派生的子代理完成任务时触发。4 钩子对子代理的操作同样生效,因此安全关卡会递归适用。
退出码的语义很关键。2 退出码 0 表示成功(继续执行),2 表示拦截该操作,1 表示钩子出错但不拦截,操作照常进行。任何对安全至关重要的钩子都必须使用 exit 2,关卡才真正起作用。
心智模型:三类保证
动手写钩子之前,先问自己:我需要的是哪一类保证?
格式保证用于事后维持一致性。挂在 Write/Edit 上的 PostToolUse 钩子会在每次文件变更后运行格式化工具。模型输出成什么样并不重要,因为格式化工具会把一切统一。这类钩子是幂等的,每次编辑都跑也很安全。
安全保证用于在危险操作执行前将其拦下。挂在 Bash 上的 PreToolUse 钩子会检查命令,并用退出码 2 拦截破坏性的模式。这类钩子必须够快(500 毫秒以内),因为每一次匹配到的工具调用都要经过它;而且必须用 exit 2 而不是 exit 1,因为 exit 1 只警告、不拦截。
质量保证用于在决策点校验状态。挂在 git commit 命令上的 PreToolUse 钩子会运行 linter 或测试套件,一旦质量检查失败就拦下这次提交。与每次编辑都触发的格式类钩子不同,质量类钩子只在特定时刻触发,开销因此很低。
概念上的祖先是 Git 钩子8: pre-commit、pre-push 和 post-commit 承担的正是同样的三种角色。Claude Code 的钩子把这一模式从 Git 操作扩展到了智能体执行的每一个工具动作。我在每个钩子都是一道伤疤中剖析了这一演变:每个钩子的存在,都是因为没有它的时候出过事。
钩子配置基础
钩子写在配置文件里。
JSON 结构如下:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/your/script.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "/path/to/another-script.sh"
}
]
}
]
}
}
每一项都包含一个用于筛选工具名的 matcher(例如 Bash、Write、Edit、Read、Glob、Grep 或 Agent),以及一个由钩子定义组成的 hooks 数组。按照官方参考,matcher 的语义是这样的:"*"、"" 或省略 matcher 表示匹配所有;由字母、数字、_、-、|、逗号和空格构成的值会被当作精确的工具名或名称列表,因此 Write|Edit 会同时匹配两个工具(对 mcp__github__search_code 这类 MCP 工具名来说,下划线很重要);除此之外的值一律按无锚定的正则表达式处理。匹配区分大小写,bash 永远匹配不到 Bash。每个钩子需指定 type(shell 命令填 "command")和要执行的 command。
在会话中,您可以用只读的 /hooks 浏览界面查看已注册的钩子;若要新增、修改或删除钩子,请直接编辑配置 JSON。5
钩子触发时,Claude Code 会以 JSON 对象的形式通过 stdin 传入上下文:工具名、工具输入(文件操作会包含 file_path),以及会话元数据。6 您的脚本从 stdin 读取这些内容做判断,通常借助 jq。此外还会设置几个提供上下文的环境变量,例如用于解析路径的 $CLAUDE_PROJECT_DIR 和表示当前 effort 级别的 $CLAUDE_EFFORT;但像文件路径这类与具体工具相关的字段只会出现在 stdin 中,并不存在按工具设置的 $FILE_PATH 变量。
5 个实用钩子
下面每个钩子都解决了我把 Claude Code 当作主力开发工具时真实遇到的问题。所有示例都采用钩子参考文档中正确的嵌套结构7。
1. 编辑文件后自动格式化
Claude 写出的代码功能上没问题,却偶尔会破坏项目的格式规范。我最初尝试在 CLAUDE.md 里加一句”编辑 Python 文件后总是运行 black”,但这条指令大约只有 80% 的时候奏效。当模型专注于跨多个文件的复杂改动时,格式化这一步有时就被跳过了。PostToolUse 钩子彻底消除了这种不稳定:无论模型选择怎么做,每次写入文件后格式化工具都会运行。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'FILE=$(jq -r \".tool_input.file_path // empty\"); if [[ \"$FILE\" == *.py ]]; then black --quiet \"$FILE\" 2>/dev/null; elif [[ \"$FILE\" == *.js || \"$FILE\" == *.ts ]]; then npx prettier --write \"$FILE\" 2>/dev/null; fi'"
}
]
}
]
}
}
这个钩子从 stdin 读取工具的 JSON 输入,用 jq 取出 .tool_input.file_path——由于 Claude Code 不会设置按工具区分的环境变量,那个 stdin 对象是文件路径唯一的来源。随后它检查扩展名并运行相应的格式化工具:Python 文件交给 black,JavaScript 和 TypeScript 文件交给 prettier。2>/dev/null 会压掉嘈杂的输出,让您只看到真正的错误。
在较大的项目里,把这条内联命令挪到独立脚本中会更易读。
2. 拦截危险命令的安全网关
挂在 Bash 工具上的 PreToolUse 钩子会检查 Claude 即将执行的命令,一旦命中危险模式就将其拦截。这个钩子的第一版,是在某次重构会话中 Claude 强制推送到 main 之后写的。(关于智能体自主性的更广泛影响,我在利爪解剖和作为基础设施的 Claude Code中做过探讨。)当时模型收到的要求是”把改动推上去”,而由于分支已经分叉,它把这句话理解成了 git push --force origin main。修复只花了几秒钟,但这次事故促成了一道永久的关卡。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); if echo \"$CMD\" | grep -qE \"rm\\s+-rf\\s+/|git\\s+push\\s+(-f|--force)\\s+(origin\\s+)?main|git\\s+reset\\s+--hard|DROP\\s+TABLE|:\\(\\)\\s*\\{\\s*:\"; then echo \"BLOCKED: Dangerous command detected: $CMD\" >&2; exit 2; fi'"
}
]
}
]
}
}
当这个钩子以退出码 2 结束时,Claude Code 会取消待执行的命令。错误信息会同时输出到您的终端和 Claude 的上下文中,模型因此明白操作为何失败,并会提出更安全的替代方案。
被拦截的模式:
- rm -rf /(从根目录开始的递归删除)
- git push --force main 和 git push -f main(向 main 分支强制推送)
- git reset --hard(销毁未提交的工作)
- DROP TABLE(误删数据库)
- Fork 炸弹(该模式匹配 :(){ 这一开头,带空格和不带空格的写法都能抓到)
请按自己的环境调整这份清单。涉及生产数据库就要加上破坏性 SQL 的模式;用 CLI 部署则需要针对部署命令的防护。
3. 变更之后运行测试
当 Claude 编辑了某个 Python 文件时,自动运行相关测试。立即执行测试,能在问题随后经过三四次文件编辑不断累积之前,把回归揪出来。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'FILE=$(jq -r \".tool_input.file_path // empty\"); if [[ \"$FILE\" == *.py && \"$FILE\" != *test_* ]]; then TEST_FILE=\"tests/test_$(basename \"$FILE\")\"; if [[ -f \"$TEST_FILE\" ]]; then if ! OUT=$(python -m pytest \"$TEST_FILE\" -x --tb=short 2>&1); then echo \"TESTS FAILED after editing $FILE:\" >&2; echo \"$OUT\" | tail -20 >&2; exit 2; fi; fi; fi'"
}
]
}
]
}
}
这个钩子从 stdin 的 JSON 中取出被编辑文件的路径,判断它是否为 Python 源码文件(而非测试文件本身),再按 test_ 前缀的命名约定寻找对应的测试文件,找到就运行。-x 让测试在第一次失败时停下,tail -20 则让输出保持简短。真正让这个钩子有用的,是失败时 exit 2 的那一段:PostToolUse 钩子无法撤销已经发生的编辑,但 exit 2 会把失败的测试输出经由 stderr 交给 Claude,Claude 便会在继续下一步之前修好这处破损。如果只是在 exit 0 的情况下打印失败信息,那些内容就进了调试日志——没人会去看。
注意: 上面的钩子假设测试放在扁平的 tests/ 目录中,并采用 test_ 前缀命名。如果项目的测试目录与源码树一一对应(例如 tests/api/test_users.py 对应 src/api/users.py),请把 TEST_FILE 那一行替换为:
TEST_FILE="tests/$(echo "$FILE" | sed 's|.*/src/||; s|\([^/]*\)\.py$|test_\1.py|')"
在 Claude 需要改动多个文件的重构会话中,测试运行钩子尤其有价值。缺少即时反馈,错误就会层层叠加:Claude 编辑文件 A,弄坏了文件 B 的测试,接着又基于 B 的错误状态去编辑文件 C。等您发现失败时,需要修的已经不是一个文件,而是三个。每次编辑后都跑测试,第一处破损当场就能被发现。
4. Claude 完成回答时发出通知
Claude Code 的一轮对话可能耗时数分钟。与其盯着终端等待,不如让它在回答结束时通知您。(Stop 在每次回答结束时触发;如果需要在会话真正关闭时执行钩子,请改用 SessionEnd。)
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude finished responding\" with title \"Claude Code\"'"
}
]
}
]
}
}
上面的 macOS 版本用 osascript 触发原生通知。在 Linux 上,把 osascript 那一行换成 notify-send "Claude Code" "Finished responding" 即可。要发到 Slack,可以用 webhook:
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
-d '{"text": "Claude Code finished responding"}'
对于用 & <task>(Claude Code 的后台模式)发起的后台任务,我用 Slack 版本;交互式会话则交给桌面通知。
5. 提交前的质量检查
在 Claude 执行 git commit 之前,先校验代码能否通过 lint。提交前的 lint 关卡能抓到仅靠格式化发现不了的问题:未使用的 import、未定义的变量、类型错误。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command\"); if echo \"$CMD\" | grep -qE \"^git\\s+commit\"; then if ! LINT_OUTPUT=$(ruff check . --select E,F,W 2>&1); then echo \"LINT FAILED -- fix before committing:\" >&2; echo \"$LINT_OUTPUT\" >&2; exit 2; fi; fi'"
}
]
}
]
}
}
只有当 Bash 命令以 git commit 开头时,这道质量关卡才会启动。它会以 error、pyflakes 和 warning 规则运行 ruff(一款快速的 Python linter)。只要存在问题,钩子就拦下这次提交(exit 2),Claude 随即看到 lint 输出,通常会据此修复问题并重试。
您还可以叠加多重质量检查:用 mypy 做类型检查,用 bandit 做安全扫描,或者跑项目自定义的校验脚本。挂在 Bash 命令上的 PreToolUse 钩子,让您在任何 shell 操作之前都拥有一道可编程的关卡。
.claude/settings.json 中的 PreToolUse 与 PostToolUse:参考
如果您是搜索 PreToolUse/PostToolUse 的配置写法来到这里,这一节就是精简版。两个事件都嵌套在 .claude/settings.json(项目级)或 ~/.claude/settings.json(用户级)的 hooks 键之下;各作用域会合并生效,完全相同的处理器则去重。把两个事件写在同一个配置块里:3
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/guard.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
}
]
}
}
三句话讲清这份约定:两个事件都通过 stdin 传入工具 JSON(Bash 取 .tool_input.command,Write/Edit 取 .tool_input.file_path——不存在按工具设置的环境变量)。6 PreToolUse 在工具调用之前触发,可以用 exit 2 将其拦下。PostToolUse 在工具成功之后触发——它无法撤销该操作,但 exit 2 会把它的 stderr 回传给 Claude,Claude 便会修正钩子指出的问题。2
想了解这两个事件的完整文档——JSON 输出字段、permissionDecision、updatedInput、超时设置——官方参考在 code.claude.com/docs/en/hooks;我的 Claude Code 指南中的钩子章节覆盖同样的范围,并附有实战验证过的模式。
猜错了事件名?对照表在这里
人们常搜索的钩子事件名,与 Claude Code 实际触发的事件对照如下:1
| 如果您猜的是…… | 真正的事件 |
|---|---|
onStart / onSessionStart |
SessionStart |
onFinish / onEnd / onStop |
Stop(Claude 每次回答结束时触发)或 SessionEnd(会话关闭时) |
onToolUse / beforeToolUse |
PreToolUse |
afterToolUse |
PostToolUse |
onPrompt / onUserMessage |
UserPromptSubmit |
onError |
PostToolUseFailure(工具错误)或 StopFailure(API 错误) |
事件总共有 31 个,指南中的事件对照表把它们逐一列了出来。
把 PreToolUse、PostToolUse 和 Stop 钩子写进同一份配置
把最常被搜索的三个事件接在一起——一个命令防护、一个格式化工具、一个完成通知:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": ".claude/hooks/guard-bash.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "bash -c 'FILE=$(jq -r \".tool_input.file_path // empty\"); [[ \"$FILE\" == *.py ]] && black --quiet \"$FILE\" || true'" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": "osascript -e 'display notification \"Claude finished responding\" with title \"Claude Code\"'" }]
}
]
}
}
guard-bash.sh 就是把钩子 2 的安全网关搬进了独立脚本:把钩子 2 那条 bash -c 单行命令的主体(外层单引号之间的全部内容)存为 .claude/hooks/guard-bash.sh,加上 #!/bin/bash 的 shebang,再 chmod +x;或者直接从Claude Code 钩子详解取用同名的现成脚本。每个事件都保留各自的语义:PreToolUse 防护可以否决命令(exit 2),PostToolUse 格式化工具在每次匹配到的编辑之后运行,而 Stop 钩子在每次回答结束时触发——它不需要 matcher,因为 Stop 不是工具事件,没有什么可筛选的。1
钩子调试技巧
钩子悄无声息地失败的频率,比您想象的要高。我常用的五个调试手法:
- 先单独测试脚本。 手动把示例 JSON 灌进脚本:
echo '{"tool_input":{"command":"git commit -m test"}}' | bash your-hook.sh。在 Claude Code 之外跑不通的,在里面同样跑不通。 - 搞清楚 stderr 究竟去了哪里。 只有钩子以 exit 2 结束时,stderr 才会进入 Claude 的上下文;exit 0 时它落在调试日志里,而其他非零退出码只会在会话记录中留下一条钩子出错的提示。开发期间请运行
claude --debug(会话中途则用/debug),并留意调试日志,exit 0 的钩子输出就落在那里。 - 当心 jq 出错。 如果 JSON 路径写错了,
jq9 会悄悄返回null,您的条件判断就一个都匹配不上。请用真实的工具输入来测试jq表达式。 - 核实退出码。 exit 2 拦截操作,exit 1 只是警告。一个不小心写成
exit 1的 PreToolUse 钩子看起来在工作,实则毫无约束力。默认放行(默认 exit 0),只对特定的拦截模式使用exit 2。 - 让钩子保持快速。 钩子是同步执行的。一个耗时 5 秒的钩子,会给每一次匹配到的工具调用都加上 5 秒。我把所有钩子都控制在 2 秒以内,最好是 500 毫秒以内。
最常见的钩子错误: 把安全网关写成 exit 1 而不是 exit 2。测试时它看起来是有效的,因为警告信息会打印到终端。但 exit 1 是不拦截的警告,危险命令照样执行。这个错误我在三个不同团队的钩子配置里都见过,而他们都以为自己已经挡住了强制推送。每个安全钩子都要通过触发被拦截的模式来测试,确认操作确实被阻止了,而不只是收到了一句警告。
下一步
这五个钩子覆盖了最基础的部分:格式化、安全、测试、通知和质量关卡。等您用熟了这些模式,就可以去构建上下文注入(在会话开始时补充项目专属的说明)、递归防护(避免子代理无限循环)和工作流编排(把多步流程串联起来)之类的钩子。
关于钩子架构、完整的 31 个事件生命周期以及进阶模式,请参阅我那份完整Claude Code 指南中的钩子章节,或Claude Code 钩子详解中逐个事件的讲解。
我还在Claude Code 钩子:我的 95 个钩子各自为何存在里写下了这 95 个生产钩子背后的来龙去脉,逐一交代了促成它们的那些事故。
参考资料
常见问题
钩子能阻止 Claude Code 执行某条命令吗?
能。PreToolUse 钩子以退出码 2 结束,就能拦下任何工具操作。Claude Code 会取消待执行的操作,并把钩子的 stderr 输出展示给模型。exit 1 属于不拦截的钩子错误,操作仍会继续。退出码的这一区别至关重要:每个安全钩子都必须用 exit 2,而不是 exit 1。2 Claude 看到被拒绝的原因后,会提出更安全的替代方案。
钩子配置文件放在哪里?
项目级钩子的配置放在 .claude/settings.json(提交到仓库,与团队共享),用户级钩子放在 ~/.claude/settings.json(个人使用,对每个项目生效)。两者同时存在时,钩子是合并而非覆盖:来自各个作用域的匹配钩子都会运行,完全相同的处理器会去重。建议脚本文件使用绝对路径,以免受工作目录影响。
钩子对子代理有效吗?
有效。钩子对子代理的操作同样会触发。4 如果 Claude 通过 Agent 工具派生了子代理,那么该子代理使用的每一个工具,都会执行您的 PreToolUse 和 PostToolUse 钩子。若不做递归约束,子代理就有可能绕开您的安全关卡。借助 SubagentStop 事件,您还可以在子代理完成任务时运行清理或校验。4
钩子多少个算太多?
真正的约束是性能,不是数量。每个钩子都同步执行,因此钩子的总执行时间会叠加到每一次匹配到的工具调用上。我在用户级和项目级配置中一共跑着 95 个钩子,却感觉不到延迟,因为每个钩子都在 200 毫秒内完成。我关注的临界点是:如果某个 PostToolUse 钩子给每次文件编辑增加了超过 500 毫秒,会话就会显得迟钝。上线之前,请用 time 给钩子做一次性能剖析。十个快钩子胜过两个慢钩子。
-
Anthropic,”Hooks reference — Hook events”。code.claude.com/docs/en/hooks#hook-events ↩↩↩↩↩
-
Anthropic,”Hooks reference — Exit code output”。code.claude.com/docs/en/hooks#exit-code-output ↩↩↩↩↩
-
Anthropic,”Hooks reference — Configuration”。code.claude.com/docs/en/hooks#configuration ↩↩↩↩
-
Anthropic,”Hooks reference — Hook events”(SubagentStart/SubagentStop)。code.claude.com/docs/en/hooks#hook-events ↩↩↩
-
Anthropic,”Hooks reference — Configuration”(
/hooks菜单)。code.claude.com/docs/en/hooks#configuration ↩ -
Anthropic,”Hooks reference — Hook input and output”。code.claude.com/docs/en/hooks#hook-input-and-output ↩↩
-
Anthropic,”Hooks reference — Configuration”(嵌套钩子结构)。code.claude.com/docs/en/hooks#configuration ↩
-
Git 官方文档,”Customizing Git: Git Hooks”。git-scm.com/book/en/v2/Customizing-Git-Git-Hooks ↩
-
jq 手册,”Command-line JSON processor”。jqlang.github.io/jq/manual ↩