说实话,我把 Claude Code 从“命令行问答工具”升级成“真正能放手的编码 Agent”,靠的就是 Hooks。早期我用它改代码,每次都要手动补一句“记得跑一下格式化”或“检查下有没有 lint 报错”,改的文件一多,这种重复指令极其消耗耐心,而且容易漏。后来研究完 Hooks 机制,把“改完文件自动跑格式化和 lint”“危险命令自动拦截”“每次会话自动归档日志”这类事情全部交给事件触发,体验直接上了一个台阶。
这篇是 Claude Code 系列指南的第四篇,专门讲 Hooks。我会把事件类型、配置语法、匹配规则、四种 Hook 变体讲清楚,然后用真实场景演示怎么落地。适合两类人:一是已经会用 Claude Code 写代码、想把它接入工程化流程的人;二是准备在团队里推广 Claude Code、需要统一安全和规范的人。读完你就能在自己的 settings.json 里写出第一套能用的 Hooks。
1. Hooks 的核心机制与设计思路
1.1 Hooks 本质上是一组“事件回调”
用过前端的人对 addEventListener 不会陌生,Hooks 的原理和它一模一样:Claude Code 在运行过程中会触发各种生命周期事件,你在配置里给这些事件挂上 shell 命令,事件发生时系统就会执行对应脚本。
举个例子,Claude 要执行 Bash 工具之前,会触发 PreToolUse 事件;执行完文件编辑之后,会触发 PostToolUse 事件;你输入完 prompt 准备让 Claude 干活时,会触发 UserPromptSubmit 事件;Claude 回答完一轮、停下来等你输入时,会触发 Stop 事件。
每一个事件都会携带结构化 JSON 数据,通过标准输入(stdin)传给你的脚本。你的脚本处理完数据后,可以通过标准输出(stdout)和标准错误(stderr)反过来影响 Claude 的行为。比如 PreToolUse 事件的脚本发现命令里有危险操作,就往 stdout 输出一个 BLOCK 标记,Claude 的工具调用就会被拦住。
这套设计最值钱的地方在于:你不需要改 Claude Code 源码,不用维护一个常驻服务,只需要写一些可以被 shell 执行的脚本,然后用 JSON 配置把它们挂到对应事件上。脚本可以是 Python、Node.js、Shell、Ruby,甚至编译好的二进制,只要系统能执行就行。
1.2 事件模型完整拆解
我把常用事件整理成了表格,方便对照理解。
| 事件类型 | 触发时机 | 典型用途 |
|---|---|---|
| PreToolUse | Claude 调用任意工具之前 | 危险命令拦截、权限审批、注入额外上下文 |
| PostToolUse | 工具执行完成后 | 自动格式化、 lint、跑测试、记录工具执行结果 |
| UserPromptSubmit | 用户提交 prompt 之后、Claude 回复之前 | prompt 审计、敏感信息脱敏、团队规范校验 |
| Stop | Claude 完成一轮回复、等待用户输入时 | 会话日志归档、状态同步、发通知 |
| SubagentStop | 子 Agent 完成任务返回时 | 汇总子任务结果、检查子 Agent 产物 |
| Notification | Claude 需要用户授权或切换模式时 | 桌面通知、Webhook 提醒 |
| PreCompact | 上下文压缩发生之前 | 备份关键状态、导出摘要 |
这里我重点说三个最容易出效果的。
PreToolUse 是所有事件的“闸门”。因为它在工具真正执行之前触发,你可以做到:Bash 工具执行前检查命令内容,发现rm -rf /或git push --force直接拒绝;Read 工具读取敏感文件前做路径拦截;WebSearch 执行前确认搜索词是否合规。拦截逻辑写在脚本里,Claude 想绕都绕不过去,因为它没有“跳过 hook”的权限。
PostToolUse 是质量门禁。Claude 写完代码后,紧接着触发格式化、lint、单测,坏了就直接把报错信息反馈给 Claude 让它继续修。这样 AI 写代码和工程规范之间就有了闭环,而不是每次写完再手动提要求。
UserPromptSubmit 是最容易被忽略但收益极高的一个事件。它能在 Claude“看到”你的 prompt 之前先让你的脚本“看一眼”,适合做日志、脱敏和规范检查。我会在第三部分专门演示。
1.3 为什么不用手动脚本,非要用 Hooks
可能有人觉得:这些事我手动跑不也行吗?可以,但实际工程里手动执行有四个致命问题。
第一,记忆不可靠。你让 Claude 改十个文件,改到第五个的时候注意力早就分散了,很容易漏跑检查。Hooks 是系统级触发,只要配置好,每次工具调用都会走一遍逻辑,不会漏。
第二,策略不统一。团队里十个人用 Claude Code,有人跑 lint 有人不跑,代码风格很快会乱。Hooks 可以放进项目仓库的.claude/settings.json,所有人在这个仓库里开发都自动生效。
第三,安全不可控。让 AI 随心所欲执行命令是有风险的,手动监督在大规模并行编辑时会失灵。PreToolUse 拦截是最后一道防线,相当于给 AI 上了一道“行为护栏”,这比事后发现写坏了再回滚成本低得多。
第四,上下文碎片化。手动执行脚本的结果在终端里一闪而过,没法自动回流给 Claude。但 PostToolUse 的 stdout 可以被 Claude 看到,这意味着你可以在脚本里处理完数据后,把关键信息“喂”给 Claude,让它基于最新结果继续决策。
2. 配置方法与参数详解
2.1 配置文件的位置与优先级
Hooks 写在 settings.json 里。Claude Code 会加载多层配置:
- 托管策略配置:由企业管理员统一下发,用户改不了,适合强制安全基线;
- 用户级配置:
~/.claude/settings.json,对你本机所有项目生效,适合放个人偏好和全局日志; - 项目级配置:
.claude/settings.json,放在项目根目录下,会随 Git 仓库走,适合团队共享质量门禁和安全策略。
配置合并的优先级通常是:项目级 > 用户级 > 托管策略。具体值的覆盖关系不一定要死记,你只需要记住一个原则:和团队规范相关的放项目级,个人习惯相关的放用户级,强制底线放托管策略。
如果你在 VS Code 里装了 Claude Code 插件,Hooks 机制完全一样,因为插件底层还是同一个 CLI,读取的也是同一套 settings.json。所以不要去找什么 “VS Code 专用 Hooks 设置界面”,没有,直接改 JSON 就对了。
2.2 Hooks 配置结构详解
先看一个最基础的配置结构:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3 scripts/check_command.py", "timeout": 30 } ] } ] } }最外层是hooks对象,key 是事件名。每个事件对应一个数组,数组里每个对象都包含matcher和hooks两部分。matcher决定这个配置组匹配哪些工具或输入,hooks是真正要执行的命令列表。
hooks数组里的每一项都有两个必填字段:
type:命令类型。默认写command,其他还有commandAsync、commandBlock、commandAllow,下一节细说。command:要执行的 shell 命令字符串。
可选字段是timeout,单位秒,默认 60 秒。超过这个时间,hook 会被认为是失败或超时,行为取决于事件类型。
我个人强烈建议所有 hook 命令都写成“调用外部脚本文件”,而不是直接在 JSON 里写一长串内联脚本。原因很简单:JSON 里写复杂 shell 命令,转义和换行很容易出错;单独放一个scripts/目录,可以用 Python 或 Node.js 写完整逻辑,还能写单元测试。团队协作时,脚本也能走代码评审。
2.3 四种 Hook 变体:Block、Allow、Async 和普通模式
这是很多人一开始就绕晕的地方。同一个事件,能挂的命令类型其实有区别,关键在于这个事件是否需要“影响 Claude 的下一步行为”。
普通command是阻断式的:Claude Code 会等它执行完,stdout 和 stderr 怎么处理取决于事件类型。而带后缀的类型会改变行为逻辑:
| type 写法 | 行为说明 | 主要使用场景 |
|---|---|---|
command | 普通阻塞执行,按事件默认规则处理输出 | 大部分通用场景 |
commandBlock | 阻塞执行,可阻止工具调用(主要用于 PreToolUse) | 危险命令拦截、权限审批 |
commandAllow | 非阻塞执行,只记录和观察,不阻止工具调用 | 审计、监控、指标采集 |
commandAsync | 异步执行,不等待结果,不阻塞主流程 | 日志上报、通知、耗时任务 |
不同事件支持的类型还不一样。PreToolUse 支持command、commandBlock、commandAllow,但不支持commandAsync,因为它必须在工具执行前同步做出“允许还是阻止”的决策。PostToolUse 支持command、commandBlock、commandAsync,可以用 Block 模式把 stdout 内容追加给 Claude,也可以用 Async 模式异步做归档。UserPromptSubmit 支持command、commandBlock、commandAllow,适合 prompt 审计和拦截。Stop、SubagentStop、Notification、PreCompact 都支持command、commandBlock、commandAsync,通常用 Async 做收尾工作,避免拖慢对话。
这里的关键认知是:Block 不一定比 Allow 好。Block 会拖慢主流程,如果脚本写得慢,用户体感就是“每次操作都卡一下”。所以我的习惯是:需要拦截的就用 Block,纯记录类的尽量用 Allow,实在耗时的上报任务用 Async。
2.4 matcher 匹配规则:从全量匹配到精确匹配
不写matcher时,这个配置组会匹配该事件的所有触发场景。写matcher可以缩小范围,避免无关工具也触发脚本。
最简单的是工具名匹配。比如 PreToolUse 里只关心 Bash 工具,就写"matcher": "Bash"。注意工具名是大小写敏感的,Bash 的 B 是大写,Write、Edit、MultiEdit 同样首字母大写。
多个工具可以用正则,比如:
{ "matcher": "/^Edit$|^Write$|^MultiEdit$/", "hooks": [ { "type": "command", "command": "python3 scripts/format_file.py" } ] }带/ /包裹的就是正则表达式,不包就是普通字符串匹配。更高级的用法是 JSONPath,直接从 hook 输入 JSON 里做条件匹配。比如你想只在读取/etc/passwd时才拦截,可以写:
{ "matcher": "$.tool_input.file_path", "hooks": [ { "type": "commandBlock", "command": "python3 scripts/check_path.py" } ] }不过 JSONPath 对新手不友好,建议先从工具名匹配开始,等确实遇到“需要根据参数内容区分”的需求再升级。
2.5 Hook 脚本的输入输出约定
每次 hook 被触发时,系统会把事件 JSON 写入脚本的 stdin。不同事件的 JSON 结构不同,但最核心的 PreToolUse 结构长这样:
{ "tool_name": "Bash", "tool_input": { "command": "git status" }, "tool_response_id": "xxxx" }PostToolUse 会在后面多一个tool_response字段,UserPromptSubmit 会带prompt字段。脚本要做的第一件事就是把 stdin 解析成 JSON,然后取你关心的字段。
输出约定需要特别注意:
- 对 PreToolUse 的 Block 型 hook,如果脚本 stdout 以
BLOCK开头,工具调用会被阻止。普通输出不会阻止,但脚本 stderr 的内容会被记录进会话,Claude 能看到。 - 对 PostToolUse 的 Block 型 hook,脚本 stdout 的内容会随工具结果一起返回给 Claude 作为上下文。stderr 同样会传给 Claude。
- 对 CommandAllow 这类观察型 hook,输出不会影响主流程。
这意味着脚本“话太多”会烧掉大量 token。PostToolUse 脚本如果每次编辑完都打印一屏日志,Claude 每轮都要为这些日志消耗上下文。正确做法是:成功时保持安静,只在失败或需要补充关键信息时才输出。
3. 核心场景实操:从安全拦截到质量门禁
3.1 实战一:拦截危险命令,给 Claude 上安全护栏
我在团队里落地 Hooks 的第一件事,就是拦住git push到主分支。因为 Claude 在自动修 bug 时,可能会顺手把代码推到 main,这在很多团队是不可接受的。
先写一个检查脚本,我放在.claude/scripts/guard_git_push.py:
import json import sys data = json.load(sys.stdin) tool_name = data.get("tool_name") tool_input = data.get("tool_input", {}) if tool_name != "Bash": sys.exit(0) cmd = tool_input.get("command", "") # 只关心 git push 相关命令 if "git push" not in cmd: sys.exit(0) # 判断是否推送到 main / master if any(branch in cmd for branch in ["main", "master"]): print("BLOCK: 禁止直接推送 main/master 分支,请使用 feature 分支并走 MR") print("已拦截对主分支的推送操作", file=sys.stderr)然后在.claude/settings.json里挂载:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "commandBlock", "command": "python3 .claude/scripts/guard_git_push.py" } ] } ] } }注意这里用commandBlock,因为我们需要在工具执行前把它拦截下来。脚本里优先用stdout输出BLOCK前缀,系统会识别并阻止工具调用;stderr里的解释会进入会话,让 Claude 知道为什么被拦,从而调整自己的行为。
这套逻辑还可以扩展。比如拦截rm -rf指向项目根目录的命令、拦截对.env文件的 Read 操作、拦截curl上传源码到外部服务的命令。规则写多了以后,Claude 在你这套环境里会自动学会避免触发这些警告,相当于用代码约束了 Agent 的行为边界。
3.2 实战二:文件编辑后自动格式化和 lint
PostToolUse 是我认为 Hooks 里“投产比”最高的事件。因为 Claude 改代码是高频操作,每次写完自动做质量检查,比事后统一跑一遍体验好得多。
配置如下:
{ "hooks": { "PostToolUse": [ { "matcher": "/^Edit$|^Write$|^MultiEdit$/", "hooks": [ { "type": "command", "command": "python3 .claude/scripts/quality_gate.py", "timeout": 30 } ] } ] } }脚本核心逻辑:
import json import subprocess import sys data = json.load(sys.stdin) tool_input = data.get("tool_input", {}) file_path = tool_input.get("file_path") if not file_path: sys.exit(0) # 仅处理当前项目内的文件 if not file_path.startswith("src/"): sys.exit(0) # 先格式化,再 lint subprocess.run(["npx", "prettier", "--write", file_path], check=False) result = subprocess.run(["npx", "eslint", file_path], capture_output=True, text=True) if result.returncode != 0: # 输出 lint 错误,让 Claude 看到并修复 print(f"LINT_FAIL: {file_path}\n{result.stdout[:2000]}")这里面有个关键细节:文件路径从tool_input.file_path取,不要自己在命令里拼接变量。因为直接往 settings.json 里写prettier --write ${FILE_PATH}这类模板变量,Claude Code 不会帮你展开,最后会变成一个空的或错误的路径。把所有逻辑放到脚本里解析 JSON,是最稳的做法。
lint 失败时,脚本输出LINT_FAIL前缀并附上错误摘要,PostToolUse 的 stdout 会回流给 Claude,Claude 会继续修复问题。这就形成了一个自动循环:写文件 → 触发质量检查 → 有问题反馈给 Claude → Claude 修复 → 再触发检查,直到通过。
3.3 实战三:UserPromptSubmit 做审计、脱敏和规范校验
我在多个项目里部署过这个模式:用户输入任何 prompt,先经过钩子脚本,写入本地日志,顺便检查有没有把密钥贴进去。
脚本如下:
import json import re import sys from datetime import datetime data = json.load(sys.stdin) prompt = data.get("prompt", "") # 1. 审计日志:记录每次 prompt with open(".claude/prompt_log.jsonl", "a") as f: f.write(json.dumps({ "time": datetime.now().isoformat(), "prompt": prompt[:500] }) + "\n") # 2. 敏感信息检测 secret_patterns = [ r"sk-[A-Za-z0-9]{20,}", r"ghp_[A-Za-z0-9]{20,}", r"AKIA[A-Z0-9]{16}", ] for pattern in secret_patterns: if re.search(pattern, prompt): print("BLOCK: 检测到疑似密钥内容,请输入经过脱敏的 prompt") print("请移除密钥后再发送", file=sys.stderr) sys.exit(0)这个脚本的效果立竿见影。之前有同事在 prompt 里贴了线上数据库连接串,Claude 直接把连接串写进代码里再提交,差点出事。加了 UserPromptSubmit 拦截之后,密钥根本进不到 Claude 上下文里。
还有团队拿它做规范校验:要求 prompt 必须带上需求单号,没有就通过 stderr 提醒。这比人工 review 聊天记录靠谱多了,因为拦截发生在模型看到内容之前,属于前置约束。
3.4 实战四:Stop 事件做会话归档与异步通知
Stop 事件在 Claude 回复完之后触发,适合做“每一轮对话的落盘”。我个人的习惯是用commandAsync把归档任务丢到后台,不阻塞下一轮输入。
{ "hooks": { "Stop": [ { "hooks": [ { "type": "commandAsync", "command": "python3 .claude/scripts/archive_session.py" } ] } ] } }归档脚本做的事情很简单:把当前会话的关键消息追加到session_archive.jsonl。这个文件的用途是之后做数据复盘,比如统计这个项目里 Claude 花了多少轮才解决问题、哪些类型的修改频繁触发 lint 错误、有没有反复修改同一个文件。
不要小看这个日志,有一次我排查“为什么某天 Claude 突然一直改错文件”,就是从 Stop hook 的归档日志里发现,上午我在 prompt 里给了一个过时的目录结构,Claude 后续所有判断都基于那个错误上下文。有了历史日志,这类问题就变成可追溯的了。
3.5 实战五:与本地服务和其他 CLI 工具联动
Hooks 本质就是执行 shell 命令,所以它能联动的对象远超 Claude Code 本身。你可以让它在文件更新后调用本地 Flutter/Django 的格式化工具,可以把工具调用记录推送给内部审计 API,也可以把 Claude 的修改摘要交给本地模型服务做二次校验或者生成变更说明。
一个比较实用的做法是:在 PostToolUse 里把“改动的文件列表”交给一个本地脚本,脚本自动生成 commit message 草案并追加到临时文件。这样当 Claude 准备提交代码时,它能直接引用一份结构化的变更说明,提交信息质量有明显提升。
再比如很多团队会写内部 CLI 来做代码规范校验,你可以直接把它作为 hook 挂在 PostToolUse 上,命令换成my-cli check $(jq -r '.tool_input.file_path' <<< "$HOOK_INPUT")。注意这里如果要用 jq,系统里必须有 jq,而且 Hook 执行环境和终端环境不一定完全相同,这在第四部分排查里我会重点提醒。
3.6 大型代码库中落地 Hooks 的三个原则
在超大仓库里用 Hooks,最重要的一点是:不要让钩子脚本变成性能瓶颈。PreToolUse 是同步阻塞的,如果脚本逻辑复杂,每次 Claude 调用工具都有明显延迟,整个体验会变得不可用。
我的三条实操原则:
第一,拦截逻辑保持轻量。PreToolUse 脚本只做模式匹配和判断,不要做深度分析。判断命令里有没有危险关键字用 Python 的in或正则就够,别在拦截脚本里做 AST 解析。
第二,重量操作异步化。格式化、日志归档这类任务,能挂 Async 就别用同步 Block。格式化可以放到 PostToolUse 的普通模式里,日志就放到 Stop 的commandAsync。
第三,用git diff限定范围。PostToolUse 触发时,不要对整个仓库跑 lint,只针对tool_input.file_path或者git diff --name-only输出的文件列表。很多团队在 CI 里已经这么做了,Hooks 里同理,否则改一个文件触发两分钟全仓扫描,谁也受不了。
4. 常见问题与排查技巧实录
4.1 Hook 没触发,先查这三件事
我遇到过最多的问题是:配置写了,脚本也挂了,但事件就是不触发。排查顺序如下:
第一,确认配置文件路径。你改了~/.claude/settings.json,但当前项目里存在.claude/settings.json,项目级配置覆盖或合并后把优先级顶掉了,导致你以为生效的规则其实没生效。建议检查时先claude里执行hooks相关的帮助命令,或者直接手动打开两个文件看有没有冲突。
第二,确认 matcher 有没有匹配上。写"matcher": "Bash"时,如果实际触发的是Bash(git status)这种带额外信息的工具名,就匹配不上。同理,正则写错了也会静默失败。最简单的验证方式是:在脚本第一行把收到的事件 JSON 写到一个临时文件,然后手动构造一次触发,看 JSON 长什么样。
第三,确认脚本本身能不能在 Claude Code 的环境里跑通。Claude Code 在 GUI 里启动时,PATH 环境变量可能和终端里不一样,python命令找不到 Python、node找不到 Node 就会静默失败。这时候不要用python,直接用绝对路径,比如/usr/bin/python3,或者用which python3查一下再写死。
4.2 Timeout 和异步执行的坑
默认情况下 hook 有 60 秒超时。看起来很长,但 PreToolUse 是阻塞式的,一旦脚本执行时间超过预期,Claude 的工具调用会被挂起,表现就是“卡住不动”。如果你的脚本里跑了npm install或全仓eslint,60 秒很容易超。
我踩过的坑是这样的:第一次写 PostToolUse 格式化脚本时,直接在命令里写了npx prettier --write,结果 npx 在第一次运行时要下载包,光下载就等了十几秒。更麻烦的是,这个下载消耗不是每次都有,于是脚本表现不稳定,有时快有时慢。
解决办法是:项目里固定依赖本地安装好的prettier/eslint,命令写node_modules/.bin/prettier而不是npx prettier。或者干脆在脚本开头对依赖做一次确定性检查,缺依赖直接返回,不要现场下载。
异步 hook 也有坑。commandAsync虽然不阻塞主流程,但它不会像同步 hook 那样捕获 stdout 回传,日志写在哪里需要你自己控制。而且异步脚本并发执行时,如果都往同一个文件追加内容,可能产生并发写问题。解决办法是在脚本里用追加模式打开文件,或者加一个简单的文件锁。
4.3 stdout 和 stderr 输出污染上下文
这个问题非常隐蔽。PostToolUse 的 Block 型 hook 会把 stdout 内容作为上下文发给 Claude,如果你的脚本习惯性打印了成功信息,比如Formatting complete.、Processed 12 files.,每一轮都会被塞进上下文。
测试跑下来你会觉得很奇怪:明明没干多少活,怎么上下文消耗这么快?查了半天发现是 hook 脚本刷屏。所以 PostToolUse 脚本的纪律是:正常情况不输出,异常情况才输出,而且输出要精炼。
对于 PreToolUse 的 stderr 也一样。Claude 能看到 stderr,如果你把脚本内部调试信息全打进去,Claude 可能会被干扰,以为发生了什么错误。建议脚本里不要用print做日志追踪,真要记录状态就写文件。
4.4 权限与路径问题
Hooks 是以当前用户的权限执行的,不会额外提权。如果你的脚本需要写/etc下的文件,或者需要访问某个只有 root 才能读的目录,它不会成功。
另一个典型问题是工作目录。Claude Code 的主进程通常以项目根目录作为 cwd,你写的脚本路径和相对路径都要基于这个理解。比如"command": "python3 .claude/scripts/check.py"里的.claude是相对于项目根目录的。但要是你在脚本里还写了open("log.txt", "w"),这个log.txt也会写到项目根目录下,而不是脚本所在目录。需要根据实际需求使用绝对路径,或通过环境变量判断当前目录。
Windows 环境下还要注意 shell 差异。settings.json 里的 command 在 Windows 上走的是 cmd 的语法,和 Linux/macOS 的 bash 语法有区别。跨平台团队的建议是:把脚本用 Python 写,命令统一成python3 .claude/scripts/xxx.py,尽量避免在 JSON 里直接写 shell 专属语法。
4.5 高频问题速查表
| 现象 | 可能原因 | 快速解法 |
|---|---|---|
| Hook 完全不触发 | 配置文件路径不对或 matcher 未匹配 | 检查项目级和用户级 settings.json,临时把 matcher 删掉测试 |
| 工具调用经常卡住 | 同步 hook 执行过慢 | 优化脚本,改用绝对路径解释器,去掉 npx 下载逻辑 |
| 上下文消耗异常高 | PostToolUse 脚本 stdout 刷屏 | 脚本只在异常时输出,成功保持静默 |
| 拦截不生效 | 用了 command 而不是 commandBlock | 确认事件是否支持拦截,改成 commandBlock |
| 异步日志丢失 | commandAsync 不捕获输出 | 日志内容直接由脚本写文件,不要依赖 stdout |
| Windows 下脚本报错 | shell 语法不兼容 | 统一用 Python 脚本,避免 bash 专属指令 |
4.6 调试 Hooks 的三个技巧
最后分享三个实战调试技巧。
第一,写一个 dump 脚本。把输入 JSON 原封不动写到文件。不确定某个事件携带什么数据结构时,就先用 dump 脚本跑一轮,看完输出再写正式逻辑。
import json, sys with open("/tmp/hook_input.json", "w") as f: json.dump(json.load(sys.stdin), f, ensure_ascii=False, indent=2)第二,在命令里临时加个标记文件。有些 hook 执行太快,你不知道它到底有没有跑到。在脚本入口处写一个touch /tmp/hook_running.flag,然后手动触发工具调用,看这个文件有没有生成。没有生成就是脚本没被执行,生成就是脚本内部逻辑有问题。
第三,用 exit code 做故障隔离。脚本里捕获所有异常,异常时sys.exit(2)并往 stderr 写信息,正常路径sys.exit(0)。这样当你看到工具被莫名卡住或拦截时,可以快速判断是脚本抛异常还是业务规则触发。
我个人在实际操作中的体会是:Hooks 这个功能,真正拉开差距的不是“会用”,而是“克制”。你不需要在第一天上手就挂十个事件,那样只会让自己疲于调试。先把 UserPromptSubmit 的审计日志跑起来,再补一个 PreToolUse 危险命令拦截,最后把 PostToolUse 的格式化和 lint 加上,三步走完已经能覆盖绝大部分工程化需求。
踩过几次坑之后,我现在最推荐的第一次尝试,是从 UserPromptSubmit 日志开始。把每天的 prompt 落到一个 JSONL 文件里,第二天回看,你会很直观地发现自己在让 Claude 重复做哪些事,然后才能真正理解该用哪个事件去自动化掉它们。