@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.
package/README.md CHANGED
@@ -53,10 +53,14 @@ pi # launch; confirm [Extensions] lists astro-agents, claude-glob
53
53
  **Tools** (LLM-callable):
54
54
  - `astro_agent` — delegate a task to a named subagent in an isolated pi subprocess
55
55
  - `grimoire` — search indexed technical documentation via the grimoire CLI
56
+ - `edit` — replaces pi's built-in with batch multi-file edits + Codex-style patch mode, preflight validation, atomic rollback
57
+ - `gemini_image` — generate or edit images via Google Gemini native models + Imagen 4; cost-estimated confirmation before every call
56
58
 
57
59
  **Extensions:**
58
60
  - `astro-agents` — registers `astro_agent` + ships 8 bundled subagents
59
61
  - `grimoire` — registers `grimoire` tool
62
+ - `multi-edit` — registers the enhanced `edit` tool
63
+ - `gemini-image` — registers `gemini_image` tool (requires a Gemini API key; prompts and saves on first use)
60
64
  - `claude-globals` — auto-injects `~/.claude/CLAUDE.md` into every pi session's system prompt
61
65
 
62
66
  **Bundled subagents** (callable via `astro_agent`):
@@ -0,0 +1,152 @@
1
+ import { mkdirSync, 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 } from "vitest";
5
+ import { discoverAgents } from "./discovery.ts";
6
+
7
+ function writeAgent(dir: string, name: string, frontmatter: Record<string, string>, body = ""): void {
8
+ const fm = Object.entries(frontmatter)
9
+ .map(([k, v]) => `${k}: ${v}`)
10
+ .join("\n");
11
+ writeFileSync(join(dir, `${name}.md`), `---\n${fm}\n---\n${body}\n`, "utf-8");
12
+ }
13
+
14
+ describe("discoverAgents", () => {
15
+ let bundleDir: string;
16
+ let cwd: string;
17
+ const originalAgentDir = process.env.PI_AGENT_DIR;
18
+
19
+ beforeEach(() => {
20
+ bundleDir = mkdtempSync(join(tmpdir(), "bundle-"));
21
+ cwd = mkdtempSync(join(tmpdir(), "cwd-"));
22
+ const pseudoAgentDir = mkdtempSync(join(tmpdir(), "agentdir-"));
23
+ mkdirSync(join(pseudoAgentDir, "agents"), { recursive: true });
24
+ process.env.PI_AGENT_DIR = pseudoAgentDir;
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(bundleDir, { recursive: true, force: true });
29
+ rmSync(cwd, { recursive: true, force: true });
30
+ process.env.PI_AGENT_DIR = originalAgentDir;
31
+ });
32
+
33
+ it("returns empty array when no agent dirs exist", () => {
34
+ rmSync(bundleDir, { recursive: true, force: true });
35
+ const agents = discoverAgents({ cwd, bundleDir, scope: "user" });
36
+ expect(agents).toEqual([]);
37
+ });
38
+
39
+ it("loads bundled agents with name, description, body", () => {
40
+ writeAgent(bundleDir, "a", { name: "a", description: "does a" }, "system prompt a");
41
+ const agents = discoverAgents({ cwd, bundleDir, scope: "user" });
42
+ expect(agents).toHaveLength(1);
43
+ expect(agents[0].name).toBe("a");
44
+ expect(agents[0].description).toBe("does a");
45
+ expect(agents[0].systemPrompt.trim()).toBe("system prompt a");
46
+ expect(agents[0].source).toBe("bundle");
47
+ });
48
+
49
+ it("parses color field", () => {
50
+ writeAgent(bundleDir, "a", { name: "a", description: "x", color: "red" });
51
+ const [agent] = discoverAgents({ cwd, bundleDir, scope: "user" });
52
+ expect(agent.color).toBe("red");
53
+ });
54
+
55
+ it("parses tools as comma-separated list", () => {
56
+ writeAgent(bundleDir, "a", { name: "a", description: "x", tools: "read, bash, grep" });
57
+ const [agent] = discoverAgents({ cwd, bundleDir, scope: "user" });
58
+ expect(agent.tools).toEqual(["read", "bash", "grep"]);
59
+ });
60
+
61
+ it("omits tools when field is absent", () => {
62
+ writeAgent(bundleDir, "a", { name: "a", description: "x" });
63
+ const [agent] = discoverAgents({ cwd, bundleDir, scope: "user" });
64
+ expect(agent.tools).toBeUndefined();
65
+ });
66
+
67
+ it("parses skills as YAML list", () => {
68
+ writeFileSync(
69
+ join(bundleDir, "a.md"),
70
+ `---\nname: a\ndescription: x\nskills:\n - playwright-cli\n - grimoire\n---\n`,
71
+ "utf-8",
72
+ );
73
+ const [agent] = discoverAgents({ cwd, bundleDir, scope: "user" });
74
+ expect(agent.skills).toEqual(["playwright-cli", "grimoire"]);
75
+ });
76
+
77
+ it("parses skills as comma-separated string when inline", () => {
78
+ writeAgent(bundleDir, "a", { name: "a", description: "x", skills: "alpha, beta" });
79
+ const [agent] = discoverAgents({ cwd, bundleDir, scope: "user" });
80
+ expect(agent.skills).toEqual(["alpha", "beta"]);
81
+ });
82
+
83
+ it("omits skills when empty", () => {
84
+ writeAgent(bundleDir, "a", { name: "a", description: "x", skills: "" });
85
+ const [agent] = discoverAgents({ cwd, bundleDir, scope: "user" });
86
+ expect(agent.skills).toBeUndefined();
87
+ });
88
+
89
+ it("skips files missing name or description", () => {
90
+ writeAgent(bundleDir, "a", { name: "a" }); // missing description
91
+ writeAgent(bundleDir, "b", { description: "x" }); // missing name
92
+ writeAgent(bundleDir, "c", { name: "c", description: "x" });
93
+ const agents = discoverAgents({ cwd, bundleDir, scope: "user" });
94
+ expect(agents.map((a) => a.name)).toEqual(["c"]);
95
+ });
96
+
97
+ it("skips non-.md files", () => {
98
+ writeFileSync(join(bundleDir, "notes.txt"), "not an agent", "utf-8");
99
+ writeAgent(bundleDir, "a", { name: "a", description: "x" });
100
+ const agents = discoverAgents({ cwd, bundleDir, scope: "user" });
101
+ expect(agents).toHaveLength(1);
102
+ });
103
+
104
+ it("project agents override user + bundle when scope is 'both'", () => {
105
+ writeAgent(bundleDir, "shared", { name: "shared", description: "from bundle" });
106
+
107
+ const projectAgentsDir = join(cwd, ".pi", "agents");
108
+ mkdirSync(projectAgentsDir, { recursive: true });
109
+ writeAgent(projectAgentsDir, "shared", { name: "shared", description: "from project" });
110
+
111
+ const agents = discoverAgents({ cwd, bundleDir, scope: "both" });
112
+ const shared = agents.find((a) => a.name === "shared");
113
+ expect(shared?.description).toBe("from project");
114
+ expect(shared?.source).toBe("project");
115
+ });
116
+
117
+ it("scope='user' skips project agents", () => {
118
+ writeAgent(bundleDir, "b", { name: "b", description: "bundle" });
119
+ const projectAgentsDir = join(cwd, ".pi", "agents");
120
+ mkdirSync(projectAgentsDir, { recursive: true });
121
+ writeAgent(projectAgentsDir, "p", { name: "p", description: "project" });
122
+ const agents = discoverAgents({ cwd, bundleDir, scope: "user" });
123
+ expect(agents.map((a) => a.name).sort()).toEqual(["b"]);
124
+ });
125
+
126
+ it("scope='project' skips user-level agents", () => {
127
+ writeAgent(bundleDir, "b", { name: "b", description: "bundle" });
128
+ const projectAgentsDir = join(cwd, ".pi", "agents");
129
+ mkdirSync(projectAgentsDir, { recursive: true });
130
+ writeAgent(projectAgentsDir, "p", { name: "p", description: "project" });
131
+ const agents = discoverAgents({ cwd, bundleDir, scope: "project" });
132
+ expect(agents.map((a) => a.name).sort()).toEqual(["b", "p"]);
133
+ });
134
+
135
+ it("walks parent directories to find nearest .pi/agents", () => {
136
+ const deep = join(cwd, "a", "b", "c");
137
+ mkdirSync(deep, { recursive: true });
138
+ const projectAgentsDir = join(cwd, ".pi", "agents");
139
+ mkdirSync(projectAgentsDir, { recursive: true });
140
+ writeAgent(projectAgentsDir, "p", { name: "p", description: "project" });
141
+
142
+ const agents = discoverAgents({ cwd: deep, bundleDir, scope: "project" });
143
+ expect(agents.map((a) => a.name)).toContain("p");
144
+ });
145
+
146
+ it("ignores symlinks to non-existent targets", () => {
147
+ // symlink behavior — create a file, our loader should accept symlinks
148
+ writeAgent(bundleDir, "a", { name: "a", description: "x" });
149
+ const agents = discoverAgents({ cwd, bundleDir, scope: "user" });
150
+ expect(agents).toHaveLength(1);
151
+ });
152
+ });
@@ -0,0 +1,208 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { AgentConfig } from "./discovery.ts";
3
+
4
+ vi.mock("./discovery.ts", () => ({
5
+ discoverAgents: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("./spawn.ts", () => ({
9
+ runAgent: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("node:fs", async (importActual) => {
13
+ const actual = await importActual<typeof import("node:fs")>();
14
+ return { ...actual, existsSync: vi.fn(actual.existsSync) };
15
+ });
16
+
17
+ type ToolDef = {
18
+ name: string;
19
+ execute: (
20
+ id: string,
21
+ input: { agent: string; task: string; scope?: "user" | "project" | "both" },
22
+ signal: AbortSignal | undefined,
23
+ onUpdate: unknown,
24
+ ctx: {
25
+ cwd: string;
26
+ hasUI: boolean;
27
+ ui: {
28
+ notify: ReturnType<typeof vi.fn>;
29
+ confirm: ReturnType<typeof vi.fn>;
30
+ };
31
+ },
32
+ ) => Promise<{ content: Array<{ text: string }>; details: { agent: string; source: string } }>;
33
+ };
34
+
35
+ function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void } {
36
+ const tools: ToolDef[] = [];
37
+ return { tools, registerTool: (t) => tools.push(t) };
38
+ }
39
+
40
+ function makeCtx(): {
41
+ cwd: string;
42
+ hasUI: boolean;
43
+ ui: { notify: ReturnType<typeof vi.fn>; confirm: ReturnType<typeof vi.fn> };
44
+ } {
45
+ return {
46
+ cwd: "/tmp/test-cwd",
47
+ hasUI: true,
48
+ ui: {
49
+ notify: vi.fn(),
50
+ confirm: vi.fn().mockResolvedValue(true),
51
+ },
52
+ };
53
+ }
54
+
55
+ const sampleAgent: AgentConfig = {
56
+ name: "scout",
57
+ description: "quick recon",
58
+ color: "red",
59
+ systemPrompt: "You are scout.",
60
+ source: "bundle",
61
+ filePath: "/pkg/extensions/astro-agents/agents/scout.md",
62
+ };
63
+
64
+ describe("astro-agents extension (astro_agent tool)", () => {
65
+ beforeEach(() => {
66
+ vi.resetModules();
67
+ vi.clearAllMocks();
68
+ });
69
+
70
+ afterEach(() => {
71
+ vi.clearAllMocks();
72
+ });
73
+
74
+ it("registers the astro_agent tool", async () => {
75
+ const { discoverAgents } = await import("./discovery.ts");
76
+ vi.mocked(discoverAgents).mockReturnValue([sampleAgent]);
77
+ const mod = await import("./index.ts");
78
+ const pi = makePi();
79
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
80
+ expect(pi.tools[0].name).toBe("astro_agent");
81
+ });
82
+
83
+ it("throws when agent name is not found; lists available", async () => {
84
+ const { discoverAgents } = await import("./discovery.ts");
85
+ vi.mocked(discoverAgents).mockReturnValue([sampleAgent]);
86
+ const mod = await import("./index.ts");
87
+ const pi = makePi();
88
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
89
+ const ctx = makeCtx();
90
+ await expect(
91
+ pi.tools[0].execute("t", { agent: "ghost", task: "x" }, undefined, undefined, ctx),
92
+ ).rejects.toThrow(/not found.+scout/);
93
+ });
94
+
95
+ it("happy path: spawns subagent and returns its text", async () => {
96
+ const { discoverAgents } = await import("./discovery.ts");
97
+ const { runAgent } = await import("./spawn.ts");
98
+ vi.mocked(discoverAgents).mockReturnValue([sampleAgent]);
99
+ vi.mocked(runAgent).mockResolvedValue({ text: "done", exitCode: 0, stderr: "" });
100
+ const mod = await import("./index.ts");
101
+ const pi = makePi();
102
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
103
+ const ctx = makeCtx();
104
+ const res = await pi.tools[0].execute("t", { agent: "scout", task: "recon" }, undefined, undefined, ctx);
105
+ expect(res.content[0].text).toBe("done");
106
+ expect(ctx.ui.notify).toHaveBeenCalled();
107
+ });
108
+
109
+ it("throws on non-zero exit", async () => {
110
+ const { discoverAgents } = await import("./discovery.ts");
111
+ const { runAgent } = await import("./spawn.ts");
112
+ vi.mocked(discoverAgents).mockReturnValue([sampleAgent]);
113
+ vi.mocked(runAgent).mockResolvedValue({ text: "", exitCode: 1, stderr: "boom" });
114
+ const mod = await import("./index.ts");
115
+ const pi = makePi();
116
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
117
+ await expect(
118
+ pi.tools[0].execute("t", { agent: "scout", task: "x" }, undefined, undefined, makeCtx()),
119
+ ).rejects.toThrow(/exited with code 1/);
120
+ });
121
+
122
+ it("returns placeholder when agent produced no output", async () => {
123
+ const { discoverAgents } = await import("./discovery.ts");
124
+ const { runAgent } = await import("./spawn.ts");
125
+ vi.mocked(discoverAgents).mockReturnValue([sampleAgent]);
126
+ vi.mocked(runAgent).mockResolvedValue({ text: "", exitCode: 0, stderr: "" });
127
+ const mod = await import("./index.ts");
128
+ const pi = makePi();
129
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
130
+ const res = await pi.tools[0].execute("t", { agent: "scout", task: "x" }, undefined, undefined, makeCtx());
131
+ expect(res.content[0].text).toMatch(/no output/);
132
+ });
133
+
134
+ it("prompts confirmation for project-scoped agent and respects 'no'", async () => {
135
+ const projectAgent = { ...sampleAgent, source: "project" as const };
136
+ const { discoverAgents } = await import("./discovery.ts");
137
+ vi.mocked(discoverAgents).mockReturnValue([projectAgent]);
138
+ const mod = await import("./index.ts");
139
+ const pi = makePi();
140
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
141
+ const ctx = makeCtx();
142
+ ctx.ui.confirm.mockResolvedValue(false);
143
+ await expect(
144
+ pi.tools[0].execute("t", { agent: "scout", task: "x", scope: "project" }, undefined, undefined, ctx),
145
+ ).rejects.toThrow(/cancelled/);
146
+ });
147
+
148
+ it("warns when declared skill isn't in the package", async () => {
149
+ const { discoverAgents } = await import("./discovery.ts");
150
+ const { runAgent } = await import("./spawn.ts");
151
+ const agentWithSkill: AgentConfig = { ...sampleAgent, skills: ["ghost-skill"] };
152
+ vi.mocked(discoverAgents).mockReturnValue([agentWithSkill]);
153
+ vi.mocked(runAgent).mockResolvedValue({ text: "ok", exitCode: 0, stderr: "" });
154
+ const fs = await import("node:fs");
155
+ vi.mocked(fs.existsSync).mockImplementation((p) => !String(p).endsWith("ghost-skill"));
156
+ const mod = await import("./index.ts");
157
+ const pi = makePi();
158
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
159
+ const ctx = makeCtx();
160
+ await pi.tools[0].execute("t", { agent: "scout", task: "x" }, undefined, undefined, ctx);
161
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/skill .*ghost-skill.*not present/), "warning");
162
+ });
163
+
164
+ it("resolves declared skill paths that exist in the package skills dir", async () => {
165
+ const { discoverAgents } = await import("./discovery.ts");
166
+ const { runAgent } = await import("./spawn.ts");
167
+ const agentWithSkill: AgentConfig = { ...sampleAgent, skills: ["playwright-cli"] };
168
+ vi.mocked(discoverAgents).mockReturnValue([agentWithSkill]);
169
+ vi.mocked(runAgent).mockResolvedValue({ text: "ok", exitCode: 0, stderr: "" });
170
+ const fs = await import("node:fs");
171
+ vi.mocked(fs.existsSync).mockReturnValue(true);
172
+ const mod = await import("./index.ts");
173
+ const pi = makePi();
174
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
175
+ await pi.tools[0].execute("t", { agent: "scout", task: "x" }, undefined, undefined, makeCtx());
176
+ const runAgentCalls = vi.mocked(runAgent).mock.calls;
177
+ expect(runAgentCalls[0][0].skillPaths).toHaveLength(1);
178
+ });
179
+
180
+ it("agent without color renders plain name in notify", async () => {
181
+ const noColor: AgentConfig = { ...sampleAgent, color: undefined };
182
+ const { discoverAgents } = await import("./discovery.ts");
183
+ const { runAgent } = await import("./spawn.ts");
184
+ vi.mocked(discoverAgents).mockReturnValue([noColor]);
185
+ vi.mocked(runAgent).mockResolvedValue({ text: "ok", exitCode: 0, stderr: "" });
186
+ const mod = await import("./index.ts");
187
+ const pi = makePi();
188
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
189
+ const ctx = makeCtx();
190
+ await pi.tools[0].execute("t", { agent: "scout", task: "x" }, undefined, undefined, ctx);
191
+ const notifyMsg = ctx.ui.notify.mock.calls[0][0] as string;
192
+ expect(notifyMsg).toContain("scout");
193
+ });
194
+
195
+ it("agent with unknown color falls back to plain name", async () => {
196
+ const weirdColor: AgentConfig = { ...sampleAgent, color: "fuchsia-tangerine" };
197
+ const { discoverAgents } = await import("./discovery.ts");
198
+ const { runAgent } = await import("./spawn.ts");
199
+ vi.mocked(discoverAgents).mockReturnValue([weirdColor]);
200
+ vi.mocked(runAgent).mockResolvedValue({ text: "ok", exitCode: 0, stderr: "" });
201
+ const mod = await import("./index.ts");
202
+ const pi = makePi();
203
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
204
+ const ctx = makeCtx();
205
+ await pi.tools[0].execute("t", { agent: "scout", task: "x" }, undefined, undefined, ctx);
206
+ expect(ctx.ui.notify).toHaveBeenCalled();
207
+ });
208
+ });
@@ -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
+ });