在 Claude Code 的 auto-format 场景里,最常见的做法是注册一个 PostToolUse Hook,让它在 Edit / Write 工具完成后自动跑 Prettier、gofmt、ruff 这类格式化器。整条链路能跑通的关键是 Hook 能从标准输入的 JSON payload 里稳定拿到 tool_input.file_path——而这个字段的来源正是 Claude Code 的「内部 tool schema」抽象层,与 MCP 工具协议无关。
一、PostToolUse 接收到的 JSON 结构
当 Edit / Write 工具执行成功后,Claude Code 会把一个 JSON 对象通过 stdin 喂给注册的 Hook 命令。这个对象是 Claude Code 自己组装的事件载荷,结构完全由 Claude Code 内部决定:
{
"session_id": "abc123",
"transcript_path": "/Users/dev/.claude/projects/.../00893aaf.jsonl",
"cwd": "/Users/dev/project",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/path/to/file.txt",
"content": "file content"
},
"tool_response": {
"filePath": "/path/to/file.txt",
"success": true
},
"tool_use_id": "toolu_01ABC123",
"duration_ms": 12
}
要拿到「被修改的文件路径」,Hook 脚本从 stdin 读 JSON、解析后取 data.tool_input.file_path 即可:
// .claude/hooks/format-edited-file.mjs
import { execFileSync } from "node:child_process";
let buf = "";
process.stdin.on("data", (c) => (buf += c));
process.stdin.on("end", () => {
const data = JSON.parse(buf || "{}");
const filePath = data.tool_input?.file_path;
if (!filePath) process.exit(0);
execFileSync("pnpm", ["prettier", "--write", filePath], { stdio: "inherit" });
});
二、file_path 来自哪一层抽象
很多教程误把 file_path 当成「MCP 协议字段」,但实际上 Edit / Write 是 Claude Code 的内置工具,不走 MCP 协议。tool_input 的字段顺序与命名遵循的是 Claude Code 的「内部 tool schema」。
| 抽象层 | 谁维护 | 工具举例 | 字段命名 | 协议 |
|---|---|---|---|---|
| 内部 tool schema | Claude Code 工程团队 | Read、Write、Edit、Bash、Grep | file_path、command、pattern |
私有 JSON 契约 |
| MCP 工具协议 | MCP 规范与各 Server | GitHub MCP、Postgres MCP | 工具定义由 Server 决定 | JSON-RPC 2.0 |
理解这一点很关键:MCP 工具的 schema 是由各 Server 自行声明的,Claude Code 只透传 mcp__<server>__<tool> 这种命名空间;而 PostToolUse payload 里的 tool_input 是 Claude Code 内部把模型发出的 tool_use 块原样回放——结构遵循 Claude Code 工具注册表里的 JSON Schema。因此,Edit / Write 的 tool_input.file_path 是 Claude Code 内部约定的字段名,不在 MCP 协议中定义。
三、为什么不同事件 payload 完全不同
PostToolUse 之外,其他事件 payload 结构差异很大。Hook 脚本必须按事件名分支处理:
| 事件 | 关键字段 | 用途 |
|---|---|---|
| UserPromptSubmit | prompt |
注入上下文、脱敏 |
| PreToolUse | tool_name、tool_input |
拦截危险命令、权限校验 |
| PostToolUse | tool_name、tool_input、tool_response |
自动格式化、记录工具副作用 |
| Stop | (任务完成信号) | 跑测试、发通知 |
| Notification | message |
通知类消息 |
常见踩坑是「直接假设所有 PostToolUse 都有 file_path」。事实上只有 Edit / Write / MultiEdit 等文件类工具的 tool_input 才带 file_path;Bash 的 tool_input.command、Grep 的 tool_input.pattern 都是各自不同的字段。工程上建议先按 tool_name 分支,再取对应字段,不要写「一把梭」脚本。
3.1 字段差异的根因
字段差异的根因是「每个工具的入参 schema 由其内部定义决定」。file_path、command、pattern 这类字段名不是 Claude Code 凭空写出来的,而是对应工具注册到工具系统时声明的 JSON Schema 名称。Hook payload 只是把模型发出的 tool_use 块按声明的 schema 原样回放。换句话说,Hook 看到的字段结构 = 工具的输入 schema,工具的输入 schema 在 Claude Code 工具注册表里固定——只要工具定义不变,Hook 拿到的字段就稳定。
四、auto-format 场景的落地步骤
把上述机制接成可用的 auto-format 链路,按四步走最稳:
- 在
.claude/settings.json的hooks.PostToolUse里注册matcher: "Edit|Write|MultiEdit",指定一个脚本路径; - 脚本从 stdin 读 JSON,按
tool_name分支后取data.tool_input.file_path; - 按文件后缀调用对应格式化器(
.ts→ Prettier、.py→ ruff、.go→ gofmt); - 无论格式化是否成功都以
exit 0收尾,避免因工具未安装而阻断主流程。
下面这段 Bash 实现展示了完整分支逻辑,落到任意 Unix 系统都能直接跑:
#!/usr/bin/env bash
# ~/.claude/hooks/auto-format.sh
set -e
payload=$(cat)
file_path=$(printf '%s' "$payload" | jq -r '.tool_input.file_path // empty')
[ -z "$file_path" ] && exit 0
case "$file_path" in
*.ts|*.tsx|*.js|*.json) pnpm prettier --write "$file_path" ;;
*.py) ruff format "$file_path" 2>/dev/null ;;
*.go) gofmt -w "$file_path" 2>/dev/null ;;
esac
exit 0
4.1 步骤背后的工程考量
第一步把 matcher 写成 Edit|Write|MultiEdit 的并集是有意为之——只匹配 Edit 会漏掉 Write 创建新文件后的格式化;只匹配 Write 又会漏掉存量文件的小幅编辑。三者并集覆盖所有写盘路径,是 auto-format 场景的最小完备集。第二步按 tool_name 分支取字段,是因为同一事件下 Bash / Grep / Edit 的 tool_input 字段名完全不同,一把抓取会让脚本在某些场景拿到空值。第三步按后缀选择格式化器,是因为不同语言的官方格式化器输出风格差异大,统一调用一个工具反而会引入跨语言的不一致。第四步永远 exit 0 收尾,是因为 PostToolUse 失败会让 Claude Code 在某些版本里把整次工具调用标记为异常,从而误导模型的下一步推理;让 Hook 自身吞掉错误、把日志写到文件,是更稳的做法。
五、与 MCP 工具链路的区别
把 auto-format 链路和 MCP 工具调用放一起看,更能凸显 PostToolUse 依赖的「内部 tool schema」这一层抽象的独特性。MCP 工具的入参由各 Server 自行声明,Claude Code 不强制字段命名,Hook 看到的是 mcp__<server>__<tool> 命名空间下、由 Server schema 决定的一组键;内部工具的入参由 Claude Code 自己的工具注册表定义,字段稳定且跨版本基本不破坏。把这一点记清楚,调试 Hook 时就不会拿着 MCP 文档去查 Edit 工具的字段含义,反之亦然。
到这里,PostToolUse 提取 file_path 的整条链路就清楚了:Claude Code 内部工具 schema 定义了 Edit / Write 的入参形状,模型发出 tool_use 块后 harness 把同一份 tool_input 回放到 PostToolUse 事件的 JSON payload 里,Hook 脚本从 stdin 读出后按 schema 取字段——这一机制依赖的是 Claude Code 的内部 tool schema,而不是 MCP 工具协议。
常见问题(FAQ)
Q1:file_path 是 MCP 协议字段吗?
不是。它是 Claude Code 内部 tool schema 为 Edit / Write 等内置工具定义的参数,PostToolUse 把它原样回放。
Q2:MCP 工具能拿到 file_path 吗?
MCP 工具的 schema 由各 Server 自定义,需要按 Server 文档读取对应字段,没有统一 file_path。
Q3:PostToolUse 失败会回滚工具吗?
不会。PostToolUse 跑在工具已成功之后,Hook 失败只影响后续动作,不会撤销已落盘的文件改动。