本教程面向已经写过一两个钩子、却只停留在"拦截 + 格式化"两件套的 Claude Code 使用者。读完之后,你将把 Claude Code 的 30 余个生命周期事件当作一条事件总线(event bus)来编排,围绕一次完整的开发回合,串联"会话启动注入 → 提示词预处理 → 工具拦截 → 工具后验证 → 回合收尾 → 压缩保护 → 会话清理"七个阶段,搭出一套全自动开发守卫。适用人群:希望用 hooks 把团队规范、安全策略、上下文管理工程化,并最终落地到 CI 里的中高级开发者。
settings.json 的三份作用域:用户级 ~/.claude/settings.json、项目级 .claude/settings.json(可提交)、本地级 .claude/settings.local.json(被 gitignore)。jq 解析 JSON(macOS brew install jq,Debian/Ubuntu apt-get install jq)。很多教程一上来就教"加一个 PreToolUse",导致初学者以为钩子只是"两个开关"。真正的全貌是:Claude Code 在会话里持续抛出事件,你的脚本/HTTP 端点/MCP 工具/LLM prompt/子代理都是这些事件的订阅者。它本质上是一个进程内的事件驱动架构(EDA)。
官方把全部事件按触发节奏分成三档,记住这三档,编排时就不容易错位:
| 节奏 | 代表事件 | 适合做什么 |
|---|---|---|
| 每会话一次 | SessionStart / SessionEnd / Setup |
环境准备、上下文注入、退出清理 |
| 每回合一次 | UserPromptSubmit / Stop / StopFailure / PostToolBatch |
提示词预处理、回合收尾、批量后处理 |
| 每次工具调用 | PreToolUse / PostToolUse / PostToolUseFailure / PermissionRequest / PermissionDenied |
拦截、审计、格式化、权限自动化 |
除此之外还有一批"旁路"事件:Notification(桌面提醒)、MessageDisplay(流式输出观察)、SubagentStart/SubagentStop(子代理生命周期)、TaskCreated/TaskCompleted(任务列表)、InstructionsLoaded(CLAUDE.md/rules 载入)、ConfigChange(配置被外部改动)、CwdChanged/FileChanged(目录与文件变化)、WorktreeCreate/WorktreeRemove(工作树生命周期)、PreCompact/PostCompact(压缩前后)、Elicitation/ElicitationResult(MCP 反向提问)、TeammateIdle(团队代理空闲)、UserPromptExpansion(斜杠命令展开前)。这套事件足够你构建一个"开发守卫中枢"。
一个钩子永远由三层嵌套构成。理解了这三层,写任何钩子都是套模板:
{
"hooks": {
"PreToolUse": [ // 第1层:钩子事件
{
"matcher": "Bash", // 第2层:matcher 过滤组
"hooks": [
{
"type": "command", // 第3层:handler
"if": "Bash(rm *)", // 可选:更细的条件
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
}
]
}
]
}
}
第 1 层——钩子事件:选择在哪个生命周期点响应。
第 2 层——matcher 过滤组:决定这一组在什么时候激活。matcher 的求值规则只有三条,务必记牢:
*、"" 或省略 → 匹配该事件的全部出现;| → 当作精确字符串或 | 分隔的精确列表,例如 Bash、Edit|Write;^Notebook、mcp__memory__.*。注意一个高频踩坑点:MCP 工具名形如 mcp__<server>__<tool>。想匹配某服务器的全部工具必须写 mcp__memory__.*——少了 .* 会被当成精确字符串,匹配到 0 个工具。
每个事件的 matcher 过滤的字段不同:工具类事件过滤 tool_name;SessionStart 过滤启动方式(startup/resume/clear/compact);Notification 过滤通知类型(permission_prompt/idle_prompt/auth_success/elicitation_dialog/elicitation_complete/elicitation_response);ConfigChange 过滤配置来源;StopFailure 过滤错误类型(rate_limit/overloaded/authentication_failed/billing_error/max_output_tokens 等)。而 UserPromptSubmit、PostToolBatch、Stop、TaskCreated、TaskCompleted、WorktreeCreate/Remove、CwdChanged、MessageDisplay 这些不支持 matcher,写了会被静默忽略——别浪费时间调试。
第 3 层——handler:真正执行的东西。type 目前支持五种,覆盖几乎所有集成场景:
| type | 形态 | 适用场景 |
|---|---|---|
command |
本地 shell 命令/脚本 | 绝大多数场景,最快、最可控 |
| HTTP endpoint | 向 URL 发 POST | 接入远程服务、webhook、企业内网 API |
| MCP tool | 调用 MCP 工具 | 复用既有 MCP 能力,无需重写逻辑 |
| prompt | LLM prompt 钩子 | 让模型做判断(如多准则审查),返回结构化 JSON |
| agent | 子代理钩子 | 需要长上下文/多步骤推理的复杂判定 |
钩子不只是"做点事",它还能反过来指挥 Claude Code。掌握决策控制,是"护栏"和"智能守卫"的分水岭。
退出码(exit code)语义——对所有 command 钩子通用:
exit 0 且无 stdout → 静默通过,走正常流程(注意:静默 ≠ 批准,工具调用仍受正常权限流约束);exit 2 → 主动阻断。对不同事件含义不同:在 PreToolUse 里是阻止该工具调用,在 UserPromptSubmit 里是拒绝该提示词,在 Stop 里是阻止回合结束。Claude 会收到 stderr 上的原因,从而自我修正;JSON 输出(更精细的控制)——当退出码不够用时,在 stdout 打印一个 JSON 对象。以 PreToolUse 为例,最关键字段是 permissionDecision:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
permissionDecision 取值 deny(拒绝,带 reason 反馈给模型)/ approve(直接批准,绕过权限弹窗)/ 不返回(走正常权限流)。Stop 事件则可返回 {"decision": "block"} 强制继续对话——这正是实现"提交前必须过测试"这类约束的钥匙。
一个关键认知:钩子能拒绝,但不能默认批准。exit 0 只是"我没意见",真正放行与否仍由权限系统决定。想自动批准,必须显式返回 approve。
if 字段:省掉进程派生开销matcher 只能按工具名粗筛。当你要在"Bash 工具中只有 rm 子命令才拦截"时,用 handler 上的 if 字段做二次过滤:
{ "type": "command", "if": "Bash(rm *)", "command": ".../block-rm.sh" }
if 采用权限规则语法,能同时匹配工具名与参数:Bash(git *) 匹配任意 git 子命令,Edit(*.ts) 只对 TypeScript 文件触发。匹配失败时脚本根本不会启动,省掉一次进程派生。在高频事件(如 PostToolUse)上,这一优化能显著降低延迟。
下面用一个真实可运行的渐进式项目,把七个阶段串成一条完整工作流。每段代码都可直接放进 .claude/settings.json 或对应脚本。
用 startup matcher 在会话开始时把"项目宪法"动态注入上下文。脚本的 stdout 会原样进入 Claude 的上下文:
{
"hooks": {
"SessionStart": [{
"matcher": "startup",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-context.sh"
}]
}]
}
}
inject-context.sh 动态产出当前 sprint 与最近提交:
#!/bin/bash
echo "## 当前约束(自动注入)"
echo "- 包管理器统一用 pnpm,禁止 npm/yarn"
echo "- 提交前必须跑 pnpm test"
echo "### 最近 5 次提交"
git log --oneline -5 2>/dev/null
echo "### 本周 sprint:认证模块重构"
flowchart TD
A[SessionStart: startup] --> B[inject-context.sh]
B --> C{stdout 注入上下文}
C --> D[Claude 拿到项目宪法+最新提交]
用户每按一次回车,UserPromptSubmit 都会触发。它能改写、拒绝、附加提示词。一个常见用途是给每个提问自动追加"思考前先复述需求"的指令:
{
"hooks": {
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/augment-prompt.sh"
}]
}]
}
}
#!/bin/bash
# augment-prompt.sh:读取原始 prompt,追加规范提醒
echo "请先复述你的理解再动手。提交相关改动前必须通过 pnpm test。"
若要拒绝某个提示词(例如检测到生产环境关键词),脚本输出原因并以 exit 2 退出即可。
这是守卫的"门卫"。下面这个脚本组合了文件保护与危险命令拦截,返回结构化 deny:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh"
}]
},
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"if": "Bash(rm *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
}]
}
]
}
}
protect-files.sh(注意 exit 2 会把 stderr 反馈给模型):
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "package-lock.json" ".git/" "pnpm-lock.yaml")
for p in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$p"* ]]; then
echo "Blocked: $FILE_PATH 命中受保护模式 '$p',如需修改请人工介入" >&2
exit 2
fi
done
exit 0
block-rm.sh 用 JSON 精准拒绝 rm -rf,但放过普通 rm file.txt:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "rm -rf 属于破坏性命令,已由守卫拦截"
}
}'
else
exit 0
fi
写完文件立刻校验,避免错误累积。matcher Edit|Write 让它只在文件编辑后触发,用 jq 提取路径喂给 linter:
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -r pnpm exec prettier --write"
}]
}]
}
}
回合结束是"强制质量门"的最佳挂载点。返回 {"decision":"block"} 能阻止回合结束,把控制权还给 Claude——这正是"测试没过就不许停"的实现:
#!/bin/bash
# stop-gate.sh:回合结束时跑一次类型检查,失败则阻止结束
if ! pnpm exec tsc --noEmit > /tmp/stop-gate.log 2>&1; then
jq -n '{
hookSpecificOutput: {
hookEventName: "Stop",
decision: "block",
reason: "类型检查未通过,请修复后再结束"
}
}'
fi
exit 0
经验法则:
Stop里只做快速检查(类型检查、lint)。完整测试套件建议放到下一节的 async hook,避免阻塞交互。
压缩会丢失细节。两道防线配合:PreCompact 在压缩前把关键状态落盘,SessionStart 的 compact matcher 在压缩后重注入:
{
"hooks": {
"PreCompact": [{
"hooks": [{
"type": "command",
"command": "echo \"[$(date)] 压缩前快照: $(git rev-parse --short HEAD)\" >> .claude/snapshots.log"
}]
}],
"SessionStart": [{
"matcher": "compact",
"hooks": [{
"type": "command",
"command": "echo '⚠️ 刚发生压缩:请记得用 pnpm、提交前必过测试、当前 sprint=认证重构'"
}]
}]
}
}
SessionEnd 的 matcher 区分退出原因(clear/resume/logout/prompt_input_exit 等)。在真实退出时归档日志、清理临时分支:
{
"hooks": {
"SessionEnd": [{
"matcher": "other",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cleanup.sh"
}]
}]
}
}
把七阶段合起来,就得到一张完整的事件总线流转图:
flowchart LR
S1[SessionStart startup<br/>注入上下文] --> S2[UserPromptSubmit<br/>提示词预处理]
S2 --> S3[PreToolUse<br/>拦截/门禁]
S3 --> S4[工具执行]
S4 --> S5[PostToolUse<br/>格式化/校验]
S5 --> S6{PostToolBatch<br/>批次完成?}
S6 -->|否| S3
S6 -->|是| S7[Stop 门<br/>类型检查]
S7 -->|失败 block| S3
S7 -->|通过| S8[SessionEnd<br/>清理归档]
C1[PreCompact 落盘] -.压缩.-> C2[SessionStart compact<br/>重注入]
style S3 fill:#fde2e2
style S7 fill:#fff3cd
prompt 钩子适合需要语义判断的场景。比如一个多准则 Stop 钩子,让模型按 schema 评估"这次改动是否破坏了向后兼容",返回结构化结论再决定是否 block——这比写正则脚本鲁棒得多。
HTTP 钩子把事件 POST 到远端,可对接企业审计平台、Slack webhook、内网合规系统,无需在本地落代码。
MCP 工具钩子直接复用既有 MCP 服务器的能力做判定,逻辑零重复。
agent 钩子派一个子代理做需要长上下文的复杂推理判定。
异步钩子(async hooks)是编排长任务的关键:把它声明为异步,Claude Code 不会等它返回就继续,脚本在后台跑。典型用法是 FileChanged 后跑完整测试套件——既不阻塞交互,又能在文件一变就自动验证:
{
"hooks": {
"FileChanged": [{
"matcher": "src/**/*.ts",
"hooks": [{
"type": "command",
"async": true,
"command": "pnpm test --silent > .claude/last-test.log 2>&1"
}]
}]
}
}
注意 FileChanged 的 matcher 是字面文件名(| 分隔),不是正则;src/**/*.ts 这类通配行为以官方 FileChanged 条目为准,落地前先用 /hooks 菜单确认已注册。
.*、或在不支持 matcher 的事件(Stop/UserPromptSubmit 等)上写了 matcher 被静默忽略。用 /hooks 菜单逐项核对。chmod +x;路径用 ${CLAUDE_PROJECT_DIR} 而非相对路径。exit 2 的原因写具体("用 X 替代"),模型才有修正方向。Stop 门太慢拖慢交互? 改用 async hook 或只放最快的检查;重活挪到 FileChanged 异步执行。allowManagedHooksOnly 关闭用户/项目/插件钩子,仅允许经审批的托管钩子,并通过 plugin marketplace 分发可信钩子。把上述七阶段抽成一个可复用插件 dev-guard,目录结构:
dev-guard/
├── .claude-plugin/plugin.json
├── hooks/hooks.json # 七阶段事件注册
└── scripts/ # 全部可执行脚本
├── inject-context.sh
├── augment-prompt.sh
├── protect-files.sh
├── block-rm.sh
├── stop-gate.sh
└── cleanup.sh
hooks/hooks.json 把七阶段一次性声明,团队任何人启用插件即获得一致的守卫行为,无需各自改 settings.json。配合 SessionStart 注入的"项目宪法",从单人工具升级为团队级、可审计、可回滚的自动化基线——这正是 hooks 从"个人小抄"走向"工程化基础设施"的关键一跃。
FileChanged / CwdChanged / CLAUDE_ENV_FILE —— 与 direnv/devbox 联动做反应式环境管理。SessionStart+PreToolUse+PostToolUse+Stop 四个事件搭出一条"门禁—校验—收口"闭环,并用 /hooks 菜单自检注册结果。allowManagedHooksOnly 演示托管钩子分发。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
