跳到主要内容

实战解读:ReadTool / EditTool / WriteTool

🟡 进阶

文件操作是 Claude Code 最频繁执行的任务。FileReadToolFileEditToolFileWriteTool 三个工具构成了完整的文件操作能力。它们看起来功能相近,但在设计上有很多精心的差异。

三个工具的功能对比

FileReadToolFileEditToolFileWriteTool
工具名ReadEditWrite
主要功能读取文件内容精确字符串替换完整文件写入(新建或覆盖)
isReadOnlytruefalsefalse
isConcurrencySafetrue(只读)false(写入)false(写入)
maxResultSizeCharsInfinity100,000100,000
需要用户确认
典型用例查看代码、读取配置修改函数、修复 bug创建新文件、重写文件

FileReadTool:不只是 cat

输入 Schema

// source/src/tools/FileReadTool/FileReadTool.ts(位于 inputSchema 定义处)
z.strictObject({
file_path: z.string().describe('The absolute path to the file to read'),
limit: z.number().optional().describe(
'The number of lines to read. Only provide if the file is too large to read at once.',
),
offset: z.number().optional().describe(
'The line number to start reading from. Only provide if the file is too large to read at once',
),
})

offsetlimit 让 Claude 可以分页读取大文件,而不是把整个文件塞进上下文。

特殊文件处理

FileReadTool 不只是简单的 fs.readFile(),它对多种文件类型有专门处理:

阻止危险设备文件

// source/src/tools/FileReadTool/FileReadTool.ts,第 98 行
const BLOCKED_DEVICE_PATHS = new Set([
'/dev/zero', // 无限输出,永远不会 EOF
'/dev/random', // 无限随机字节
'/dev/urandom', // 同上
'/dev/stdin', // 阻塞等待输入
'/dev/tty', // 终端设备
// ...
])

读取这些路径会导致进程挂起或上下文被无限数据填满,所以直接拒绝。

PDF 文件支持

// 根据扩展名和 API 能力决定处理方式
if (isPDFExtension(filePath)) {
if (isPDFSupported()) {
// 将 PDF 作为图片发给 Claude Vision API
return await readPDF(fullFilePath, { pages, limit, offset })
} else {
// 回退:提取纯文本(会丢失格式)
return await extractPDFPages(fullFilePath)
}
}

图片文件支持

// 图片文件转换为 base64,作为 vision 内容发给 Claude
if (IMAGE_EXTENSIONS.has(extension)) {
const imageBuffer = await readFileAsync(fullFilePath)
const resized = await maybeResizeAndDownsampleImageBuffer(imageBuffer, ...)
return {
type: 'base64_image',
data: resized.toString('base64'),
mediaType: detectImageFormat(imageBuffer),
}
}

Jupyter Notebook 支持

// .ipynb 文件以 JSON 读取后格式化展示
if (filePath.endsWith('.ipynb')) {
const notebook = await readNotebook(fullFilePath)
return mapNotebookCellsToToolResult(notebook)
}

maxResultSizeChars 为什么是 Infinity?

// source/src/tools/FileReadTool/FileReadTool.ts(buildTool 配置处)
maxResultSizeChars: Infinity,

注释里解释了原因:"Set to Infinity for tools whose output must never be persisted (e.g. Read, where persisting creates a circular Read→file→Read loop)"。

如果读取结果太大就持久化到文件,然后告诉 Claude "请用 FileRead 去读这个文件"——Claude 去读那个文件,结果又太大,又被持久化……这是一个无限循环。所以 FileReadTool 自己管理大文件(通过 limit 参数),而不交给通用的持久化机制。

文件修改时间戳缓存

// 读取文件后,把内容和时间戳存入缓存
toolUseContext.readFileState.set(absoluteFilePath, {
content: fileContent,
timestamp: await getFileModificationTimeAsync(absoluteFilePath),
offset,
limit,
})

这个缓存在 FileEditTool 的验证阶段有重要作用——防止 Claude 基于过期的文件内容进行编辑。

FileEditTool:精确字符串替换的设计哲学

为什么是 old_string/new_string 模式?

这是文件编辑工具设计中最关键的问题。其他方案包括:

  • 行号范围替换(lines: [10, 20], content: "...")
  • Unified Diff 格式(diff: "@@ -10,5 +10,7 @@...")
  • 直接传新文件内容

old_string/new_string 模式的优势:

1. 对 Claude 友好:Claude 在生成代码时,自然地"读到旧代码,写出新代码",这与 old_string/new_string 的思维模型完美契合。行号模式要求 Claude 精确记住行号,这在长上下文中容易出错。

2. 防止意外替换old_string 要求完整匹配。如果 Claude 要替换的代码块在文件中不存在(比如文件被并发修改了),操作会立即失败,而不是静默地写入错误位置。

