Claude Code Hooks 实战:改完自动整理格式、拦住不该动的文件、等你批准时发通知
Hooks 是你写的 shell 命令,Claude Code 在固定时机一定会执行,不取决于模型愿不愿意。本站实测了三个:改完文件自动跑 eslint --fix,拦下对受保护文件的修改,等待批准时提醒你。附配置、脚本、真实的拦截画面,和它管不到的地方。
本站已实测,测试日期:2026-10-11。
本页目录(11 节)
这篇教程解决一个问题:有些事你要 Claude Code 每一次都做,或者每一次都不许做,怎么保证。
写进 CLAUDE.md 是不够的。CLAUDE.md 那篇里说过,它的内容是交给模型参考的上下文,模型会尽量照做,但不保证。Hooks 不一样:它们是你写的 shell 命令,Claude Code 在固定的时机直接执行,不经过模型的判断。官方文档的说法是,hook 给你的是确定性的控制,某些动作一定会发生,而不是指望模型选择去做。
本站实测了最常用的三个,配置和脚本都可以直接拿去改。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-11 |
| 版本 | Claude Code 2.1.296 |
| 系统 | Ubuntu 24.04,已安装 jq |
| 模型 | Opus 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 示例仓库 | Express 的源码,v5.3.0 |
先看懂一个 hook 长什么样
Hook 写在设置文件的 hooks 块里。三层结构:哪个事件、匹配什么、执行什么。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "这里写要执行的命令" }
]
}
]
}
}
| 部分 | 意思 |
|---|---|
PostToolUse |
事件:在一次工具调用成功之后 |
matcher |
过滤:只在工具是 Edit 或 Write 时触发。不写就每次都触发 |
command |
要执行的 shell 命令 |
写在哪个文件,决定它管多大范围:
| 位置 | 范围 | 能不能共享 |
|---|---|---|
~/.claude/settings.json |
你的所有项目 | 不能,只在本机 |
.claude/settings.json |
这一个项目 | 能,可以提交进仓库 |
.claude/settings.local.json |
这一个项目 | 不能,不进 git |
下面三个例子,前两个写在项目的 .claude/settings.json 里,团队共用。
例一:改完文件,自动整理格式
要解决的事:它每次改完 JavaScript 文件,都按项目的 lint 规则自动修一遍格式,不用你提醒,也不用它记得。
用 PostToolUse 事件,匹配 Edit|Write:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | grep -E '\\.js$' | xargs -r npx eslint --fix"
}
]
}
]
}
}
这条命令做了三件事。事件触发时,Claude Code 会把这次工具调用的信息以 JSON 的形式从标准输入传给命令;jq 从里面取出被改的文件路径;grep 只留下 .js 文件;最后交给 eslint --fix。官方文档的例子用的是 Prettier,我们换成了 Express 自己在用的 ESLint,另外加了 grep 那一段,免得改 Markdown 时也去跑 lint。
实测。 让它写一个故意格式不对的文件:
用 Write 工具新建 notes/fmt.js:一个函数 add(a, b),返回 a + b,并导出它。函数体故意用 4 个空格缩进,
return 那一行行尾多留 3 个空格。写完不要自己整理格式,也不要跑 lint 和测试。
写完后我们用 cat -A 看文件的实际内容:
function add(a, b) {$
return a + b;$
}$
$
module.exports = add;$
缩进是 2 个空格,行尾没有多余的空格。hook 生效了。
有意思的是它自己的反应。它发现文件和它写的不一样,去查了原因,然后这样报告:
hook 改了文件。 .claude/settings.json 里配了一个 PostToolUse hook,每次 Edit/Write 一个 .js 文件后都会
自动跑 npx eslint --fix。它把 4 空格缩进改成了 2 空格,行尾空格就算有也会被删掉。这一步是 harness 跑的,
我拦不住。
“我拦不住”这四个字就是 hook 和 CLAUDE.md 的区别。
官方文档提醒:hook 成功时,对话里什么都不显示。要确认它跑了,就像我们这样去看文件。
例二:拦住不该动的文件
要解决的事:有些文件不许它改,比如 .env、锁文件、.git/ 下的东西,或者像 Express 的 History.md 这样只在发版时由维护者手工更新的文件。
这次用 PreToolUse,它在工具调用执行之前触发,可以把调用拦下来。逻辑稍多,写成一个脚本。保存为 .claude/hooks/protect-files.sh:
#!/bin/bash
# protect-files.sh:拦下对受保护文件的修改
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED_PATTERNS=(".env" "History.md" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH 是受保护的文件(匹配 '$pattern')。History.md 只在发版时由维护者手工修改。" >&2
exit 2
fi
done
exit 0
脚本改自官方文档的例子。关键在最后几行的退出码:
| 退出码 | 意思 |
|---|---|
| 0 | 没有意见。注意这不等于批准,正常的权限流程照常走 |
| 2 | 拦下这个动作。写到标准错误的内容会交给 Claude,让它知道为什么 |
| 其他 | 当作 hook 自己出错了,动作照常进行 |
所以拦截的理由要写清楚,那是写给模型看的。
给脚本加上执行权限,再在设置里登记:
chmod +x .claude/hooks/protect-files.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
$CLAUDE_PROJECT_DIR 是项目的根目录,用它可以避免相对路径找不到脚本。
先手工测一遍脚本。 这是官方文档推荐的排错办法,不用启动 Claude Code 就能验证:
echo '{"tool_name":"Edit","tool_input":{"file_path":"/home/ccdemo/demo/a2-express/History.md"}}' | ./.claude/hooks/protect-files.sh
echo "退出码: $?"
我们得到的是那句 Blocked: 和 退出码: 2;把路径换成 lib/utils.js,退出码是 0。
实测。 让它去改 History.md:
现在在 History.md 最上面加一行:* demo: hook 测试
第一次的结果出乎意料,它根本没有去试:
这行我没有加。项目的 PreToolUse hook(.claude/hooks/protect-files.sh)把 History.md 列为受保护文件,
脚本里写的规则是:“History.md 只在发版时由维护者手工修改”。
技术上我可以用 Bash 写进去绕开这个 hook,因为它只拦 Edit/Write。但这等于跳过你们自己设的保护规则,
所以我不会自己这么做。
它先读了那个脚本,知道会被拦,就没有动手。为了看到拦截本身的样子,我们让它照做一次:
● Update(History.md)
⎿ Error: PreToolUse:Edit hook error: ["$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-files.sh]: Blocked:
/home/ccdemo/demo/a2-express/History.md 是受保护的文件(匹配 'History.md')。History.md 只在发版时由维护者手工修改。
● Bash(git status --short History.md; head -2 History.md)
⎿ 5.3.0
=====
● Edit 被 hook 拦下了,History.md 没有被改动。
修改没有发生,脚本写到标准错误的那句话原样交给了它。
这个 hook 管不到的地方
上面那段回答里,它自己把漏洞说出来了:这个 hook 只匹配 Edit 和 Write,用命令行改文件它看不见。 例一里它也提过同样的事:改用 Bash 的 heredoc 写文件,格式化的 hook 就不会触发。
这次它选择了不绕过,但这是模型的判断,不是保证。在权限模式那篇的实测里,它有两次就是用命令而不是编辑工具改的文件。
官方文档对此有明确的提醒,并给了几个办法:
- 想按每次调用来管,就让 matcher 同时匹配
Bash,在脚本里用git status --porcelain列出被改动和新增的文件。 - 想每一轮结束时统一检查一次,用
Stop事件扫一遍工作区。 - 想盯住某个具体的文件,不管是谁改的,用
FileChanged事件。
另外,“不许读、不许改某个文件”这类要求,用权限规则里的 deny 往往更直接,写法见权限模式那篇。
例三:它在等你的时候,提醒你
要解决的事:你让它干活,自己切去做别的。它停下来等你批准的时候,你不知道。
用 Notification 事件。在有桌面的电脑上,官方文档给的命令是:
| 系统 | 命令 |
|---|---|
| macOS | osascript -e 'display notification "Claude Code needs your attention" with title "Claude Code"' |
| Linux | notify-send 'Claude Code' 'Claude Code needs your attention' |
这类个人习惯适合放在 ~/.claude/settings.json 里。macOS 的完整写法:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
matcher 留空表示所有类型的通知都触发。常用的两个取值:
| matcher | 什么时候触发 |
|---|---|
permission_prompt |
它需要你批准一次工具调用,而且确认框已经等了大约 6 秒 |
idle_prompt |
它答完大约 60 秒了,你还没有输入 |
实测。 我们的环境是一台没有桌面的服务器,notify-send 用不了(官方文档也说了,无界面的服务器和 SSH 会话没有通知服务)。所以把命令换成往文件里写一行,来验证事件确实触发了:
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "echo \"$(date '+%H:%M:%S') Claude Code 在等你批准\" >> \"$CLAUDE_PROJECT_DIR\"/.claude/notify.log"
}
]
}
切到 Manual 模式,让它跑一条需要批准的命令,然后不去管它:
| 时间 | 日志文件 |
|---|---|
| 确认框出现后 4 秒 | 还没有内容 |
| 确认框出现后 13 秒 | 17:01:44 Claude Code 在等你批准 |
和文档说的“大约 6 秒”对得上:你马上就处理的确认框不会打扰你,放着不管的才会提醒。
macOS 上如果通知不出现,官方文档给了原因:osascript 的通知是通过“脚本编辑器”这个应用发的,它没有通知权限时命令会悄悄失败。先在终端里运行一次那条命令,再到“系统设置 › 通知”里给脚本编辑器打开权限。这一条我们没有实测。
用 /hooks 查看
在会话里输入 /hooks,可以看到当前生效的所有 hook,按事件分组,并标明来自哪个设置文件:
Hooks
3 hooks on 3 events
This menu is read-only. To add or change a hook, edit settings.json or ask Claude.
PreToolUse before tool execution
❯ [Project] "$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-files… Edit|Write · command
PostToolUse after tool execution
[Project] jq -r '.tool_input.file_path' | grep -E '\.js$' |… Edit|Write · command
Notification when notifications are sent
[Project] echo "$(date '+%H:%M:%S') Claude Code 在等你批准"… permission_prompt · command
这个菜单是只读的。要改 hook,直接改设置文件,或者让 Claude 帮你写。官方文档说,会话运行期间改了设置文件,通常会自动生效,不用重启。
还有哪些事件
官方文档列了三十多个事件。最常用的几个:
| 事件 | 什么时候触发 | 典型用法 |
|---|---|---|
PreToolUse |
工具调用执行之前,可以拦 | 禁止某些命令、保护文件 |
PostToolUse |
工具调用成功之后 | 格式化、跑 lint |
UserPromptSubmit |
你发出一句话、Claude 处理之前 | 给每句话附加背景信息 |
Stop |
Claude 答完的时候 | 检查没通过就不让它停 |
SessionStart |
会话开始或恢复时 | 注入背景;配 compact 匹配可以在上下文压缩后把要紧的规矩重新塞回去 |
Notification |
Claude Code 发通知时 | 桌面提醒 |
PermissionRequest |
它要向你请求批准时 | 自动批准你总是同意的那几种 |
除了执行命令,hook 还有另外几种类型:http 是把事件数据发到一个地址;prompt 是让一个模型做一次判断,适合“这件事算不算做完”这类不能用规则写死的问题;agent 是带工具的多轮核查,官方注明还是实验性的。
用 Stop hook 做“检查不过就接着干”的思路,和 /goal 是一回事,见Harness 工程和 Loop 工程是什么。
几条要知道的限制
按官方文档:
PostToolUse撤销不了动作。 它触发时工具已经执行完了,只能事后补救。要拦就用PreToolUse。PreToolUse的拒绝在任何权限模式下都有效,包括bypassPermissions。这让你可以定一条换权限模式也绕不过去的规矩。反过来不成立:hook 返回“允许”,盖不过设置里的 deny 规则。- 同一个事件上的多个 hook 是并行跑的。 一个 hook 拒绝了,不会阻止其他 hook 执行。
- 有超时。 命令类的 hook 默认 10 分钟,
UserPromptSubmit是 30 秒;可以用timeout字段单独设。 Stop在它每次答完时都会触发,不只是任务完成时。- matcher 区分大小写。
要一次关掉所有 hook,在设置里写 "disableAllHooks": true。
不生效的时候怎么查
- 运行
/hooks,确认它出现在正确的事件下面。 - 检查 matcher 和工具名是否完全一致,包括大小写。
- 像上面那样,用
echo '...' | ./脚本手工测一遍,看输出和退出码。 - 对话里出现
hook error字样,说明脚本以意料之外的退出码结束了。提示找不到命令时,用绝对路径或者$CLAUDE_PROJECT_DIR。 - 要看完整的错误输出,用
claude --debug启动。
什么时候用 hook,什么时候不用
| 你想要的 | 用什么 |
|---|---|
| 它应该知道的背景和约定 | CLAUDE.md |
| 只在某些任务里用到的步骤 | skill |
| 每一次都必须发生的动作 | hook |
| 不许用某个工具、不许碰某个路径 | 权限规则里的 deny |
判断的办法很简单:如果它偶尔不做也没关系,写进 CLAUDE.md;如果一次都不能漏,写成 hook。
这一趟花了多少
整个会话 /cost 显示 0.31 美元,模型工作 1 分 10 秒。这是 Claude Code 按官方价格估算的数字,不是中转站实际扣的金额。hook 本身是在你的机器上跑的 shell 命令,不消耗 token。
这次实测没有覆盖到的
- 桌面通知没有实测。 服务器没有桌面,我们用写日志代替,验证的是事件会触发、大约在 6 秒后触发。
- 只测了命令类的 hook,
http、prompt、agent三种没有测。 Stop、SessionStart、PermissionRequest等事件没有测,按官方文档写的。- 例二里它没有绕过 hook,只看了这一次。 不要把它当成保证,该补的漏洞照上面的办法补。
- Windows 上的写法没有涉及。 官方文档里有 PowerShell 的例子。