gentle-pi 3.4.0 → 3.5.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.
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import askUserQuestion from "../extensions/ask-user-question.ts";
3
+ import askUserQuestion, { askMultiSelect } from "../extensions/ask-user-question.ts";
4
4
 
5
5
  /** Plain theme fake: identity styling keeps rendered assertions readable. */
6
6
  interface Theme {
@@ -107,6 +107,38 @@ function run(tool: RegisteredTool, params: unknown, ctx: unknown): Promise<ToolR
107
107
  return tool.execute("call", params, new AbortController().signal, undefined, ctx);
108
108
  }
109
109
 
110
+ const INTERACTIVE_HOST_ENV = "GENTLE_SHELL_INTERACTIVE_HOST";
111
+
112
+ /** Fake interactive-RPC-host ctx: scripted `select` answers, one per call. */
113
+ function rpcHostContext(selectAnswers: readonly (string | undefined)[]) {
114
+ const selectCalls: Array<{ title: string; options: string[] }> = [];
115
+ let index = 0;
116
+ return {
117
+ ctx: {
118
+ mode: "rpc",
119
+ hasUI: true,
120
+ ui: {
121
+ select: async (title: string, options: string[]) => {
122
+ selectCalls.push({ title, options });
123
+ const answer = selectAnswers[index];
124
+ index += 1;
125
+ return answer;
126
+ },
127
+ },
128
+ },
129
+ selectCalls,
130
+ };
131
+ }
132
+
133
+ function withInteractiveHostEnv(t: { after(fn: () => void): void }): void {
134
+ const previous = process.env[INTERACTIVE_HOST_ENV];
135
+ process.env[INTERACTIVE_HOST_ENV] = "1";
136
+ t.after(() => {
137
+ if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
138
+ else process.env[INTERACTIVE_HOST_ENV] = previous;
139
+ });
140
+ }
141
+
110
142
  const option = (label: string, description = `${label} description`, preview?: string) =>
111
143
  preview === undefined ? { label, description } : { label, description, preview };
112
144
 
@@ -185,6 +217,200 @@ test("ask_user_question stays unavailable outside the interactive TUI", async ()
185
217
  assert.deepEqual(emitted, []);
186
218
  });
187
219
 
