@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 +2 -0
- package/extensions/gemini-image/index.test.ts +43 -0
- package/extensions/gemini-image/index.ts +35 -11
- package/extensions/gemini-image/pricing.test.ts +43 -0
- package/extensions/gemini-image/pricing.ts +32 -0
- package/extensions/notify-on-stop/index.test.ts +179 -0
- package/extensions/notify-on-stop/index.ts +71 -0
- package/extensions/security-guard/config.test.ts +107 -0
- package/extensions/security-guard/config.ts +120 -0
- package/extensions/security-guard/defaults.ts +38 -0
- package/extensions/security-guard/index.test.ts +296 -0
- package/extensions/security-guard/index.ts +173 -0
- package/extensions/security-guard/rules.test.ts +96 -0
- package/extensions/security-guard/rules.ts +90 -0
- package/extensions/security-guard/types.ts +12 -0
- package/package.json +1 -1
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<
|
|
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
|
-
|
|
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<
|
|
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
|
|
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: {
|
|
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
|
+
});
|