@astrofoundry/pi-astro 0.6.5 → 0.6.7

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 CHANGED
@@ -61,6 +61,8 @@ pi # launch; confirm [Extensions] lists astro-agents, claude-glob
61
61
  - `grimoire` — registers `grimoire` tool
62
62
  - `multi-edit` — registers the enhanced `edit` tool
63
63
  - `gemini-image` — registers `gemini_image` tool (requires a Gemini API key; prompts and saves on first use)
64
+ - `security-guard` — blocks/prompts destructive bash commands and sensitive file access; configure at `~/.pi/agent/security-guard.json` (example written on first run); `/security-guard status|reload|test`
65
+ - `notify-on-stop` — runs a shell command when the agent finishes a turn (sound, voice, desktop notification). Configure via env `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`. Defaults to macOS Glass sound / Linux `notify-send`. Disable with `PI_STOP_NOTIFY_OFF=1`. `/notify status|test|off`.
64
66
  - `claude-globals` — auto-injects `~/.claude/CLAUDE.md` into every pi session's system prompt
65
67
 
66
68
  **Bundled subagents** (callable via `astro_agent`):
@@ -371,6 +371,49 @@ describe("gemini-image extension", () => {
371
371
  rmSync(sourceDir, { recursive: true, force: true });
372
372
  });
373
373
 
374
+ it("summary includes exact cost for Imagen (flat pricing)", async () => {
375
+ generateImagesMock.mockResolvedValue({
376
+ generatedImages: [
377
+ { image: { imageBytes: "A", mimeType: "image/png" } },
378
+ { image: { imageBytes: "B", mimeType: "image/png" } },
379
+ ],
380
+ });
381
+ const { tool } = await load();
382
+ const res = await tool.execute(
383
+ "t",
384
+ {
385
+ prompt: "x",
386
+ model: "imagen-4.0-fast-generate-001",
387
+ number_of_images: 2,
388
+ skip_confirm: true,
389
+ },
390
+ undefined,
391
+ undefined,
392
+ makeCtx({ cwd: fakeCwd }),
393
+ );
394
+ const text = (res.content as Array<{ type: string; text?: string }>)[0].text as string;
395
+ expect(text).toMatch(/Cost:\s+\$0\.0400/); // 2 × 0.02
396
+ expect(res.details.actualCostUsd).toBeCloseTo(0.04, 4);
397
+ });
398
+
399
+ it("summary includes cost using usageMetadata.promptTokenCount when present", async () => {
400
+ generateContentMock.mockResolvedValue({
401
+ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
402
+ usageMetadata: { promptTokenCount: 1000 },
403
+ });
404
+ const { tool } = await load();
405
+ const res = await tool.execute(
406
+ "t",
407
+ { prompt: "x", model: "gemini-3-pro-image-preview", skip_confirm: true },
408
+ undefined,
409
+ undefined,
410
+ makeCtx({ cwd: fakeCwd }),
411
+ );
412
+ const text = (res.content as Array<{ type: string; text?: string }>)[0].text as string;
413
+ expect(text).toMatch(/Cost:/);
414
+ expect(text).toContain("1000 input tokens");
415
+ });
416
+
374
417
  it("Imagen config: numberOfImages + negative_prompt + seed + person_generation all pass through", async () => {
375
418
  generateImagesMock.mockResolvedValue({
376
419
  generatedImages: [{ image: { imageBytes: "X", mimeType: "image/png" } }],
@@ -19,7 +19,7 @@ import {
19
19
  type ImageModel,
20
20
  } from "./models.ts";
21
21
  import { parseTweaks } from "./parseTweaks.ts";
22
- import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
22
+ import { computeActualCost, estimateCost, formatUsd, hasPricing } from "./pricing.ts";
23
23
  import { formatResolution, resolveResolution } from "./resolution.ts";
24
24
 
25
25
  const DEFAULT_SAVE_SUBDIR = ".gemini-images";
@@ -131,12 +131,17 @@ interface DecodedImage {
131
131
  mimeType: string;
132
132
  }
133
133
 
134
+ interface CallResult {
135
+ images: DecodedImage[];
136
+ inputTokens?: number;
137
+ }
138
+
134
139
  async function callGeminiNative(
135
140
  ai: GoogleGenAI,
136
141
  model: string,
137
142
  p: Params,
138
143
  signal: AbortSignal | undefined,
139
- ): Promise<DecodedImage[]> {
144
+ ): Promise<CallResult> {
140
145
  const imageConfig: Record<string, string> = {};
141
146
  if (p.aspect_ratio) imageConfig.aspectRatio = p.aspect_ratio;
142
147
  if (p.image_size) imageConfig.imageSize = p.image_size;
@@ -176,7 +181,9 @@ async function callGeminiNative(
176
181
  }
177
182
  }
178
183
  }
179
- return images;
184
+ const inputTokens = (response as { usageMetadata?: { promptTokenCount?: number } }).usageMetadata
185
+ ?.promptTokenCount;
186
+ return { images, inputTokens };
180
187
  }
181
188
 
182
189
  async function callImagen(
@@ -184,7 +191,7 @@ async function callImagen(
184
191
  model: string,
185
192
  p: Params,
186
193
  signal: AbortSignal | undefined,
187
- ): Promise<DecodedImage[]> {
194
+ ): Promise<CallResult> {
188
195
  const config: Record<string, unknown> = {};
189
196
  if (p.aspect_ratio) config.aspectRatio = p.aspect_ratio;
190
197
  if (p.image_size) config.imageSize = p.image_size;
@@ -209,7 +216,7 @@ async function callImagen(
209
216
  });
210
217
  }
211
218
  }
212
- return images;
219
+ return { images };
213
220
  }
