gentle-pi 3.5.0 → 3.5.1

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 { spawnSync } from "node:child_process";
3
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -53,8 +53,8 @@ function writePiScript(path: string, version: string) {
53
53
  chmodSync(path, 0o755);
54
54
  }
55
55
 
56
- function run(env: NodeJS.ProcessEnv, args: string[]) {
57
- return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env });
56
+ function run(env: NodeJS.ProcessEnv, args: string[], options: { cwd?: string } = {}) {
57
+ return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env, ...options });
58
58
  }
59
59
 
60
60
  test("--help exits 0 and prints usage", (t) => {
@@ -186,3 +186,651 @@ test("--version prints three lines", (t) => {
186
186
  assert.match(lines[1], /^pi 0\.85\.1$/);
187
187
  assert.match(lines[2], /^home isolated /);
188
188
  });
189
+
190
+ // --- --link takeover of a conflicting path package -----------------------
191
+ //
192
+ // Regression coverage for the settings.json shape that triggered the tool
193
+ // conflict bug: gentle-pi declared as a relative *path* package (not
194
+ // npm:gentle-pi) alongside another package. findGentlePiDeclaration now
195
+ // recognises that path declaration by reading its package.json "name", and
196
+ // the launcher takes over the pi invocation instead of also injecting its
197
+ // own -e, which used to load two copies of gentle-pi side by side.
198
+
199
+ test("--link takes over a path-declared conflicting gentle-pi: --no-extensions, the other package's dir, then this launcher's own -e, settings byte-identical", (t) => {
200
+ const f = fixture(t);
201
+ const piAgentDir = join(f.root, "pi-agent");
202
+ mkdirSync(piAgentDir, { recursive: true });
203
+
204
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
205
+ mkdirSync(otherGentlePiDir, { recursive: true });
206
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
207
+
208
+ // "npm:some-other" must actually be installed under
209
+ // <agentDir>/npm/node_modules for it to be re-injected (R3-001): a
210
+ // declared-but-missing package dir is now skipped with a warning instead.
211
+ mkdirSync(join(piAgentDir, "npm", "node_modules", "some-other"), { recursive: true });
212
+
213
+ const settingsPath = join(piAgentDir, "settings.json");
214
+ const settingsText = JSON.stringify({ packages: ["npm:some-other", "../other-gentle-pi"] });
215
+ writeFileSync(settingsPath, settingsText);
216
+
217
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
218
+ const result = run(env, ["--link", "--mode", "rpc"], { cwd: f.root });
219
+ assert.equal(result.status, 0, result.stderr);
220
+
221
+ assert.match(result.stderr, /taking over gentle-pi from/);
222
+ assert.match(result.stderr, /other-gentle-pi/);
223
+
224
+ const payload = JSON.parse(result.stdout);
225
+ assert.deepEqual(payload.args, [
226
+ "--no-extensions",
227
+ "-e",
228
+ join(piAgentDir, "npm", "node_modules", "some-other"),
229
+ "-e",
230
+ packageRoot,
231
+ "--theme",
232
+ join(packageRoot, "themes"),
233
+ "--skill",
234
+ join(packageRoot, "skills"),
235
+ "--prompt-template",
236
+ join(packageRoot, "prompts"),
237
+ "--mode",
238
+ "rpc",
239
+ ]);
240
+
241
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
242
+ assert.equal(payload.PI_CODING_AGENT_DIR, piAgentDir);
243
+ });
244
+
245
+ test("--link install npm:x with a path-declared conflicting gentle-pi in settings forwards the bare subcommand, no take-over", (t) => {
246
+ const f = fixture(t);
247
+ const piAgentDir = join(f.root, "pi-agent");
248
+ mkdirSync(piAgentDir, { recursive: true });
249
+
250
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
251
+ mkdirSync(otherGentlePiDir, { recursive: true });
252
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
253
+
254
+ const settingsPath = join(piAgentDir, "settings.json");
255
+ const settingsText = JSON.stringify({ packages: ["npm:some-other", "../other-gentle-pi"] });
256
+ writeFileSync(settingsPath, settingsText);
257
+
258
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
259
+ const result = run(env, ["--link", "install", "npm:x"], { cwd: f.root });
260
+ assert.equal(result.status, 0, result.stderr);
261
+ assert.doesNotMatch(result.stderr, /taking over gentle-pi from/);
262
+
263
+ const payload = JSON.parse(result.stdout);
264
+ assert.deepEqual(payload.args, ["install", "npm:x"]);
265
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
266
+ assert.equal(payload.PI_CODING_AGENT_DIR, piAgentDir);
267
+ });
268
+
269
+ test("--link does not take over a git-sourced other package: it is skipped with a warning, not injected", (t) => {
270
+ const f = fixture(t);
271
+ const piAgentDir = join(f.root, "pi-agent");
272
+ mkdirSync(piAgentDir, { recursive: true });
273
+
274
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
275
+ mkdirSync(otherGentlePiDir, { recursive: true });
276
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
277
+
278
+ const settingsPath = join(piAgentDir, "settings.json");
279
+ writeFileSync(settingsPath, JSON.stringify({ packages: ["git:github.com/foo/bar", "../other-gentle-pi"] }));
280
+
281
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
282
+ const result = run(env, ["--link"], { cwd: f.root });
283
+ assert.equal(result.status, 0, result.stderr);
284
+ assert.match(result.stderr, /skipping git-sourced package/);
285
+
286
+ const payload = JSON.parse(result.stdout);
287
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
288
+ });
289
+
290
+ test("--package-root forces a takeover even when settings already declare a matching npm:gentle-pi", (t) => {
291
+ const f = fixture(t);
292
+ const piAgentDir = join(f.root, "pi-agent");
293
+ mkdirSync(piAgentDir, { recursive: true });
294
+ const settingsPath = join(piAgentDir, "settings.json");
295
+ const settingsText = JSON.stringify({ packages: ["npm:gentle-pi"] });
296
+ writeFileSync(settingsPath, settingsText);
297
+
298
+ const forcedRoot = join(f.root, "forced-root");
299
+ mkdirSync(forcedRoot, { recursive: true });
300
+
301
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
302
+ const result = run(env, ["--link", "--package-root", forcedRoot], { cwd: f.root });
303
+ assert.equal(result.status, 0, result.stderr);
304
+ assert.match(result.stderr, /taking over gentle-pi from npm:gentle-pi/);
305
+
306
+ const payload = JSON.parse(result.stdout);
307
+ assert.deepEqual(payload.args, [
308
+ "--no-extensions",
309
+ "-e",
310
+ forcedRoot,
311
+ "--theme",
312
+ join(forcedRoot, "themes"),
313
+ "--skill",
314
+ join(forcedRoot, "skills"),
315
+ "--prompt-template",
316
+ join(forcedRoot, "prompts"),
317
+ ]);
318
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
319
+ });
320
+
321
+ test("--package-root forces a takeover even with no gentle-pi declaration at all: --no-extensions and the other packages are still injected", (t) => {
322
+ const f = fixture(t);
323
+ const piAgentDir = join(f.root, "pi-agent");
324
+ mkdirSync(piAgentDir, { recursive: true });
325
+ // "npm:some-other" must actually be installed under
326
+ // <agentDir>/npm/node_modules for it to be re-injected (R3-001).
327
+ mkdirSync(join(piAgentDir, "npm", "node_modules", "some-other"), { recursive: true });
328
+ const settingsPath = join(piAgentDir, "settings.json");
329
+ const settingsText = JSON.stringify({ packages: ["npm:some-other"] });
330
+ writeFileSync(settingsPath, settingsText);
331
+
332
+ const forcedRoot = join(f.root, "forced-root");
333
+ mkdirSync(forcedRoot, { recursive: true });
334
+
335
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
336
+ const result = run(env, ["--link", "--package-root", forcedRoot], { cwd: f.root });
337
+ assert.equal(result.status, 0, result.stderr);
338
+ assert.match(result.stderr, /taking over gentle-pi from the requested package root/);
339
+
340
+ const payload = JSON.parse(result.stdout);
341
+ assert.deepEqual(payload.args, [
342
+ "--no-extensions",
343
+ "-e",
344
+ join(piAgentDir, "npm", "node_modules", "some-other"),
345
+ "-e",
346
+ forcedRoot,
347
+ "--theme",
348
+ join(forcedRoot, "themes"),
349
+ "--skill",
350
+ join(forcedRoot, "skills"),
351
+ "--prompt-template",
352
+ join(forcedRoot, "prompts"),
353
+ ]);
354
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
355
+ });
356
+
357
+ test("--link take-over with --package-root injects exactly one -e when a settings path entry reaches the same physical directory through a symlink (R4-forced-root-symlink-double-injection)", (t) => {
358
+ const f = fixture(t);
359
+ const piAgentDir = join(f.root, "pi-agent");
360
+ mkdirSync(piAgentDir, { recursive: true });
361
+
362
+ const packagesDir = join(piAgentDir, "packages");
363
+ const realOtherDir = join(packagesDir, "real-other");
364
+ mkdirSync(realOtherDir, { recursive: true });
365
+ const linkOtherDir = join(packagesDir, "link-other");
366
+ symlinkSync(realOtherDir, linkOtherDir, "dir");
367
+
368
+ // settings declares the SYMLINK path; --package-root names the REAL path
369
+ // directly. Both spellings reach the same physical directory, so it must
370
+ // be injected exactly once instead of twice (once as an "other package"
371
+ // via the symlink, once as this launcher's own package root).
372
+ const settingsPath = join(piAgentDir, "settings.json");
373
+ writeFileSync(settingsPath, JSON.stringify({ packages: [join("packages", "link-other")] }));
374
+
375
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
376
+ const result = run(env, ["--link", "--package-root", realOtherDir], { cwd: f.root });
377
+ assert.equal(result.status, 0, result.stderr);
378
+
379
+ const payload = JSON.parse(result.stdout);
380
+ assert.equal(payload.args.filter((arg: string) => arg === "-e").length, 1);
381
+ assert.deepEqual(payload.args, [
382
+ "--no-extensions",
383
+ "-e",
384
+ realOtherDir,
385
+ "--theme",
386
+ join(realOtherDir, "themes"),
387
+ "--skill",
388
+ join(realOtherDir, "skills"),
389
+ "--prompt-template",
390
+ join(realOtherDir, "prompts"),
391
+ ]);
392
+ });
393
+
394
+ // --- loose extension files re-injected during a take-over -----------------
395
+ //
396
+ // Regression coverage for R4-003/R3-003 (and the follow-up bug it left
397
+ // behind): --no-extensions drops pi's normal settings-driven extension
398
+ // discovery, which also happens to be how pi finds loose (non-package)
399
+ // extensions under <agentDir>/extensions and the project-local
400
+ // <cwd>/.pi/extensions. Re-injecting those two directories wholesale as
401
+ // `-e <dir>` does not work: pi's `-e` flag hands the path straight to its
402
+ // module loader (no directory-discovery pass), so a bare directory holding
403
+ // only loose files fails with "Cannot find module ...". A take-over must
404
+ // instead discover each loose file the same way pi's own directory scan
405
+ // would (see discoverLooseExtensionEntries) and inject it individually.
406
+
407
+ test("--link take-over re-injects loose extension files, one -e per discovered file, from <agentDir>/extensions and the project-local .pi/extensions", (t) => {
408
+ const f = fixture(t);
409
+ const piAgentDir = join(f.root, "pi-agent");
410
+ mkdirSync(piAgentDir, { recursive: true });
411
+
412
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
413
+ mkdirSync(otherGentlePiDir, { recursive: true });
414
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
415
+
416
+ const settingsPath = join(piAgentDir, "settings.json");
417
+ const settingsText = JSON.stringify({ packages: ["../other-gentle-pi"] });
418
+ writeFileSync(settingsPath, settingsText);
419
+
420
+ // <agentDir>/extensions: a.ts, b.js, c.ts.bak-pre-fullscreen-fix (skipped,
421
+ // suffix doesn't end in .ts/.js/.mjs), sub/index.ts, .hidden.ts (skipped).
422
+ const looseAgentExtensions = join(piAgentDir, "extensions");
423
+ mkdirSync(join(looseAgentExtensions, "sub"), { recursive: true });
424
+ writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};");
425
+ writeFileSync(join(looseAgentExtensions, "b.js"), "export default () => {};");
426
+ writeFileSync(join(looseAgentExtensions, "gentle-agent-state.ts.bak-pre-fullscreen-fix"), "stale backup");
427
+ writeFileSync(join(looseAgentExtensions, ".hidden.ts"), "export default () => {};");
428
+ writeFileSync(join(looseAgentExtensions, "sub", "index.ts"), "export default () => {};");
429
+
430
+ const projectDir = join(f.root, "project");
431
+ const looseProjectExtensions = join(projectDir, ".pi", "extensions");
432
+ mkdirSync(looseProjectExtensions, { recursive: true });
433
+ writeFileSync(join(looseProjectExtensions, "project-ext.mjs"), "export default () => {};");
434
+ // The launcher derives this dir from process.cwd() inside the spawned
435
+ // child, which resolves symlinks (e.g. macOS's /tmp -> /private/tmp);
436
+ // realpath the expectation the same way so the two agree everywhere.
437
+ const resolvedLooseProjectExtensions = join(realpathSync(projectDir), ".pi", "extensions");
438
+
439
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
440
+ const result = run(env, ["--link", "--mode", "rpc"], { cwd: projectDir });
441
+ assert.equal(result.status, 0, result.stderr);
442
+ assert.match(result.stderr, /taking over gentle-pi from/);
443
+
444
+ const payload = JSON.parse(result.stdout);
445
+ assert.deepEqual(payload.args, [
446
+ "--no-extensions",
447
+ "-e",
448
+ join(looseAgentExtensions, "a.ts"),
449
+ "-e",
450
+ join(looseAgentExtensions, "b.js"),
451
+ "-e",
452
+ join(looseAgentExtensions, "sub", "index.ts"),
453
+ "-e",
454
+ join(resolvedLooseProjectExtensions, "project-ext.mjs"),
455
+ "-e",
456
+ packageRoot,
457
+ "--theme",
458
+ join(packageRoot, "themes"),
459
+ "--skill",
460
+ join(packageRoot, "skills"),
461
+ "--prompt-template",
462
+ join(packageRoot, "prompts"),
463
+ "--mode",
464
+ "rpc",
465
+ ]);
466
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
467
+ });
468
+
469
+ test("--link take-over injects a root-level index.ts as its own loose file entry, alongside a sibling loose file (R4-loose-index-collapses-sibling-extensions)", (t) => {
470
+ const f = fixture(t);
471
+ const piAgentDir = join(f.root, "pi-agent");
472
+ mkdirSync(piAgentDir, { recursive: true });
473
+
474
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
475
+ mkdirSync(otherGentlePiDir, { recursive: true });
476
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
477
+
478
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
479
+
480
+ const looseAgentExtensions = join(piAgentDir, "extensions");
481
+ mkdirSync(looseAgentExtensions, { recursive: true });
482
+ writeFileSync(join(looseAgentExtensions, "index.ts"), "export default () => {};");
483
+ // A root-level index.ts is just another loose file, not a marker that
484
+ // collapses the whole directory into a single -e <dir>: pi's own
485
+ // discovery loads every direct *.ts/*.js/*.mjs file individually, so a
486
+ // sibling like extra.ts must keep loading too instead of being dropped.
487
+ writeFileSync(join(looseAgentExtensions, "extra.ts"), "export default () => {};");
488
+
489
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
490
+ const result = run(env, ["--link"], { cwd: f.root });
491
+ assert.equal(result.status, 0, result.stderr);
492
+
493
+ const payload = JSON.parse(result.stdout);
494
+ assert.deepEqual(payload.args, [
495
+ "--no-extensions",
496
+ "-e",
497
+ join(looseAgentExtensions, "extra.ts"),
498
+ "-e",
499
+ join(looseAgentExtensions, "index.ts"),
500
+ "-e",
501
+ packageRoot,
502
+ "--theme",
503
+ join(packageRoot, "themes"),
504
+ "--skill",
505
+ join(packageRoot, "skills"),
506
+ "--prompt-template",
507
+ join(packageRoot, "prompts"),
508
+ ]);
509
+ });
510
+
511
+ test("--link take-over injects a subdirectory's own index.ts entry point even when the loose extensions dir has no other direct files", (t) => {
512
+ const f = fixture(t);
513
+ const piAgentDir = join(f.root, "pi-agent");
514
+ mkdirSync(piAgentDir, { recursive: true });
515
+
516
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
517
+ mkdirSync(otherGentlePiDir, { recursive: true });
518
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
519
+
520
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
521
+
522
+ const looseAgentExtensions = join(piAgentDir, "extensions");
523
+ const subDir = join(looseAgentExtensions, "sub");
524
+ mkdirSync(subDir, { recursive: true });
525
+ writeFileSync(join(subDir, "index.ts"), "export default () => {};");
526
+
527
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
528
+ const result = run(env, ["--link"], { cwd: f.root });
529
+ assert.equal(result.status, 0, result.stderr);
530
+
531
+ const payload = JSON.parse(result.stdout);
532
+ assert.deepEqual(payload.args, [
533
+ "--no-extensions",
534
+ "-e",
535
+ join(subDir, "index.ts"),
536
+ "-e",
537
+ packageRoot,
538
+ "--theme",
539
+ join(packageRoot, "themes"),
540
+ "--skill",
541
+ join(packageRoot, "skills"),
542
+ "--prompt-template",
543
+ join(packageRoot, "prompts"),
544
+ ]);
545
+ });
546
+
547
+ test("--link take-over omits -e flags for loose extension dirs that do not exist", (t) => {
548
+ const f = fixture(t);
549
+ const piAgentDir = join(f.root, "pi-agent");
550
+ mkdirSync(piAgentDir, { recursive: true });
551
+
552
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
553
+ mkdirSync(otherGentlePiDir, { recursive: true });
554
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
555
+
556
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
557
+
558
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
559
+ const result = run(env, ["--link"], { cwd: f.root });
560
+ assert.equal(result.status, 0, result.stderr);
561
+
562
+ const payload = JSON.parse(result.stdout);
563
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
564
+ });
565
+
566
+ // --- R3-001/R4-takeover-injects-unverified-package-dirs -------------------
567
+ //
568
+ // A settings.json package that is declared but not actually installed on
569
+ // disk (hand-edited file, a failed or interrupted `pi install`, an npm store
570
+ // laid out anywhere other than <agentDir>/npm/node_modules) must not be
571
+ // handed to pi as an unresolvable -e: pi's module loader fails on it with
572
+ // "Cannot find module", which would break every take-over launch against a
573
+ // partially-installed home. It is filtered the same way loose extension
574
+ // candidates already are, with one stderr warning naming the source and the
575
+ // resolved path, and the launch still succeeds.
576
+
577
+ test("--link take-over skips a declared package directory that is not installed, warns, and still launches; an installed one is still injected", (t) => {
578
+ const f = fixture(t);
579
+ const piAgentDir = join(f.root, "pi-agent");
580
+ mkdirSync(piAgentDir, { recursive: true });
581
+
582
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
583
+ mkdirSync(otherGentlePiDir, { recursive: true });
584
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
585
+
586
+ // "npm:some-other" is declared but never installed under
587
+ // <agentDir>/npm/node_modules, so its resolved directory does not exist.
588
+ // "npm:installed-other" IS installed, so it must still be injected.
589
+ const installedOtherDir = join(piAgentDir, "npm", "node_modules", "installed-other");
590
+ mkdirSync(installedOtherDir, { recursive: true });
591
+
592
+ const settingsPath = join(piAgentDir, "settings.json");
593
+ const settingsText = JSON.stringify({ packages: ["npm:some-other", "npm:installed-other", "../other-gentle-pi"] });
594
+ writeFileSync(settingsPath, settingsText);
595
+
596
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
597
+ const result = run(env, ["--link", "--mode", "rpc"], { cwd: f.root });
598
+ assert.equal(result.status, 0, result.stderr);
599
+
600
+ assert.match(result.stderr, /skipping declared package "npm:some-other"/);
601
+ assert.match(result.stderr, /is not a directory/);
602
+
603
+ const payload = JSON.parse(result.stdout);
604
+ assert.deepEqual(payload.args, [
605
+ "--no-extensions",
606
+ "-e",
607
+ installedOtherDir,
608
+ "-e",
609
+ packageRoot,
610
+ "--theme",
611
+ join(packageRoot, "themes"),
612
+ "--skill",
613
+ join(packageRoot, "skills"),
614
+ "--prompt-template",
615
+ join(packageRoot, "prompts"),
616
+ "--mode",
617
+ "rpc",
618
+ ]);
619
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
620
+ });
621
+
622
+ test("--link take-over injects the launcher's own package root once when --package-root names a directory settings also declare as a plain path entry", (t) => {
623
+ const f = fixture(t);
624
+ const piAgentDir = join(f.root, "pi-agent");
625
+ mkdirSync(piAgentDir, { recursive: true });
626
+
627
+ const forcedRoot = join(f.root, "forced-root");
628
+ mkdirSync(forcedRoot, { recursive: true });
629
+
630
+ const settingsPath = join(piAgentDir, "settings.json");
631
+ // The forced root is also declared as an ordinary (non-gentle-pi) path
632
+ // package, so otherPackageInjections would resolve it to the same
633
+ // directory as --package-root.
634
+ const settingsText = JSON.stringify({ packages: ["../forced-root"] });
635
+ writeFileSync(settingsPath, settingsText);
636
+
637
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
638
+ const result = run(env, ["--link", "--package-root", forcedRoot], { cwd: f.root });
639
+ assert.equal(result.status, 0, result.stderr);
640
+
641
+ const payload = JSON.parse(result.stdout);
642
+ const eFlags = payload.args.filter((arg: string, index: number) => payload.args[index - 1] === "-e");
643
+ assert.deepEqual(eFlags, [forcedRoot]);
644
+ assert.deepEqual(payload.args, [
645
+ "--no-extensions",
646
+ "-e",
647
+ forcedRoot,
648
+ "--theme",
649
+ join(forcedRoot, "themes"),
650
+ "--skill",
651
+ join(forcedRoot, "skills"),
652
+ "--prompt-template",
653
+ join(forcedRoot, "prompts"),
654
+ ]);
655
+ });
656
+
657
+ test("--isolated --package-root produces the plain injection (no --no-extensions): the take-over path is gated on link mode", (t) => {
658
+ const f = fixture(t);
659
+ const forcedRoot = join(f.root, "forced-root");
660
+ mkdirSync(forcedRoot, { recursive: true });
661
+
662
+ const result = run(f.env, ["--isolated", "--package-root", forcedRoot, "--mode", "rpc"]);
663
+ assert.equal(result.status, 0, result.stderr);
664
+ assert.doesNotMatch(result.stderr, /taking over gentle-pi from/);
665
+
666
+ const payload = JSON.parse(result.stdout);
667
+ assert.deepEqual(payload.args, [
668
+ "-e",
669
+ forcedRoot,
670
+ "--theme",
671
+ join(forcedRoot, "themes"),
672
+ "--skill",
673
+ join(forcedRoot, "skills"),
674
+ "--prompt-template",
675
+ join(forcedRoot, "prompts"),
676
+ "--mode",
677
+ "rpc",
678
+ ]);
679
+ });
680
+
681
+ test("--package-root naming a directory that does not exist fails with a clear error instead of launching", (t) => {
682
+ const f = fixture(t);
683
+ const missingRoot = join(f.root, "does-not-exist");
684
+
685
+ const result = run(f.env, ["--package-root", missingRoot]);
686
+ assert.notEqual(result.status, 0);
687
+ assert.match(result.stderr, /--package-root/);
688
+ assert.match(result.stderr, /does not exist|not a directory/);
689
+ });
690
+
691
+ // --- R4-loose-extension-enumeration-fails-silently -------------------------
692
+
693
+ test("--link take-over warns once when a loose extensions directory cannot be read, instead of failing silently", { skip: process.platform === "win32" || process.getuid?.() === 0 ? "mode bits do not block readdir here" : false }, (t) => {
694
+ const f = fixture(t);
695
+ const piAgentDir = join(f.root, "pi-agent");
696
+ mkdirSync(piAgentDir, { recursive: true });
697
+
698
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
699
+ mkdirSync(otherGentlePiDir, { recursive: true });
700
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
701
+
702
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
703
+
704
+ const looseAgentExtensions = join(piAgentDir, "extensions");
705
+ mkdirSync(looseAgentExtensions, { recursive: true });
706
+ writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};");
707
+ chmodSync(looseAgentExtensions, 0o000);
708
+
709
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
710
+ const result = run(env, ["--link"], { cwd: f.root });
711
+ // Restore permissions immediately, before any assertion can throw and
712
+ // skip cleanup: fixture()'s own t.after (registered before this test body
713
+ // runs) removes f.root recursively, which requires read/execute
714
+ // permission on every subdirectory, including this one.
715
+ chmodSync(looseAgentExtensions, 0o755);
716
+ assert.equal(result.status, 0, result.stderr);
717
+ assert.match(result.stderr, /could not read loose extension directory/);
718
+ assert.match(result.stderr, /extensions/);
719
+
720
+ const payload = JSON.parse(result.stdout);
721
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
722
+ });
723
+
724
+ // --- R3-004: loose-extensions manifest branch coverage ----------------------
725
+
726
+ test("--link take-over falls through to per-file discovery when a loose dir's package.json declares an empty pi.extensions array", (t) => {
727
+ const f = fixture(t);
728
+ const piAgentDir = join(f.root, "pi-agent");
729
+ mkdirSync(piAgentDir, { recursive: true });
730
+
731
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
732
+ mkdirSync(otherGentlePiDir, { recursive: true });
733
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
734
+
735
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
736
+
737
+ const looseAgentExtensions = join(piAgentDir, "extensions");
738
+ mkdirSync(looseAgentExtensions, { recursive: true });
739
+ writeFileSync(join(looseAgentExtensions, "package.json"), JSON.stringify({ pi: { extensions: [] } }));
740
+ writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};");
741
+
742
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
743
+ const result = run(env, ["--link"], { cwd: f.root });
744
+ assert.equal(result.status, 0, result.stderr);
745
+
746
+ const payload = JSON.parse(result.stdout);
747
+ assert.deepEqual(payload.args, [
748
+ "--no-extensions",
749
+ "-e",
750
+ join(looseAgentExtensions, "a.ts"),
751
+ "-e",
752
+ packageRoot,
753
+ "--theme",
754
+ join(packageRoot, "themes"),
755
+ "--skill",
756
+ join(packageRoot, "skills"),
757
+ "--prompt-template",
758
+ join(packageRoot, "prompts"),
759
+ ]);
760
+ });
761
+
762
+ test("--link take-over treats a malformed package.json as no manifest and falls through to per-file discovery", (t) => {
763
+ const f = fixture(t);
764
+ const piAgentDir = join(f.root, "pi-agent");
765
+ mkdirSync(piAgentDir, { recursive: true });
766
+
767
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
768
+ mkdirSync(otherGentlePiDir, { recursive: true });
769
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
770
+
771
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
772
+
773
+ const looseAgentExtensions = join(piAgentDir, "extensions");
774
+ mkdirSync(looseAgentExtensions, { recursive: true });
775
+ writeFileSync(join(looseAgentExtensions, "package.json"), "{ not valid json");
776
+ writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};");
777
+
778
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
779
+ const result = run(env, ["--link"], { cwd: f.root });
780
+ assert.equal(result.status, 0, result.stderr);
781
+
782
+ const payload = JSON.parse(result.stdout);
783
+ assert.deepEqual(payload.args, [
784
+ "--no-extensions",
785
+ "-e",
786
+ join(looseAgentExtensions, "a.ts"),
787
+ "-e",
788
+ packageRoot,
789
+ "--theme",
790
+ join(packageRoot, "themes"),
791
+ "--skill",
792
+ join(packageRoot, "skills"),
793
+ "--prompt-template",
794
+ join(packageRoot, "prompts"),
795
+ ]);
796
+ });
797
+
798
+ // --- R4-takeover-leaves-two-gentle-pi-skill-sets-loaded ---------------------
799
+
800
+ test("--link take-over stderr message also notes that the taken-over declaration's skills, prompts, and themes still load alongside this launcher's", (t) => {
801
+ const f = fixture(t);
802
+ const piAgentDir = join(f.root, "pi-agent");
803
+ mkdirSync(piAgentDir, { recursive: true });
804
+
805
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
806
+ mkdirSync(otherGentlePiDir, { recursive: true });
807
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
808
+
809
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
810
+
811
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
812
+ const result = run(env, ["--link"], { cwd: f.root });
813
+ assert.equal(result.status, 0, result.stderr);
814
+ assert.match(result.stderr, /taking over gentle-pi from/);
815
+ assert.match(result.stderr, /skills, prompts, and themes/);
816
+ });
817
+
818
+ test("--link take-over treats a file named `extensions` as not a loose extension dir", (t) => {
819
+ const f = fixture(t);
820
+ const piAgentDir = join(f.root, "pi-agent");
821
+ mkdirSync(piAgentDir, { recursive: true });
822
+
823
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
824
+ mkdirSync(otherGentlePiDir, { recursive: true });
825
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
826
+
827
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] }));
828
+ writeFileSync(join(piAgentDir, "extensions"), "not a directory");
829
+
830
+ const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
831
+ const result = run(env, ["--link"], { cwd: f.root });
832
+ assert.equal(result.status, 0, result.stderr);
833
+
834
+ const payload = JSON.parse(result.stdout);
835
+ assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot, "--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")]);
836
+ });