跳到正文
码上手 coding.chengyu.dev

Hooks:把规矩写成必然发生的动作

CLAUDE.md 里的要求 Claude 偶尔会忘,Hooks 不会。学会在生命周期事件上挂脚本,用 matcher 精确触发,用退出码和 JSON 控制结果,做出自动格式化、高危命令拦截和完成通知三个实用 Hook。

约 25 分钟 进阶 Claude Code 核实于 2026-10-25

学完这一课,你能

  • 能说出常用的 Hook 事件各在什么时候触发,以及哪些事件能阻止动作
  • 能写出一段结构正确的 hooks 配置,并用 matcher 和 if 精确控制触发范围
  • 能用退出码 0 / 2 和 JSON 输出控制 Hook 的结果
  • 能独立配好编辑后格式化、拦截危险命令、完成时桌面通知三个 Hook

你在 CLAUDE.md 里写了「每次改完代码都要跑 Prettier」,Claude 大多数时候会照做,但总有那么一两次忘了,提交时才发现格式全乱。你又写了「绝对不要 force push」,可它到底会不会遵守,你心里没底。

问题在于:CLAUDE.md 是建议,模型会尽量遵守,但不保证。Hooks 是规则:在 Claude Code 生命周期的固定时刻,由程序(而不是模型)去执行你写好的命令,每次必然发生。这一课就把「希望它记得」变成「一定会发生」。

Hook 由三层组成

一个 Hook 配置有三层嵌套:

  1. 事件(hook event):什么时刻触发,比如「工具调用之前」PreToolUse、「Claude 回复完毕」Stop;
  2. 匹配器(matcher):在这个事件里再筛一道,比如「只针对 Bash 工具」;
  3. 处理器(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"',再到「系统设置 → 通知」里找到「脚本编辑器」打开允许。

claude — ~/projects/shop-admin
> 帮我把本地分支强制推到远程,覆盖掉同事的提交
⏺ 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 Claude Code

请帮我为这个项目配置 Claude Code Hooks,要求如下:

  1. 编辑或写入文件后:.ts/.vue/.css 文件用项目里 node_modules 下的 prettier 格式化,其他文件不处理;
  2. 执行 Bash 命令前:遇到 rm -rf、git push –force、git reset –hard 直接拦截(exit 2,并用中文写清拦截原因);普通 git push 改为需要我确认;
  3. 脚本放在 .claude/hooks/ 下并加执行权限,配置写进 .claude/settings.json;如果文件里已有 hooks,合并而不是覆盖;
  4. 写完后用 echo 一段模拟的 JSON 输入分别测试两个脚本,把退出码给我看;
  5. 不要修改 hooks 以外的任何配置。

动手清单

0/5

参考来源

工具更新很快,若与官方文档不一致,以官方为准。

学完了吗?

标记完成会记录在本机浏览器,计入你的学习进度和连续天数。

Esc

输入关键词开始搜索,例如「CLAUDE.md」「MCP」「部署」