214
221
 
215
222
  function saveImages(images: DecodedImage[], dir: string, prompt: string): string[] {
@@ -342,19 +349,27 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
342
349
  const finalModel = (final.model as ImageModel | undefined) ?? DEFAULT_MODEL;
343
350
 
344
351
  const ai = new GoogleGenAI({ apiKey });
345
- const images = isImagen(finalModel)
352
+ const call = isImagen(finalModel)
346
353
  ? await callImagen(ai, finalModel, final, signal)
347
354
  : await callGeminiNative(ai, finalModel, final, signal);
348
355
 
349
- if (images.length === 0) {
356
+ if (call.images.length === 0) {
350
357
  throw new Error("Model returned no images.");
351
358
  }
352
359
 
353
360
  const saveDir = final.save_to ? resolve(ctx.cwd, final.save_to) : join(ctx.cwd, DEFAULT_SAVE_SUBDIR);
354
- const savedPaths = saveImages(images, saveDir, final.prompt);
361
+ const savedPaths = saveImages(call.images, saveDir, final.prompt);
362
+
363
+ const actual = hasPricing(finalModel)
364
+ ? computeActualCost(finalModel, {
365
+ numberOfImages: call.images.length,
366
+ imageSize: final.image_size,
367
+ inputTokens: call.inputTokens,
368
+ })
369
+ : undefined;
355
370
 
356
371
  const summaryLines = [
357
- `Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${finalModel}.`,
372
+ `Generated ${call.images.length} image${call.images.length > 1 ? "s" : ""} with ${finalModel}.`,
358
373
  `Prompt: "${final.prompt}"`,
359
374
  ];
360
375
  if (final.aspect_ratio) summaryLines.push(`Aspect ratio: ${final.aspect_ratio}`);
@@ -362,16 +377,25 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
362
377
  if (final.negative_prompt) summaryLines.push(`Negative: "${final.negative_prompt}"`);
363
378
  if (final.person_generation) summaryLines.push(`People: ${final.person_generation}`);
364
379
  if (final.seed !== undefined) summaryLines.push(`Seed: ${final.seed}`);
380
+ if (actual) {
381
+ const exact = isImagen(finalModel) || call.inputTokens !== undefined;
382
+ summaryLines.push(`Cost: ${exact ? formatUsd(actual.usd) : `~${formatUsd(actual.usd)}`} (${actual.breakdown})`);
383
+ }
365
384
  summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
366
385
 
367
386
  const content: AgentToolResult<unknown>["content"] = [
368
387
  { type: "text", text: summaryLines.join("\n") },
369
- ...images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
388
+ ...call.images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
370
389
  ];
371
390
 
372
391
  return {
373
392
  content,
374
- details: { model: finalModel, images: images.length, savedPaths },
393
+ details: {
394
+ model: finalModel,
395
+ images: call.images.length,
396
+ savedPaths,
397
+ actualCostUsd: actual?.usd,
398
+ },
375
399
  };
376
400
  },
377
401
  });
