src / tools / edit.ts
import { promises as fs } from "fs";
import { atomicWrite, fsError, looksBinary, MAX_FILE_BYTES } from "../fsutil";
import { resolveInScope } from "../paths";
export async function runEdit(
root: string,
args: { path: string; old_string: string; new_string: string; replace_all?: boolean },
): Promise<string> {
if (args.old_string === "") return "Error: old_string must not be empty";
if (args.old_string === args.new_string) return "Error: old_string and new_string are identical";
const r = await resolveInScope(root, args.path);
if (!r.ok) return r.error;
let text: string;
try {
const st = await fs.stat(r.abs);
if (st.isDirectory()) return `Error: is a directory, not a file: ${args.path}`;
if (!st.isFile()) return `Error: not a regular file: ${args.path}`;
if (st.size > MAX_FILE_BYTES) {
return `Error: file is too large to edit (${st.size} bytes, limit ${MAX_FILE_BYTES})`;
}
const buf = await fs.readFile(r.abs);
if (looksBinary(buf.subarray(0, 8192))) return `Error: binary file, cannot edit as text: ${args.path}`;
text = buf.toString("utf8");
if (!Buffer.from(text, "utf8").equals(buf)) return `Error: file is not valid UTF-8, cannot edit: ${args.path}`;
} catch (e) {
return fsError(e, args.path);
}
// Files that are purely CRLF: models send "\n", so translate both strings to match.
const pureCrlf = text.includes("\r\n") && !/(^|[^\r])\n/.test(text);
const toFile = (s: string) => (pureCrlf ? s.replace(/\r?\n/g, "\r\n") : s);
const oldS = toFile(args.old_string);
const newS = toFile(args.new_string);
let count = 0;
for (let i = text.indexOf(oldS); i !== -1; i = text.indexOf(oldS, i + oldS.length)) count++;
if (count === 0) return `Error: old_string not found in ${args.path}`;
if (count > 1 && !args.replace_all) {
return (
`Error: old_string matches ${count} places in ${args.path}; ` +
`add surrounding context to make it unique, or set replace_all=true`
);
}
let updated: string;
try {
updated = args.replace_all
? text.split(oldS).join(newS)
: text.slice(0, text.indexOf(oldS)) + newS + text.slice(text.indexOf(oldS) + oldS.length);
} catch {
return `Error: edit result too large or invalid for ${args.path}`;
}
const replacements = args.replace_all ? count : 1;
try {
await atomicWrite(r.abs, updated);
} catch (e) {
return fsError(e, args.path);
}
return `edited ${args.path}: ${replacements} replacement(s)`;
}
src / tools / edit.ts
import { promises as fs } from "fs";
import { atomicWrite, fsError, looksBinary, MAX_FILE_BYTES } from "../fsutil";
import { resolveInScope } from "../paths";
export async function runEdit(
root: string,
args: { path: string; old_string: string; new_string: string; replace_all?: boolean },
): Promise<string> {
if (args.old_string === "") return "Error: old_string must not be empty";
if (args.old_string === args.new_string) return "Error: old_string and new_string are identical";
const r = await resolveInScope(root, args.path);
if (!r.ok) return r.error;
let text: string;
try {
const st = await fs.stat(r.abs);
if (st.isDirectory()) return `Error: is a directory, not a file: ${args.path}`;
if (!st.isFile()) return `Error: not a regular file: ${args.path}`;
if (st.size > MAX_FILE_BYTES) {
return `Error: file is too large to edit (${st.size} bytes, limit ${MAX_FILE_BYTES})`;
}
const buf = await fs.readFile(r.abs);
if (looksBinary(buf.subarray(0, 8192))) return `Error: binary file, cannot edit as text: ${args.path}`;
text = buf.toString("utf8");
if (!Buffer.from(text, "utf8").equals(buf)) return `Error: file is not valid UTF-8, cannot edit: ${args.path}`;
} catch (e) {
return fsError(e, args.path);
}
// Files that are purely CRLF: models send "\n", so translate both strings to match.
const pureCrlf = text.includes("\r\n") && !/(^|[^\r])\n/.test(text);
const toFile = (s: string) => (pureCrlf ? s.replace(/\r?\n/g, "\r\n") : s);
const oldS = toFile(args.old_string);
const newS = toFile(args.new_string);
let count = 0;
for (let i = text.indexOf(oldS); i !== -1; i = text.indexOf(oldS, i + oldS.length)) count++;
if (count === 0) return `Error: old_string not found in ${args.path}`;
if (count > 1 && !args.replace_all) {
return (
`Error: old_string matches ${count} places in ${args.path}; ` +
`add surrounding context to make it unique, or set replace_all=true`
);
}
let updated: string;
try {
updated = args.replace_all
? text.split(oldS).join(newS)
: text.slice(0, text.indexOf(oldS)) + newS + text.slice(text.indexOf(oldS) + oldS.length);
} catch {
return `Error: edit result too large or invalid for ${args.path}`;
}
const replacements = args.replace_all ? count : 1;
try {
await atomicWrite(r.abs, updated);
} catch (e) {
return fsError(e, args.path);
}
return `edited ${args.path}: ${replacements} replacement(s)`;
}