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

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

- 网址：https://coding.chengyu.dev/learn/claude-code/hooks/
- 学习路径：Claude Code 精通
- 预计用时：25 分钟；核实日期：2026-10-25
- 官方来源：[Hooks reference（Claude Code 官方文档）](https://code.claude.com/docs/en/hooks)，[Automate actions with hooks（Claude Code 官方文档 · Hooks 指南）](https://code.claude.com/docs/en/hooks-guide)

## 学习目标

- 能说出常用的 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`（提醒），先把这四个用熟。事件清单还在持续增加，用到冷门事件时以[官方参考](https://code.claude.com/docs/en/hooks)为准。

## 配置写在哪、怎么写

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. 编辑后自动格式化（项目级）**

```bash
#!/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. 拦截危险命令（项目级）**

```bash
#!/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，路径里有空格也不怕）：

```json
{
  "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`）时触发：

```json
{
  "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"'`，再到「系统设置 → 通知」里找到「脚本编辑器」打开允许。

```text
> 帮我把本地分支强制推到远程，覆盖掉同事的提交
⏺ 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 课](/learn/claude-code/permissions/)），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**

```text
请帮我为这个项目配置 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 以外的任何配置。
```

- [ ] 在 ~/.claude/settings.json 配好 Stop 和 Notification 两个桌面通知 Hook，并确认真的弹出了通知
- [ ] 为一个项目写好 format.sh，注册到 PostToolUse，让 Claude 改一个文件后检查是否被格式化
- [ ] 写好 guard-bash.sh，用 echo 模拟 JSON 的方式手动测试，确认高危命令返回退出码 2
- [ ] 在会话里输入 /hooks，确认三个 Hook 都在、来源文件正确
- [ ] 故意让 Claude 执行一次 git push --force，观察拦截信息和它的后续反应