@@ -93,3 +93,46 @@ describe("hasPricing", () => {
93
93
  expect(hasPricing("imagen-4.0-fast-generate-001")).toBe(true);
94
94
  });
95
95
  });
96
+
97
+ describe("computeActualCost", () => {
98
+ it("Imagen: flat × count, exact", async () => {
99
+ const { computeActualCost } = await import("./pricing.ts");
100
+ const r = computeActualCost("imagen-4.0-fast-generate-001", { numberOfImages: 3 });
101
+ expect(r.usd).toBeCloseTo(0.06, 5);
102
+ expect(r.breakdown).toContain("0.02");
103
+ });
104
+
105
+ it("Gemini native with real input tokens: exact formula", async () => {
106
+ const { computeActualCost } = await import("./pricing.ts");
107
+ const r = computeActualCost("gemini-3-pro-image-preview", {
108
+ numberOfImages: 2,
109
+ imageSize: "2K",
110
+ inputTokens: 500,
111
+ });
112
+ // output: 2 × 0.134 = 0.268; input: 500 / 1M × 2.00 = 0.001; total ~0.269
113
+ expect(r.usd).toBeCloseTo(0.269, 3);
114
+ expect(r.breakdown).toContain("500 input tokens");
115
+ });
116
+
117
+ it("Gemini native with missing input tokens: falls back to 0", async () => {
118
+ const { computeActualCost } = await import("./pricing.ts");
119
+ const r = computeActualCost("gemini-2.5-flash-image", { numberOfImages: 1 });
120
+ expect(r.usd).toBeGreaterThanOrEqual(0);
121
+ expect(r.breakdown).toContain("0 input tokens");
122
+ });
123
+
124
+ it("4K output uses higher rate", async () => {
125
+ const { computeActualCost } = await import("./pricing.ts");
126
+ const at2k = computeActualCost("gemini-3-pro-image-preview", {
127
+ numberOfImages: 1,
128
+ imageSize: "2K",
129
+ inputTokens: 0,
130
+ });
131
+ const at4k = computeActualCost("gemini-3-pro-image-preview", {
132
+ numberOfImages: 1,
133
+ imageSize: "4K",
134
+ inputTokens: 0,
135
+ });
136
+ expect(at4k.usd).toBeGreaterThan(at2k.usd);
137
+ });
138
+ });
@@ -97,6 +97,38 @@ export function formatUsd(usd: number): string {
97
97
  return `$${usd.toFixed(4)}`;
98
98
  }
99
99
 
