Skip to content

Commit 4d697c3

Browse files
shreyas-lyzrclaude
andcommitted
feat: add edit tool for string replacement and regex-based editing
Introduces an 'edit' built-in tool that lets the agent modify files without rewriting them in full. Supports exact string replacement (default, with uniqueness check), replace_all for bulk substitutions, and regex mode with $1-style backreferences and custom flags. Added for both the local filesystem (edit.ts) and E2B sandbox (sandbox-edit.ts), registered in createBuiltinTools. Bumps version to 1.4.2. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bdad596 commit 4d697c3

5 files changed

Lines changed: 223 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gitclaw",
3-
"version": "1.4.1",
3+
"version": "1.4.2",
44
"description": "A universal git-native multimodal always learning AI Agent (TinyHuman)",
55
"author": "shreyaskapale",
66
"license": "MIT",

src/tools/edit.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { readFile, writeFile } from "fs/promises";
2+
import { resolve } from "path";
3+
import { homedir } from "os";
4+
import type { AgentTool } from "@mariozechner/pi-agent-core";
5+
import { editSchema } from "./shared.js";
6+
7+
function resolvePath(path: string, cwd: string): string {
8+
if (path.startsWith("~/") || path === "~") {
9+
path = homedir() + path.slice(1);
10+
}
11+
return path.startsWith("/") ? path : resolve(cwd, path);
12+
}
13+
14+
function countOccurrences(haystack: string, needle: string): number {
15+
if (!needle) return 0;
16+
let count = 0;
17+
let idx = 0;
18+
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
19+
count++;
20+
idx += needle.length;
21+
}
22+
return count;
23+
}
24+
25+
export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
26+
return {
27+
name: "edit",
28+
label: "edit",
29+
description:
30+
"Edit a file by replacing text. By default, performs an exact string replacement that must match uniquely. Set replace_all=true to replace every occurrence. Set regex=true to treat old_string as a JS regular expression (new_string may use $1-style backreferences).",
31+
parameters: editSchema,
32+
execute: async (
33+
_toolCallId: string,
34+
{
35+
path,
36+
old_string,
37+
new_string,
38+
replace_all,
39+
regex,
40+
flags,
41+
}: { path: string; old_string: string; new_string: string; replace_all?: boolean; regex?: boolean; flags?: string },
42+
signal?: AbortSignal,
43+
) => {
44+
if (signal?.aborted) throw new Error("Operation aborted");
45+
46+
const absolutePath = resolvePath(path, cwd);
47+
const original = await readFile(absolutePath, "utf-8");
48+
49+
if (old_string === new_string) {
50+
throw new Error("old_string and new_string are identical — nothing to change");
51+
}
52+
53+
let updated: string;
54+
let replacements = 0;
55+
56+
if (regex) {
57+
let rxFlags = flags || "";
58+
if (replace_all && !rxFlags.includes("g")) rxFlags += "g";
59+
let rx: RegExp;
60+
try {
61+
rx = new RegExp(old_string, rxFlags);
62+
} catch (err: any) {
63+
throw new Error(`Invalid regex: ${err.message}`);
64+
}
65+
const matches = original.match(new RegExp(old_string, rxFlags.includes("g") ? rxFlags : rxFlags + "g"));
66+
replacements = matches ? matches.length : 0;
67+
if (replacements === 0) {
68+
throw new Error(`Regex pattern not found in ${path}`);
69+
}
70+
if (!replace_all && replacements > 1) {
71+
throw new Error(
72+
`Regex matched ${replacements} times in ${path}. Make the pattern more specific or set replace_all=true.`,
73+
);
74+
}
75+
updated = original.replace(rx, new_string);
76+
} else {
77+
if (!old_string) {
78+
throw new Error("old_string cannot be empty");
79+
}
80+
replacements = countOccurrences(original, old_string);
81+
if (replacements === 0) {
82+
throw new Error(`old_string not found in ${path}`);
83+
}
84+
if (!replace_all && replacements > 1) {
85+
throw new Error(
86+
`old_string matches ${replacements} times in ${path}. Provide more surrounding context to make it unique, or set replace_all=true.`,
87+
);
88+
}
89+
if (replace_all) {
90+
updated = original.split(old_string).join(new_string);
91+
} else {
92+
updated = original.replace(old_string, new_string);
93+
}
94+
}
95+
96+
if (updated === original) {
97+
throw new Error("No changes applied — replacement produced identical content");
98+
}
99+
100+
await writeFile(absolutePath, updated, "utf-8");
101+
102+
const applied = replace_all ? replacements : 1;
103+
return {
104+
content: [{ type: "text", text: `Edited ${path}${applied} replacement${applied === 1 ? "" : "s"} applied` }],
105+
details: undefined,
106+
};
107+
},
108+
};
109+
}

src/tools/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import type { MemoryLayerDef } from "../plugin-types.js";
44
import { createCliTool } from "./cli.js";
55
import { createReadTool } from "./read.js";
66
import { createWriteTool } from "./write.js";
7+
import { createEditTool } from "./edit.js";
78
import { createMemoryTool } from "./memory.js";
89
import { createTaskTrackerTool } from "./task-tracker.js";
910
import { createSkillLearnerTool } from "./skill-learner.js";
1011
import { createCapturePhotoTool } from "./capture-photo.js";
1112
import { createSandboxCliTool } from "./sandbox-cli.js";
1213
import { createSandboxReadTool } from "./sandbox-read.js";
1314
import { createSandboxWriteTool } from "./sandbox-write.js";
15+
import { createSandboxEditTool } from "./sandbox-edit.js";
1416
import { createSandboxMemoryTool } from "./sandbox-memory.js";
1517

