@astrofoundry/pi-astro 0.5.1 → 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.
@@ -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
+ }
@@ -0,0 +1,369 @@
1
+ import { mkdtempSync, readdirSync, 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
+ const generateContentMock = vi.fn();
7
+ const generateImagesMock = vi.fn();
8
+
9
+ vi.mock("@google/genai", () => {
10
+ class FakeGoogleGenAI {
11
+ models = {
12
+ generateContent: generateContentMock,
13
+ generateImages: generateImagesMock,
14
+ };
15
+ }
16
+ return { GoogleGenAI: FakeGoogleGenAI };
17
+ });
18
+
19
+ type ToolDef = {
20
+ name: string;
21
+ execute: (
22
+ id: string,
23
+ input: Record<string, unknown>,
24
+ signal: AbortSignal | undefined,
25
+ onUpdate: unknown,
26
+ ctx: {
27
+ cwd: string;
28
+ hasUI: boolean;
29
+ ui: {
30
+ notify: ReturnType<typeof vi.fn>;
31
+ confirm: ReturnType<typeof vi.fn>;
32
+ input: ReturnType<typeof vi.fn>;
33
+ };
34
+ },
35
+ ) => Promise<{ content: unknown[]; details: Record<string, unknown> }>;
36
+ };
37
+
38
+ function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void } {
39
+ const tools: ToolDef[] = [];
40
+ return { tools, registerTool: (t) => tools.push(t) };
41
+ }
42
+
43
+ function makeCtx(override: Partial<{ hasUI: boolean; confirm: boolean; inputValue: string | undefined }> = {}): {
44
+ cwd: string;
45
+ hasUI: boolean;
46
+ ui: {
47
+ notify: ReturnType<typeof vi.fn>;
48
+ confirm: ReturnType<typeof vi.fn>;
49
+ input: ReturnType<typeof vi.fn>;
50
+ };
51
+ } {
52
+ const hasUI = override.hasUI ?? true;
53
+ return {
54
+ cwd: "/tmp/cwd",
55
+ hasUI,
56
+ ui: {
57
+ notify: vi.fn(),
58
+ confirm: vi.fn().mockResolvedValue(override.confirm ?? true),
59
+ input: vi.fn().mockResolvedValue(override.inputValue ?? "abc-key-from-paste"),
60
+ },
61
+ };
62
+ }
63
+
64
+ describe("gemini-image extension", () => {
65
+ let fakeHome: string;
66
+ const originalHome = process.env.HOME;
67
+ const originalKey = process.env.GEMINI_API_KEY;
68
+
69
+ beforeEach(() => {
70
+ fakeHome = mkdtempSync(join(tmpdir(), "gem-ext-"));
71
+ process.env.HOME = fakeHome;
72
+ process.env.GEMINI_API_KEY = "test-key";
73
+ vi.resetModules();
74
+ generateContentMock.mockReset();
75
+ generateImagesMock.mockReset();
76
+ });
77
+
78
+ afterEach(() => {
79
+ rmSync(fakeHome, { recursive: true, force: true });
80
+ process.env.HOME = originalHome;
81
+ if (originalKey === undefined) delete process.env.GEMINI_API_KEY;
82
+ else process.env.GEMINI_API_KEY = originalKey;
83
+ });
84
+
85
+ async function load(): Promise<{ tool: ToolDef }> {
86
+ const mod = await import("./index.ts");
87
+ const pi = makePi();
88
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
89
+ return { tool: pi.tools[0] };
90
+ }
91
+
92
+ it("registers a tool named gemini_image", async () => {
93
+ const { tool } = await load();
94
+ expect(tool.name).toBe("gemini_image");
95
+ });
96
+
97
+ it("uses default model gemini-2.5-flash-image and calls generateContent", async () => {
98
+ generateContentMock.mockResolvedValue({
99
+ candidates: [
100
+ { content: { parts: [{ inlineData: { mimeType: "image/png", data: "BASE64DATA" } }] } },
101
+ ],
102
+ });
103
+ const { tool } = await load();
104
+ const ctx = makeCtx();
105
+ const res = await tool.execute("t", { prompt: "a cat" }, undefined, undefined, ctx);
106
+ expect(res.details.model).toBe("gemini-2.5-flash-image");
107
+ expect(res.content[0]).toMatchObject({ type: "image", data: "BASE64DATA" });
108
+ });
109
+
110
+ it("calls Imagen for imagen-4.0-generate-001 with numberOfImages", async () => {
111
+ generateImagesMock.mockResolvedValue({
112
+ generatedImages: [
113
+ { image: { imageBytes: "IMG1", mimeType: "image/png" } },
114
+ { image: { imageBytes: "IMG2", mimeType: "image/png" } },
115
+ ],
116
+ });
117
+ const { tool } = await load();
118
+ const ctx = makeCtx();
119
+ const res = await tool.execute(
120
+ "t",
121
+ {
122
+ prompt: "landscape",
123
+ model: "imagen-4.0-generate-001",
124
+ number_of_images: 2,
125
+ },
126
+ undefined,
127
+ undefined,
128
+ ctx,
129
+ );
130
+ expect(res.details.images).toBe(2);
131
+ const args = generateImagesMock.mock.calls[0][0];
132
+ expect(args.config.numberOfImages).toBe(2);
133
+ });
134
+
135
+ it("rejects unknown model", async () => {
136
+ const { tool } = await load();
137
+ await expect(
138
+ tool.execute("t", { prompt: "x", model: "bogus-model" }, undefined, undefined, makeCtx()),
139
+ ).rejects.toThrow(/Unknown model/);
140
+ });
141
+
142
+ it("rejects invalid aspect_ratio for Imagen (e.g. 21:9)", async () => {
143
+ const { tool } = await load();
144
+ await expect(
145
+ tool.execute(
146
+ "t",
147
+ { prompt: "x", model: "imagen-4.0-generate-001", aspect_ratio: "21:9" },
148
+ undefined,
149
+ undefined,
150
+ makeCtx(),
151
+ ),
152
+ ).rejects.toThrow(/aspect_ratio .* Imagen/);
153
+ });
154
+
155
+ it("rejects 4K image_size for Imagen", async () => {
156
+ const { tool } = await load();
157
+ await expect(
158
+ tool.execute(
159
+ "t",
160
+ { prompt: "x", model: "imagen-4.0-generate-001", image_size: "4K" },
161
+ undefined,
162
+ undefined,
163
+ makeCtx(),
164
+ ),
165
+ ).rejects.toThrow(/image_size .* Imagen/);
166
+ });
167
+
168
+ it("rejects input_images on Imagen", async () => {
169
+ const { tool } = await load();
170
+ await expect(
171
+ tool.execute(
172
+ "t",
173
+ {
174
+ prompt: "x",
175
+ model: "imagen-4.0-generate-001",
176
+ input_images: ["/tmp/x.png"],
177
+ },
178
+ undefined,
179
+ undefined,
180
+ makeCtx(),
181
+ ),
182
+ ).rejects.toThrow(/input_images/);
183
+ });
184
+
185
+ it("rejects negative_prompt on Gemini native", async () => {
186
+ const { tool } = await load();
187
+ await expect(
188
+ tool.execute(
189
+ "t",
190
+ { prompt: "x", negative_prompt: "ugly" },
191
+ undefined,
192
+ undefined,
193
+ makeCtx(),
194
+ ),
195
+ ).rejects.toThrow(/negative_prompt/);
196
+ });
197
+
198
+ it("rejects seed on Gemini native", async () => {
199
+ const { tool } = await load();
200
+ await expect(
201
+ tool.execute("t", { prompt: "x", seed: 42 }, undefined, undefined, makeCtx()),
202
+ ).rejects.toThrow(/seed/);
203
+ });
204
+
205
+ it("rejects number_of_images > 1 on Gemini native", async () => {
206
+ const { tool } = await load();
207
+ await expect(
208
+ tool.execute("t", { prompt: "x", number_of_images: 3 }, undefined, undefined, makeCtx()),
209
+ ).rejects.toThrow(/number_of_images/);
210
+ });
211
+
212
+ it("prompts for API key when none configured and saves if confirmed", async () => {
213
+ delete process.env.GEMINI_API_KEY;
214
+ generateContentMock.mockResolvedValue({
215
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
216
+ });
217
+ const { tool } = await load();
218
+ const ctx = makeCtx({ inputValue: "pasted-key" });
219
+ await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
220
+ expect(ctx.ui.input).toHaveBeenCalled();
221
+ // Confirm was called for both the save prompt and the cost confirmation
222
+ expect(ctx.ui.confirm.mock.calls.length).toBeGreaterThanOrEqual(1);
223
+ });
224
+
225
+ it("errors when API key missing and no UI available", async () => {
226
+ delete process.env.GEMINI_API_KEY;
227
+ const { tool } = await load();
228
+ const ctx = makeCtx({ hasUI: false });
229
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/no interactive UI/);
230
+ });
231
+
232
+ it("errors when user provides empty key at prompt", async () => {
233
+ delete process.env.GEMINI_API_KEY;
234
+ const { tool } = await load();
235
+ const ctx = makeCtx({ inputValue: " " });
236
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/No API key/);
237
+ });
238
+
239
+ it("cost confirmation: throws when user rejects", async () => {
240
+ generateContentMock.mockResolvedValue({
241
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
242
+ });
243
+ const { tool } = await load();
244
+ const ctx = makeCtx({ confirm: false });
245
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/User cancelled/);
246
+ });
247
+
248
+ it("skip_confirm bypasses cost prompt", async () => {
249
+ generateContentMock.mockResolvedValue({
250
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
251
+ });
252
+ const { tool } = await load();
253
+ const ctx = makeCtx();
254
+ await tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, ctx);
255
+ // confirm should not be called for cost
256
+ expect(ctx.ui.confirm).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("save_to writes PNG files and reports paths", async () => {
260
+ generateImagesMock.mockResolvedValue({
261
+ generatedImages: [
262
+ { image: { imageBytes: Buffer.from("fakeimage").toString("base64"), mimeType: "image/png" } },
263
+ ],
264
+ });
265
+ const saveDir = mkdtempSync(join(tmpdir(), "gem-save-"));
266
+ const { tool } = await load();
267
+ const ctx = makeCtx();
268
+ const res = await tool.execute(
269
+ "t",
270
+ {
271
+ prompt: "a-landscape",
272
+ model: "imagen-4.0-fast-generate-001",
273
+ save_to: saveDir,
274
+ },
275
+ undefined,
276
+ undefined,
277
+ ctx,
278
+ );
279
+ const paths = res.details.savedPaths as string[];
280
+ expect(paths).toHaveLength(1);
281
+ expect(readdirSync(saveDir)).toHaveLength(1);
282
+ rmSync(saveDir, { recursive: true, force: true });
283
+ });
284
+
285
+ it("throws when model returns no images", async () => {
286
+ generateContentMock.mockResolvedValue({ candidates: [] });
287
+ const { tool } = await load();
288
+ await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, makeCtx())).rejects.toThrow(/no images/);
289
+ });
290
+
291
+ it("input_images are read from disk and passed as inlineData", async () => {
292
+ const sourceDir = mkdtempSync(join(tmpdir(), "gem-input-"));
293
+ const imgPath = join(sourceDir, "input.png");
294
+ writeFileSync(imgPath, "binary-image");
295
+ generateContentMock.mockResolvedValue({
296
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "OUT" } }] } }],
297
+ });
298
+ const { tool } = await load();
299
+ const ctx = makeCtx();
300
+ await tool.execute(
301
+ "t",
302
+ { prompt: "edit", input_images: [imgPath] },
303
+ undefined,
304
+ undefined,
305
+ ctx,
306
+ );
307
+ const callArgs = generateContentMock.mock.calls[0][0];
308
+ // First content is the text, subsequent are inlineData parts
309
+ expect(callArgs.contents.some((c: Record<string, unknown>) => "inlineData" in c)).toBe(true);
310
+ rmSync(sourceDir, { recursive: true, force: true });
311
+ });
312
+
313
+ it("passes aspect_ratio + image_size into imageConfig for Gemini native", async () => {
314
+ generateContentMock.mockResolvedValue({
315
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
316
+ });
317
+ const { tool } = await load();
318
+ await tool.execute(
319
+ "t",
320
+ { prompt: "x", aspect_ratio: "16:9", image_size: "2K" },
321
+ undefined,
322
+ undefined,
323
+ makeCtx(),
324
+ );
325
+ const cfg = generateContentMock.mock.calls[0][0].config;
326
+ expect(cfg.imageConfig.aspectRatio).toBe("16:9");
327
+ expect(cfg.imageConfig.imageSize).toBe("2K");
328
+ });
329
+
330
+ it("Imagen config: numberOfImages + negative_prompt + seed + person_generation all pass through", async () => {
331
+ generateImagesMock.mockResolvedValue({
332
+ generatedImages: [{ image: { imageBytes: "X", mimeType: "image/png" } }],
333
+ });
334
+ const { tool } = await load();
335
+ await tool.execute(
336
+ "t",
337
+ {
338
+ prompt: "x",
339
+ model: "imagen-4.0-ultra-generate-001",
340
+ number_of_images: 2,
341
+ negative_prompt: "blurry",
342
+ seed: 1337,
343
+ person_generation: "allow_adult",
344
+ },
345
+ undefined,
346
+ undefined,
347
+ makeCtx(),
348
+ );
349
+ const cfg = generateImagesMock.mock.calls[0][0].config;
350
+ expect(cfg.numberOfImages).toBe(2);
351
+ expect(cfg.negativePrompt).toBe("blurry");
352
+ expect(cfg.seed).toBe(1337);
353
+ expect(cfg.personGeneration).toBe("allow_adult");
354
+ });
355
+
356
+ it("rejects invalid Gemini aspect_ratio", async () => {
357
+ const { tool } = await load();
358
+ await expect(
359
+ tool.execute("t", { prompt: "x", aspect_ratio: "17:3" }, undefined, undefined, makeCtx()),
360
+ ).rejects.toThrow(/aspect_ratio .* Gemini native/);
361
+ });
362
+
363
+ it("rejects invalid Gemini image_size", async () => {
364
+ const { tool } = await load();
365
+ await expect(
366
+ tool.execute("t", { prompt: "x", image_size: "8K" as "4K" }, undefined, undefined, makeCtx()),
367
+ ).rejects.toThrow(/image_size/);
368
+ });
369
+ });