@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.
Files changed (35) hide show
  1. package/README.md +4 -0
  2. package/extensions/astro-agents/agents/code-reviewer.md +0 -2
  3. package/extensions/astro-agents/agents/google-tech-lead.md +0 -2
  4. package/extensions/astro-agents/agents/spec-writer.md +0 -2
  5. package/extensions/astro-agents/agents/tester-api.md +0 -2
  6. package/extensions/astro-agents/agents/tester-ui.md +0 -2
  7. package/extensions/astro-agents/agents/ui-architect.md +0 -2
  8. package/extensions/astro-agents/agents/ui-design-system.md +0 -2
  9. package/extensions/astro-agents/agents/ui-frontend-developer.md +0 -2
  10. package/extensions/astro-agents/discovery.test.ts +152 -0
  11. package/extensions/astro-agents/index.test.ts +208 -0
  12. package/extensions/astro-agents/index.ts +22 -4
  13. package/extensions/astro-agents/spawn.test.ts +218 -0
  14. package/extensions/claude-globals/index.test.ts +77 -0
  15. package/extensions/gemini-image/credentials.test.ts +130 -0
  16. package/extensions/gemini-image/credentials.ts +53 -0
  17. package/extensions/gemini-image/index.test.ts +369 -0
  18. package/extensions/gemini-image/index.ts +313 -0
  19. package/extensions/gemini-image/models.test.ts +45 -0
  20. package/extensions/gemini-image/models.ts +50 -0
  21. package/extensions/gemini-image/pricing.test.ts +95 -0
  22. package/extensions/gemini-image/pricing.ts +102 -0
  23. package/extensions/grimoire/index.test.ts +244 -0
  24. package/extensions/multi-edit/classic.test.ts +274 -0
  25. package/extensions/multi-edit/classic.ts +435 -0
  26. package/extensions/multi-edit/diff.test.ts +65 -0
  27. package/extensions/multi-edit/diff.ts +143 -0
  28. package/extensions/multi-edit/index.test.ts +170 -0
  29. package/extensions/multi-edit/index.ts +267 -0
  30. package/extensions/multi-edit/patch.test.ts +242 -0
  31. package/extensions/multi-edit/patch.ts +463 -0
  32. package/extensions/multi-edit/types.ts +53 -0
  33. package/extensions/multi-edit/workspace.test.ts +165 -0
  34. package/extensions/multi-edit/workspace.ts +85 -0
  35. 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.5.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 --passWithNoTests",
70
+ "test": "vitest run --coverage",
65
71
  "lint": "eslint .",
66
72
  "typecheck": "tsc --noEmit",
67
- "check": "tsc --noEmit && eslint . && vitest run --passWithNoTests",
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"