@astrofoundry/pi-astro 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/extensions/astro-agents/agents/code-reviewer.md +0 -2
- package/extensions/astro-agents/agents/google-tech-lead.md +0 -2
- package/extensions/astro-agents/agents/spec-writer.md +0 -2
- package/extensions/astro-agents/agents/tester-api.md +0 -2
- package/extensions/astro-agents/agents/tester-ui.md +0 -2
- package/extensions/astro-agents/agents/ui-architect.md +0 -2
- package/extensions/astro-agents/agents/ui-design-system.md +0 -2
- package/extensions/astro-agents/agents/ui-frontend-developer.md +0 -2
- package/extensions/astro-agents/discovery.test.ts +152 -0
- package/extensions/astro-agents/index.test.ts +208 -0
- package/extensions/astro-agents/index.ts +22 -4
- package/extensions/astro-agents/spawn.test.ts +218 -0
- package/extensions/claude-globals/index.test.ts +77 -0
- package/extensions/gemini-image/credentials.test.ts +130 -0
- package/extensions/gemini-image/credentials.ts +53 -0
- package/extensions/gemini-image/index.test.ts +369 -0
- package/extensions/gemini-image/index.ts +313 -0
- package/extensions/gemini-image/models.test.ts +45 -0
- package/extensions/gemini-image/models.ts +50 -0
- package/extensions/gemini-image/pricing.test.ts +95 -0
- package/extensions/gemini-image/pricing.ts +102 -0
- package/extensions/grimoire/index.test.ts +244 -0
- package/extensions/multi-edit/classic.test.ts +274 -0
- package/extensions/multi-edit/classic.ts +435 -0
- package/extensions/multi-edit/diff.test.ts +65 -0
- package/extensions/multi-edit/diff.ts +143 -0
- package/extensions/multi-edit/index.test.ts +170 -0
- package/extensions/multi-edit/index.ts +267 -0
- package/extensions/multi-edit/patch.test.ts +242 -0
- package/extensions/multi-edit/patch.ts +463 -0
- package/extensions/multi-edit/types.ts +53 -0
- package/extensions/multi-edit/workspace.test.ts +165 -0
- package/extensions/multi-edit/workspace.ts +85 -0
- package/package.json +9 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateDiffString } from "./diff.ts";
|
|
3
|
+
|
|
4
|
+
describe("generateDiffString", () => {
|
|
5
|
+
it("returns empty diff + undefined firstChangedLine for identical content", () => {
|
|
6
|
+
const { diff, firstChangedLine } = generateDiffString("a\nb\nc\n", "a\nb\nc\n");
|
|
7
|
+
expect(diff).toBe("");
|
|
8
|
+
expect(firstChangedLine).toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("marks added line with + and correct new-file line number", () => {
|
|
12
|
+
const { diff, firstChangedLine } = generateDiffString("a\nb\n", "a\nb\nc\n");
|
|
13
|
+
expect(diff).toContain("+");
|
|
14
|
+
expect(diff).toContain("c");
|
|
15
|
+
expect(firstChangedLine).toBe(3);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("marks removed line with - and correct old-file line number", () => {
|
|
19
|
+
const { diff, firstChangedLine } = generateDiffString("a\nb\nc\n", "a\nc\n");
|
|
20
|
+
expect(diff).toContain("-");
|
|
21
|
+
expect(diff).toContain("b");
|
|
22
|
+
expect(firstChangedLine).toBe(2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("pads line numbers to the widest count across both files", () => {
|
|
26
|
+
const old10 = Array.from({ length: 10 }, (_, i) => `l${i}`).join("\n") + "\n";
|
|
27
|
+
const new10 = old10.replace("l5", "lX");
|
|
28
|
+
const { diff } = generateDiffString(old10, new10);
|
|
29
|
+
// line numbers 1..10 → width 2; "1 " (single char) would indicate no padding
|
|
30
|
+
expect(diff).toMatch(/\s\s\d /);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("collapses long unchanged runs between changes with a ... marker", () => {
|
|
34
|
+
const oldContent = ["A", ...Array.from({ length: 20 }, (_, i) => `x${i}`), "B"].join("\n") + "\n";
|
|
35
|
+
const newContent = oldContent.replace("A", "A2");
|
|
36
|
+
const { diff } = generateDiffString(oldContent, newContent);
|
|
37
|
+
expect(diff).toContain("...");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns undefined firstChangedLine when contents match", () => {
|
|
41
|
+
const { firstChangedLine } = generateDiffString("x", "x");
|
|
42
|
+
expect(firstChangedLine).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("handles mixed add+remove with both line numbers reported", () => {
|
|
46
|
+
const { diff, firstChangedLine } = generateDiffString("a\nb\nc\n", "a\nB\nc\n");
|
|
47
|
+
expect(diff).toContain("-");
|
|
48
|
+
expect(diff).toContain("+");
|
|
49
|
+
expect(firstChangedLine).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("respects custom context-line window", () => {
|
|
53
|
+
const oldContent = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n") + "\n";
|
|
54
|
+
const newContent = oldContent.replace("line10", "lineTEN");
|
|
55
|
+
const { diff: diffWide } = generateDiffString(oldContent, newContent, 10);
|
|
56
|
+
const { diff: diffNarrow } = generateDiffString(oldContent, newContent, 1);
|
|
57
|
+
expect(diffWide.length).toBeGreaterThan(diffNarrow.length);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not emit dead context when no changes exist before or after a run", () => {
|
|
61
|
+
// All identical -> no changes, no context should be emitted
|
|
62
|
+
const { diff } = generateDiffString("a\nb\n", "a\nb\n");
|
|
63
|
+
expect(diff).toBe("");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-pass diff renderer on top of `diff.diffLines`.
|
|
3
|
+
*
|
|
4
|
+
* Pass 1 — `expand`: flatten the `diffLines` change parts into a typed
|
|
5
|
+
* `Entry[]` stream where every entry carries its own 1-indexed old/new line
|
|
6
|
+
* numbers. No rendering decisions happen here.
|
|
7
|
+
*
|
|
8
|
+
* Pass 2 — `render`: walk the entries, buffer each unchanged run, and decide
|
|
9
|
+
* per run whether to show it whole, collapse it with a `...` marker, or
|
|
10
|
+
* discard it (when the run sits outside any change's context window).
|
|
11
|
+
*
|
|
12
|
+
* Output contract (stable, tested in __tests__/diff.test.ts):
|
|
13
|
+
* - Added lines: `+NN text`
|
|
14
|
+
* - Removed lines: `-NN text`
|
|
15
|
+
* - Context lines: ` NN text`
|
|
16
|
+
* - Collapsed run: ` __ ...` (blank gutter + ellipsis)
|
|
17
|
+
*
|
|
18
|
+
* Line numbers are right-padded to the width of the longest 1-indexed line
|
|
19
|
+
* number across both files so the gutter stays aligned.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as Diff from "diff";
|
|
23
|
+
|
|
24
|
+
type ContextEntry = { kind: "context"; oldLine: number; newLine: number; text: string };
|
|
25
|
+
type AddedEntry = { kind: "added"; newLine: number; text: string };
|
|
26
|
+
type RemovedEntry = { kind: "removed"; oldLine: number; text: string };
|
|
27
|
+
type Entry = ContextEntry | AddedEntry | RemovedEntry;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walk `diff.diffLines` parts and yield a flat entry list plus the 1-indexed
|
|
31
|
+
* new-file line number of the first change. `firstChangedLine` is captured
|
|
32
|
+
* eagerly during expansion so callers don't have to re-scan the entries.
|
|
33
|
+
*/
|
|
34
|
+
function expand(parts: Diff.Change[]): { entries: Entry[]; firstChangedLine: number | undefined } {
|
|
35
|
+
const entries: Entry[] = [];
|
|
36
|
+
let oldNum = 1;
|
|
37
|
+
let newNum = 1;
|
|
38
|
+
let firstChangedLine: number | undefined;
|
|
39
|
+
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
const lines = part.value.split("\n");
|
|
42
|
+
// `diff.diffLines` always terminates with an empty string from the trailing
|
|
43
|
+
// newline — drop it so we don't emit a ghost row per part.
|
|
44
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
45
|
+
lines.pop();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const text of lines) {
|
|
49
|
+
if (part.added) {
|
|
50
|
+
if (firstChangedLine === undefined) firstChangedLine = newNum;
|
|
51
|
+
entries.push({ kind: "added", newLine: newNum, text });
|
|
52
|
+
newNum++;
|
|
53
|
+
} else if (part.removed) {
|
|
54
|
+
if (firstChangedLine === undefined) firstChangedLine = newNum;
|
|
55
|
+
entries.push({ kind: "removed", oldLine: oldNum, text });
|
|
56
|
+
oldNum++;
|
|
57
|
+
} else {
|
|
58
|
+
entries.push({ kind: "context", oldLine: oldNum, newLine: newNum, text });
|
|
59
|
+
oldNum++;
|
|
60
|
+
newNum++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { entries, firstChangedLine };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render an expanded entry stream into gutter-formatted output lines. Context
|
|
70
|
+
* runs are collapsed when their length exceeds the visible head + tail window.
|
|
71
|
+
*/
|
|
72
|
+
function render(entries: Entry[], contextLines: number, lineNumWidth: number): string[] {
|
|
73
|
+
const pad = (n: number) => String(n).padStart(lineNumWidth, " ");
|
|
74
|
+
const blankGutter = " ".repeat(lineNumWidth);
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < entries.length) {
|
|
79
|
+
const entry = entries[i];
|
|
80
|
+
|
|
81
|
+
if (entry.kind === "added") {
|
|
82
|
+
out.push(`+${pad(entry.newLine)} ${entry.text}`);
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (entry.kind === "removed") {
|
|
87
|
+
out.push(`-${pad(entry.oldLine)} ${entry.text}`);
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// entry.kind === "context" — buffer the whole run before deciding.
|
|
93
|
+
const runStart = i;
|
|
94
|
+
while (i < entries.length && entries[i].kind === "context") {
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
const runEnd = i;
|
|
98
|
+
const runLen = runEnd - runStart;
|
|
99
|
+
|
|
100
|
+
const hasChangeBefore = runStart > 0;
|
|
101
|
+
const hasChangeAfter = runEnd < entries.length;
|
|
102
|
+
|
|
103
|
+
// Context that isn't adjacent to any change is dead weight — drop it.
|
|
104
|
+
if (!hasChangeBefore && !hasChangeAfter) continue;
|
|
105
|
+
|
|
106
|
+
const head = hasChangeBefore ? contextLines : 0;
|
|
107
|
+
const tail = hasChangeAfter ? contextLines : 0;
|
|
108
|
+
|
|
109
|
+
const writeAt = (idx: number) => {
|
|
110
|
+
const e = entries[idx] as ContextEntry;
|
|
111
|
+
out.push(` ${pad(e.oldLine)} ${e.text}`);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (runLen <= head + tail) {
|
|
115
|
+
for (let j = runStart; j < runEnd; j++) writeAt(j);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let j = 0; j < head; j++) writeAt(runStart + j);
|
|
120
|
+
out.push(` ${blankGutter} ...`);
|
|
121
|
+
for (let j = tail; j > 0; j--) writeAt(runEnd - j);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function generateDiffString(
|
|
128
|
+
oldContent: string,
|
|
129
|
+
newContent: string,
|
|
130
|
+
contextLines = 4,
|
|
131
|
+
): { diff: string; firstChangedLine: number | undefined } {
|
|
132
|
+
const parts = Diff.diffLines(oldContent, newContent);
|
|
133
|
+
const { entries, firstChangedLine } = expand(parts);
|
|
134
|
+
|
|
135
|
+
// Gutter width: pad to the widest 1-indexed line number that can appear.
|
|
136
|
+
const oldLineCount = oldContent.split("\n").length;
|
|
137
|
+
const newLineCount = newContent.split("\n").length;
|
|
138
|
+
const lineNumWidth = String(Math.max(oldLineCount, newLineCount)).length;
|
|
139
|
+
|
|
140
|
+
const lines = render(entries, contextLines, lineNumWidth);
|
|
141
|
+
|
|
142
|
+
return { diff: lines.join("\n"), firstChangedLine };
|
|
143
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import extensionDefault from "./index.ts";
|
|
6
|
+
|
|
7
|
+
interface ToolDef {
|
|
8
|
+
name: string;
|
|
9
|
+
execute: (
|
|
10
|
+
id: string,
|
|
11
|
+
params: Record<string, unknown>,
|
|
12
|
+
signal: AbortSignal | undefined,
|
|
13
|
+
onUpdate: unknown,
|
|
14
|
+
ctx: { cwd: string; events?: { emit: (...a: unknown[]) => void } },
|
|
15
|
+
) => Promise<{ content: Array<{ text: string }>; details: { diff?: string; firstChangedLine?: number } }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void; events: { emit: () => void } } {
|
|
19
|
+
const tools: ToolDef[] = [];
|
|
20
|
+
return {
|
|
21
|
+
tools,
|
|
22
|
+
registerTool: (t) => tools.push(t),
|
|
23
|
+
events: { emit: () => {} },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeCtx(cwd: string): { cwd: string; events: { emit: () => void } } {
|
|
28
|
+
return { cwd, events: { emit: () => {} } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("multi-edit extension (edit tool)", () => {
|
|
32
|
+
let root: string;
|
|
33
|
+
const pi = makePi();
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
root = mkdtempSync(join(tmpdir(), "medit-idx-"));
|
|
37
|
+
// Extension registers once — only call default on first iteration.
|
|
38
|
+
if (pi.tools.length === 0) {
|
|
39
|
+
extensionDefault(pi as unknown as Parameters<typeof extensionDefault>[0]);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(root, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("registers a tool named 'edit'", () => {
|
|
48
|
+
expect(pi.tools[0].name).toBe("edit");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("single edit via top-level path/oldText/newText", async () => {
|
|
52
|
+
const f = join(root, "a.ts");
|
|
53
|
+
writeFileSync(f, "hi\n", "utf-8");
|
|
54
|
+
const res = await pi.tools[0].execute(
|
|
55
|
+
"t",
|
|
56
|
+
{ path: f, oldText: "hi", newText: "HI" },
|
|
57
|
+
undefined,
|
|
58
|
+
undefined,
|
|
59
|
+
makeCtx(root),
|
|
60
|
+
);
|
|
61
|
+
expect(res.content[0].text).toMatch(/.+/);
|
|
62
|
+
expect(readFileSync(f, "utf-8")).toContain("HI");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("multi edit array applies to one file", async () => {
|
|
66
|
+
const f = join(root, "b.ts");
|
|
67
|
+
writeFileSync(f, "a\nb\n", "utf-8");
|
|
68
|
+
await pi.tools[0].execute(
|
|
69
|
+
"t",
|
|
70
|
+
{
|
|
71
|
+
multi: [
|
|
72
|
+
{ path: f, oldText: "a", newText: "A" },
|
|
73
|
+
{ path: f, oldText: "b", newText: "B" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
undefined,
|
|
77
|
+
undefined,
|
|
78
|
+
makeCtx(root),
|
|
79
|
+
);
|
|
80
|
+
expect(readFileSync(f, "utf-8")).toBe("A\nB\n");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("multi inherits top-level path when item omits it", async () => {
|
|
84
|
+
const f = join(root, "c.ts");
|
|
85
|
+
writeFileSync(f, "1\n2\n", "utf-8");
|
|
86
|
+
await pi.tools[0].execute(
|
|
87
|
+
"t",
|
|
88
|
+
{
|
|
89
|
+
path: f,
|
|
90
|
+
multi: [
|
|
91
|
+
{ oldText: "1", newText: "ONE" },
|
|
92
|
+
{ oldText: "2", newText: "TWO" },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
undefined,
|
|
96
|
+
undefined,
|
|
97
|
+
makeCtx(root),
|
|
98
|
+
);
|
|
99
|
+
expect(readFileSync(f, "utf-8")).toBe("ONE\nTWO\n");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("patch mode creates a new file", async () => {
|
|
103
|
+
const patch = "*** Begin Patch\n*** Add File: new.ts\n+fresh\n*** End Patch";
|
|
104
|
+
await pi.tools[0].execute(
|
|
105
|
+
"t",
|
|
106
|
+
{ patch },
|
|
107
|
+
undefined,
|
|
108
|
+
undefined,
|
|
109
|
+
makeCtx(root),
|
|
110
|
+
);
|
|
111
|
+
expect(readFileSync(join(root, "new.ts"), "utf-8")).toBe("fresh\n");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("patch + classic params together is an error", async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
pi.tools[0].execute(
|
|
117
|
+
"t",
|
|
118
|
+
{ patch: "*** Begin Patch\n*** End Patch", path: "x" },
|
|
119
|
+
undefined,
|
|
120
|
+
undefined,
|
|
121
|
+
makeCtx(root),
|
|
122
|
+
),
|
|
123
|
+
).rejects.toThrow(/mutually exclusive/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("incomplete top-level (path + oldText without newText) is an error", async () => {
|
|
127
|
+
await expect(
|
|
128
|
+
pi.tools[0].execute(
|
|
129
|
+
"t",
|
|
130
|
+
{ path: "x", oldText: "y" },
|
|
131
|
+
undefined,
|
|
132
|
+
undefined,
|
|
133
|
+
makeCtx(root),
|
|
134
|
+
),
|
|
135
|
+
).rejects.toThrow(/Incomplete top-level/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("no params at all is an error", async () => {
|
|
139
|
+
await expect(
|
|
140
|
+
pi.tools[0].execute("t", {}, undefined, undefined, makeCtx(root)),
|
|
141
|
+
).rejects.toThrow(/No edits provided/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("multi item missing path and no top-level path is an error", async () => {
|
|
145
|
+
await expect(
|
|
146
|
+
pi.tools[0].execute(
|
|
147
|
+
"t",
|
|
148
|
+
{ multi: [{ oldText: "x", newText: "y" }] },
|
|
149
|
+
undefined,
|
|
150
|
+
undefined,
|
|
151
|
+
makeCtx(root),
|
|
152
|
+
),
|
|
153
|
+
).rejects.toThrow(/missing a path/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("preflight error prevents real writes", async () => {
|
|
157
|
+
const f = join(root, "d.ts");
|
|
158
|
+
writeFileSync(f, "actual", "utf-8");
|
|
159
|
+
await expect(
|
|
160
|
+
pi.tools[0].execute(
|
|
161
|
+
"t",
|
|
162
|
+
{ path: f, oldText: "missing-text", newText: "x" },
|
|
163
|
+
undefined,
|
|
164
|
+
undefined,
|
|
165
|
+
makeCtx(root),
|
|
166
|
+
),
|
|
167
|
+
).rejects.toThrow(/Preflight failed/);
|
|
168
|
+
expect(readFileSync(f, "utf-8")).toBe("actual");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Edit Extension — replaces the built-in `edit` tool.
|
|
3
|
+
*
|
|
4
|
+
* Supports all original parameters (path, oldText, newText) plus:
|
|
5
|
+
* - `multi`: array of {path, oldText, newText} edits applied in sequence
|
|
6
|
+
* - `patch`: Codex-style apply_patch payload
|
|
7
|
+
*
|
|
8
|
+
* When both top-level params and `multi` are provided, the top-level edit
|
|
9
|
+
* is treated as an implicit first item prepended to the multi list.
|
|
10
|
+
*
|
|
11
|
+
* A preflight pass is performed before mutating files:
|
|
12
|
+
* - multi/top-level mode: preflight via virtualized built-in edit tool
|
|
13
|
+
* - patch mode: preflight by applying patch operations on a virtual filesystem
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
|
|
19
|
+
import { applyClassicEdits } from "./classic.ts";
|
|
20
|
+
import { applyPatchOperations, parsePatch } from "./patch.ts";
|
|
21
|
+
import type { EditItem } from "./types.ts";
|
|
22
|
+
import { createRealWorkspace, createVirtualWorkspace } from "./workspace.ts";
|
|
23
|
+
|
|
24
|
+
const editItemSchema = Type.Object({
|
|
25
|
+
path: Type.Optional(
|
|
26
|
+
Type.String({
|
|
27
|
+
description:
|
|
28
|
+
"Path to the file to edit (relative or absolute). Inherits from top-level path if omitted.",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
oldText: Type.String({
|
|
32
|
+
description: "Exact text to find and replace (must match exactly)",
|
|
33
|
+
}),
|
|
34
|
+
newText: Type.String({
|
|
35
|
+
description: "New text to replace the old text with",
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const multiEditSchema = Type.Object({
|
|
40
|
+
path: Type.Optional(
|
|
41
|
+
Type.String({
|
|
42
|
+
description: "Path to the file to edit (relative or absolute)",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
oldText: Type.Optional(
|
|
46
|
+
Type.String({
|
|
47
|
+
description: "Exact text to find and replace (must match exactly)",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
newText: Type.Optional(
|
|
51
|
+
Type.String({ description: "New text to replace the old text with" }),
|
|
52
|
+
),
|
|
53
|
+
multi: Type.Optional(
|
|
54
|
+
Type.Array(editItemSchema, {
|
|
55
|
+
description:
|
|
56
|
+
"Multiple edits to apply in sequence. Each item has path, oldText, and newText.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
patch: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
description:
|
|
62
|
+
"Codex-style apply_patch payload (*** Begin Patch ... *** End Patch). Mutually exclusive with path/oldText/newText/multi.",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default function (pi: ExtensionAPI) {
|
|
68
|
+
pi.registerTool({
|
|
69
|
+
name: "edit",
|
|
70
|
+
label: "edit",
|
|
71
|
+
description:
|
|
72
|
+
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits. Supports a `multi` parameter for batch edits across one or more files, and a `patch` parameter for Codex-style patches.",
|
|
73
|
+
promptSnippet:
|
|
74
|
+
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
|
75
|
+
promptGuidelines: [
|
|
76
|
+
"Use edit for precise changes (old text must match exactly)",
|
|
77
|
+
"Use the `multi` parameter to apply multiple edits in a single tool call",
|
|
78
|
+
"Use the `patch` parameter for Codex-style multi-file / hunk-based edits",
|
|
79
|
+
],
|
|
80
|
+
parameters: multiEditSchema,
|
|
81
|
+
|
|
82
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
83
|
+
const { path, oldText, newText, multi, patch } = params;
|
|
84
|
+
|
|
85
|
+
const hasAnyClassicParam =
|
|
86
|
+
path !== undefined ||
|
|
87
|
+
oldText !== undefined ||
|
|
88
|
+
newText !== undefined ||
|
|
89
|
+
multi !== undefined;
|
|
90
|
+
if (patch !== undefined && hasAnyClassicParam) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"The `patch` parameter is mutually exclusive with path/oldText/newText/multi.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (patch !== undefined) {
|
|
97
|
+
const ops = parsePatch(patch);
|
|
98
|
+
|
|
99
|
+
// Preflight on virtual filesystem before mutating real files.
|
|
100
|
+
await applyPatchOperations(
|
|
101
|
+
ops,
|
|
102
|
+
createVirtualWorkspace(ctx.cwd),
|
|
103
|
+
ctx.cwd,
|
|
104
|
+
signal,
|
|
105
|
+
{ collectDiff: false },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Apply for real.
|
|
109
|
+
const applied = await applyPatchOperations(
|
|
110
|
+
ops,
|
|
111
|
+
createRealWorkspace(pi),
|
|
112
|
+
ctx.cwd,
|
|
113
|
+
signal,
|
|
114
|
+
{ collectDiff: true },
|
|
115
|
+
);
|
|
116
|
+
const summary = applied
|
|
117
|
+
.map((r, i) => `${i + 1}. ${r.message}`)
|
|
118
|
+
.join("\n");
|
|
119
|
+
const combinedDiff = applied
|
|
120
|
+
.filter((r) => r.diff)
|
|
121
|
+
.map((r) => `File: ${r.path}\n${r.diff}`)
|
|
122
|
+
.join("\n\n");
|
|
123
|
+
const firstChangedLine = applied.find(
|
|
124
|
+
(r) => r.firstChangedLine !== undefined,
|
|
125
|
+
)?.firstChangedLine;
|
|
126
|
+
return {
|
|
127
|
+
content: [
|
|
128
|
+
{
|
|
129
|
+
type: "text" as const,
|
|
130
|
+
text: `Applied patch with ${applied.length} operation(s).\n${summary}`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
details: {
|
|
134
|
+
diff: combinedDiff,
|
|
135
|
+
firstChangedLine,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build classic edit list.
|
|
141
|
+
const edits: EditItem[] = [];
|
|
142
|
+
const hasTopLevel =
|
|
143
|
+
path !== undefined && oldText !== undefined && newText !== undefined;
|
|
144
|
+
|
|
145
|
+
if (hasTopLevel) {
|
|
146
|
+
edits.push({ path: path!, oldText: oldText!, newText: newText! });
|
|
147
|
+
} else if (
|
|
148
|
+
path !== undefined ||
|
|
149
|
+
oldText !== undefined ||
|
|
150
|
+
newText !== undefined
|
|
151
|
+
) {
|
|
152
|
+
// When multi is present, only a bare top-level `path` (for inheritance) is allowed.
|
|
153
|
+
// Any other partial combination (e.g. path+oldText, oldText+newText) is an error.
|
|
154
|
+
const hasOnlyPath =
|
|
155
|
+
path !== undefined && oldText === undefined && newText === undefined;
|
|
156
|
+
if (!hasOnlyPath || multi === undefined) {
|
|
157
|
+
const missing: string[] = [];
|
|
158
|
+
if (path === undefined) missing.push("path");
|
|
159
|
+
if (oldText === undefined) missing.push("oldText");
|
|
160
|
+
if (newText === undefined) missing.push("newText");
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Incomplete top-level edit: missing ${missing.join(", ")}. Provide all three (path, oldText, newText) or use only the multi parameter.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
// path-only top-level with multi is fine — path is inherited below.
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (multi) {
|
|
169
|
+
for (const item of multi) {
|
|
170
|
+
edits.push({
|
|
171
|
+
path: item.path ?? path ?? "",
|
|
172
|
+
oldText: item.oldText,
|
|
173
|
+
newText: item.newText,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (edits.length === 0) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
"No edits provided. Supply path/oldText/newText, a multi array, or a patch.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate that every edit has a path.
|
|
185
|
+
for (let i = 0; i < edits.length; i++) {
|
|
186
|
+
if (!edits[i].path) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Edit ${i + 1} is missing a path. Provide a path on each multi item or set a top-level path to inherit.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Preflight pass on virtual workspace before mutating real files.
|
|
194
|
+
// Uses sequential occurrence matching so same-file edits are resolved
|
|
195
|
+
// in file order (positional ordering).
|
|
196
|
+
try {
|
|
197
|
+
await applyClassicEdits(
|
|
198
|
+
edits,
|
|
199
|
+
createVirtualWorkspace(ctx.cwd),
|
|
200
|
+
ctx.cwd,
|
|
201
|
+
signal,
|
|
202
|
+
{ collectDiff: false },
|
|
203
|
+
);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
206
|
+
throw new Error(`Preflight failed before mutating files.\n${message}`, {
|
|
207
|
+
cause: err,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Apply for real. `continueOnError` lets successful edits land even
|
|
212
|
+
// when a sibling fails; `rollbackOnError` restores files if an
|
|
213
|
+
// unexpected error (I/O, abort) breaks the batch mid-write.
|
|
214
|
+
const isBatch = edits.length > 1;
|
|
215
|
+
const results = await applyClassicEdits(
|
|
216
|
+
edits,
|
|
217
|
+
createRealWorkspace(pi),
|
|
218
|
+
ctx.cwd,
|
|
219
|
+
signal,
|
|
220
|
+
{
|
|
221
|
+
collectDiff: true,
|
|
222
|
+
rollbackOnError: true,
|
|
223
|
+
continueOnError: isBatch,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const succeeded = results.filter((r) => r?.success);
|
|
228
|
+
const failed = results.filter((r) => r && !r.success);
|
|
229
|
+
|
|
230
|
+
if (results.length === 1) {
|
|
231
|
+
const r = results[0];
|
|
232
|
+
return {
|
|
233
|
+
content: [{ type: "text" as const, text: r.message }],
|
|
234
|
+
details: {
|
|
235
|
+
diff: r.diff ?? "",
|
|
236
|
+
firstChangedLine: r.firstChangedLine,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const combinedDiff = results
|
|
242
|
+
.filter((r) => r?.diff)
|
|
243
|
+
.map((r) => r.diff)
|
|
244
|
+
.join("\n");
|
|
245
|
+
|
|
246
|
+
const firstChanged = results.find(
|
|
247
|
+
(r) => r?.firstChangedLine !== undefined,
|
|
248
|
+
)?.firstChangedLine;
|
|
249
|
+
const summary = results
|
|
250
|
+
.map((r, i) => `${i + 1}. ${r.message}`)
|
|
251
|
+
.join("\n");
|
|
252
|
+
|
|
253
|
+
const statusLine =
|
|
254
|
+
failed.length > 0
|
|
255
|
+
? `Applied ${succeeded.length}/${results.length} edit(s). ${failed.length} failed:\n${summary}`
|
|
256
|
+
: `Applied ${results.length} edit(s) successfully.\n${summary}`;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: "text" as const, text: statusLine }],
|
|
260
|
+
details: {
|
|
261
|
+
diff: combinedDiff,
|
|
262
|
+
firstChangedLine: firstChanged,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|