import { readFile, writeFile, stat } from "fs/promises"; import { createHash } from "crypto"; import { logger } from "../utils/globalLogger.js"; import type { ToolPlugin, ToolResult, ToolContext } from "./types.js"; import { resolvePath, getDisplayPath } from "../utils/path.js"; import { escapeRegExp, analyzeEditMismatch } from "../utils/editUtils.js"; import { EDIT_TOOL_NAME, READ_TOOL_NAME } from "../constants/tools.js"; /** * Format compact parameter display */ function formatCompactParams( args: Record, context: ToolContext, ): string { const filePath = args.file_path as string; return getDisplayPath(filePath || "", context.workdir); } /** * Single file edit tool plugin */ export const editTool: ToolPlugin = { name: EDIT_TOOL_NAME, isConcurrencySafe: false, formatCompactParams, prompt: () => `Performs exact string replacements in files. Usage: - You must use your \`${READ_TOOL_NAME}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. - When editing text from ${READ_TOOL_NAME} tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - Use the smallest \`old_string\` that's clearly unique — usually 2-4 adjacent lines is sufficient. Avoid including 10+ lines of context when less uniquely identifies the target. Shorter matches are less likely to contain reproduction errors. - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`. - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`, config: { type: "function", function: { name: EDIT_TOOL_NAME, description: "A tool for editing files", parameters: { type: "object", properties: { file_path: { type: "string", description: "The absolute path to the file to modify", }, old_string: { type: "string", description: "The text to replace", }, new_string: { type: "string", description: "The text to replace it with (must be different from old_string)", }, replace_all: { type: "boolean", default: false, description: "Replace all occurences of old_string (default false)", }, }, required: ["file_path", "old_string", "new_string"], additionalProperties: false, }, }, }, execute: async ( args: Record, context: ToolContext, ): Promise => { const filePath = args.file_path as string; const oldString = args.old_string as string; const newString = args.new_string as string; const replaceAll = (args.replace_all as boolean) || false; // Validate required parameters if (!filePath || typeof filePath !== "string") { return { success: false, content: "", error: "file_path parameter is required and must be a string", }; } if (typeof oldString !== "string") { return { success: false, content: "", error: "old_string parameter is required and must be a string", }; } if (typeof newString !== "string") { return { success: false, content: "", error: "new_string parameter is required and must be a string", }; } if (oldString === newString) { return { success: false, content: "", error: "old_string and new_string must be different", }; } // Trigger conditional rule loading for this file context.messageManager?.triggerFileRead(filePath); // Enforce read-before-edit: the file must have been read or written first. // readFileState is populated by Read, Write, and Edit tools — single source // of truth, aligned with Claude Code's readFileState approach. const resolvedPath = resolvePath(filePath, context.workdir); if (!context.readFileState?.has(resolvedPath)) { return { success: false, content: "", error: `You must read the file with the ${READ_TOOL_NAME} tool before editing it. Use ${READ_TOOL_NAME} on ${filePath} first.`, }; } try { // Read file content let originalContent: string; try { originalContent = await readFile(resolvedPath, "utf-8"); } catch (readError) { return { success: false, content: "", error: `Failed to read file: ${readError instanceof Error ? readError.message : String(readError)}`, }; } // Staleness check: file must not have been modified since last Read if (context.readFileState) { const state = context.readFileState.get(resolvedPath); if (state) { const currentStats = await stat(resolvedPath); if (currentStats.mtime.getTime() !== state.mtime) { return { success: false, content: "", error: "File has been unexpectedly modified since last read. Read it again before editing it.", }; } } } // Normalize line endings for matching const normalizedContent = originalContent.replace(/\r\n/g, "\n"); const normalizedOldString = oldString.replace(/\r\n/g, "\n"); // Check if old_string exists const index = normalizedContent.indexOf(normalizedOldString); const matchedOldString = index !== -1 ? normalizedOldString : null; const startLineNumber = index !== -1 ? normalizedContent.substring(0, index).split("\n").length : undefined; if (!matchedOldString) { return { success: false, content: "", error: analyzeEditMismatch(normalizedOldString), }; } let newContent: string; let replacementCount: number; if (replaceAll) { // Replace all matches const regex = new RegExp(escapeRegExp(matchedOldString), "g"); newContent = normalizedContent.replace(regex, newString); replacementCount = (normalizedContent.match(regex) || []).length; } else { // Replace only the first match, but first check if it's unique const matches = normalizedContent.split(matchedOldString).length - 1; if (matches > 1) { return { success: false, content: "", error: `old_string appears ${matches} times in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all=true to change every instance.`, }; } newContent = normalizedContent.replace(matchedOldString, newString); replacementCount = 1; } // Permission check after validation but before real operation if (context.permissionManager) { try { const permissionContext = context.permissionManager.createContext( EDIT_TOOL_NAME, context.permissionMode || "default", context.canUseToolCallback, { file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll, startLineNumber, }, context.toolCallId, ); const permissionResult = await context.permissionManager.checkPermission(permissionContext); if (permissionResult.behavior === "deny") { return { success: false, content: "", error: `${EDIT_TOOL_NAME} operation denied by user, reason: ${permissionResult.message || "No reason provided"}`, }; } } catch { return { success: false, content: "", error: "Permission check failed", }; } } // Record snapshot for reversion let snapshotId: string | undefined; if (context.reversionManager && context.messageId) { snapshotId = await context.reversionManager.recordSnapshot( context.messageId, resolvedPath, "modify", ); } // Write file try { await writeFile(resolvedPath, newContent, "utf-8"); // Commit snapshot on success if (context.reversionManager && snapshotId) { await context.reversionManager.commitSnapshot(snapshotId); } } catch (writeError) { return { success: false, content: "", error: `Failed to write file: ${writeError instanceof Error ? writeError.message : String(writeError)}`, }; } // Update readFileState so subsequent serialized edits see fresh state if (context.readFileState) { const newStats = await stat(resolvedPath); const hash = createHash("sha256").update(newContent).digest("hex"); context.readFileState.set(resolvedPath, { mtime: newStats.mtime.getTime(), hash, offset: undefined, limit: undefined, }); } const shortResult = replaceAll ? `Replaced ${replacementCount} instances` : "Text replaced successfully"; logger.debug(`Edit tool: ${shortResult}`); return { success: true, content: shortResult, shortResult, filePath: resolvedPath, startLineNumber, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Edit tool error: ${errorMessage}`); return { success: false, content: "", error: errorMessage, }; } }, };