@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,53 @@
|
|
|
1
|
+
export interface EditItem {
|
|
2
|
+
path: string;
|
|
3
|
+
oldText: string;
|
|
4
|
+
newText: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface EditResult {
|
|
8
|
+
path: string;
|
|
9
|
+
success: boolean;
|
|
10
|
+
message: string;
|
|
11
|
+
diff?: string;
|
|
12
|
+
firstChangedLine?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A single edit window inside an Update File operation.
|
|
17
|
+
*
|
|
18
|
+
* `oldBlock` is the exact literal substring to find in the target file;
|
|
19
|
+
* `newBlock` is what it should be replaced with. Both are raw strings (not
|
|
20
|
+
* arrays of lines) so the applier can work directly via `indexOf` without
|
|
21
|
+
* reconstructing line arrays.
|
|
22
|
+
*
|
|
23
|
+
* `contextPrefix` is an optional anchor from a "@@ foo" hunk header. When set,
|
|
24
|
+
* the applier must find `contextPrefix` before searching for `oldBlock`, so
|
|
25
|
+
* the same oldBlock can appear multiple times in the file and be disambiguated
|
|
26
|
+
* by the anchor.
|
|
27
|
+
*/
|
|
28
|
+
export interface Hunk {
|
|
29
|
+
contextPrefix?: string;
|
|
30
|
+
oldBlock: string;
|
|
31
|
+
newBlock: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PatchOperation =
|
|
35
|
+
| { kind: "add"; path: string; contents: string }
|
|
36
|
+
| { kind: "delete"; path: string }
|
|
37
|
+
| { kind: "update"; path: string; hunks: Hunk[] };
|
|
38
|
+
|
|
39
|
+
export interface PatchOpResult {
|
|
40
|
+
path: string;
|
|
41
|
+
message: string;
|
|
42
|
+
diff?: string;
|
|
43
|
+
firstChangedLine?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Workspace {
|
|
47
|
+
readText: (absolutePath: string) => Promise<string>;
|
|
48
|
+
writeText: (absolutePath: string, content: string) => Promise<void>;
|
|
49
|
+
deleteFile: (absolutePath: string) => Promise<void>;
|
|
50
|
+
exists: (absolutePath: string) => Promise<boolean>;
|
|
51
|
+
/** Check that the file is writable. Rejects if not. Virtual implementations may still touch the real FS so preflights fail fast on read-only files. */
|
|
52
|
+
checkWriteAccess: (absolutePath: string) => Promise<void>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync, chmodSync, readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createRealWorkspace, createVirtualWorkspace } from "./workspace.ts";
|
|
6
|
+
|
|
7
|
+
describe("createVirtualWorkspace", () => {
|
|
8
|
+
let root: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
root = mkdtempSync(join(tmpdir(), "virt-ws-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(root, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("reads file content from disk on first access, then from memory", async () => {
|
|
19
|
+
const file = join(root, "a.txt");
|
|
20
|
+
writeFileSync(file, "hello", "utf-8");
|
|
21
|
+
const ws = createVirtualWorkspace(root);
|
|
22
|
+
expect(await ws.readText(file)).toBe("hello");
|
|
23
|
+
writeFileSync(file, "mutated on disk", "utf-8"); // underlying change
|
|
24
|
+
expect(await ws.readText(file)).toBe("hello"); // still cached
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("throws when reading a file that does not exist", async () => {
|
|
28
|
+
const ws = createVirtualWorkspace(root);
|
|
29
|
+
await expect(ws.readText(join(root, "missing.txt"))).rejects.toThrow(/not found/i);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("writes to the virtual state and later reads return the new content", async () => {
|
|
33
|
+
const file = join(root, "b.txt");
|
|
34
|
+
writeFileSync(file, "old", "utf-8");
|
|
35
|
+
const ws = createVirtualWorkspace(root);
|
|
36
|
+
await ws.writeText(file, "new");
|
|
37
|
+
expect(await ws.readText(file)).toBe("new");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("deletes a virtual file and errors on read after delete", async () => {
|
|
41
|
+
const file = join(root, "c.txt");
|
|
42
|
+
writeFileSync(file, "x", "utf-8");
|
|
43
|
+
const ws = createVirtualWorkspace(root);
|
|
44
|
+
await ws.deleteFile(file);
|
|
45
|
+
await expect(ws.readText(file)).rejects.toThrow(/not found/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects delete on non-existent file", async () => {
|
|
49
|
+
const ws = createVirtualWorkspace(root);
|
|
50
|
+
await expect(ws.deleteFile(join(root, "missing.txt"))).rejects.toThrow(/not found/i);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reports existence correctly", async () => {
|
|
54
|
+
const file = join(root, "d.txt");
|
|
55
|
+
writeFileSync(file, "x", "utf-8");
|
|
56
|
+
const ws = createVirtualWorkspace(root);
|
|
57
|
+
expect(await ws.exists(file)).toBe(true);
|
|
58
|
+
expect(await ws.exists(join(root, "missing.txt"))).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("exists returns false after virtual delete", async () => {
|
|
62
|
+
const file = join(root, "e.txt");
|
|
63
|
+
writeFileSync(file, "x", "utf-8");
|
|
64
|
+
const ws = createVirtualWorkspace(root);
|
|
65
|
+
await ws.deleteFile(file);
|
|
66
|
+
expect(await ws.exists(file)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("checkWriteAccess resolves for writable files", async () => {
|
|
70
|
+
const file = join(root, "w.txt");
|
|
71
|
+
writeFileSync(file, "x", "utf-8");
|
|
72
|
+
const ws = createVirtualWorkspace(root);
|
|
73
|
+
await expect(ws.checkWriteAccess(file)).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("checkWriteAccess rejects for read-only files", async () => {
|
|
77
|
+
const file = join(root, "ro.txt");
|
|
78
|
+
writeFileSync(file, "x", "utf-8");
|
|
79
|
+
chmodSync(file, 0o444);
|
|
80
|
+
const ws = createVirtualWorkspace(root);
|
|
81
|
+
await expect(ws.checkWriteAccess(file)).rejects.toThrow();
|
|
82
|
+
chmodSync(file, 0o644); // restore for rmSync
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("createRealWorkspace", () => {
|
|
87
|
+
let root: string;
|
|
88
|
+
let events: Array<{ name: string; payload: unknown }>;
|
|
89
|
+
const piStub = {
|
|
90
|
+
events: {
|
|
91
|
+
emit: vi.fn((name: string, payload: unknown) => {
|
|
92
|
+
events.push({ name, payload });
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
} as unknown as Parameters<typeof createRealWorkspace>[0];
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
root = mkdtempSync(join(tmpdir(), "real-ws-"));
|
|
99
|
+
events = [];
|
|
100
|
+
vi.mocked(piStub.events.emit).mockClear();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
rmSync(root, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reads file and caches on subsequent calls", async () => {
|
|
108
|
+
const file = join(root, "a.txt");
|
|
109
|
+
writeFileSync(file, "hello", "utf-8");
|
|
110
|
+
const ws = createRealWorkspace(piStub);
|
|
111
|
+
expect(await ws.readText(file)).toBe("hello");
|
|
112
|
+
writeFileSync(file, "changed on disk", "utf-8");
|
|
113
|
+
expect(await ws.readText(file)).toBe("hello"); // cached
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("writes to disk and emits context-guard:file-modified event", async () => {
|
|
117
|
+
const file = join(root, "b.txt");
|
|
118
|
+
writeFileSync(file, "old", "utf-8");
|
|
119
|
+
const ws = createRealWorkspace(piStub);
|
|
120
|
+
await ws.readText(file); // populate cache
|
|
121
|
+
await ws.writeText(file, "new");
|
|
122
|
+
expect(readFileSync(file, "utf-8")).toBe("new");
|
|
123
|
+
expect(events.some((e) => e.name === "context-guard:file-modified")).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("skips write when new content matches cached content (no-op dedup)", async () => {
|
|
127
|
+
const file = join(root, "c.txt");
|
|
128
|
+
writeFileSync(file, "same", "utf-8");
|
|
129
|
+
const ws = createRealWorkspace(piStub);
|
|
130
|
+
await ws.readText(file);
|
|
131
|
+
events.length = 0;
|
|
132
|
+
await ws.writeText(file, "same");
|
|
133
|
+
// file on disk untouched, no event emitted
|
|
134
|
+
expect(events.length).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("deletes a file and emits event", async () => {
|
|
138
|
+
const file = join(root, "d.txt");
|
|
139
|
+
writeFileSync(file, "x", "utf-8");
|
|
140
|
+
const ws = createRealWorkspace(piStub);
|
|
141
|
+
await ws.deleteFile(file);
|
|
142
|
+
expect(existsSync(file)).toBe(false);
|
|
143
|
+
expect(events.some((e) => e.name === "context-guard:file-modified")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("reports existence for real files", async () => {
|
|
147
|
+
const file = join(root, "e.txt");
|
|
148
|
+
writeFileSync(file, "x", "utf-8");
|
|
149
|
+
const ws = createRealWorkspace(piStub);
|
|
150
|
+
expect(await ws.exists(file)).toBe(true);
|
|
151
|
+
expect(await ws.exists(join(root, "missing.txt"))).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("checkWriteAccess resolves for writable + rejects for RO", async () => {
|
|
155
|
+
const wfile = join(root, "w.txt");
|
|
156
|
+
const rofile = join(root, "ro.txt");
|
|
157
|
+
writeFileSync(wfile, "x", "utf-8");
|
|
158
|
+
writeFileSync(rofile, "x", "utf-8");
|
|
159
|
+
chmodSync(rofile, 0o444);
|
|
160
|
+
const ws = createRealWorkspace(piStub);
|
|
161
|
+
await expect(ws.checkWriteAccess(wfile)).resolves.toBeUndefined();
|
|
162
|
+
await expect(ws.checkWriteAccess(rofile)).rejects.toThrow();
|
|
163
|
+
chmodSync(rofile, 0o644);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { constants } from "fs";
|
|
3
|
+
import { access as fsAccess, readFile as fsReadFile, unlink as fsUnlink, writeFile as fsWriteFile } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
import type { Workspace } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export function createRealWorkspace(pi: ExtensionAPI): Workspace {
|
|
8
|
+
const readCache = new Map<string, string>();
|
|
9
|
+
return {
|
|
10
|
+
readText: async (absolutePath: string) => {
|
|
11
|
+
if (readCache.has(absolutePath)) return readCache.get(absolutePath)!;
|
|
12
|
+
const content = await fsReadFile(absolutePath, "utf-8");
|
|
13
|
+
readCache.set(absolutePath, content);
|
|
14
|
+
return content;
|
|
15
|
+
},
|
|
16
|
+
writeText: async (absolutePath: string, content: string) => {
|
|
17
|
+
// Skip the write (and the file-modified event) when content is
|
|
18
|
+
// identical to what we last read. Prevents thrashing downstream
|
|
19
|
+
// consumers (watchers, context-guard) after no-op dedups.
|
|
20
|
+
const existing = readCache.get(absolutePath);
|
|
21
|
+
if (existing === content) return;
|
|
22
|
+
readCache.delete(absolutePath);
|
|
23
|
+
await fsWriteFile(absolutePath, content, "utf-8");
|
|
24
|
+
pi.events.emit("context-guard:file-modified", { path: absolutePath });
|
|
25
|
+
},
|
|
26
|
+
deleteFile: async (absolutePath: string) => {
|
|
27
|
+
readCache.delete(absolutePath);
|
|
28
|
+
await fsUnlink(absolutePath);
|
|
29
|
+
pi.events.emit("context-guard:file-modified", { path: absolutePath });
|
|
30
|
+
},
|
|
31
|
+
exists: async (absolutePath: string) => {
|
|
32
|
+
try {
|
|
33
|
+
await fsAccess(absolutePath, constants.F_OK);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
checkWriteAccess: (absolutePath: string) => fsAccess(absolutePath, constants.R_OK | constants.W_OK),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createVirtualWorkspace(cwd: string): Workspace {
|
|
44
|
+
const state = new Map<string, string | null>();
|
|
45
|
+
|
|
46
|
+
async function ensureLoaded(absolutePath: string): Promise<void> {
|
|
47
|
+
if (state.has(absolutePath)) return;
|
|
48
|
+
try {
|
|
49
|
+
const content = await fsReadFile(absolutePath, "utf-8");
|
|
50
|
+
state.set(absolutePath, content);
|
|
51
|
+
} catch {
|
|
52
|
+
state.set(absolutePath, null);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
readText: async (absolutePath) => {
|
|
58
|
+
await ensureLoaded(absolutePath);
|
|
59
|
+
const content = state.get(absolutePath);
|
|
60
|
+
if (content === null || content === undefined) {
|
|
61
|
+
throw new Error(`File not found: ${absolutePath.replace(`${cwd}/`, "")}`);
|
|
62
|
+
}
|
|
63
|
+
return content;
|
|
64
|
+
},
|
|
65
|
+
writeText: async (absolutePath, content) => {
|
|
66
|
+
state.set(absolutePath, content);
|
|
67
|
+
},
|
|
68
|
+
deleteFile: async (absolutePath) => {
|
|
69
|
+
await ensureLoaded(absolutePath);
|
|
70
|
+
if (state.get(absolutePath) === null) {
|
|
71
|
+
throw new Error(`File not found: ${absolutePath.replace(`${cwd}/`, "")}`);
|
|
72
|
+
}
|
|
73
|
+
state.set(absolutePath, null);
|
|
74
|
+
},
|
|
75
|
+
exists: async (absolutePath) => {
|
|
76
|
+
await ensureLoaded(absolutePath);
|
|
77
|
+
return state.get(absolutePath) !== null;
|
|
78
|
+
},
|
|
79
|
+
checkWriteAccess: async (absolutePath: string) => {
|
|
80
|
+
// Check real-fs write permission during the virtual preflight so
|
|
81
|
+
// that read-only files fail fast *before* any real file is touched.
|
|
82
|
+
await fsAccess(absolutePath, constants.W_OK);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrofoundry/pi-astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -52,7 +52,9 @@
|
|
|
52
52
|
"@mariozechner/pi-ai": "^0.70.0",
|
|
53
53
|
"@mariozechner/pi-coding-agent": "^0.70.0",
|
|
54
54
|
"@mariozechner/pi-tui": "^0.70.0",
|
|
55
|
+
"@types/diff": "^8.0.0",
|
|
55
56
|
"@types/node": "^25.6.0",
|
|
57
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
56
58
|
"eslint": "^10.2.1",
|
|
57
59
|
"globals": "^17.5.0",
|
|
58
60
|
"typebox": "^1.1.33",
|
|
@@ -60,11 +62,15 @@
|
|
|
60
62
|
"typescript-eslint": "^8.59.0",
|
|
61
63
|
"vitest": "^4.1.5"
|
|
62
64
|
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@google/genai": "^1.50.1",
|
|
67
|
+
"diff": "^9.0.0"
|
|
68
|
+
},
|
|
63
69
|
"scripts": {
|
|
64
|
-
"test": "vitest run --
|
|
70
|
+
"test": "vitest run --coverage",
|
|
65
71
|
"lint": "eslint .",
|
|
66
72
|
"typecheck": "tsc --noEmit",
|
|
67
|
-
"check": "tsc --noEmit && eslint . && vitest run --
|
|
73
|
+
"check": "tsc --noEmit && eslint . && vitest run --coverage",
|
|
68
74
|
"release:patch": "v=$(pnpm version patch --no-git-tag-version) && git add -A && git commit -m \"Release $v\" && git tag $v && git push && git push origin $v",
|
|
69
75
|
"release:minor": "v=$(pnpm version minor --no-git-tag-version) && git add -A && git commit -m \"Release $v\" && git tag $v && git push && git push origin $v",
|
|
70
76
|
"release:major": "v=$(pnpm version major --no-git-tag-version) && git add -A && git commit -m \"Release $v\" && git tag $v && git push && git push origin $v"
|