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.
- package/README.md +43 -41
- package/bin/gentle-shell.mjs +183 -8
- package/docs/readme-reference.md +63 -12
- package/lib/gentle-shell-launcher.ts +421 -36
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +420 -35
- package/tests/agents-rpc-publisher.test.ts +66 -0
- package/tests/gentle-agents.test.ts +46 -0
- package/tests/gentle-shell-bin.test.ts +651 -3
- package/tests/gentle-shell-launcher.test.ts +659 -10
- package/tests/package-manifest.test.ts +2 -2
|
@@ -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
|
+
});
|