220
+ test("ask_user_question stays unavailable on a plain rpc host without the interactive-host variable", async (t) => {
221
+ const { tool, emitted } = registerQuestionTool();
222
+ let selectCalls = 0;
223
+ const ctx = {
224
+ mode: "rpc",
225
+ hasUI: true,
226
+ ui: { select: async () => { selectCalls++; return undefined; } },
227
+ };
228
+ t.after(() => { delete process.env[INTERACTIVE_HOST_ENV]; });
229
+ delete process.env[INTERACTIVE_HOST_ENV];
230
+
231
+ const result = await run(tool, { questions: single() }, ctx);
232
+
233
+ assert.match(result.content[0]?.text ?? "", /unavailable outside the interactive TUI/);
234
+ assert.equal(result.details.errorKind, "unavailable_outside_tui");
235
+ assert.equal(selectCalls, 0);
236
+ assert.deepEqual(emitted, []);
237
+ });
238
+
239
+ test("ask_user_question resolves a single-select answer through RPC dialogs on an interactive host", async (t) => {
240
+ withInteractiveHostEnv(t);
241
+ const { tool, emitted } = registerQuestionTool();
242
+ const { ctx, selectCalls } = rpcHostContext(["Alpha"]);
243
+
244
+ const result = await run(tool, { questions: single() }, ctx);
245
+
246
+ assert.equal(selectCalls.length, 1);
247
+ assert.equal(selectCalls[0]?.title, "Proceed: Proceed?");
248
+ assert.deepEqual(selectCalls[0]?.options, ["Alpha", "Beta"]);
249
+ assert.equal(result.content[0]?.text, "1. Proceed? — Alpha");
250
+ assert.deepEqual(result.details.answers, [
251
+ { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha" },
252
+ ]);
253
+ assert.deepEqual(emitted, [
254
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
255
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
256
+ ]);
257
+ });
258
+
259
+ test("ask_user_question echoes an option preview through RPC dialogs", async (t) => {
260
+ withInteractiveHostEnv(t);
261
+ const { tool } = registerQuestionTool();
262
+ const questions = [
263
+ { question: "Proceed?", header: "Proceed", options: [option("Alpha", "First choice", "Preview A"), option("Beta")] },
264
+ ];
265
+ const { ctx } = rpcHostContext(["Alpha"]);
266
+
267
+ const result = await run(tool, { questions }, ctx);
268
+
269
+ assert.equal(result.content[0]?.text, "1. Proceed? — Alpha\n selected preview: Preview A");
270
+ assert.deepEqual(result.details.answers, [
271
+ { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview A" },
272
+ ]);
273
+ });
274
+
275
+ test("ask_user_question loops select with a trailing Done entry for a multiSelect question on an interactive host", async (t) => {
276
+ withInteractiveHostEnv(t);
277
+ const { tool } = registerQuestionTool();
278
+ const questions = [
279
+ { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
280
+ ];
281
+ const { ctx, selectCalls } = rpcHostContext(["[ ] One", "Done"]);
282
+
283
+ const result = await run(tool, { questions }, ctx);
284
+
285
+ assert.deepEqual(selectCalls[0]?.options, ["[ ] One", "[ ] Two", "Done"]);
286
+ assert.deepEqual(selectCalls[1]?.options, ["[x] One", "[ ] Two", "Done"]);
287
+ assert.equal(result.content[0]?.text, "1. Pick? — selected: One");
288
+ assert.deepEqual(result.details.answers, [
289
+ { questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
290
+ ]);
291
+ });
292
+
293
+ test("ask_user_question multiSelect finishes once every option is toggled without needing Done", async (t) => {
294
+ withInteractiveHostEnv(t);
295
+ const { tool } = registerQuestionTool();
296
+ const questions = [
297
+ { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
298
+ ];
299
+ const { ctx, selectCalls } = rpcHostContext(["[ ] One", "[ ] Two"]);
300
+
301
+ const result = await run(tool, { questions }, ctx);
302
+
303
+ assert.equal(selectCalls.length, 2, "bounded by the safety cap, but finished early once fully toggled");
304
+ assert.deepEqual(result.details.answers, [
305
+ { questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] },
306
+ ]);
307
+ });
308
+
309
+ test("ask_user_question multiSelect toggle/untoggle/toggle sequence still selects correctly after Done", async (t) => {
310
+ withInteractiveHostEnv(t);
311
+ const { tool } = registerQuestionTool();
312
+ const questions = [
313
+ { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
314
+ ];
315
+ // Toggle One on, then off, then on again, then explicit Done: four rounds,
316
+ // one more than the old options.length + 1 = 3 round bound, so an
317
+ // un-toggle must never count against the loop's budget.
318
+ const { ctx, selectCalls } = rpcHostContext(["[ ] One", "[x] One", "[ ] One", "Done"]);
319
+
320
+ const result = await run(tool, { questions }, ctx);
321
+
322
+ assert.equal(selectCalls.length, 4);
323
+ assert.deepEqual(result.details.answers, [
324
+ { questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
325
+ ]);
326
+ });
327
+
328
+ test("ask_user_question multiSelect cancels once the safety cap is hit without Done", async (t) => {
329
+ withInteractiveHostEnv(t);
330
+ const { tool } = registerQuestionTool();
331
+ const questions = [
332
+ { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
333
+ ];
334
+ // Toggle only "One" back and forth forever: "Two" never toggles, so the
335
+ // loop never auto-finishes, and Done is never picked. It must hit the
336
+ // hard safety cap and refuse to commit whatever was toggled at that point.
337
+ const selectAnswers = Array.from({ length: 32 }, (_, round) => (round % 2 === 0 ? "[ ] One" : "[x] One"));
338
+ const { ctx, selectCalls } = rpcHostContext(selectAnswers);
339
+
340
+ const result = await run(tool, { questions }, ctx);
341
+
342
+ assert.equal(selectCalls.length, 32, "every round of the safety cap must be spent before giving up");
343
+ assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
344
+ assert.deepEqual(result.details, { cancelled: true });
345
+ });
346
+
347
+ test("askMultiSelect scales its round cap so a 40-option question can toggle every option and still reach Done", async () => {
348
+ // The shipped `ask_user_question` tool schema caps authored options at 4
349
+ // (`lib/questionnaire/schema.ts`'s `MAX_OPTIONS`), so this exercises
350
+ // `askMultiSelect` directly rather than through the schema-validated tool,
351
+ // the same way a future caller with more options would.
352
+ const labels = Array.from({ length: 40 }, (_, index) => `Option ${index + 1}`);
353
+ const question = { question: "Pick?", header: "Pick", options: labels.map((label) => option(label)), multiSelect: true };
354
+ // Toggle the first 39 options on, one per round, then an explicit Done:
355
+ // 40 rounds total, past the old flat 32-round cap but inside the new
356
+ // `Math.max(32, options.length + 2)` = 42 bound.
357
+ const toggleAnswers = labels.slice(0, 39).map((label) => `[ ] ${label}`);
358
+ const { ctx, selectCalls } = rpcHostContext([...toggleAnswers, "Done"]);
359
+
360
+ const answer = await askMultiSelect(ctx as never, question as never);
361
+
362
+ assert.equal(selectCalls.length, 40, "every toggle plus the explicit Done fits inside the scaled cap");
363
+ assert.deepEqual(answer, { questionIndex: -1, question: "Pick?", kind: "multi", answer: null, selected: labels.slice(0, 39) });
364
+ });
365
+
366
+ test("ask_user_question multiSelect cancels on an unrecognised host answer instead of committing a partial state", async (t) => {
367
+ withInteractiveHostEnv(t);
368
+ const { tool } = registerQuestionTool();
369
+ const questions = [
370
+ { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
371
+ ];
372
+ // "One" gets toggled on first, then the host answers with something that
373
+ // matches none of the current round's options: the loop must cancel the
374
+ // whole questionnaire, never commit the partial "One" toggle.
375
+ const { ctx, selectCalls } = rpcHostContext(["[ ] One", "not a real option"]);
376
+
377
+ const result = await run(tool, { questions }, ctx);
378
+
379
+ assert.equal(selectCalls.length, 2);
380
+ assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
381
+ assert.deepEqual(result.details, { cancelled: true });
382
+ });
383
+
384
+ test("ask_user_question cancels through RPC dialogs like the TUI path when select returns undefined", async (t) => {
385
+ withInteractiveHostEnv(t);
386
+ const { tool, emitted } = registerQuestionTool();
387
+ const { ctx } = rpcHostContext([undefined]);
388
+
389
+ const result = await run(tool, { questions: single() }, ctx);
390
+
391
+ assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
392
+ assert.deepEqual(result.details, { cancelled: true });
393
+ assert.deepEqual(emitted, [
394
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
395
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
396
+ ]);
397
+ });
398
+
399
+ test("ask_user_question cancels a questionnaire through RPC dialogs on a later question", async (t) => {
400
+ withInteractiveHostEnv(t);
401
+ const { tool } = registerQuestionTool();
402
+ const questions = [
403
+ { question: "First?", header: "First", options: [option("Alpha"), option("Beta")] },
404
+ { question: "Second?", header: "Second", options: [option("Gamma"), option("Delta")] },
405
+ ];
406
+ const { ctx, selectCalls } = rpcHostContext(["Alpha", undefined]);
407
+
408
+ const result = await run(tool, { questions }, ctx);
409
+
410
+ assert.equal(selectCalls.length, 2, "the first question is answered before the cancel is observed");
411
+ assert.deepEqual(result.details, { cancelled: true });
412
+ });
413
+
188
414
  test("ask_user_question commits a single-select answer end-to-end", async () => {
189
415
  const { tool, emitted } = registerQuestionTool();
190
416
  const rendered = { value: "" };
@@ -379,6 +379,96 @@ for (const mode of ["print", "tui", "rpc"] as const) {
379
379
  });
380
380
  }
381
381
 
382
+ /** Controllable fake for `AgentsDeps.schedule`: records every scheduled callback instead of running it, so a test can fire the RPC publisher's coalescing window deterministically. */
383
+ function fakeScheduler() {
384
+ const pending: Array<{ id: number; fn: () => void }> = [];
385
+ let nextId = 0;
386
+ return {
387
+ schedule: (fn: () => void, _ms: number) => {
388
+ const id = nextId++;
389
+ pending.push({ id, fn });
390
+ return () => {
391
+ const index = pending.findIndex((entry) => entry.id === id);
392
+ if (index !== -1) pending.splice(index, 1);
393
+ };
394
+ },
395
+ flushAll: () => {
396
+ const due = pending.splice(0, pending.length);
397
+ for (const entry of due) entry.fn();
398
+ },
399
+ };
400
+ }
401
+
402
+ for (const scenario of [
403
+ { label: "an interactive RPC host", mode: "rpc", env: { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" }, expectPublish: true },
404
+ { label: "plain RPC without the interactive-host variable", mode: "rpc", env: { PATH: "/bin" }, expectPublish: false },
405
+ { label: "TUI", mode: "tui", env: { PATH: "/bin" }, expectPublish: false },
406
+ ] as const) {
407
+ test(`gentle-agents publishes the live activity payload through setWidget only on ${scenario.label}`, async (t) => {
408
+ const h = fakePi();
409
+ const runtime = deps();
410
+ const scheduler = fakeScheduler();
411
+ runtime.deps.schedule = scheduler.schedule;
412
+ runtime.deps.env = scenario.env;
413
+ gentleAgents(h.pi, {}, runtime.deps);
414
+ const { ctx } = fakeContext();
415
+ Object.assign(ctx, { mode: scenario.mode, hasUI: scenario.mode === "tui" || scenario.mode === "rpc" });
416
+ const setWidget = t.mock.method(ctx.ui, "setWidget");
417
+
418
+ await h.fire("session_start", ctx);
419
+ scheduler.flushAll(); // consume the publisher's own start-time frame, if any
420
+ setWidget.mock.resetCalls();
421
+
422
+ await h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
423
+ scheduler.flushAll();
424
+
425
+ // The TUI card's own setWidget call always carries a component-factory
426
+ // function, never an array; only the RPC publisher pushes an array.
427
+ const activityCalls = setWidget.mock.calls.filter((call) => call.arguments[0] === "gentle-agents" && Array.isArray(call.arguments[1]));
428
+ if (!scenario.expectPublish) {
429
+ assert.deepEqual(activityCalls, [], "no array-shaped setWidget push outside an interactive RPC host");
430
+ return;
431
+ }
432
+ assert.equal(activityCalls.length, 1, "one push per coalescing window");
433
+ // The fake `ui.setWidget` types `content` as the TUI-only component factory;
434
+ // the RPC publisher instead calls it with a plain `string[]` (real pi's
435
+ // RPC-mode contract), which needs an unknown-mediated cast here.
436
+ const [key, lines] = activityCalls[0]!.arguments as unknown as [string, string[]];
437
+ assert.equal(key, "gentle-agents");
438
+ assert.equal(lines.length, 1);
439
+ const activity = JSON.parse(lines[0]!) as { schema: string; tasks: Array<{ summary: { id: string } }> };
440
+ assert.equal(activity.schema, "gentle-agents.activity/v1");
441
+ assert.ok(activity.tasks.some((task) => typeof task.summary.id === "string" && task.summary.id.length > 0), "the newly launched task must be in the payload");
442
+ });
443
+ }
444
+
445
+ test("gentle-agents notifies once, deduplicated, when the RPC activity publisher's setWidget throws", async (t) => {
446
+ const h = fakePi();
447
+ const runtime = deps();
448
+ const scheduler = fakeScheduler();
449
+ runtime.deps.schedule = scheduler.schedule;
450
+ runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" };
451
+ gentleAgents(h.pi, {}, runtime.deps);
452
+ const { ctx } = fakeContext();
453
+ Object.assign(ctx, { mode: "rpc", hasUI: true });
454
+ const notify = t.mock.method(ctx.ui, "notify");
455
+ // Only the publisher's array-shaped push fails; the TUI card's own
456
+ // component-factory push (`showWidget`) must stay untouched.
457
+ ctx.ui.setWidget = ((_key: string, content: unknown) => {
458
+ if (Array.isArray(content)) throw new Error("boom");
459
+ }) as typeof ctx.ui.setWidget;
460
+
461
+ await h.fire("session_start", ctx);
462
+ scheduler.flushAll(); // the publisher's own start-time frame fails: one notify
463
+
464
+ await h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
465
+ scheduler.flushAll(); // a second flush with the same recurring failure must not notify again
466
+
467
+ assert.equal(notify.mock.callCount(), 1, "the same recurring setWidget failure is deduplicated to one notify per session");
468
+ assert.match(String(notify.mock.calls[0]?.arguments[0]), /boom/);
469
+ assert.equal(notify.mock.calls[0]?.arguments[1], "warning");
470
+ });
471
+
382
472
  test("all nine subagent registrations own their transcript shell", () => {
383
473
  const { pi, tools } = fakePi();
384
474
  gentleAgents(pi, {}, deps().deps);
@@ -0,0 +1,188 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import test from "node:test";
8
+
9
+ // Integration tests for the thin bin/gentle-shell.mjs entry: they run the real
10
+ // file via spawnSync with an isolated HOME and a fake pi script standing in for
11
+ // the real @earendil-works/pi-coding-agent runtime, so the launcher's own logic
12
+ // (already covered at the unit level in tests/gentle-shell-launcher.test.ts)
13
+ // gets exercised end-to-end through real argv, env, and child-process wiring.
14
+
15
+ const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url);
16
+ const binPath = fileURLToPath(binUrl);
17
+ const packageRoot = dirname(dirname(binPath));
18
+
19
+ function fixture(t: test.TestContext) {
20
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-bin-"));
21
+ t.after(() => rmSync(root, { recursive: true, force: true }));
22
+ const home = join(root, "home");
23
+ mkdirSync(home, { recursive: true });
24
+ const piScript = join(root, "fake-pi.mjs");
25
+ writePiScript(piScript, "0.85.1");
26
+ const gentleShellHome = join(root, "gentle-shell-home");
27
+ const env: NodeJS.ProcessEnv = {
28
+ ...process.env,
29
+ HOME: home,
30
+ USERPROFILE: home,
31
+ GENTLE_SHELL_HOME: gentleShellHome,
32
+ GENTLE_SHELL_PI: piScript,
33
+ };
34
+ return { root, home, gentleShellHome, piScript, env };
35
+ }
36
+
37
+ function writePiScript(path: string, version: string) {
38
+ writeFileSync(
39
+ path,
40
+ [
41
+ "#!/usr/bin/env node",
42
+ "const args = process.argv.slice(2);",
43
+ `if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`,
44
+ "console.log(JSON.stringify({",
45
+ " args,",
46
+ " PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
47
+ " GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
48
+ "}));",
49
+ "process.exit(0);",
50
+ "",
51
+ ].join("\n"),
52
+ );
53
+ chmodSync(path, 0o755);
54
+ }
55
+
56
+ function run(env: NodeJS.ProcessEnv, args: string[]) {
57
+ return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env });
58
+ }
59
+
60
+ test("--help exits 0 and prints usage", (t) => {
61
+ const f = fixture(t);
62
+ const result = run(f.env, ["--help"]);
63
+ assert.equal(result.status, 0, result.stderr);
64
+ assert.match(result.stdout, /Usage: gentle-shell/);
65
+ });
66
+
67
+ test("home with no args prints the default isolated home", (t) => {
68
+ const f = fixture(t);
69
+ const result = run(f.env, ["home"]);
70
+ assert.equal(result.status, 0, result.stderr);
71
+ assert.equal(result.stdout.trim(), `isolated ${f.gentleShellHome}`);
72
+ });
73
+
74
+ test("home link persists and a later home reflects it", (t) => {
75
+ const f = fixture(t);
76
+ const save = run(f.env, ["home", "link"]);
77
+ assert.equal(save.status, 0, save.stderr);
78
+ const configPath = join(f.home, ".gentle-shell", "config.json");
79
+ assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { home: "link" });
80
+ const check = run(f.env, ["home"]);
81
+ assert.equal(check.status, 0, check.stderr);
82
+ assert.match(check.stdout, /^link /);
83
+ });
84
+
85
+ test("home <path> persists a custom directory", (t) => {
86
+ const f = fixture(t);
87
+ const target = join(f.root, "custom-home");
88
+ const save = run(f.env, ["home", target]);
89
+ assert.equal(save.status, 0, save.stderr);
90
+ const configPath = join(f.home, ".gentle-shell", "config.json");
91
+ assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { home: target });
92
+ const check = run(f.env, ["home"]);
93
+ assert.equal(check.stdout.trim(), `path ${target}`);
94
+ });
95
+
96
+ test("first isolated run bootstraps the home, writes fullscreen, and prints the hint once", (t) => {
97
+ const f = fixture(t);
98
+ assert.equal(existsSync(f.gentleShellHome), false);
99
+
100
+ const first = run(f.env, []);
101
+ assert.equal(first.status, 0, first.stderr);
102
+ assert.match(first.stderr, /using a separate home at/);
103
+ assert.match(first.stderr, /gentle-shell --link/);
104
+ const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
105
+ assert.equal(settings.tuiMode, "fullscreen");
106
+
107
+ const second = run(f.env, []);
108
+ assert.equal(second.status, 0, second.stderr);
109
+ assert.doesNotMatch(second.stderr, /using a separate home at/);
110
+ });
111
+
112
+ test("forwarded args reach pi after the injected extension flags, in order", (t) => {
113
+ const f = fixture(t);
114
+ const result = run(f.env, ["--mode", "rpc", "-p", "hi"]);
115
+ assert.equal(result.status, 0, result.stderr);
116
+ const payload = JSON.parse(result.stdout);
117
+ assert.deepEqual(payload.args, [
118
+ "-e",
119
+ packageRoot,
120
+ "--theme",
121
+ join(packageRoot, "themes"),
122
+ "--skill",
123
+ join(packageRoot, "skills"),
124
+ "--prompt-template",
125
+ join(packageRoot, "prompts"),
126
+ "--mode",
127
+ "rpc",
128
+ "-p",
129
+ "hi",
130
+ ]);
131
+ assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
132
+ });
133
+
134
+ test("--link skips injection and leaves settings.json byte-identical when it already declares gentle-pi", (t) => {
135
+ const f = fixture(t);
136
+ const piAgentDir = join(f.root, "pi-agent");
137
+ mkdirSync(piAgentDir, { recursive: true });
138
+ const settingsPath = join(piAgentDir, "settings.json");
139
+ const settingsText = '{"packages":["npm:gentle-pi"],"theme":"kept"}';
140
+ writeFileSync(settingsPath, settingsText);
141
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
142
+
143
+ const result = run(env, ["--link", "--mode", "rpc"]);
144
+ assert.equal(result.status, 0, result.stderr);
145
+ const payload = JSON.parse(result.stdout);
146
+ assert.deepEqual(payload.args, ["--mode", "rpc"]);
147
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
148
+ assert.equal(payload.PI_CODING_AGENT_DIR, piAgentDir);
149
+ assert.equal(existsSync(join(piAgentDir, ".gentle-shell")), false);
150
+ });
151
+
152
+ test("gentle-shell list forwards to pi as a bare subcommand, with no injected extension flags", (t) => {
153
+ const f = fixture(t);
154
+ const result = run(f.env, ["list"]);
155
+ assert.equal(result.status, 0, result.stderr);
156
+ const payload = JSON.parse(result.stdout);
157
+ assert.deepEqual(payload.args, ["list"]);
158
+ assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
159
+ });
160
+
161
+ test("gentle-shell install npm:<pkg> forwards the subcommand and its argument verbatim", (t) => {
162
+ const f = fixture(t);
163
+ const result = run(f.env, ["install", "npm:pi-btw"]);
164
+ assert.equal(result.status, 0, result.stderr);
165
+ const payload = JSON.parse(result.stdout);
166
+ assert.deepEqual(payload.args, ["install", "npm:pi-btw"]);
167
+ assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
168
+ });
169
+
170
+ test("a too-old pi exits 1 naming both versions", (t) => {
171
+ const f = fixture(t);
172
+ writePiScript(f.piScript, "0.80.0");
173
+ const result = run(f.env, []);
174
+ assert.equal(result.status, 1);
175
+ assert.match(result.stderr, /0\.80\.0/);
176
+ assert.match(result.stderr, /0\.85\.1/);
177
+ });
178
+
179
+ test("--version prints three lines", (t) => {
180
+ const f = fixture(t);
181
+ const result = run(f.env, ["--version"]);
182
+ assert.equal(result.status, 0, result.stderr);
183
+ const lines = result.stdout.trim().split("\n");
184
+ assert.equal(lines.length, 3);
185
+ assert.match(lines[0], /^gentle-shell /);
186
+ assert.match(lines[1], /^pi 0\.85\.1$/);
187
+ assert.match(lines[2], /^home isolated /);
188
+ });