1618
export interface BuiltinToolsConfig {
@@ -32,6 +34,7 @@ export function createBuiltinTools(config: BuiltinToolsConfig): AgentTool<any>[]
3234
createSandboxCliTool(config.sandbox, config.timeout),
3335
createSandboxReadTool(config.sandbox),
3436
createSandboxWriteTool(config.sandbox),
37+
createSandboxEditTool(config.sandbox),
3538
createSandboxMemoryTool(config.sandbox),
3639
];
3740
}
@@ -40,6 +43,7 @@ export function createBuiltinTools(config: BuiltinToolsConfig): AgentTool<any>[]
4043
createCliTool(config.dir, config.timeout),
4144
createReadTool(config.dir),
4245
createWriteTool(config.dir),
46+
createEditTool(config.dir),
4347
createMemoryTool(config.dir, config.pluginMemoryLayers),
4448
createCapturePhotoTool(config.dir),
4549
];

src/tools/sandbox-edit.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { AgentTool } from "@mariozechner/pi-agent-core";
2+
import type { SandboxContext } from "../sandbox.js";
3+
import { editSchema, resolveSandboxPath } from "./shared.js";
4+
5+
function countOccurrences(haystack: string, needle: string): number {
6+
if (!needle) return 0;
7+
let count = 0;
8+
let idx = 0;
9+
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
10+
count++;
11+
idx += needle.length;
12+
}
13+
return count;
14+
}
15+
16+
export function createSandboxEditTool(ctx: SandboxContext): AgentTool<typeof editSchema> {
17+
return {
18+
name: "edit",
19+
label: "edit",
20+
description:
21+
"Edit a file in the sandbox VM by replacing text. By default, performs an exact string replacement that must match uniquely. Set replace_all=true to replace every occurrence. Set regex=true to treat old_string as a JS regular expression (new_string may use $1-style backreferences).",
22+
parameters: editSchema,
23+
execute: async (
24+
_toolCallId: string,
25+
{
26+
path,
27+
old_string,
28+
new_string,
29+
replace_all,
30+
regex,
31+
flags,
32+
}: { path: string; old_string: string; new_string: string; replace_all?: boolean; regex?: boolean; flags?: string },
33+
signal?: AbortSignal,
34+
) => {
35+
if (signal?.aborted) throw new Error("Operation aborted");
36+
37+
const sandboxPath = resolveSandboxPath(path, ctx.repoPath);
38+
const original: string = await ctx.machine.readFile(sandboxPath);
39+
40+
if (old_string === new_string) {
41+
throw new Error("old_string and new_string are identical — nothing to change");
42+
}
43+
44+
let updated: string;
45+
let replacements = 0;
46+
47+
if (regex) {
48+
let rxFlags = flags || "";
49+
if (replace_all && !rxFlags.includes("g")) rxFlags += "g";
50+
let rx: RegExp;
51+
try {
52+
rx = new RegExp(old_string, rxFlags);
53+
} catch (err: any) {
54+
throw new Error(`Invalid regex: ${err.message}`);
55+
}
56+
const matches = original.match(new RegExp(old_string, rxFlags.includes("g") ? rxFlags : rxFlags + "g"));
57+
replacements = matches ? matches.length : 0;
58+
if (replacements === 0) {
59+
throw new Error(`Regex pattern not found in ${path}`);
60+
}
61+
if (!replace_all && replacements > 1) {
62+
throw new Error(
63+
`Regex matched ${replacements} times in ${path}. Make the pattern more specific or set replace_all=true.`,
64+
);
65+
}
66+
updated = original.replace(rx, new_string);
67+
} else {
68+
if (!old_string) {
69+
throw new Error("old_string cannot be empty");
70+
}
71+
replacements = countOccurrences(original, old_string);
72+
if (replacements === 0) {
73+
throw new Error(`old_string not found in ${path}`);
74+
}
75+
if (!replace_all && replacements > 1) {
76+
throw new Error(
77+
`old_string matches ${replacements} times in ${path}. Provide more surrounding context to make it unique, or set replace_all=true.`,
78+
);
79+
}
80+
if (replace_all) {
81+
updated = original.split(old_string).join(new_string);
82+
} else {
83+
updated = original.replace(old_string, new_string);
84+
}
85+
}
86+
87+
if (updated === original) {
88+
throw new Error("No changes applied — replacement produced identical content");
89+
}
90+
91+
await ctx.machine.writeFile(sandboxPath, updated);
92+
93+
const applied = replace_all ? replacements : 1;
94+
return {
95+
content: [{ type: "text", text: `Edited ${path}${applied} replacement${applied === 1 ? "" : "s"} applied` }],
96+
details: undefined,
97+
};
98+
},
99+
};
100+
}

src/tools/shared.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ export const writeSchema = Type.Object({
2929
createDirs: Type.Optional(Type.Boolean({ description: "Create parent directories if needed (default: true)" })),
3030
});
3131

32+
export const editSchema = Type.Object({
33+
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
34+
old_string: Type.String({ description: "Exact text to find and replace. Must match uniquely unless replace_all is true." }),
35+
new_string: Type.String({ description: "Replacement text" }),
36+
replace_all: Type.Optional(Type.Boolean({ description: "Replace every occurrence (default: false)" })),
37+
regex: Type.Optional(Type.Boolean({ description: "Treat old_string as a JavaScript regular expression (default: false). When true, new_string may reference groups like $1." })),
38+
flags: Type.Optional(Type.String({ description: "Regex flags (e.g. 'i', 'm', 's'). Only used when regex=true. 'g' is added automatically when replace_all is true." })),
39+
});
40+
3241
export const memorySchema = Type.Object({
3342
action: StringEnum(["load", "save"], { description: "Whether to load or save memory" }),
3443
content: Type.Optional(Type.String({ description: "Memory content to save (required for save)" })),

0 commit comments

Comments
 (0)