在 CLAUDE.md 里写了"改完代码记得跑 prettier""提交前先跑测试",Claude 大部分时候照做,偶尔也会忘,因为写进提示词的规则执不执行,由模型自己决定。
Hooks 换了个思路:把这些事挂到 Claude Code 的生命周期事件上,比如"每次编辑文件之后""每次准备停下之前",到点就由 Claude Code 自己执行一条 shell 命令,不经过模型判断。官方文档的说法是,Hooks 提供的是"确定性控制"。
这篇从零配几个最常用的 Hook:编辑后自动格式化、改完代码后台跑测试、禁止改敏感文件、停下前确认测试通过,再讲一下退出码、matcher 和排错。配置和字段都按 2026 年 10 月的官方文档核对过;脚本按文档示例改写,本文未逐个实测,用之前请先在自己的项目里试一遍。
一、Hook 配在哪
Hook 写在 settings 文件的 hooks 字段里。放在哪个文件,决定了它管多大范围:
| 位置 | 作用范围 | 能不能提交到仓库 |
|---|---|---|
~/.claude/settings.json |
你本机的所有项目 | 不能,只在本机 |
.claude/settings.json |
当前项目 | 能,团队共享 |
.claude/settings.local.json |
当前项目 | 不能,Claude Code 写入时会加进 gitignore |
| 组织托管策略 | 整个组织 | 管理员下发 |
插件的 hooks/hooks.json |
插件启用期间 | 随插件分发 |
| Skill / 子 Agent 的 frontmatter | Skill 调用后 / 子 Agent 运行期间 | 跟着文件走 |
几个层级的 Hook 是合并 关系,不会互相覆盖。格式化、跑测试这类和项目工具链绑定的,放 .claude/settings.json,团队成员拉下代码就有;桌面通知这种个人习惯,放 ~/.claude/settings.json。
配置分三层:事件 → matcher 分组 → 具体的 hook。
json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "echo 改完了" }
]
}
]
}
}
改完配置后,在 Claude Code 里输入 /hooks,可以看到当前生效的所有 Hook,每条都标了来源(user、project、local、plugin 等)。这个菜单是只读的,要改还是得改文件。直接改 settings 文件时,文件监听一般会自动重新加载。
二、先认识几个常用事件
官方列出的事件有三十多个,入门阶段用得上的就这几个:
| 事件 | 什么时候触发 | 能不能拦下来(exit 2) |
|---|---|---|
SessionStart |
会话开始或恢复,包括压缩上下文之后 | 不能 |
UserPromptSubmit |
你发出提示词、Claude 处理之前 | 能,拦下这条提示词 |
PreToolUse |
工具调用执行之前 | 能,拦下这次调用 |
PostToolUse |
工具调用成功之后 | 不能撤销,但能把反馈交给 Claude |
Notification |
Claude Code 发通知时,比如等你授权 | 不能 |
Stop |
Claude 回答完、准备停下时 | 能,让它继续干活 |
简单记:Pre 开头的能拦,Post 开头的只能事后补救 。工具已经执行完了,PostToolUse 再怎么返回也撤不回去。
三、编辑后自动格式化
这是最常见的需求:用 PostToolUse 事件,matcher 写 Edit|Write,只在编辑和写文件之后触发。下面这段写进项目根目录的 .claude/settings.json:
json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
原理:触发时,Claude Code 把这次调用的信息以 JSON 形式写进命令的 stdin,其中 tool_input.file_path 就是刚改的文件(绝对路径)。jq 取出路径,交给 Prettier。
其他语言换成对应的格式化命令就行:
bash
# Python
jq -r '.tool_input.file_path' | xargs ruff format
# Go
jq -r '.tool_input.file_path' | xargs gofmt -w
如果项目里多种语言混着,可以先判断扩展名再决定调哪个工具,这时候写成单独的脚本更清楚,见第五节。
要注意两点:
- Hook 执行成功时,对话里什么都不显示 。想确认它跑了没有,看文件有没有被重新格式化,或者按
Ctrl+O打开 transcript 视图。 - 这个 Hook 只管 Claude 用 Edit / Write 工具改的文件。如果是 Bash 命令(比如
sed -i)改的,它不会触发。官方建议这种情况改用FileChanged事件。
jq 没装的话:macOS 用 brew install jq,Debian / Ubuntu 用 apt-get install jq。不想装 jq,也可以用 Python 或 Node 解析 JSON。
四、改完代码后台跑测试
测试跑一次可能要几十秒,如果同步执行,Claude 每改一个文件就得干等。加上 "async": true,Hook 就在后台跑,Claude 接着干活,测试结果在下一轮对话时交给它。
先写脚本 .claude/hooks/run-tests-async.sh:
bash
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 只在改了源码时跑
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js ]]; then
exit 0
fi
RESULT=$(npm test 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
MSG="Tests passed after editing $FILE_PATH"
else
MSG="Tests failed after editing $FILE_PATH: $RESULT"
fi
jq -nc --arg msg "$MSG" '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $msg}}'
bash
chmod +x .claude/hooks/run-tests-async.sh
再注册:
json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests-async.sh",
"args": [],
"async": true
}
]
}
]
}
}
几个细节:
${CLAUDE_PROJECT_DIR}是会话启动时的项目根目录,用它引用脚本,就不怕 Claude 中途cd到别的目录后找不到文件。"args": []表示用 exec 形式直接启动脚本,不经过 shell,路径里有空格也不用加引号。- 脚本最后输出的
additionalContext会作为系统提醒交给 Claude。测试挂了,它下一轮就能看到报错并去修。 - 异步 Hook 拦不住任何操作,
decision、permissionDecision这些字段对它无效。另外它的结果要等下一轮对话才送达;会话空闲时,会一直等到你下次输入。
五、禁止改敏感文件
.env、package-lock.json、.git/ 这类文件,一般不希望 Claude 动。用 PreToolUse 在编辑之前检查路径,命中就用 exit 2 拦下。
.claude/hooks/protect-files.sh:
bash
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Windows 下路径是反斜杠,先统一成正斜杠
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0
注册:
json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
拦下之后,stderr 里那句 Blocked: ... 会作为反馈交给 Claude,它知道为什么被拒,会换别的办法。
另外,PreToolUse 在任何权限模式下都会先执行,包括 bypassPermissions 和 --dangerously-skip-permissions。也就是说,就算你图省事开了跳过权限,这道检查依然有效。反过来不成立:Hook 返回 allow 不能绕过 settings 里的 deny 规则。
六、停下前确认测试通过
Stop 事件在 Claude 准备结束回答时触发。exit 2 会让它继续干活,stderr 就是继续的理由。下面这个脚本在测试不通过时不让它停:
bash
#!/bin/bash
INPUT=$(cat)
# 已经是被 Stop hook 拉回来继续的,就放行,避免死循环
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! npm test >/dev/null 2>&1; then
echo "测试没有通过,请先运行 npm test 修好再结束" >&2
exit 2
fi
exit 0
stop_hook_active 这个判断一定要加。官方说明:Stop hook 连续拦截 8 次、中间 Claude 没调用过任何工具时,Claude Code 会强行结束这一轮并给出警告(上限可以用环境变量 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 调)。不加判断,碰上一时修不好的测试,就会白白空转。
Stop 在 Claude 每次回答完都会触发,不只是任务做完的时候。你问一句"这个函数是干什么的",它答完也会跑一遍测试。项目测试很慢的话,这个 Hook 不太合适,用第四节的异步方案更好。
如果"做完没有"不好用脚本判断,可以用 type: "prompt" 的 Hook,让一个模型来判断:
json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}
]
}
]
}
}
七、等你授权时弹通知
Claude 跑长任务时,你多半切到别的窗口去了。它卡在等授权的时候,用 Notification 事件弹个系统通知。这种个人习惯放 ~/.claude/settings.json:
json
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
Linux 把命令换成 notify-send 'Claude Code' 'Claude Code needs your attention'。macOS 上不弹的话,多半是「脚本编辑器」没有通知权限,到「系统设置 > 通知」里打开。
matcher 留空表示所有通知类型都触发。只想在等授权时提醒,写 permission_prompt(等待约 6 秒后触发);只想在它答完、你一分钟没动时提醒,写 idle_prompt。
八、退出码和 JSON 输出
Hook 和 Claude Code 之间靠 stdin、stdout、stderr 和退出码通信:
| 退出码 | 含义 | 效果 |
|---|---|---|
0 |
成功 | stdout 如果是一个 JSON 对象,按 JSON 处理;对 PreToolUse 来说,0 不等于批准,照常走权限流程 |
2 |
阻断 | 能拦的事件会被拦下,stderr 作为原因 |
| 其他 | 非阻断错误 | 操作照常进行,对话里显示一条 hook error |
最容易踩的坑:exit 1 拦不住任何东西。 很多人写脚本习惯出错就 exit 1,结果检查失败了操作照样执行。要拦截,必须 exit 2。
想要更细的控制,就 exit 0 并在 stdout 输出 JSON。比如 PreToolUse 拒绝一次调用:
json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}
permissionDecision 可选 allow、deny、ask(defer 只用于 -p 非交互模式)。多个 Hook 同时匹配时,取最严格的结果:deny > defer > ask > allow。
两种方式一个 Hook 里只选一种:要么 exit 2 加 stderr,要么 exit 0 加 JSON。
九、matcher 和 if
matcher 的规则:
| 写法 | 含义 |
|---|---|
"*"、"" 或不写 |
匹配全部 |
只有字母、数字、_、-、空格、,、` |
` |
| 含其他字符 | 当作 JavaScript 正则,不锚定首尾 |
所以 Edit.* 也会匹配到 NotebookEdit,要精确就写 ^Edit$。matcher 区分大小写。MCP 工具名的格式是 mcp__<server>__<tool>,匹配整个 server 要写 mcp__memory__.*,只写 mcp__memory 什么都匹配不到。
matcher 只能按工具名过滤。想按参数过滤,用 hook 上的 if 字段,语法和权限规则一样:
json
{
"type": "command",
"if": "Bash(git *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-git-policy.sh"
}
这样只有 Bash 里出现 git 子命令时才启动脚本,省得每条命令都起一个进程。官方特别说明 if 是尽力而为的过滤:解析不了的命令会照样触发 Hook。真正要硬性禁止的,交给权限系统的 deny 规则。另外 if 只在工具类事件上有效,加在 Stop 这类事件上,Hook 永远不会执行。
十、安全提醒
- 命令型 Hook 以你的用户权限 执行,能读写你账号能访问的所有文件。别人仓库里的
.claude/settings.json也可以带 Hook。 - 交互模式下,Claude Code 会先弹出工作区信任确认,确认之前不跑任何 settings 里的 Hook。但
claude -p和 SDK 会话不弹这个确认,直接当作已信任 。用-p批量处理别人的仓库之前,先看一眼它的.claude/目录,或者加上--settings '{"disableAllHooks": true}'关掉 Hook。 - 写脚本时变量加引号(
"$VAR"),检查路径里的..,用绝对路径引用脚本。
常见问题速查
| 现象 | 可能原因 | 处理 |
|---|---|---|
| Hook 完全不触发 | matcher 大小写不对;事件选错;/hooks 里看不到 |
用 /hooks 确认它挂在正确的事件下 |
/hooks 里看不到刚加的 Hook |
JSON 有尾逗号或注释;文件放错位置 | 校验 JSON;项目级是 .claude/settings.json |
command not found |
用了相对路径,Claude 已经切到别的目录 | 用 ${CLAUDE_PROJECT_DIR} 或绝对路径 |
jq: command not found |
没装 jq | 安装 jq,或改用 Python / Node 解析 |
| 脚本不执行 | 没有执行权限 | chmod +x .claude/hooks/xxx.sh |
| 返回了 JSON 却没生效,也不报错 | shell 配置文件里有 echo,输出跑到 JSON 前面;或字段放错层级 |
echo 用 if [[ $- == *i* ]] 包起来;permissionDecision、additionalContext 要放进 hookSpecificOutput |
| 检查失败了操作还是执行了 | 用了 exit 1 |
改成 exit 2 |
| Stop hook 来回循环后被强制结束 | 没判断 stop_hook_active |
参考第六节加判断 |
手动调试脚本,最快的办法是喂一段假的输入:
bash
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?
要看完整的执行记录,用 claude --debug-file /tmp/claude.log 启动,另开一个终端 tail -f /tmp/claude.log。已经在会话里了,就输入 /debug 打开日志。
小结
CLAUDE.md 适合写"希望它怎么做",Hooks 适合写"必须发生的事"。建议从两个开始:项目里配一个编辑后自动格式化,本机配一个等授权时的通知。用顺手了,再加敏感文件保护和测试检查。
参考资料
- Claude Code 官方文档:Automate actions with hooks,code.claude.com/docs/en/hoo...
- Claude Code 官方文档:Hooks reference,code.claude.com/docs/en/hoo...