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