Hooks
Hooks 是 Claude Code 在特定事件发生时会执行的一段用户脚本。你可以在 Claude 动 Bash 之前拦一道、每次改完文件顺手跑 prettier、会话开始时把项目状态推给它。所有的固化流程都从 Hooks 起步。
为什么需要 Hooks
CLAUDE.md 讲的是让 Claude 记住规则,但记住不等于会做。你写了"每次改完文件都要跑 prettier",Claude 十次里可能有九次会跑,剩下那一次它忘了你也没办法。Hooks 把"会做"这件事从人的自律转成程序的必然,只要事件触发就一定执行,不看 Claude 心情。
Hooks 还能做另一件事:拦截。有些命令一旦执行就没法回退,比如 rm -rf 或 git push --force。你在 Hook 里判断出这些命令,直接返回非零退出码,Claude Code 就不会真的把命令交给系统执行。
七种事件
Claude Code 提供七种 Hook 事件,覆盖会话生命周期的绝大部分节点。
SessionStart:会话启动时触发一次,适合把项目当前状态注入上下文。UserPromptSubmit:用户按下回车提交提示后、模型开始思考前触发,可以改写或拒绝提示。PreToolUse:Claude 决定调用某个工具、但工具还没执行时触发,可以拦截。PostToolUse:工具执行完毕后触发,可以做后处理,比如格式化。Notification:Claude Code 想向用户发通知时触发,可以接系统托盘或推送。Stop:Claude 完成一轮回复、准备把控制权还给用户时触发,适合做清理或写日志。PreCompact:上下文接近上限、自动摘要前触发,可以在被摘要前抢救一些关键信息。
七种事件不必都用。绝大多数团队实际配置的只有 PreToolUse、PostToolUse 和 SessionStart 三种。
在哪里配置
Hook 放在 settings.json 的 hooks 字段下。层级和优先级与 settings.json 一致:~/.claude/settings.json 是全局,./.claude/settings.json 是团队共享的项目配置,./.claude/settings.local.json 是本地个人覆盖。团队共享的 Hook 应该进 git,个人偏好留在 local。
Hook 脚本本身可以放在任何地方,但推荐放在 ./.claude/hooks/ 目录下,和 settings.json 一起进 git,团队里所有人拉下来立即可用。脚本要有可执行权限(Windows 系统建议直接指向解释器,比如 bash .claude/hooks/foo.sh 或 python .claude/hooks/foo.py),否则 Claude Code 调用时会报错。
一个最小配置骨架:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "path/to/script.sh" }
]
}
]
}
}matcher 用来筛选事件,比如只对 Bash 工具生效、只对 Edit 工具生效,或者用正则匹配多个工具。hooks 是数组,可以挂多个命令,按顺序执行,其中任何一条返回非零都会让整个事件被视为失败。
示例一:PreToolUse 拒绝 rm -rf
rm -rf 是最经典的踩雷命令。哪怕 Claude 只是想清理临时目录,只要参数写错就是一场事故。用 Hook 把它拦下来。
.claude/hooks/block-rm-rf.sh:
#!/usr/bin/env bash
# stdin 是 Claude Code 传给 Hook 的 JSON,含工具名和参数
payload=$(cat)
cmd=$(echo "$payload" | jq -r '.tool_input.command')
if echo "$cmd" | grep -qE '(^|[[:space:]])rm[[:space:]]+.*(-rf|-fr|--recursive.*--force)'; then
echo "拒绝执行:命令包含 rm -rf。请拆分成更安全的删除步骤。" >&2
exit 2
fi
exit 0.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/block-rm-rf.sh" }
]
}
]
}
}退出码为 2 会让 Claude Code 拦截本次工具调用,stderr 的内容会作为反馈回传给模型,Claude 读到之后会换一种做法,比如逐个 rm 文件。
示例二:PostToolUse 自动跑 prettier
改完 TypeScript 或 JSON 后自动格式化,避免和团队风格不一致。
.claude/hooks/format-on-edit.sh:
#!/usr/bin/env bash
payload=$(cat)
file=$(echo "$payload" | jq -r '.tool_input.file_path')
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.md)
pnpm exec prettier --write "$file" >/dev/null 2>&1
;;
esac
exit 0.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/format-on-edit.sh" }
]
}
]
}
}matcher 用正则同时匹配 Edit 和 Write。Hook 静默执行,只要 prettier 能改就改,Claude 不需要知道这件事发生了。
示例三:SessionStart 注入项目状态
会话一开始就把项目当前的 Git 分支、未提交文件数、上次 CI 结果推给 Claude,让它接下来的决策更贴合当下状态。
.claude/hooks/session-brief.sh:
#!/usr/bin/env bash
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
last_ci=$(gh run list --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null)
cat <<EOF
当前项目状态:
- Git 分支:${branch:-未知}
- 未提交文件数:${dirty:-0}
- 最近一次 CI:${last_ci:-未知}
EOF
exit 0.claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/session-brief.sh" }
]
}
]
}
}SessionStart 不需要 matcher,因为它不针对某个工具。stdout 的内容会作为附加上下文注入到会话,Claude 读到"你现在在 feat/refund 分支上,有 3 个未提交文件"之类的信息后,行为会比冷启动更准。
Hook 收到的输入
Hook 通过标准输入接收一段 JSON,字段随事件类型不同而不同,但都会带上事件名、当前会话 ID、Claude Code 版本号。以 PreToolUse:Bash 为例,输入长这样:
{
"hook_event_name": "PreToolUse",
"session_id": "abc123",
"tool_name": "Bash",
"tool_input": {
"command": "git status",
"description": "查看未提交文件"
}
}拿到 JSON 之后你可以用 jq 或者语言原生的 JSON 解析取字段。推荐用 jq,因为几乎所有工作站都装了,脚本可移植性最好。
退出码和 stderr 的含义
Hook 的行为完全由退出码和 stderr 决定,这是最容易搞混的一层。
- 退出码
0:Hook 执行成功。PreToolUse 会放行工具调用,PostToolUse 忽略输出。 - 退出码
2:拦截。PreToolUse 会阻止工具执行,stderr 内容作为反馈发给 Claude。UserPromptSubmit 阻止本轮提示进入模型。 - 其他非零退出码:视为 Hook 自身出错,Claude Code 会记录错误但一般不阻塞工作流。
stdout 在不同事件里含义不同。SessionStart 和 UserPromptSubmit 的 stdout 会附加进上下文,PostToolUse 的 stdout 一般被忽略。写 Hook 之前先明确目标是"拦截"还是"注入信息",选对退出码才不会拧巴。
Hook 是有代价的
每一个 Hook 都会在事件触发时同步执行,慢的 Hook 会让 Claude 卡住。写 Hook 时优先让它快,能后台跑就后台跑,能只处理受影响的文件就别扫全库。
调试 Hook 的实用招数
Hook 出问题时先在命令里加 set -x,让 stderr 打印每一步。Claude Code 会把 stderr 汇总到会话,你能一眼看出哪一步崩了。调完再删掉 set -x 上线。
常见反模式
新手容易踩三种坑,提前打个预防针。
第一种是把 Hook 当作一次性脚本。真正合适的 Hook 要短、要幂等、要在任何仓库状态下都能安全跑。写完一段 Hook 后自问:如果连续触发十次会不会出乱?答案是会,就该重构。
第二种是滥用退出码 2。退出码 2 会打断 Claude 的工作,滥用会让整个会话卡壳。只在真正需要阻止的场景才返回 2,比如 rm -rf。日常的格式化、日志记录,Hook 应该静默成功。
第三种是把 Hook 当 CLAUDE.md 用。有人会在 UserPromptSubmit 里塞一大段项目介绍,希望 Claude 每次都读到。这是错误做法,长期约束应该写进 CLAUDE.md,Hook 只塞会话开始时的动态状态,比如当前分支、CI 结果。