3. 自然支持最小化 diff:工具会展示类似 git diff 的变化,让用户清楚地看到精确修改了什么。

validateInput():防止幂等失败

// source/src/tools/FileEditTool/FileEditTool.ts,第 137 行
async validateInput(input, toolUseContext) {
const { file_path, old_string, new_string, replace_all = false } = input

// 防止无意义的相同内容替换
if (old_string === new_string) {
return {
result: false,
message: 'No changes to make: old_string and new_string are identical.',
errorCode: 0,
}
}
// ...
}

更重要的是"过时写入"检测。如果 Claude 读取了文件后,在生成编辑命令之前文件被外部修改了,这次编辑应该失败而不是静默覆盖:

// 检查文件是否在读取后被修改
const readState = toolUseContext.readFileState.get(fullFilePath)
if (readState) {
const currentTimestamp = getFileModificationTime(fullFilePath)
if (currentTimestamp !== readState.timestamp) {
return {
result: false,
message: FILE_UNEXPECTEDLY_MODIFIED_ERROR,
errorCode: 2,
}
}
}

FILE_UNEXPECTEDLY_MODIFIED_ERROR 常量的内容会告知 Claude:"文件在读取后被外部修改,请重新读取文件内容再进行编辑"。

findActualString():模糊匹配容错

// source/src/tools/FileEditTool/utils.ts(简化)
export function findActualString(
fileContent: string,
oldString: string,
): string | null {
// 精确匹配
if (fileContent.includes(oldString)) return oldString

// 忽略行尾空白的模糊匹配
const normalizedContent = fileContent.replace(/[ \t]+$/gm, '')
const normalizedOld = oldString.replace(/[ \t]+$/gm, '')
if (normalizedContent.includes(normalizedOld)) {
// 找到对应位置,返回原始文件中的实际字符串
// ...
}

return null // 找不到
}

这个"容错机制"处理了 Claude 生成的 old_string 与文件中实际内容有细微空白差异的情况(常见于不同的编辑器设置)。

FileWriteTool:完整文件写入

输入 Schema 的极简设计

// source/src/tools/FileWriteTool/FileWriteTool.ts,第 56 行
z.strictObject({
file_path: z.string().describe(
'The absolute path to the file to write (must be absolute, not relative)',
),
content: z.string().describe('The content to write to the file'),
})

只有两个字段,没有分页、没有模式选项。FileWriteTool 就是"完整替换文件内容",不多不少。

create vs update:输出类型区分

// source/src/tools/FileWriteTool/FileWriteTool.ts,第 68 行
const outputSchema = lazySchema(() =>
z.object({
type: z.enum(['create', 'update']), // 新建还是更新
filePath: z.string(),
content: z.string(),
structuredPatch: z.array(hunkSchema()), // diff 展示
originalFile: z.string().nullable(), // 原始内容(null 表示新文件)
gitDiff: gitDiffSchema().optional(), // Git diff(如果在 git 仓库中)
}),
)

type 字段区分"创建新文件"和"覆盖现有文件",让 UI 可以展示不同的样式(新建用绿色,覆盖用橙色提示)。

权限级别:写入比读取严格

// FileWriteTool 的权限检查
async checkPermissions(input, context) {
const appState = context.getAppState()
return checkWritePermissionForTool(
FileWriteTool,
input,
appState.toolPermissionContext,
)
}

checkWritePermissionForToolcheckReadPermissionForTool 的区别:

  • 读取:仅检查 deny 规则,默认允许(isReadOnly = true
  • 写入:先检查 deny 规则,再检查 allow 规则,默认需要用户确认isReadOnly = false

default 权限模式下,每次 FileWrite 操作都会弹出确认框,显示文件路径和 diff。用户选择"总是允许"后会添加 allow 规则,后续相同路径的写入就不再询问。

三者的协作模式

在实际使用中,三个工具经常协同工作:

Read(file.ts)              → 获取文件内容,建立时间戳缓存
Edit(file.ts, old, new) → 精确修改特定代码段
Edit(file.ts, old2, new2) → 继续修改第二处(基于更新后的状态)
Write(new_file.ts, ...) → 创建新的测试文件
Read(new_file.ts) → 验证写入结果

这个流程之所以安全,是因为:

  • Read 建立了时间戳基线
  • Edit 在执行前验证时间戳是否变化
  • 多次 Edit 之间的时间戳通过 readFileState 缓存自动更新

如果中途文件被外部工具修改,Edit 会立即报错,Claude 知道需要重新 Read 再操作。

📄source/src/tools/FileReadTool/FileReadTool.tsL1-300查看源码 →
📄source/src/tools/FileEditTool/FileEditTool.tsL86-300查看源码 →
📄source/src/tools/FileWriteTool/FileWriteTool.tsL94-200查看源码 →