做了这么久Claude Code的深度用户,我得说Hooks是我见过最容易被低估的功能。很多人把它当成一个“高级用法”放着不管,实际上它才是让Claude Code从“好用的AI命令行工具”变成“真正属于你自己的自动化工作流引擎”的关键分水岭。简单说,Hooks就是一套事件触发器,能在Claude Code执行某个动作之前或之后,自动调用你指定的本地脚本或命令。你可以用它拦截危险操作、记录审计日志、同步知识库、甚至做权限管控。这篇指南我打算从原理讲到实操,再把我踩过的坑一起倒出来,给想把这套机制真正用起来的人一条完整的上手路径。
1. 先搞懂Hooks的核心模型:它到底在“钩”什么
1.1 从“哨兵脚本”到“门卫机制”:Hooks的本质是一次命令拦截
很多人第一次看到Hooks这个词会联想到React里的useState、useEffect,其实两者的核心思想相通:在某个生命周期节点上插入一段你自定义的逻辑。Claude Code本质上是跑在终端里的AI代理,它有自己的执行流程,比如接收用户输入、决定调用哪个工具(读文件、写文件、执行命令)、生成回复、结束会话。Hooks就是在这个流程的关键节点上放一个“哨兵”,让外部脚本有机会介入。
你可以把Claude Code想象成一辆自动驾驶的汽车,Hooks就是路口的红绿灯和限高杆。汽车默认会按照路线行驶,但到了特定路口,红绿灯(Hooks)会告诉它:停车、减速、还是直接通过。这个类比的关键在于,Hooks不仅能“看”,还能“拦”。当Claude Code打算修改某个重要文件时,一个Hook脚本可以在改动真正落地前检查条件、输出一个“block”信号,直接掐断这次操作。这种拦截能力是普通提示词工程完全做不到的,它是代码层面的强制控制。
1.2 Hooks能解决哪些实际问题
我总结了几类最典型的应用场景,这些场景是我在实际使用中验证过、确实能带来效率提升的:
如果你是团队里多人共用一台构建机,或者跑着长时间无人值守的自动化任务,Hooks就是你最需要的“行为审计员”。每次Claude Code执行了什么命令、改了哪些文件,都会被记录下来。这不是日志收集,而是每一次关键操作都有据可循。
当Claude Code准备修改部署配置、数据库连接串、生产环境脚本时,Hooks可以实时检查文件路径,发现高危路径直接拦截,并给出提示。这类保护尤其适合“AI写代码、直接落地生效”的工作流。
Hooks可以调用外部API、读取本地数据库,把Claude Code当前会话的关键信息推送到你的知识管理工具、钉钉群、Slack频道。有时候我们希望AI能自动沉淀知识,Hooks就是那座“桥”。
Claude Code执行到某些节点时,自动跑一遍Lint、跑一遍测试、或者做一次格式化。在AI生成大量代码后,这些原本需要手动执行的检查变得全自动。
看到这里你应该明白,Hooks不是某种炫技的“高级配置”,而是一套让AI行为变得可控、可观测、可编程的工程化基础设施。
2. 配置前的准备工作:环境与配置文件
2.1 确认Claude Code版本与配置入口
Hooks需要比较新的Claude Code版本支持。如果你还没装,或者不确定版本,先在终端跑一句:
claude --version以我手头的版本为例,2.x版本已经完整支持Hooks配置。如果你发现自己的版本过老,建议先升级:
npm update -g @anthropic-ai/claude-codeClaude Code有两种主要的配置方式:项目级配置(放在当前项目的.claude/settings.json下)和用户级配置(放在~/.claude/settings.json)。Hooks既可以在项目级配置里定义,也可以放在用户级,两者规则相同。我通常把跟特定项目相关的Hooks(比如禁止修改某个项目的配置文件)写在项目级,把通用的审计类Hooks写在用户级,这样换项目也不丢。
2.2 认识settings.json的完整结构
先看一个最简配置长什么样子:
{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node /path/to/my-hook.js", "timeout": 30 } ] } ] } }这个配置的意思非常直白:在Claude Code调用Edit或Write这个两个工具之前,运行一次node /path/to/my-hook.js这个脚本,最长等待30秒。matcher是匹配工具名,支持正则表达式。type目前主要用command,也就是执行本地命令。
还有一个常见的字段是cwd,指定脚本运行的工作目录。如果不设置,默认是Claude Code当前所在的目录。timeout的默认值是60秒,但考虑到AI对话场景,我建议你自己显式设置一个更短的值,比如10秒或30秒,这样即使脚本卡住也不会让整个对话等太久。
有个细节需要注意:配置文件里的command不会经过shell解析,而是直接作为命令执行。所以如果你有复杂的管道、重定向,得包一层bash -c "...":
{ "type": "command", "command": "bash -c \"echo 'hello' >> /tmp/log.txt\"" }3. Hooks生命周期事件全解析
3.1 常用事件:PreToolUse、PostToolUse、UserPromptSubmit
Claude Code的Hooks事件数量不算多,但每个事件的触发时机,决定了你能在它身上做什么文章。
PreToolUse是使用频率最高的事件。它在Claude Code调用任何一个工具之前触发。这里有个很容易踩的细节:PreToolUse里你可以让脚本决定“放行”还是“拦截”。脚本只要输出一个包含decision字段的JSON到stdout,Claude Code就会根据结果决定是否继续。流程大概是:脚本收到一个关于即将执行的工具调用的JSON信息,脚本处理完,输出一个JSON响应,如果响应里写的是"decision": "block",这次工具调用就被拦截。
PostToolUse在工具执行完之后触发。它不具备拦截能力,更适合做日志记录、结果校验、数据同步。比如你可以在Claude Code执行完一个重命名操作后,自动把新的文件结构同步到README索引里。
UserPromptSubmit在用户把提示词发给Claude Code之前触发。这个事件的妙处在于,你可以对用户输入做预处理,比如查敏感词、自动补充上下文、记录所有用户的提问历史。
3.2 进阶事件:Notification、Stop、SubagentStop
Notification是Claude Code需要向你请求权限时触发的事件。比如Claude Code想执行一个命令,按默认配置可能会弹出一个确认框,但在Hooks场景下,你可以通过Notification事件配合一个脚本来自动同意或拒绝。这就是很多“无人值守”模式的基础。
Stop在Claude Code完成一段回复时触发,适合做“对话结束”后的整理工作,比如把这一整段对话的摘要写进笔记。
SubagentStop是Claude Code的子代理完成任务时触发的事件。当你开启多个子代理并行干活时,可以用这个事件统一收口,把子代理的结果汇总到某个文件里。
我给这些事件做了一个速查表:
| 事件 | 触发时机 | 能否拦截 | 典型场景 |
|---|---|---|---|
| PreToolUse | 工具调用前 | 能 | 高危操作拦截、参数改写 |
| PostToolUse | 工具调用后 | 否 | 日志采集、结果校验 |
| UserPromptSubmit | 用户输入后 | 能 | 敏感词过滤、输入审计 |
| Notification | 请求权限时 | 否 | 自动审批、无人值守 |
| Stop | 回复结束时 | 否 | 会话摘要、后续任务触发 |
| SubagentStop | 子代理结束时 | 否 | 多代理解散收口 |
3.3 事件负载与stdin输出格式
每个Hook被触发时,会有一段上下文环境变量传入脚本,与此同时,事件本身的详细数据通过stdin传入。我以PreToolUse为例,收到的大概是这样的JSON:
{ "session_id": "xxxx", "transcript_path": "/path/to/log", "cwd": "/path/to/project", "hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": { "file_path": "/path/to/target.js", "new_content": "..." }, "tool_use_id": "toolu_xxxx" }不同的事件字段会略有差异,但session_id、cwd、transcript_path这几个是通用的。脚本处理完需要向stdout输出一个JSON,告知Claude Code结果。对于PreToolUse事件,格式如下:
{ "decision": "allow", "reason": "允许通过" }如果要拦截,就把decision改成block,并写上原因。Claude Code会把reason内容反馈给用户,所以这里可以写得具体一点,比如“该路径为生产配置,禁止修改”。其他不能拦截的事件,可以输出{"decision": "allow"}占位,或者什么都不输出,都能正常继续。
4. 手把手实现三个Hook脚本
4.1 示例一:自动记录每次提问与关键操作
我自己的做法是把Claude Code的提问和工具调用都记录到一个本地Markdown日志里,这样每次会话结束还有一份“干了啥”的档案,而不是靠记忆。实现起来很简单,新建一个log-hook.js:
#!/usr/bin/env node const fs = require("fs"); const path = require("path"); let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const logFile = path.join(payload.cwd, ".claude", "hooks.log"); const timestamp = new Date().toISOString(); const event = payload.hook_event_name; let entry = `[${timestamp}] ${event}`; if (event === "UserPromptSubmit") { entry += ` | 用户提问: ${payload.prompt}`; } if (event === "PreToolUse") { entry += ` | 工具: ${payload.tool_name} | 路径: ${payload.tool_input.file_path || ""}`; } if (event === "PostToolUse") { entry += ` | 工具: ${payload.tool_name} | 状态: ${payload.tool_response && payload.tool_response.status || "done"}`; } fs.appendFileSync(logFile, entry + "\n"); process.stdout.write(JSON.stringify({ decision: "allow" })); } catch (e) { // 解析失败时不阻塞主流程 process.stdout.write(JSON.stringify({ decision: "allow" })); } });然后在settings.json里挂上这事:
{ "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "node /path/to/log-hook.js", "timeout": 10 } ] } ], "PreToolUse": [ { "hooks": [ { "type": "command", "command": "node /path/to/log-hook.js", "timeout": 10 } ] } ], "PostToolUse": [ { "hooks": [ { "type": "command", "command": "node /path/to/log-hook.js", "timeout": 10 } ] } ] } }这里要特别注意:PreToolUse后挂载了log-hook,那log-hook里如果不输出JSON,或者输出格式不对,会导致所有工具调用被阻塞。所以我的脚本里process.stdout.write(JSON.stringify(...))这行是保底设计,就算解析失败,也输出一个allow。
4.2 示例二:对高危文件修改做实时拦截
这个例子更接近“门卫”角色。假设你的项目里有个deploy/config.json,不希望AI在无人监督时乱改。先用Node写一个保护脚本:
#!/usr/bin/env node const fs = require("fs"); const BLOCK_PATTERNS = [ /deploy[\\/]config\.json$/, /\.env(\.local)?$/ ]; let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const filePath = payload.tool_input.file_path || ""; const normalized = filePath.replace(/\\/g, "/"); const isBlocked = BLOCK_PATTERNS.some(pattern => pattern.test(normalized)); if (isBlocked) { process.stdout.write(JSON.stringify({ decision: "block", reason: `路径 ${filePath} 受保护,禁止AI修改。如需变更请手动操作或临时调整Hooks配置。` })); } else { process.stdout.write(JSON.stringify({ decision: "allow" })); } } catch (e) { process.stdout.write(JSON.stringify({ decision: "allow" })); } });再在settings.json里挂上Edit|Write的匹配器:
{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node /path/to/protect-files.js", "timeout": 10 } ] } ] } }这样只要Claude Code打算用Edit或Write工具写这些文件,脚本就会提前拦截。我实际测过,拦截时Claude Code会把reason内容原样展示,它还会主动向用户解释“这个文件受保护,无法修改”,体验上很自然。
需要注意的是,matcher是正则表达式字符串,不是简单的通配符。想匹配多个工具,用|连接就行。但别写成一个容易被误匹配的正则,比如"Edit"其实也能匹配到包含“Edit”字样的工具,但Claude Code的工具集里目前没有这种歧义,放心用。
4.3 示例三:让本地知识库与对话内容同步
这个场景比较进阶:团队里有人用Obsidian这类工具做知识库,希望每次Claude Code进行完一段对话后,能自动把关键结论同步到一个指定笔记里。当然,这个同步不是把完整对话丢进去,而是提取关键内容,方便后续检索。
我写过一个基于Stop事件的简单版本:
#!/usr/bin/env node const fs = require("fs"); const path = require("path"); let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { try { const payload = JSON.parse(input); const sessionDir = path.dirname(payload.transcript_path); const transcript = fs.readFileSync(payload.transcript_path, "utf8"); // 简单提取有代码块的段落,认为这是“技术结论” const codeBlockCount = (transcript.match(/```/g) || []).length; const notePath = path.join(payload.cwd, "knowledge-base", "ai-conversations.md"); const timestamp = new Date().toISOString(); const snippet = `- ${timestamp} | 会话: ${payload.session_id} | 代码块数量: ${codeBlockCount}\n`; fs.mkdirSync(path.dirname(notePath), { recursive: true }); fs.appendFileSync(notePath, snippet); process.stdout.write(JSON.stringify({ decision: "allow" })); } catch (e) { process.stdout.write(JSON.stringify({ decision: "allow" })); } });这个例子不一定适合所有人,但思路值得借鉴:Hooks是打通Claude Code和外部系统之间的桥梁。只要你能在脚本里拿到transcript_path,就相当于拿到了整个会话记录的钥匙,几乎所有文本处理都能做。
5. 常见问题与排查心得
5.1 脚本不触发、参数不生效的原因
我一开始也遇到过Hooks完全没反应的情况,后来排查下来,最常见的原因是配置文件放错了位置。Claude Code读取配置的优先级是:项目级.claude/settings.json会覆盖用户级~/.claude/settings.json,如果你的项目里恰好有一个项目级配置,它会里没有Hooks,那用户级里的Hooks就不会生效,因为整个配置文件会被替换掉,而不是合并。
这个细节非常关键。我之前就是把用户级Hooks写得妥妥的,结果项目里已有的.claude/settings.json只有几行模型设置,导致所有Hooks都失效。解决办法很朴素:把Hooks配置合并到项目级配置里,或者把它也复制一份到项目级。
另外,命令行工具本身不会常驻后台,修改settings.json后也不需要“重启服务”,只要新开一个Claude Code会话就会重新加载配置。如果改了不生效,检查一下是不是当前会话没有退出。
5.2 超时、输出格式错误与阻塞策略
Hooks最常见的报错就是脚本输出不合法。Claude Code要求脚本往stdout输出一个JSON对象,如果脚本里不小心打印了无关的日志行、调试信息,比如console.log("debug"),那整个输出就不再是标准JSON,Claude Code会视为格式错误,可能导致工具调用被强制拦截。
这个问题我在调试Hooks时踩过好几次。解决方法是:不要用console.log输出调试信息,而是把调试信息写到stderr。Claude Code只解析stdout,stderr的内容只会作为错误信息提示给用户,不影响JSON解析。我习惯在脚本里用console.error记录调试日志。
另一个常见问题是超时。默认是60秒,但如果你的Hook里跑了个子进程,比如执行了一个慢速的Shell命令,很容易拖累对话节奏。timeout不是硬盘坏了,是让脚本最多允许跑这么久,超时后Claude Code会继续流程,但会报一个Hook超时的警告。对多数场景,timeout设置为10~30秒是合理的,既不会阻塞太久,也不会因为网络抖动就频繁失败。
5.3 调试Hooks的实用技巧
调试Hooks不像调试普通Node脚本那么直观,因为触发它的是AI会话,很难复现同一个输入。我的办法很简单:先在终端里手动跑一次脚本,构造一个符合规范的JSON塞给它。
echo '{"session_id":"test","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/tmp/test.txt"}}' | node protect-files.js这样能很快看到脚本输出的是什么、有没有报错。等脚本本身没问题了,再挂到Claude Code里跑端到端验证。这个方法对排查JSON转义问题特别有用,配置里的正则表达式或者路径如果有特殊字符,可以先在命令行里跑一遍,避免在Claude Code里反复试错。
还有一个排查思路:Claude Code的transcript_path里保存了完整的会话记录,如果你怀疑某个Hooks没生效,可以先看会话日志,搜索hook关键字。有时候Hooks不是没触发,而是触发后脚本出错了,Claude Code会在日志里留下痕迹。
最后给一个排障速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Hook完全不执行 | 配置文件未加载或被项目级覆盖 | 检查项目级与用户级配置是否合并 |
| 工具调用被中断 | 脚本stdout输出了非JSON内容 | 用stderr输出调试信息 |
| 脚本报错但节奏未断 | 脚本抛异常后没兜底 | 在catch块里输出allow |
| 修改配置后不生效 | 旧会话未结束 | 重启claude会话 |
| 事件匹配不准 | matcher写错正则 | 用node手动测试正则 |
私心地说,Hooks这套机制的潜力远不止我今天写的这几个示例。它本质上给了你一个“任意介入Claude Code行为”的编程接口,你完全可以根据自己的需求组合出复杂的自动化流程,比如只允许在特定分支上执行命令、在测试不通过时拦截提交通知、把AI的关键操作汇总成日报等等。
我在实际使用中最舒服的一点是,Hooks是代码层面的强制控制,不依赖AI的自觉性。就算Claude Code某次没理解我的意图,Hooks也能在底层兜住。这种“确定性”在AI场景里太金贵了。如果你也在用Claude Code做一些有点风险的操作,我真心建议花一个下午把Hooks配置起来,它会很快成为你离不开的工作流基建。最后再分享一个小技巧:Hooks脚本尽量写成纯Node脚本,比Shell脚本在跨平台和转义上省心很多,尤其你跟我一样经常在Windows和macOS之间横跳的时候。