Claude Code Hooks 入门:自动格式化、自动跑测试、拦截敏感文件

在 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 适合写"必须发生的事"。建议从两个开始:项目里配一个编辑后自动格式化,本机配一个等授权时的通知。用顺手了,再加敏感文件保护和测试检查。

参考资料

相关推荐
ianzh1 小时前
两个 WorkBuddy 客户端总被“合并”解决方法
ai编程
桃西西呀2 小时前
语义缓存:命中率上去了,却把问「今天天气」的人答成了「明天天气」
人工智能·llm·ai编程
不得了zz3 小时前
04-Spring AI Alibaba ReactAgent 生产级实践:工具调用、结构化输出、HITL 与上下文管理
java·ai编程·springaialibaba
aichitang20243 小时前
QCU 2.0.0:补齐 Windows、Linux 与 GNOME Wayland
linux·运维·windows·ai·agent·ai编程
人丰5 小时前
AI 写的代码越漂亮,越不敢改|Spec Kit 实战:7 阶段 + 多模型分工,终结许愿式编程
ai编程
lugiax9 小时前
拿了奥数满分的 AI,为什么偏偏是个“生活小白”?
ai编程
用户842558130879 小时前
界面说「已保存」,接口说「每天只能发 5 篇」:一次发布失败的完整取证
ai编程
用户539418729079 小时前
别只在 prompt 里让 AI 别碰文件:我给仓库装了四道提交闸门,越界直接拒绝提交
ai编程·cursor
ZzT9 小时前
Claude Code 团队的代码评审:合并量翻倍,回滚率降了 40%
ai编程·claude