100
+ export interface ActualUsage {
101
+ inputTokens?: number;
102
+ numberOfImages: number;
103
+ imageSize?: "1K" | "2K" | "4K";
104
+ }
105
+
106
+ export interface ActualCostResult {
107
+ usd: number;
108
+ breakdown: string;
109
+ }
110
+
111
+ export function computeActualCost(model: ImageModel, usage: ActualUsage): ActualCostResult {
112
+ const price = PRICES[model];
113
+ if (price.kind === "flat") {
114
+ return {
115
+ usd: price.perImage * usage.numberOfImages,
116
+ breakdown: `${usage.numberOfImages} × $${price.perImage.toFixed(2)}/image`,
117
+ };
118
+ }
119
+ const perImage =
120
+ usage.imageSize === "4K" ? price.outputPerImage4K : price.outputPerImage1Kor2K;
121
+ const outputCost = perImage * usage.numberOfImages;
122
+ const inputTokens = usage.inputTokens ?? 0;
123
+ const inputCost = (inputTokens / 1_000_000) * price.inputPer1M;
124
+ return {
125
+ usd: inputCost + outputCost,
126
+ breakdown:
127
+ `${usage.numberOfImages} × $${perImage.toFixed(3)} (${usage.imageSize ?? "1K/2K"}) + ` +
128
+ `${inputTokens} input tokens`,
129
+ };
130
+ }
131
+
100
132
  export function hasPricing(model: ImageModel): boolean {
101
133
  return model in PRICES;
102
134
  }
