import { stat } from "node:fs/promises";
import type { PilotDeckToolDefinition } from "../protocol/types.js";
import { PilotDeckToolRuntimeError } from "../protocol/errors.js";
import { isNotebookPath } from "./filesystem/fileTypeSafety.js";
import { resolvePilotDeckWorkspacePath } from "./filesystem/pathSafety.js";
import { readTextFile } from "./filesystem/readTextFile.js";
import { writeTextFile } from "./filesystem/writeTextFile.js";
import {
ensureWriteSnapshotFresh,
invalidateReadFileState,
recordWriteSnapshot,
validateWriteSnapshotFresh,
} from "./filesystem/writeSnapshots.js";
import { findActualString, normalizeEditInput } from "./filesystem/editNormalization.js";
export type EditFileInput = {
file_path: string;
old_string: string;
new_string: string;
replace_all?: boolean;
};
export function createEditFileTool(): PilotDeckToolDefinition<EditFileInput> {
return {
name: "edit_file",
aliases: ["Edit"],
description:
"Edit a workspace text file by replacing an exact string match.\n\nUsage:\n- You must read the target file with read_file before editing it. This tool will reject the input if the file has not been read in this session.\n- old_string must exactly match the file content character-by-character, including indentation. Copy old_string directly from read_file output without adding or removing spaces.\n- Use this tool for targeted changes to an existing file.\n- old_string must appear in the target file.\n- If old_string is not unique, either provide a more specific old_string or set replace_all to update every occurrence.\n- Use replace_all when renaming or replacing repeated text across the same file.\n- If the file is outside the workspace or does not exist, the tool returns a controlled error.",
kind: "filesystem",
inputSchema: {
type: "object",
required: ["file_path", "old_string", "new_string"],
additionalProperties: false,
properties: {
file_path: {
type: "string",
description: "Relative or absolute path of the file to edit. The path must resolve inside the workspace.",
},
old_string: {
type: "string",
description: "The exact substring to find and replace. It must appear in the target file.",
},
new_string: {
type: "string",
description: "The replacement string that will replace old_string.",
},
replace_all: {
type: "boolean",
description:
"When true, replace all occurrences of old_string. Defaults to false, which requires old_string to be unique.",
},
},
},
isReadOnly: () => false,
isConcurrencySafe: () => false,
isDestructive: () => false,
validateInput: async (input, context) => {
const resolved = resolvePilotDeckWorkspacePath(input.file_path, context, { forWrite: true });
if (!resolved.ok) {
return {
ok: false,
issues: [{
path: "file_path",
code: "invalid_schema",
message: resolved.error.message,
}],
};
}
if (isNotebookPath(resolved.absolutePath)) {
return {
ok: false,
issues: [{
path: "file_path",
code: "invalid_schema",
message: "File is a Jupyter notebook. Use edit_notebook to edit this file.",
}],
};
}
if (input.old_string !== "" && input.old_string === input.new_string) {
return {
ok: false,
issues: [{
path: "new_string",
code: "invalid_schema",
message: "old_string and new_string must differ.",
}],
};
}
let freshness: { exists: boolean };
try {
freshness = await validateWriteSnapshotFresh(context, resolved.absolutePath);
} catch (error) {
const normalized = error instanceof PilotDeckToolRuntimeError ? error.message : String(error);
if (
normalized === "File has not been read yet. Read it first before writing to it."
|| normalized === "File has changed since the last read. Read it again before writing to it."
) {
return {
ok: false,
issues: [{
path: "file_path",
code: "invalid_schema",
message: normalized,
}],
};
}
throw error;
}
if (!freshness.exists) {
if (input.old_string === "") {
return { ok: true, input };
}
return {
ok: false,
issues: [{
path: "file_path",
code: "invalid_schema",
message: `File ${input.file_path} does not exist.`,
}],
};
}
if (input.old_string !== "") {
return { ok: true, input };
}
const content = await readTextFile(resolved.absolutePath);
if (content.length === 0) {
return { ok: true, input };
}
return {
ok: false,
issues: [{
path: "old_string",
code: "invalid_schema",
message: "old_string may be empty only when creating a new file or writing to an empty file.",
}],
};
},
execute: async (input, context) => {
const resolved = resolvePilotDeckWorkspacePath(input.file_path, context, { forWrite: true });
if (!resolved.ok) {
throw new PilotDeckToolRuntimeError(resolved.error.code, resolved.error.message, resolved.error.details);
}
const freshness = await ensureWriteSnapshotFresh(context, resolved.absolutePath);
if (context.fileHistory) {
await context.fileHistory.trackEdit(
resolved.absolutePath,
context.messageId ?? context.turnId,
);
}
const content = freshness.previousContent ?? "";
let occurrences = 0;
let nextContent: string;
if (input.old_string === "") {
if (freshness.exists && content.length !== 0) {
throw new PilotDeckToolRuntimeError(
"invalid_tool_input",
"old_string may be empty only when creating a new file or writing to an empty file.",
);
}
nextContent = input.new_string;
} else {
const { oldString: normalizedOld, newString: normalizedNew } =
normalizeEditInput(resolved.absolutePath, input.old_string, input.new_string);
const actualOldString = findActualString(content, normalizedOld);
if (!actualOldString) {
throw new PilotDeckToolRuntimeError(
"invalid_tool_input",
`String to replace not found in file.\nString: ${input.old_string}`,
);
}
occurrences = countOccurrences(content, actualOldString);
if (occurrences > 1 && !input.replace_all) {
throw new PilotDeckToolRuntimeError(
"invalid_tool_input",
`Found ${occurrences} matches of old_string. Set replace_all to true to replace all occurrences, or provide a more specific old_string.`,
);
}
nextContent = input.replace_all
? content.split(actualOldString).join(normalizedNew)
: content.replace(actualOldString, normalizedNew);
}
const action = await writeTextFile(resolved.absolutePath, nextContent, { allowOverwrite: true });
const fileStat = await stat(resolved.absolutePath);
invalidateReadFileState(context, resolved.absolutePath);
recordWriteSnapshot(context, resolved.absolutePath, nextContent, Math.floor(fileStat.mtimeMs));
const update = {
absolutePath: resolved.absolutePath,
relativePath: resolved.relativePath,
root: resolved.root,
content: nextContent,
previousContent: freshness.previousContent,
};
await context.fileUpdateNotifier?.didChange?.(update);
await context.fileUpdateNotifier?.didSave?.(update);
const replacements = input.old_string === "" ? 0 : input.replace_all ? occurrences : 1;
return {
content: [{
type: "text",
text: `${action === "created" ? "Created" : "Updated"} ${resolved.relativePath}${replacements > 0 ? ` (${replacements} replacement).` : "."}`,
}],
data: {
filePath: resolved.relativePath,
replacements,
changed: action === "created" || nextContent !== content,
},
metadata: {
bytesWritten: Buffer.byteLength(nextContent, "utf8"),
mtimeMs: Math.floor(fileStat.mtimeMs),
},
};
},
};
}
function countOccurrences(value: string, search: string): number {
let count = 0;
let index = value.indexOf(search);
while (index !== -1) {
count += 1;
index = value.indexOf(search, index + search.length);
}
return count;
}