跳到主要内容

钩子:确定性自动化

高级

钩子是 Claude Code 在其生命周期的指定节点自动运行的 shell 命令权限决定某个操作是否被允许,而钩子让在其前后运行确定性逻辑——格式化、校验、日志、关卡。它们是把行为从“请记得做”变成必然发生的方式。

What you'll learn
  • 何时该用钩子而不是一条指令或一项权限
  • 钩子如何接线:事件、匹配器,以及 stdin 上的 JSON 负载
  • 钩子拦截一个操作的两种方式——退出码 2 与 stdout 上的 JSON
  • 把快速、安全的钩子与迟钝、静默的钩子区分开来的良好实践与常见错误

何时该用钩子

当你希望某个行为是必然发生的、而不仅仅是被请求时,就该用钩子。每个常见任务都对应一个生命周期事件:

  • 每次文件编辑后自动格式化 / lintPostToolUse)。
  • 在某个违规操作运行之前拦截它(PreToolUse)。
  • 会话结束或任务完成时通知或记录Stop)。
  • 在会话开始时注入上下文
钩子事件速览
按 Enter 或空格键翻转卡片。使用左右方向键在卡片之间切换。已显示术语。
1 / 4

它们如何工作

你在 settings.json 里注册钩子,匹配一个事件(通常还配上一个工具匹配器)。当事件触发时,Claude 运行你的命令,并在 stdin 上传入一个 JSON 负载(工具名、它的输入、会话)。你命令的退出码和输出决定接下来发生什么。

Guided walkthrough1 of 4
  1. 在 settings.json 里,把钩子注册到你关心的生命周期事件下——例如 PostToolUse。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}

上面这个钩子从 stdin 的 JSON 里读出被编辑文件的路径(.tool_input.file_path)并对其格式化。不要假设某个环境变量持有该路径——从 stdin 读取它。${CLAUDE_PROJECT_DIR} 这样有用的路径占位符可用的,用于定位脚本。

钩子如何拦截

有两种方式,取决于事件:

  • 退出码 2 — 钩子让该操作失败,它写到 stderr 的任何内容会成为 Claude 看到的消息。简单,且对命令钩子有效。
  • stdout 上的 JSON(退出 0) — 返回一个结构化决策。对 PreToolUse 来说,那是一个值为 denypermissionDecision;对 PostToolUse/Stop/等等来说,它是 {"decision": "block", "reason": "…"}

下面这个脚本是 Bash 工具上的一个 PreToolUse 钩子。从头读到尾:它从 stdin 里拉出命令,如果看起来具有破坏性,就把一个理由写到 stderr 并以 2 退出来拦截。

#!/usr/bin/env bash
# PreToolUse hook on the Bash tool: refuse to delete things.
command=$(jq -r '.tool_input.command' < /dev/stdin)
if [[ "$command" == rm\ * || "$command" == *"rm -rf"* ]]; then
echo "Blocked: destructive 'rm' is not allowed by policy." >&2
exit 2
fi
exit 0

心智模型

一个 PreToolUse 钩子在操作之前运行,可以拦截它;一个 PostToolUse 钩子在操作成功之后运行,对结果做出反应。

良好实践

  • 让钩子又快又幂等——它们会频繁运行。
  • 对真正的问题大声报错,但不要因为表面问题就拦截。
  • 把钩子输出当作给 Claude 的反馈——一条清晰的消息能帮它自我纠正。
  • 钩子以你 shell 的权限运行——对任何不是你自己写的钩子都要审查(审查第三方代码)。

常见错误

  • 从环境变量读取文件路径。 路径在 stdin 的 JSON 里(.tool_input.file_path),而不在 $CLAUDE_FILE_PATH。把 stdin 通过 jq 管道传入。
  • 静默拦截。 如果一个 PreToolUse 钩子以退出码 2 退出却在 stderr 上什么都没写,Claude 被拦住了却不知道为什么,也无法适应。永远写一个清晰的理由。
  • 慢钩子。 一个 PostToolUse 钩子在每一次匹配的编辑后都会运行。一个 3 秒的 linter 会让整个会话感觉迟钝——保持钩子快速,且最好只对发生变化的内容动作。
  • 过于宽泛的匹配器。 matcher: ".*" 会在每个工具上触发。用一个精确的名字、一个 Edit|Write 列表,或每个处理器的 if 字段(例如 "if": "Bash(git push *)")来收窄。
  • 信任不是你自己写的钩子。 一个钩子会以你的权限运行任意 shell。先审查任何来自插件或模板的钩子——见审查第三方代码
Watch out
  • 一个钩子会以你的权限运行任意 shell——绝不要在没有先读过它的情况下接入任何来自插件或模板的钩子。

可复制粘贴的起始模板见钩子与 settings.json 配方

自动格式化被编辑的文件(在 Edit|Write 上的 PostToolUse)

{
"hooks": {
  "PostToolUse": [
    {
      "matcher": "Edit|Write",
      "hooks": [
        { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
      ]
    }
  ]
}
}

自我检测

0/3
  1. 钩子在哪里找到刚被编辑文件的路径?
  2. 一个 PreToolUse 钩子以退出码 2 退出。会发生什么?
  3. 为什么 matcher ".*" 被视为一个常见错误?
Key takeaways
  • 钩子让行为必然发生,而非仅被请求——它们在操作前后运行确定性逻辑,而权限只能允许或拒绝这些操作。
  • 在 settings.json 里针对一个事件加一个匹配器注册钩子;Claude 在 stdin 上管道传入一个 JSON 负载,并读取你的退出码和输出。
  • 从 stdin 读取文件路径(.tool_input.file_path)——而不是从某个环境变量。
  • 用退出码 2 拦截(stderr 会成为消息),或用 stdout 上的结构化 JSON 拦截(退出 0);永远包含一个清晰的理由。
  • 保持钩子快速、幂等且匹配范围收窄——并审查任何不是你自己写的钩子,因为它会以你 shell 的权限运行。

下一步