gentle-pi 3.4.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.
@@ -0,0 +1,868 @@
1
+ // Generated by scripts/build-runtime-modules.mjs. Do not edit.
2
+ import { join, resolve as resolvePath } from "node:path";
3
+
4
+ // The gentle-shell launcher: pure, side-effect-free functions over injected
5
+ // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
6
+ // filesystem and child process so this module stays fully unit-testable.
7
+
8
+
9
+
10
+ // pi's own package-management subcommands (see pi's cli/args.ts printHelp
11
+ // "Commands" list): each is dispatched by pi itself, before pi's own flag
12
+ // parsing, purely on argv[0]. `uninstall` is pi's alias for `remove`.
13
+ export const PI_SUBCOMMANDS = ["install", "remove", "uninstall", "update", "list", "config", "auth"] ;
14
+
15
+
16
+
17
+ function isPiSubcommand(token ) {
18
+ return (PI_SUBCOMMANDS ).includes(token);
19
+ }
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+  
33
+
34
+
35
+
36
+
37
+
38
+
39
+ // Home-subcommand parsing is deliberately shallow: `home` only counts as the
40
+ // subcommand when it is argv[0], and everything after it is handed over
41
+ // untouched as commandArgs — T2 owns interpreting `home link|isolated|<path>`.
42
+ export function parseLauncherArgs(argv ) {
43
+ if (argv[0] === "home") {
44
+ return {
45
+ link: false,
46
+ isolated: false,
47
+ home: undefined,
48
+ packageRoot: undefined,
49
+ help: false,
50
+ version: false,
51
+ command: "home",
52
+ commandArgs: argv.slice(1),
53
+ passthrough: [],
54
+ piSubcommand: undefined,
55
+ error: undefined,
56
+ };
57
+ }
58
+
59
+ let link = false;
60
+ let isolated = false;
61
+ let home ;
62
+ let packageRoot ;
63
+ let help = false;
64
+ let version = false;
65
+ let error ;
66
+ let piSubcommand ;
67
+ const passthrough = [];
68
+
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const arg = argv[i];
71
+ if (arg === "--") {
72
+ const rest = argv.slice(i + 1);
73
+ if (passthrough.length === 0 && rest.length > 0 && isPiSubcommand(rest[0])) {
74
+ piSubcommand = rest[0];
75
+ }
76
+ passthrough.push(...rest);
77
+ break;
78
+ }
79
+ if (arg === "--link") {
80
+ link = true;
81
+ continue;
82
+ }
83
+ if (arg === "--isolated") {
84
+ isolated = true;
85
+ continue;
86
+ }
87
+ if (arg === "--help" || arg === "-h") {
88
+ help = true;
89
+ continue;
90
+ }
91
+ if (arg === "--version") {
92
+ version = true;
93
+ continue;
94
+ }
95
+ if (arg.startsWith("--home=")) {
96
+ const value = arg.slice("--home=".length);
97
+ if (value.length === 0) {
98
+ error = "--home requires a non-empty path argument";
99
+ continue;
100
+ }
101
+ home = value;
102
+ continue;
103
+ }
104
+ if (arg === "--home") {
105
+ const value = argv[i + 1];
106
+ if (value === undefined || value.length === 0) {
107
+ error = "--home requires a non-empty path argument";
108
+ if (value !== undefined) i += 1;
109
+ continue;
110
+ }
111
+ home = value;
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (arg.startsWith("--package-root=")) {
116
+ const value = arg.slice("--package-root=".length);
117
+ if (value.length === 0) {
118
+ error = "--package-root requires a non-empty path argument";
119
+ continue;
120
+ }
121
+ packageRoot = value;
122
+ continue;
123
+ }
124
+ if (arg === "--package-root") {
125
+ const value = argv[i + 1];
126
+ if (value === undefined || value.length === 0) {
127
+ error = "--package-root requires a non-empty path argument";
128
+ if (value !== undefined) i += 1;
129
+ continue;
130
+ }
131
+ packageRoot = value;
132
+ i += 1;
133
+ continue;
134
+ }
135
+ if (passthrough.length === 0 && isPiSubcommand(arg)) {
136
+ piSubcommand = arg;
137
+ }
138
+ passthrough.push(arg);
139
+ }
140
+
141
+ if (error === undefined) {
142
+ if (link && isolated) {
143
+ error = "--link cannot be combined with --isolated";
144
+ } else if (link && home !== undefined) {
145
+ error = "--link cannot be combined with --home";
146
+ } else if (isolated && home !== undefined) {
147
+ error = "--isolated cannot be combined with --home";
148
+ }
149
+ }
150
+
151
+ return { link, isolated, home, packageRoot, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
152
+ }
153
+
154
+ // --- home resolution -------------------------------------------------------
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+ // A discriminated union instead of a plain `home: string` field: `resolveHome`
166
+ // switches on `mode` rather than re-parsing the raw on-disk string, and the
167
+ // `path` case carries its `dir` explicitly so a "link"/"isolated" string can
168
+ // never be mistaken for a filesystem path at the call site.
169
+
170
+
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+ // Pi Subagents resolves `PI_CODING_AGENT_DIR || ~/.pi/agent`; `--link` reuses
179
+ // that exact home so gentle-shell never diverges from the user's own pi.
180
+ function linkDir(env , homedir ) {
181
+ return env.PI_CODING_AGENT_DIR || join(homedir, ".pi", "agent");
182
+ }
183
+
184
+ function isolatedDir(env , homedir ) {
185
+ return env.GENTLE_SHELL_HOME || join(homedir, ".gentle-shell", "agent");
186
+ }
187
+
188
+ export function resolveHome(input ) {
189
+ const { args, env, homedir, config } = input;
190
+
191
+ if (args.link) return { mode: "link", dir: linkDir(env, homedir), source: "flag" };
192
+ if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" };
193
+ if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" };
194
+
195
+ if (config !== undefined) {
196
+ if (config.mode === "link") return { mode: "link", dir: linkDir(env, homedir), source: "config" };
197
+ if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" };
198
+ return { mode: "path", dir: config.dir, source: "config" };
199
+ }
200
+
201
+ return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" };
202
+ }
203
+
204
+ export function launcherConfigPath(homedir ) {
205
+ return join(homedir, ".gentle-shell", "config.json");
206
+ }
207
+
208
+ // Tolerant on purpose: a malformed or foreign config.json must never crash
209
+ // the launcher, it just falls through to the default isolated home.
210
+ //
211
+ // The on-disk shape stays the flat `{ "home": "link" | "isolated" | "<path>" }`
212
+ // documented in the feature scope; only the parsed, in-memory `LauncherConfig`
213
+ // is a discriminated union. Any non-empty string other than the exact literals
214
+ // "link" or "isolated" is treated as a path, including a near-miss like
215
+ // "linked" — this is deliberate: there is no separate "unrecognised mode"
216
+ // error, a typo just resolves to a (probably nonexistent) path instead.
217
+ export function parseLauncherConfig(text ) {
218
+ let parsed ;
219
+ try {
220
+ parsed = JSON.parse(text);
221
+ } catch {
222
+ return undefined;
223
+ }
224
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
225
+ const home = (parsed ).home;
226
+ if (typeof home !== "string" || home.length === 0) return undefined;
227
+ if (home === "link") return { mode: "link" };
228
+ if (home === "isolated") return { mode: "isolated" };
229
+ return { mode: "path", dir: home };
230
+ }
231
+
232
+ // --- pi runtime resolution ---------------------------------------------------
233
+
234
+
235
+
236
+
237
+
238
+
239
+
240
+
241
+
242
+
243
+
244
+
245
+
246
+
247
+
248
+
249
+ export function resolvePiRuntime(deps ) {
250
+ const envOverride = deps.env.GENTLE_SHELL_PI;
251
+ if (envOverride !== undefined && envOverride.length > 0) return { kind: "env", command: envOverride, args: [] };
252
+
253
+ const bundledCliPath = deps.resolveBundledCli();
254
+ if (bundledCliPath !== undefined) return { kind: "bundled", command: deps.nodeExecPath, args: [bundledCliPath] };
255
+
256
+ const onPath = deps.findOnPath("pi");
257
+ if (onPath !== undefined) return { kind: "path", command: onPath, args: [] };
258
+
259
+ return undefined;
260
+ }
261
+
262
+ export function missingPiMessage() {
263
+ return [
264
+ "No pi runtime could be found. Pick one of:",
265
+ " - Set GENTLE_SHELL_PI to the path of a pi executable.",
266
+ " - Install @earendil-works/pi-coding-agent next to gentle-pi (it ships as an optional peer dependency).",
267
+ " - Install pi and make sure it is on your PATH.",
268
+ ].join("\n");
269
+ }
270
+
271
+ // --- pi version gate ---------------------------------------------------------
272
+
273
+ export const MIN_PI_VERSION = "0.85.1";
274
+
275
+
276
+
277
+ const VERSION_PATTERN = /v?(\d+)\.(\d+)\.(\d+)/;
278
+
279
+ function compareVersions(a , b ) {
280
+ for (let i = 0; i < 3; i += 1) {
281
+ if (a[i] !== b[i]) return a[i] - b[i];
282
+ }
283
+ return 0;
284
+ }
285
+
286
+ export function checkPiVersion(output , minimum = MIN_PI_VERSION) {
287
+ const match = VERSION_PATTERN.exec(output);
288
+ if (!match) {
289
+ return { ok: false, message: `Could not determine the pi version from "${output.trim()}" (need at least ${minimum}).` };
290
+ }
291
+ const version = `${match[1]}.${match[2]}.${match[3]}`;
292
+ const minimumMatch = VERSION_PATTERN.exec(minimum);
293
+ if (!minimumMatch) throw new Error(`invalid minimum version "${minimum}"`);
294
+ const found = [Number(match[1]), Number(match[2]), Number(match[3])];
295
+ const wanted = [Number(minimumMatch[1]), Number(minimumMatch[2]), Number(minimumMatch[3])];
296
+ if (compareVersions(found, wanted) < 0) {
297
+ return { ok: false, version, message: `pi version ${version} is older than the required minimum ${minimum}.` };
298
+ }
299
+ return { ok: true, version };
300
+ }
301
+
302
+ // --- packaging drift guard -----------------------------------------------------
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+ // Keeps the MIN_PI_VERSION drift-guard test's failure readable: a missing
311
+ // peerDependencies block, a missing peer entry, or a malformed range must
312
+ // fail with a clear assertion message, not a raw TypeError from indexing an
313
+ // undefined value the way a direct `packageJson.peerDependencies[peerName]`
314
+ // lookup would.
315
+ export function checkPeerVersionPin(packageJson , peerName , minVersion ) {
316
+ const peerDependencies = packageJson.peerDependencies;
317
+ if (peerDependencies === undefined) {
318
+ return { ok: false, message: "package.json is missing a peerDependencies block" };
319
+ }
320
+ const pinned = peerDependencies[peerName];
321
+ if (typeof pinned !== "string") {
322
+ return { ok: false, message: `package.json peerDependencies is missing "${peerName}"` };
323
+ }
324
+ if (!/^>=\d+\.\d+\.\d+$/.test(pinned)) {
325
+ return { ok: false, message: `package.json peerDependencies["${peerName}"] ("${pinned}") is not a simple >=x.y.z range` };
326
+ }
327
+ const version = pinned.replace(/^>=/, "");
328
+ if (version !== minVersion) {
329
+ return { ok: false, message: `MIN_PI_VERSION ("${minVersion}") does not match the pinned peer range ("${pinned}")` };
330
+ }
331
+ return { ok: true, pinned };
332
+ }
333
+
334
+ // --- settings.json package declaration detection --------------------------
335
+
336
+ // Matches the raw git URL forms pi accepts without a `git:` prefix.
337
+ const GIT_URL_PATTERN = /^(?:https?|ssh|git):\/\//;
338
+
339
+ function entrySource(entry ) {
340
+ if (typeof entry === "string") return entry;
341
+ if (entry !== null && typeof entry === "object") {
342
+ const source = (entry ).source;
343
+ if (typeof source === "string") return source;
344
+ }
345
+ return undefined;
346
+ }
347
+
348
+
349
+
350
+ // A settings `packages` entry is npm- or git-sourced only via an explicit
351
+ // `npm:`/`git:` prefix or a bare git URL; every other source (relative or
352
+ // absolute) is a local path, per pi's own package-source rules.
353
+ export function packageSourceKind(source ) {
354
+ if (source.startsWith("npm:")) return "npm";
355
+ if (source.startsWith("git:")) return "git";
356
+ if (GIT_URL_PATTERN.test(source)) return "git";
357
+ return "path";
358
+ }
359
+
360
+ function npmSourceDeclaresGentlePi(source ) {
361
+ return source === "npm:gentle-pi" || source.startsWith("npm:gentle-pi@");
362
+ }
363
+
364
+ // npm:<name> or npm:<name>@<version>, tolerating a scoped `@scope/name`: only
365
+ // the first `@` *after* the leading scope marker starts a version suffix.
366
+ function npmPackageName(source ) {
367
+ const spec = source.slice("npm:".length);
368
+ if (spec.startsWith("@")) {
369
+ const versionAt = spec.indexOf("@", 1);
370
+ return versionAt === -1 ? spec : spec.slice(0, versionAt);
371
+ }
372
+ const versionAt = spec.indexOf("@");
373
+ return versionAt === -1 ? spec : spec.slice(0, versionAt);
374
+ }
375
+
376
+ function parseSettingsPackages(settingsText ) {
377
+ if (settingsText === undefined) return undefined;
378
+ let parsed ;
379
+ try {
380
+ parsed = JSON.parse(settingsText);
381
+ } catch {
382
+ return undefined;
383
+ }
384
+ if (typeof parsed !== "object" || parsed === null) return undefined;
385
+ const packages = (parsed ).packages;
386
+ return Array.isArray(packages) ? packages : undefined;
387
+ }
388
+
389
+ function packageEntryDeclaresGentlePi(entry ) {
390
+ const source = entrySource(entry);
391
+ return source !== undefined && packageSourceKind(source) === "npm" && npmSourceDeclaresGentlePi(source);
392
+ }
393
+
394
+ // Deprecated: recognises only an `npm:gentle-pi` declaration. Kept as a thin
395
+ // compatibility wrapper over the pre-existing behaviour for any caller that
396
+ // only cares about the npm case; findGentlePiDeclaration below also detects
397
+ // a path package whose own package.json names it "gentle-pi".
398
+ export function settingsDeclareGentlePi(settingsText ) {
399
+ const packages = parseSettingsPackages(settingsText);
400
+ if (packages === undefined) return false;
401
+ return packages.some(packageEntryDeclaresGentlePi);
402
+ }
403
+
404
+
405
+
406
+
407
+
408
+
409
+
410
+
411
+
412
+
413
+ // Detects a settings.json `packages` entry that already loads gentle-pi,
414
+ // either as `npm:gentle-pi[@version]` or as a local path (string or object
415
+ // `source`) whose own package.json declares `"name": "gentle-pi"`. Path
416
+ // entries are resolved relative to `opts.agentDir`, matching how pi itself
417
+ // resolves a settings-relative local path.
418
+ export function findGentlePiDeclaration(settingsText , opts ) {
419
+ const packages = parseSettingsPackages(settingsText);
420
+ if (packages === undefined) return undefined;
421
+
422
+ for (const entry of packages) {
423
+ const source = entrySource(entry);
424
+ if (source === undefined) continue;
425
+ const kind = packageSourceKind(source);
426
+ if (kind === "npm" && npmSourceDeclaresGentlePi(source)) return { kind: "npm" };
427
+ if (kind === "path") {
428
+ const dir = resolvePath(opts.agentDir, source);
429
+ if (opts.readPackageName(dir) === "gentle-pi") return { kind: "path", dir };
430
+ }
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ // --- take-over decision ---------------------------------------------------
436
+
437
+
438
+
439
+
440
+
441
+
442
+
443
+
444
+
445
+
446
+
447
+
448
+
449
+
450
+ export function decideTakeOver(input ) {
451
+ if (input.packageRootExplicit) return true;
452
+ if (input.declaration === undefined) return false;
453
+ if (input.declaration.kind === "npm") return false;
454
+ const realDeclaredDir = input.realDeclaredDir ?? input.declaration.dir;
455
+ return realDeclaredDir !== input.realPackageRoot;
456
+ }
457
+
458
+ // --- other-package injection planning --------------------------------------
459
+
460
+
461
+
462
+
463
+
464
+
465
+
466
+
467
+
468
+
469
+
470
+
471
+
472
+
473
+
474
+
475
+
476
+
477
+
478
+
479
+
480
+
481
+
482
+
483
+
484
+
485
+
486
+
487
+
488
+
489
+
490
+
491
+
492
+
493
+
494
+
495
+
496
+ function entryFilterKeys(entry ) {
497
+ if (entry === null || typeof entry !== "object") return [];
498
+ const record = entry ;
499
+ const keys = [];
500
+ if ("extensions" in record) keys.push("extensions");
501
+ if ("autoload" in record) keys.push("autoload");
502
+ return keys;
503
+ }
504
+
505
+ // Plans the `-e <dir>` flags a take-over must add for every OTHER settings
506
+ // package once `--no-extensions` drops normal settings-driven extension
507
+ // discovery. git-sourced packages are skipped (their install directory is
508
+ // not derivable without pi's own package manager) with a warning; object
509
+ // entries carrying `extensions`/`autoload` filters are still included, with
510
+ // a warning that the take-over cannot honour those filters (their skills,
511
+ // prompts, and themes still load through ordinary settings discovery, which
512
+ // --no-extensions does not affect).
513
+ export function otherPackageInjections(input ) {
514
+ const paths = [];
515
+ const warnings = [];
516
+ const packages = parseSettingsPackages(input.settingsText);
517
+ if (packages === undefined) return { paths, warnings };
518
+ const isDirectory = input.isDirectory ?? (() => true);
519
+ const realpath = input.realpath ?? ((dir ) => dir);
520
+
521
+ for (const entry of packages) {
522
+ const source = entrySource(entry);
523
+ if (source === undefined) continue;
524
+ const kind = packageSourceKind(source);
525
+
526
+ // Skip every gentle-pi entry unconditionally, not only the one
527
+ // matching `skip`'s kind: settings can carry more than one gentle-pi
528
+ // declaration (for example an npm:gentle-pi entry alongside the path
529
+ // declaration actually being taken over), and re-injecting any of
530
+ // them as an "other package" would double-load gentle-pi extensions.
531
+ if (kind === "npm" && npmSourceDeclaresGentlePi(source)) continue;
532
+ if (kind === "path") {
533
+ const dir = resolvePath(input.agentDir, source);
534
+ // Compared through realpath on BOTH sides (not the raw resolved
535
+ // strings): skip.dir may already be a realpath itself
536
+ // (bin/gentle-shell.mjs's --package-root take-over) or may not be
537
+ // (a plain settings.json declaration), so only comparing one side
538
+ // through realpath would break whichever case does not match that
539
+ // assumption. Realpath'ing both keeps the exact-match case
540
+ // (skip.dir derived from the very same source) trivially correct
541
+ // while also recognising a settings entry that reaches the same
542
+ // physical directory as skip through a symlink.
543
+ if (input.skip.kind === "path" && realpath(dir) === realpath(input.skip.dir)) continue;
544
+ }
545
+
546
+ if (kind === "git") {
547
+ warnings.push(
548
+ `gentle-shell: skipping git-sourced package "${source}" during takeover (its install directory is not derivable without pi's own package manager).`,
549
+ );
550
+ continue;
551
+ }
552
+
553
+ const filters = entryFilterKeys(entry);
554
+ if (filters.length > 0) {
555
+ warnings.push(
556
+ `gentle-shell: package "${source}" has ${filters.join("/")} filters that this takeover cannot honour for extensions; its skills, prompts, and themes still load through settings discovery.`,
557
+ );
558
+ }
559
+
560
+ const dir = kind === "npm" ? join(input.agentDir, "npm", "node_modules", npmPackageName(source)) : resolvePath(input.agentDir, source);
561
+ if (!isDirectory(dir)) {
562
+ warnings.push(`gentle-shell: skipping declared package "${source}": ${dir} is not a directory`);
563
+ continue;
564
+ }
565
+ paths.push(dir);
566
+ }
567
+ return { paths, warnings };
568
+ }
569
+
570
+ // --- loose extension discovery ----------------------------------------------
571
+
572
+
573
+
574
+
575
+
576
+
577
+
578
+
579
+
580
+
581
+
582
+
583
+
584
+
585
+
586
+
587
+ // scripts/build-runtime-modules.mjs rewrites every occurrence of a dot, the
588
+ // letters ts, and an immediately following closing quote (single or double)
589
+ // to end in mjs instead, when it generates runtime/gentle-shell-launcher.mjs
590
+ // — a plain `.replace(/\.ts(["'])/g, ...)` that cannot tell an import
591
+ // specifier from an ordinary string literal. Any other string ending the
592
+ // same way — a dot, the letters ts, and a closing quote right after — would
593
+ // get silently corrupted into the mjs form in the generated runtime module,
594
+ // so the three constants below are built by concatenation instead of
595
+ // written as literals that would trigger the same rewrite.
596
+ const TS_EXTENSION = `.t${"s"}`;
597
+ const INDEX_TS_FILENAME = `index${TS_EXTENSION}`;
598
+ const DECLARATION_FILE_SUFFIX = `.d${TS_EXTENSION}`;
599
+ const LOOSE_EXTENSION_FILE_PATTERN = /\.(?:ts|js|mjs)$/;
600
+
601
+ function isLooseExtensionFile(name ) {
602
+ if (name.startsWith(".")) return false;
603
+ if (name.endsWith(DECLARATION_FILE_SUFFIX)) return false;
604
+ return LOOSE_EXTENSION_FILE_PATTERN.test(name);
605
+ }
606
+
607
+ // Mirrors pi's own discoverExtensionsInDir (packages/coding-agent/src/core/
608
+ // extensions/loader.ts): direct *.ts/*.js/*.mjs files, plus <subdir>/index.ts
609
+ // (falling back to <subdir>/index.js) for a child directory that has one. No
610
+ // recursion beyond that one level, matching pi's own rule that a more complex
611
+ // nested package must use a package.json manifest instead.
612
+ //
613
+ // Unlike pi's own scan, hidden entries (dotfiles, and hidden subdirectories)
614
+ // and *.d.ts files are deliberately excluded here: pi's `-e <file>` flag hands
615
+ // the path straight to its module loader with no directory-discovery pass of
616
+ // its own (see buildPiInvocation's takeOver branch), so a hidden file or a
617
+ // type-only declaration file was never a runnable extension and would only
618
+ // surface a confusing "Cannot find module"/empty-module error once injected.
619
+ //
620
+ // Returns already-resolved absolute file paths, sorted by name so the result
621
+ // (and therefore -e ordering) does not depend on the host filesystem's
622
+ // unspecified readdir order.
623
+ export function discoverLooseExtensionEntries(dir , fs ) {
624
+ let entries ;
625
+ try {
626
+ entries = fs.readdir(dir);
627
+ } catch {
628
+ return [];
629
+ }
630
+
631
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
632
+ const discovered = [];
633
+
634
+ for (const entry of sorted) {
635
+ if (entry.name.startsWith(".")) continue;
636
+
637
+ if (entry.isFile) {
638
+ if (isLooseExtensionFile(entry.name)) discovered.push(join(dir, entry.name));
639
+ continue;
640
+ }
641
+
642
+ if (!entry.isDirectory) continue;
643
+ const childDir = join(dir, entry.name);
644
+ const indexTs = join(childDir, INDEX_TS_FILENAME);
645
+ const indexJs = join(childDir, "index.js");
646
+ if (fs.exists(indexTs)) discovered.push(indexTs);
647
+ else if (fs.exists(indexJs)) discovered.push(indexJs);
648
+ }
649
+
650
+ return discovered;
651
+ }
652
+
653
+ // --- pi invocation builder ---------------------------------------------------
654
+
655
+
656
+
657
+
658
+
659
+
660
+
661
+
662
+
663
+
664
+
665
+
666
+
667
+
668
+
669
+
670
+
671
+  
672
+
673
+
674
+  
675
+  
676
+
677
+
678
+
679
+
680
+
681
+
682
+
683
+
684
+
685
+
686
+
687
+
688
+
689
+
690
+
691
+
692
+
693
+
694
+ function packageRootAssetArgs(packageRoot ) {
695
+ return ["--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")];
696
+ }
697
+
698
+ function packageRootInjectionArgs(packageRoot ) {
699
+ return ["-e", packageRoot, ...packageRootAssetArgs(packageRoot)];
700
+ }
701
+
702
+ // Four cases, checked in this order — `piSubcommand` first, then `takeOver`:
703
+ // - piSubcommand: pi dispatches install/remove/uninstall/update/list/
704
+ // config/auth on argv[0] before it even parses flags, so any injected
705
+ // -e/--theme/--skill/--prompt-template flag ahead of it stops pi from
706
+ // recognising its subcommand at all — this is exactly the observed
707
+ // 2026-09-22 bug where `gentle-shell install npm:x` opened an
708
+ // interactive pi session instead of running the package manager. No
709
+ // injection of any kind (including a take-over's --no-extensions and
710
+ // other-package/loose-extension -e flags) may precede it.
711
+ // - takeOver: the target settings declare a *different* gentle-pi, or
712
+ // --package-root forced a takeover regardless of any declaration. This
713
+ // must win over the next two cases even when there is no declaration to
714
+ // report, or the plain branch would silently drop --no-extensions and
715
+ // the other-package injections while bin/gentle-shell.mjs still prints
716
+ // the "taking over" message. `--no-extensions` drops normal
717
+ // settings-driven extension discovery, so it is replaced by an explicit
718
+ // `-e <dir>` for every OTHER settings package (skills/prompts/themes
719
+ // for those packages still load through ordinary settings discovery,
720
+ // which --no-extensions does not affect), then an explicit `-e <file>`
721
+ // for every loose extension entry normal discovery would otherwise have
722
+ // found under <agentDir>/extensions and the project-local
723
+ // .pi/extensions, and finally this launcher's own packageRoot injected
724
+ // last so it wins any conflict. Every -e path is injected at most once
725
+ // (R3-001): a loose entry that duplicates an other-package path, or
726
+ // repeats within looseExtensionEntries itself, is skipped rather than
727
+ // loaded twice.
728
+ // - Not takeOver, no declaration: inject this launcher's own packageRoot,
729
+ // exactly as when nothing else in settings loads gentle-pi.
730
+ // - Not takeOver, with a declaration: no injection at all — the target
731
+ // settings already load a gentle-pi the launcher accepts as-is (the
732
+ // `--link` case with a pi-managed install matching this launcher).
733
+ export function buildPiInvocation(input ) {
734
+ const args = [...input.runtime.args];
735
+
736
+ if (input.piSubcommand !== undefined) {
737
+ // No injection at all: pi must see the bare subcommand as argv[0].
738
+ } else if (input.takeOver) {
739
+ args.push("--no-extensions");
740
+ const injected = new Set ();
741
+ for (const otherPath of input.otherPackagePaths) {
742
+ if (injected.has(otherPath)) continue;
743
+ injected.add(otherPath);
744
+ args.push("-e", otherPath);
745
+ }
746
+ for (const entry of input.looseExtensionEntries ?? []) {
747
+ if (injected.has(entry)) continue;
748
+ injected.add(entry);
749
+ args.push("-e", entry);
750
+ }
751
+ // R3-003: the launcher's own package root must also be checked
752
+ // against the dedupe set instead of being appended unconditionally,
753
+ // or a settings package/loose entry that resolves to the same
754
+ // directory as --package-root would be injected twice.
755
+ if (!injected.has(input.packageRoot)) {
756
+ injected.add(input.packageRoot);
757
+ args.push("-e", input.packageRoot);
758
+ }
759
+ args.push(...packageRootAssetArgs(input.packageRoot));
760
+ } else if (input.declaration === undefined) {
761
+ args.push(...packageRootInjectionArgs(input.packageRoot));
762
+ }
763
+
764
+ args.push(...input.passthrough);
765
+
766
+ return {
767
+ command: input.runtime.command,
768
+ args,
769
+ env: { ...input.baseEnv, PI_CODING_AGENT_DIR: input.home.dir, GENTLE_PI_AGENT_HOME: input.home.dir },
770
+ };
771
+ }
772
+
773
+ // --- spawn planning ------------------------------------------------------------
774
+
775
+ // R3-001: `findOnPath` can resolve a PATHEXT candidate such as a .CMD or .BAT
776
+ // shim on win32 (exactly how an npm-installed `pi` lands on PATH), and a
777
+ // GENTLE_SHELL_PI override can point at one too. Current Node releases refuse
778
+ // to spawn a batch file directly without `shell: true` (EINVAL), so both the
779
+ // version probe and the real launch route a batch shim through cmd.exe as one
780
+ // quoted command line instead of spawning it directly.
781
+ const CMD_EXE_SPECIAL_CHARS = /[\s"&|<>^%()]/;
782
+
783
+ // cmd.exe quoting is deliberately simple, not a full cmd.exe parser: wrap a
784
+ // token in double quotes when it is empty or contains whitespace or any of
785
+ // `"&|<>^%()`, and escape an inner `"` as `\"` — doubling inner quotes is not
786
+ // reliable in cmd.exe, unlike the `\"` convention Node's own Windows spawn
787
+ // helpers use.
788
+ export function quoteForCmdExe(token ) {
789
+ if (token.length > 0 && !CMD_EXE_SPECIAL_CHARS.test(token)) return token;
790
+ return `"${token.replace(/"/g, '\\"')}"`;
791
+ }
792
+
793
+
794
+
795
+
796
+
797
+
798
+
799
+
800
+
801
+
802
+
803
+
804
+
805
+ export function planSpawn(input ) {
806
+ const { command, args, platform } = input;
807
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
808
+ return { command: [command, ...args].map(quoteForCmdExe).join(" "), args: [], shell: true };
809
+ }
810
+ return { command, args, shell: false };
811
+ }
812
+
813
+ // --- reporting ---------------------------------------------------------------
814
+
815
+
816
+
817
+
818
+
819
+
820
+
821
+ export function describeVersion(input ) {
822
+ return [
823
+ `gentle-shell ${input.gentlePiVersion}`,
824
+ `pi ${input.piVersion ?? "not found"}`,
825
+ `home ${input.home.mode} ${input.home.dir}`,
826
+ ].join("\n");
827
+ }
828
+
829
+ export function helpText() {
830
+ return [
831
+ "Usage: gentle-shell [options] [-- pi-args...]",
832
+ " gentle-shell home [link|isolated|<path>]",
833
+ "",
834
+ "Opens pi with the Gentle Shell package loaded, without touching your",
835
+ "vanilla pi installation.",
836
+ "",
837
+ "Options:",
838
+ " --link Use your existing pi agent home (never edits its settings.json).",
839
+ " --isolated Use the dedicated ~/.gentle-shell/agent home (default).",
840
+ " --home <path> Use a custom agent home directory.",
841
+ " --package-root <dir> Force this directory as the gentle-pi package to load, taking over",
842
+ " from any conflicting package the target settings.json already declares.",
843
+ " --help, -h Show this help text.",
844
+ " --version Show gentle-shell, pi, and home version information.",
845
+ "",
846
+ "Commands:",
847
+ " home Print or persist the effective home mode (link, isolated, or a path).",
848
+ "",
849
+ "Managing packages:",
850
+ " gentle-shell install npm:<pkg> Run pi's own 'install' against the resolved home.",
851
+ " gentle-shell remove <source> Run pi's own 'remove' against the resolved home.",
852
+ " gentle-shell list Run pi's own 'list' against the resolved home.",
853
+ " gentle-shell update [target] Run pi's own 'update' against the resolved home.",
854
+ " gentle-shell config Run pi's own 'config' against the resolved home.",
855
+ " gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
856
+ " These run pi's own commands, forwarded verbatim, against the --isolated home",
857
+ " (or your own pi home with --link). Running 'gentle-shell install npm:gentle-pi'",
858
+ " inside the isolated home is unnecessary: gentle-shell already loads the",
859
+ " package itself.",
860
+ "",
861
+ "Environment variables:",
862
+ " GENTLE_SHELL_PI Path to the pi executable to run.",
863
+ " GENTLE_SHELL_HOME Directory for the isolated home (default: ~/.gentle-shell/agent).",
864
+ " PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.",
865
+ "",
866
+ "Every other argument is forwarded to pi unchanged.",
867
+ ].join("\n");
868
+ }