@@ -0,0 +1,179 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const execMock = vi.fn();
4
+
5
+ vi.mock("node:child_process", () => ({
6
+ exec: (cmd: string, opts: unknown, cb?: unknown) => {
7
+ execMock(cmd, opts, cb);
8
+ // Return a chainable fake child with on/stdout/stderr
9
+ const fake = {
10
+ on: vi.fn(),
11
+ stdout: { on: vi.fn() },
12
+ stderr: { on: vi.fn() },
13
+ };
14
+ return fake;
15
+ },
16
+ }));
17
+
18
+ type Handler = (event: unknown, ctx: unknown) => void | Promise<void>;
19
+ type CommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
20
+
21
+ function makePi(): {
22
+ handlers: Record<string, Handler>;
23
+ commands: Map<string, { handler: CommandHandler }>;
24
+ on: (event: string, h: Handler) => void;
25
+ registerCommand: (name: string, opts: { description?: string; handler: CommandHandler }) => void;
26
+ } {
27
+ const handlers: Record<string, Handler> = {};
28
+ const commands = new Map();
29
+ return {
30
+ handlers,
31
+ commands,
32
+ on: (event, h) => {
33
+ handlers[event] = h;
34
+ },
35
+ registerCommand: (name, opts) => {
36
+ commands.set(name, { handler: opts.handler });
37
+ },
38
+ };
39
+ }
40
+
41
+ function makeCtx(): { ui: { notify: ReturnType<typeof vi.fn> } } {
42
+ return { ui: { notify: vi.fn() } };
43
+ }
44
+
45
+ const origPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
46
+
47
+ function setPlatform(p: NodeJS.Platform): void {
48
+ Object.defineProperty(process, "platform", { value: p, configurable: true });
49
+ }
50
+
51
+ describe("notify-on-stop", () => {
52
+ const origCmd = process.env.PI_STOP_NOTIFY;
53
+ const origOff = process.env.PI_STOP_NOTIFY_OFF;
54
+
55
+ beforeEach(() => {
56
+ vi.resetModules();
57
+ execMock.mockReset();
58
+ delete process.env.PI_STOP_NOTIFY;
59
+ delete process.env.PI_STOP_NOTIFY_OFF;
60
+ });
61
+
62
+ afterEach(() => {
63
+ Object.defineProperty(process, "platform", origPlatform);
64
+ if (origCmd === undefined) delete process.env.PI_STOP_NOTIFY;
65
+ else process.env.PI_STOP_NOTIFY = origCmd;
66
+ if (origOff === undefined) delete process.env.PI_STOP_NOTIFY_OFF;
67
+ else process.env.PI_STOP_NOTIFY_OFF = origOff;
68
+ });
69
+
70
+ it("runs default command on agent_end (macOS)", async () => {
71
+ setPlatform("darwin");
72
+ const mod = await import("./index.ts");
73
+ const pi = makePi();
74
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
75
+ pi.handlers.agent_end({}, makeCtx());
76
+ expect(execMock).toHaveBeenCalled();
77
+ expect(execMock.mock.calls[0][0]).toMatch(/afplay/);
78
+ });
79
+
80
+ it("runs custom PI_STOP_NOTIFY over default", async () => {
81
+ setPlatform("darwin");
82
+ process.env.PI_STOP_NOTIFY = "my custom --cmd";
83
+ const mod = await import("./index.ts");
84
+ const pi = makePi();
85
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
86
+ pi.handlers.agent_end({}, makeCtx());
87
+ expect(execMock.mock.calls[0][0]).toBe("my custom --cmd");
88
+ });
89
+
90
+ it("does NOT run anything when PI_STOP_NOTIFY_OFF=1", async () => {
91
+ setPlatform("darwin");
92
+ process.env.PI_STOP_NOTIFY_OFF = "1";
93
+ const mod = await import("./index.ts");
94
+ const pi = makePi();
95
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
96
+ pi.handlers.agent_end({}, makeCtx());
97
+ expect(execMock).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("does nothing on an unsupported platform with no env", async () => {
101
+ setPlatform("win32");
102
+ const mod = await import("./index.ts");
103
+ const pi = makePi();
104
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
105
+ pi.handlers.agent_end({}, makeCtx());
106
+ expect(execMock).not.toHaveBeenCalled();
107
+ });
108
+
109
+ it("Linux default uses notify-send", async () => {
110
+ setPlatform("linux");
111
+ const mod = await import("./index.ts");
112
+ const pi = makePi();
113
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
114
+ pi.handlers.agent_end({}, makeCtx());
115
+ expect(execMock.mock.calls[0][0]).toMatch(/notify-send/);
116
+ });
117
+
118
+ it("trims whitespace on PI_STOP_NOTIFY and ignores empty strings", async () => {
119
+ setPlatform("darwin");
120
+ process.env.PI_STOP_NOTIFY = " ";
121
+ const mod = await import("./index.ts");
122
+ const pi = makePi();
123
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
124
+ pi.handlers.agent_end({}, makeCtx());
125
+ expect(execMock.mock.calls[0][0]).toMatch(/afplay/);
126
+ });
127
+
128
+ it("/notify status reports current command", async () => {
129
+ setPlatform("darwin");
130
+ const mod = await import("./index.ts");
131
+ const pi = makePi();
132
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
133
+ const ctx = makeCtx();
134
+ await pi.commands.get("notify")!.handler("status", ctx);
135
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/afplay/), "info");
136
+ });
137
+
138
+ it("/notify status reports disabled when PI_STOP_NOTIFY_OFF", async () => {
139
+ setPlatform("darwin");
140
+ process.env.PI_STOP_NOTIFY_OFF = "1";
141
+ const mod = await import("./index.ts");
142
+ const pi = makePi();
143
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
144
+ const ctx = makeCtx();
145
+ await pi.commands.get("notify")!.handler("", ctx);
146
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/disabled/), "info");
147
+ });
148
+
149
+ it("/notify test runs the command immediately", async () => {
150
+ setPlatform("darwin");
151
+ process.env.PI_STOP_NOTIFY = "test-cmd";
152
+ const mod = await import("./index.ts");
153
+ const pi = makePi();
154
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
155
+ const ctx = makeCtx();
156
+ await pi.commands.get("notify")!.handler("test", ctx);
157
+ expect(execMock.mock.calls[0][0]).toBe("test-cmd");
158
+ });
159
+
160
+ it("/notify test warns when nothing configured", async () => {
161
+ setPlatform("win32");
162
+ const mod = await import("./index.ts");
163
+ const pi = makePi();
164
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
165
+ const ctx = makeCtx();
166
+ await pi.commands.get("notify")!.handler("test", ctx);
167
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/nothing configured/), "warning");
168
+ });
169
+
170
+ it("/notify off gives persistence guidance", async () => {
171
+ setPlatform("darwin");
172
+ const mod = await import("./index.ts");
173
+ const pi = makePi();
174
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
175
+ const ctx = makeCtx();
176
+ await pi.commands.get("notify")!.handler("off", ctx);
177
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/PI_STOP_NOTIFY_OFF/), "info");
178
+ });
179
+ });
@@ -0,0 +1,71 @@
1
+ import { exec } from "node:child_process";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+
4
+ const ENV_CMD = "PI_STOP_NOTIFY";
5
+ const ENV_OFF = "PI_STOP_NOTIFY_OFF";
6
+ const TIMEOUT_MS = 10_000;
7
+
8
+ function defaultCommand(): string | undefined {
9
+ if (process.platform === "darwin") {
10
+ return "afplay /System/Library/Sounds/Glass.aiff";
11
+ }
12
+ if (process.platform === "linux") {
13
+ return 'command -v notify-send >/dev/null 2>&1 && notify-send "pi" "Agent done"';
14
+ }
15
+ return undefined;
16
+ }
17
+
18
+ function resolveCommand(): string | undefined {
19
+ if (process.env[ENV_OFF] === "1") return undefined;
20
+ const custom = process.env[ENV_CMD]?.trim();
21
+ if (custom) return custom;
22
+ return defaultCommand();
23
+ }
24
+
25
+ function runNotify(cmd: string): void {
26
+ const child = exec(cmd, { shell: "/bin/sh", timeout: TIMEOUT_MS });
27
+ // Swallow errors so a broken notify command never surfaces into pi output.
28
+ child.on("error", () => {});
29
+ child.stderr?.on("data", () => {});
30
+ child.stdout?.on("data", () => {});
31
+ }
32
+
33
+ export default function notifyOnStopExtension(pi: ExtensionAPI): void {
34
+ pi.on("agent_end", () => {
35
+ const cmd = resolveCommand();
36
+ if (!cmd) return;
37
+ runNotify(cmd);
38
+ });
39
+
40
+ pi.registerCommand("notify", {
41
+ description:
42
+ "Stop-notify status/test. Usage: /notify [status|test|off]. Configure via env PI_STOP_NOTIFY, disable with PI_STOP_NOTIFY_OFF=1.",
43
+ handler: async (args, ctx) => {
44
+ const sub = args.trim().toLowerCase() || "status";
45
+ if (sub === "test") {
46
+ const cmd = resolveCommand();
47
+ if (!cmd) {
48
+ ctx.ui.notify("notify: nothing configured / disabled", "warning");
49
+ return;
50
+ }
51
+ runNotify(cmd);
52
+ ctx.ui.notify(`notify: ran "${cmd}"`, "info");
53
+ return;
54
+ }
55
+ if (sub === "off") {
56
+ ctx.ui.notify(
57
+ `notify: to disable persistently, set ${ENV_OFF}=1 in your shell rc; this session's behavior is driven by env at startup.`,
58
+ "info",
59
+ );
60
+ return;
61
+ }
62
+ const cmd = resolveCommand();
63
+ ctx.ui.notify(
64
+ cmd
65
+ ? `notify: will run "${cmd}" on agent_end`
66
+ : `notify: disabled (no default for this platform or ${ENV_OFF}=1)`,
67
+ "info",
68
+ );
69
+ },
70
+ });
71
+ }
@@ -0,0 +1,107 @@
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("config", () => {
7
+ let root: string;
8
+ const originalDir = process.env.PI_CODING_AGENT_DIR;
9
+ const originalDryRun = process.env.PI_SECURITY_GUARD_DRY_RUN;
10
+
11
+ beforeEach(() => {
12
+ root = mkdtempSync(join(tmpdir(), "sg-cfg-"));
13
+ process.env.PI_CODING_AGENT_DIR = root;
14
+ delete process.env.PI_SECURITY_GUARD_DRY_RUN;
15
+ vi.resetModules();
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(root, { recursive: true, force: true });
20
+ process.env.PI_CODING_AGENT_DIR = originalDir;
21
+ if (originalDryRun === undefined) delete process.env.PI_SECURITY_GUARD_DRY_RUN;
22
+ else process.env.PI_SECURITY_GUARD_DRY_RUN = originalDryRun;
23
+ });
24
+
25
+ it("uses defaults when config file missing", async () => {
26
+ const { loadConfig } = await import("./config.ts");
27
+ const r = loadConfig();
28
+ expect(r.configExists).toBe(false);
29
+ expect(r.rules.operations.length).toBeGreaterThan(0);
30
+ expect(r.errors).toEqual([]);
31
+ });
32
+
33
+ it("loads user JSON rules overriding defaults when non-empty", async () => {
34
+ writeFileSync(
35
+ join(root, "security-guard.json"),
36
+ JSON.stringify({
37
+ operations: [{ pattern: "myop", action: "block" }],
38
+ writes: [{ pattern: "*.secret", action: "prompt" }],
39
+ }),
40
+ "utf-8",
41
+ );
42
+ const { loadConfig } = await import("./config.ts");
43
+ const r = loadConfig();
44
+ expect(r.configExists).toBe(true);
45
+ expect(r.rules.operations).toEqual([{ pattern: "myop", action: "block" }]);
46
+ expect(r.rules.writes).toEqual([{ pattern: "*.secret", action: "prompt" }]);
47
+ // reads missing → defaults used
48
+ expect(r.rules.reads.length).toBeGreaterThan(0);
49
+ });
50
+
51
+ it("invalid JSON falls back to defaults and records error", async () => {
52
+ writeFileSync(join(root, "security-guard.json"), "{not json", "utf-8");
53
+ const { loadConfig } = await import("./config.ts");
54
+ const r = loadConfig();
55
+ expect(r.errors.length).toBeGreaterThan(0);
56
+ expect(r.rules.operations.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ it("malformed rule (bad action) is skipped with error", async () => {
60
+ writeFileSync(
61
+ join(root, "security-guard.json"),
62
+ JSON.stringify({
63
+ operations: [
64
+ { pattern: "ok", action: "block" },
65
+ { pattern: "bad", action: "nuke-from-orbit" },
66
+ { pattern: "", action: "prompt" }, // empty pattern
67
+ "not-an-object",
68
+ ],
69
+ }),
70
+ "utf-8",
71
+ );
72
+ const { loadConfig } = await import("./config.ts");
73
+ const r = loadConfig();
74
+ expect(r.rules.operations).toEqual([{ pattern: "ok", action: "block" }]);
75
+ expect(r.errors.length).toBeGreaterThanOrEqual(3);
76
+ });
77
+
78
+ it("non-array section logs error and falls back to default for that section", async () => {
79
+ writeFileSync(
80
+ join(root, "security-guard.json"),
81
+ JSON.stringify({ operations: "not an array" }),
82
+ "utf-8",
83
+ );
84
+ const { loadConfig } = await import("./config.ts");
85
+ const r = loadConfig();
86
+ expect(r.errors.some((e) => e.includes("operations"))).toBe(true);
87
+ });
88
+
89
+ it("ensureExampleConfig creates .example file once", async () => {
90
+ const { ensureExampleConfig } = await import("./config.ts");
91
+ mkdirSync(root, { recursive: true });
92
+ ensureExampleConfig();
93
+ const p = join(root, "security-guard.json.example");
94
+ expect(existsSync(p)).toBe(true);
95
+ const first = readFileSync(p, "utf-8");
96
+ // Running again doesn't overwrite / doesn't throw
97
+ ensureExampleConfig();
98
+ expect(readFileSync(p, "utf-8")).toBe(first);
99
+ });
100
+
101
+ it("isDryRun flips with env var", async () => {
102
+ const { isDryRun } = await import("./config.ts");
103
+ expect(isDryRun()).toBe(false);
104
+ process.env.PI_SECURITY_GUARD_DRY_RUN = "1";
105
+ expect(isDryRun()).toBe(true);
106
+ });
107
+ });