@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,218 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("node:child_process", () => ({
5
+ spawn: vi.fn(),
6
+ }));
7
+
8
+ function makeFakeProc(): {
9
+ proc: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: ReturnType<typeof vi.fn> };
10
+ close: (code: number) => void;
11
+ error: (err: Error) => void;
12
+ emit: (w: "stdout" | "stderr", chunk: string) => void;
13
+ } {
14
+ const proc = Object.assign(new EventEmitter(), {
15
+ stdout: new EventEmitter(),
16
+ stderr: new EventEmitter(),
17
+ kill: vi.fn(),
18
+ });
19
+ return {
20
+ proc,
21
+ close: (code) => proc.emit("close", code),
22
+ error: (err) => proc.emit("error", err),
23
+ emit: (w, chunk) => proc[w].emit("data", Buffer.from(chunk, "utf-8")),
24
+ };
25
+ }
26
+
27
+ describe("runAgent (spawn.ts)", () => {
28
+ beforeEach(() => {
29
+ vi.resetModules();
30
+ vi.clearAllMocks();
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ it("spawns pi with --mode json -p --no-session and captures agent_end", async () => {
38
+ const { spawn } = await import("node:child_process");
39
+ const fake = makeFakeProc();
40
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
41
+ const { runAgent } = await import("./spawn.ts");
42
+
43
+ const promise = runAgent({
44
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "sys" },
45
+ task: "do thing",
46
+ cwd: "/tmp",
47
+ });
48
+
49
+ // Emit an agent_end with a single assistant message
50
+ fake.emit(
51
+ "stdout",
52
+ JSON.stringify({
53
+ type: "agent_end",
54
+ messages: [{ role: "assistant", content: [{ type: "text", text: "result" }] }],
55
+ }) + "\n",
56
+ );
57
+ fake.close(0);
58
+
59
+ const res = await promise;
60
+ expect(res.exitCode).toBe(0);
61
+ expect(res.text).toBe("result");
62
+
63
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[];
64
+ expect(args).toContain("--mode");
65
+ expect(args).toContain("json");
66
+ expect(args).toContain("-p");
67
+ expect(args).toContain("--no-session");
68
+ });
69
+
70
+ it("includes --tools when agent has tools", async () => {
71
+ const { spawn } = await import("node:child_process");
72
+ const fake = makeFakeProc();
73
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
74
+ const { runAgent } = await import("./spawn.ts");
75
+ const promise = runAgent({
76
+ agent: {
77
+ name: "a",
78
+ description: "x",
79
+ source: "bundle",
80
+ filePath: "/tmp/a.md",
81
+ systemPrompt: "",
82
+ tools: ["read", "bash"],
83
+ },
84
+ task: "t",
85
+ cwd: "/tmp",
86
+ });
87
+ fake.close(0);
88
+ await promise;
89
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[];
90
+ expect(args).toContain("--tools");
91
+ expect(args).toContain("read,bash");
92
+ });
93
+
94
+ it("includes --no-skills + --skill paths when skills declared", async () => {
95
+ const { spawn } = await import("node:child_process");
96
+ const fake = makeFakeProc();
97
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
98
+ const { runAgent } = await import("./spawn.ts");
99
+ const promise = runAgent({
100
+ agent: {
101
+ name: "a",
102
+ description: "x",
103
+ source: "bundle",
104
+ filePath: "/tmp/a.md",
105
+ systemPrompt: "",
106
+ skills: ["playwright-cli"],
107
+ },
108
+ task: "t",
109
+ cwd: "/tmp",
110
+ skillPaths: ["/pkg/skills/playwright-cli"],
111
+ });
112
+ fake.close(0);
113
+ await promise;
114
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[];
115
+ expect(args).toContain("--no-skills");
116
+ expect(args).toContain("--skill");
117
+ expect(args).toContain("/pkg/skills/playwright-cli");
118
+ });
119
+
120
+ it("appends --append-system-prompt when agent has a systemPrompt", async () => {
121
+ const { spawn } = await import("node:child_process");
122
+ const fake = makeFakeProc();
123
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
124
+ const { runAgent } = await import("./spawn.ts");
125
+ const promise = runAgent({
126
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "body" },
127
+ task: "t",
128
+ cwd: "/tmp",
129
+ });
130
+ fake.close(0);
131
+ await promise;
132
+ const args = vi.mocked(spawn).mock.calls[0][1] as string[];
133
+ expect(args).toContain("--append-system-prompt");
134
+ });
135
+
136
+ it("returns empty text when agent_end has no assistant messages", async () => {
137
+ const { spawn } = await import("node:child_process");
138
+ const fake = makeFakeProc();
139
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
140
+ const { runAgent } = await import("./spawn.ts");
141
+ const promise = runAgent({
142
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
143
+ task: "t",
144
+ cwd: "/tmp",
145
+ });
146
+ fake.emit("stdout", JSON.stringify({ type: "agent_end", messages: [] }) + "\n");
147
+ fake.close(0);
148
+ const res = await promise;
149
+ expect(res.text).toBe("");
150
+ });
151
+
152
+ it("ignores non-JSON stdout lines gracefully", async () => {
153
+ const { spawn } = await import("node:child_process");
154
+ const fake = makeFakeProc();
155
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
156
+ const { runAgent } = await import("./spawn.ts");
157
+ const promise = runAgent({
158
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
159
+ task: "t",
160
+ cwd: "/tmp",
161
+ });
162
+ fake.emit("stdout", "garbage not json\n");
163
+ fake.close(0);
164
+ const res = await promise;
165
+ expect(res.exitCode).toBe(0);
166
+ });
167
+
168
+ it("captures stderr into the result", async () => {
169
+ const { spawn } = await import("node:child_process");
170
+ const fake = makeFakeProc();
171
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
172
+ const { runAgent } = await import("./spawn.ts");
173
+ const promise = runAgent({
174
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
175
+ task: "t",
176
+ cwd: "/tmp",
177
+ });
178
+ fake.emit("stderr", "oops");
179
+ fake.close(1);
180
+ const res = await promise;
181
+ expect(res.stderr).toContain("oops");
182
+ expect(res.exitCode).toBe(1);
183
+ });
184
+
185
+ it("resolves with exitCode 1 on spawn error", async () => {
186
+ const { spawn } = await import("node:child_process");
187
+ const fake = makeFakeProc();
188
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
189
+ const { runAgent } = await import("./spawn.ts");
190
+ const promise = runAgent({
191
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
192
+ task: "t",
193
+ cwd: "/tmp",
194
+ });
195
+ fake.error(new Error("spawn failed"));
196
+ const res = await promise;
197
+ expect(res.exitCode).toBe(1);
198
+ expect(res.stderr).toContain("spawn failed");
199
+ });
200
+
201
+ it("kills process on abort signal", async () => {
202
+ const { spawn } = await import("node:child_process");
203
+ const fake = makeFakeProc();
204
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
205
+ const { runAgent } = await import("./spawn.ts");
206
+ const ac = new AbortController();
207
+ const promise = runAgent({
208
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
209
+ task: "t",
210
+ cwd: "/tmp",
211
+ signal: ac.signal,
212
+ });
213
+ ac.abort();
214
+ expect(fake.proc.kill).toHaveBeenCalledWith("SIGTERM");
215
+ fake.close(0);
216
+ await promise;
217
+ });
218
+ });
@@ -0,0 +1,77 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } 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
+
6
+ type Handler = (event: { systemPrompt: string }) => Promise<{ systemPrompt: string } | undefined>;
7
+
8
+ interface CapturedPi {
9
+ on: (event: string, handler: Handler) => void;
10
+ handlers: Record<string, Handler>;
11
+ }
12
+
13
+ function makePi(): CapturedPi {
14
+ const handlers: Record<string, Handler> = {};
15
+ return {
16
+ handlers,
17
+ on: (event, handler) => {
18
+ handlers[event] = handler;
19
+ },
20
+ };
21
+ }
22
+
23
+ describe("claude-globals extension", () => {
24
+ let fakeHome: string;
25
+ const homeEnv = process.env.HOME;
26
+
27
+ beforeEach(() => {
28
+ fakeHome = mkdtempSync(join(tmpdir(), "claude-glob-"));
29
+ process.env.HOME = fakeHome;
30
+ vi.resetModules();
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(fakeHome, { recursive: true, force: true });
35
+ process.env.HOME = homeEnv;
36
+ });
37
+
38
+ it("appends ~/.claude/CLAUDE.md content inside a wrapper tag", async () => {
39
+ const claudeDir = join(fakeHome, ".claude");
40
+ const { mkdirSync } = await import("node:fs");
41
+ mkdirSync(claudeDir, { recursive: true });
42
+ writeFileSync(join(claudeDir, "CLAUDE.md"), "RULE ONE\nRULE TWO", "utf-8");
43
+
44
+ const mod = await import("./index.ts");
45
+ const pi = makePi();
46
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
47
+
48
+ const result = await pi.handlers.before_agent_start({ systemPrompt: "SYS" });
49
+ expect(result).toBeDefined();
50
+ expect(result?.systemPrompt).toContain("SYS");
51
+ expect(result?.systemPrompt).toContain("<claude-global-rules");
52
+ expect(result?.systemPrompt).toContain("RULE ONE");
53
+ expect(result?.systemPrompt).toContain("RULE TWO");
54
+ expect(result?.systemPrompt).toContain("</claude-global-rules>");
55
+ });
56
+
57
+ it("returns undefined when ~/.claude/CLAUDE.md does not exist", async () => {
58
+ const mod = await import("./index.ts");
59
+ const pi = makePi();
60
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
61
+ const result = await pi.handlers.before_agent_start({ systemPrompt: "SYS" });
62
+ expect(result).toBeUndefined();
63
+ });
64
+
65
+ it("returns undefined when ~/.claude/CLAUDE.md is empty / whitespace", async () => {
66
+ const claudeDir = join(fakeHome, ".claude");
67
+ const { mkdirSync } = await import("node:fs");
68
+ mkdirSync(claudeDir, { recursive: true });
69
+ writeFileSync(join(claudeDir, "CLAUDE.md"), " \n\n ", "utf-8");
70
+
71
+ const mod = await import("./index.ts");
72
+ const pi = makePi();
73
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
74
+ const result = await pi.handlers.before_agent_start({ systemPrompt: "SYS" });
75
+ expect(result).toBeUndefined();
76
+ });
77
+ });
@@ -0,0 +1,130 @@
1
+ import { existsSync, mkdirSync, 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, vi } from "vitest";
5
+
6
+ describe("credentials", () => {
7
+ let fakeHome: string;
8
+ const originalHome = process.env.HOME;
9
+ const originalGemini = process.env.GEMINI_API_KEY;
10
+
11
+ beforeEach(() => {
12
+ fakeHome = mkdtempSync(join(tmpdir(), "gem-cred-"));
13
+ process.env.HOME = fakeHome;
14
+ delete process.env.GEMINI_API_KEY;
15
+ vi.resetModules();
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(fakeHome, { recursive: true, force: true });
20
+ process.env.HOME = originalHome;
21
+ if (originalGemini === undefined) delete process.env.GEMINI_API_KEY;
22
+ else process.env.GEMINI_API_KEY = originalGemini;
23
+ });
24
+
25
+ it("resolves env var first when set", async () => {
26
+ process.env.GEMINI_API_KEY = "from-env";
27
+ const { resolveExistingApiKey } = await import("./credentials.ts");
28
+ expect(resolveExistingApiKey()).toBe("from-env");
29
+ });
30
+
31
+ it("returns undefined when neither env nor file set", async () => {
32
+ const { resolveExistingApiKey } = await import("./credentials.ts");
33
+ expect(resolveExistingApiKey()).toBeUndefined();
34
+ });
35
+
36
+ it("reads auth.json google.key when env is absent", async () => {
37
+ const dir = join(fakeHome, ".pi", "agent");
38
+ mkdirSync(dir, { recursive: true });
39
+ writeFileSync(
40
+ join(dir, "auth.json"),
41
+ JSON.stringify({ google: { type: "api_key", key: "from-file" } }),
42
+ "utf-8",
43
+ );
44
+ const { resolveExistingApiKey } = await import("./credentials.ts");
45
+ expect(resolveExistingApiKey()).toBe("from-file");
46
+ });
47
+
48
+ it("returns undefined when auth.json google entry is malformed", async () => {
49
+ const dir = join(fakeHome, ".pi", "agent");
50
+ mkdirSync(dir, { recursive: true });
51
+ writeFileSync(join(dir, "auth.json"), JSON.stringify({ google: "not-an-object" }), "utf-8");
52
+ const { resolveExistingApiKey } = await import("./credentials.ts");
53
+ expect(resolveExistingApiKey()).toBeUndefined();
54
+ });
55
+
56
+ it("returns undefined when auth.json is corrupt JSON", async () => {
57
+ const dir = join(fakeHome, ".pi", "agent");
58
+ mkdirSync(dir, { recursive: true });
59
+ writeFileSync(join(dir, "auth.json"), "{not-json", "utf-8");
60
+ const { resolveExistingApiKey } = await import("./credentials.ts");
61
+ expect(resolveExistingApiKey()).toBeUndefined();
62
+ });
63
+
64
+ it("persistApiKey creates auth.json with google entry", async () => {
65
+ const { persistApiKey } = await import("./credentials.ts");
66
+ persistApiKey("new-key");
67
+ const data = JSON.parse(readFileSync(join(fakeHome, ".pi", "agent", "auth.json"), "utf-8"));
68
+ expect(data.google).toEqual({ type: "api_key", key: "new-key" });
69
+ });
70
+
71
+ it("persistApiKey merges with existing entries", async () => {
72
+ const dir = join(fakeHome, ".pi", "agent");
73
+ mkdirSync(dir, { recursive: true });
74
+ writeFileSync(
75
+ join(dir, "auth.json"),
76
+ JSON.stringify({ anthropic: { type: "api_key", key: "ant-k" } }),
77
+ "utf-8",
78
+ );
79
+ const { persistApiKey } = await import("./credentials.ts");
80
+ persistApiKey("new-google-key");
81
+ const data = JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"));
82
+ expect(data.anthropic).toEqual({ type: "api_key", key: "ant-k" });
83
+ expect(data.google).toEqual({ type: "api_key", key: "new-google-key" });
84
+ });
85
+
86
+ it("persistApiKey overwrites an existing google entry", async () => {
87
+ const dir = join(fakeHome, ".pi", "agent");
88
+ mkdirSync(dir, { recursive: true });
89
+ writeFileSync(
90
+ join(dir, "auth.json"),
91
+ JSON.stringify({ google: { type: "api_key", key: "old" } }),
92
+ "utf-8",
93
+ );
94
+ const { persistApiKey } = await import("./credentials.ts");
95
+ persistApiKey("replacement");
96
+ const data = JSON.parse(readFileSync(join(dir, "auth.json"), "utf-8"));
97
+ expect(data.google.key).toBe("replacement");
98
+ });
99
+
100
+ it("env var with whitespace is trimmed", async () => {
101
+ process.env.GEMINI_API_KEY = " spaced ";
102
+ const { resolveExistingApiKey } = await import("./credentials.ts");
103
+ expect(resolveExistingApiKey()).toBe("spaced");
104
+ });
105
+
106
+ it("empty string env var is treated as unset", async () => {
107
+ process.env.GEMINI_API_KEY = " ";
108
+ const { resolveExistingApiKey } = await import("./credentials.ts");
109
+ expect(resolveExistingApiKey()).toBeUndefined();
110
+ });
111
+
112
+ it("auth file exists but lacks google entry -> undefined", async () => {
113
+ const dir = join(fakeHome, ".pi", "agent");
114
+ mkdirSync(dir, { recursive: true });
115
+ writeFileSync(
116
+ join(dir, "auth.json"),
117
+ JSON.stringify({ anthropic: { type: "api_key", key: "x" } }),
118
+ "utf-8",
119
+ );
120
+ const { resolveExistingApiKey } = await import("./credentials.ts");
121
+ expect(resolveExistingApiKey()).toBeUndefined();
122
+ });
123
+
124
+ it("persistApiKey creates parent dirs implicitly if needed", async () => {
125
+ expect(existsSync(join(fakeHome, ".pi", "agent"))).toBe(false);
126
+ const { persistApiKey } = await import("./credentials.ts");
127
+ persistApiKey("x");
128
+ expect(existsSync(join(fakeHome, ".pi", "agent", "auth.json"))).toBe(true);
129
+ });
130
+ });
@@ -0,0 +1,53 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ const AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
6
+ const PROVIDER_KEY = "google";
7
+
8
+ interface AuthEntry {
9
+ type: "api_key";
10
+ key: string;
11
+ }
12
+
13
+ type AuthFile = Record<string, AuthEntry | unknown>;
14
+
15
+ function readAuthFile(): AuthFile {
16
+ if (!existsSync(AUTH_FILE)) return {};
17
+ try {
18
+ const raw = readFileSync(AUTH_FILE, "utf-8");
19
+ const parsed = JSON.parse(raw) as unknown;
20
+ return typeof parsed === "object" && parsed !== null ? (parsed as AuthFile) : {};
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function writeAuthFile(data: AuthFile): void {
27
+ mkdirSync(dirname(AUTH_FILE), { recursive: true });
28
+ writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2) + "\n", "utf-8");
29
+ try {
30
+ chmodSync(AUTH_FILE, 0o600);
31
+ } catch {
32
+ // non-fatal: filesystem may not support chmod (e.g. some Windows envs)
33
+ }
34
+ }
35
+
36
+ export function resolveExistingApiKey(): string | undefined {
37
+ const fromEnv = process.env.GEMINI_API_KEY?.trim();
38
+ if (fromEnv) return fromEnv;
39
+
40
+ const authFile = readAuthFile();
41
+ const entry = authFile[PROVIDER_KEY];
42
+ if (entry && typeof entry === "object" && "type" in entry && entry.type === "api_key") {
43
+ const { key } = entry as AuthEntry;
44
+ if (typeof key === "string" && key.trim().length > 0) return key.trim();
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export function persistApiKey(key: string): void {
50
+ const authFile = readAuthFile();
51
+ authFile[PROVIDER_KEY] = { type: "api_key", key };
52
+ writeAuthFile(authFile);
53
+ }