gentle-pi 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
- import { join } from "node:path";
1
+ import { join, resolve as resolvePath } from "node:path";
2
2
 
3
3
  // The gentle-shell launcher: pure, side-effect-free functions over injected
4
4
  // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
5
5
  // filesystem and child process so this module stays fully unit-testable.
6
6
 
7
- export type LauncherCommand = "home";
7
+ export type LauncherCommand = "home" | "setup";
8
8
 
9
9
  // pi's own package-management subcommands (see pi's cli/args.ts printHelp
10
10
  // "Commands" list): each is dispatched by pi itself, before pi's own flag
@@ -21,6 +21,7 @@ export interface ParsedLauncherArgs {
21
21
  link: boolean;
22
22
  isolated: boolean;
23
23
  home?: string;
24
+ packageRoot?: string;
24
25
  help: boolean;
25
26
  version: boolean;
26
27
  command?: LauncherCommand;
@@ -43,6 +44,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
43
44
  link: false,
44
45
  isolated: false,
45
46
  home: undefined,
47
+ packageRoot: undefined,
46
48
  help: false,
47
49
  version: false,
48
50
  command: "home",
@@ -56,9 +58,12 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
56
58
  let link = false;
57
59
  let isolated = false;
58
60
  let home: string | undefined;
61
+ let packageRoot: string | undefined;
59
62
  let help = false;
60
63
  let version = false;
61
64
  let error: string | undefined;
65
+ let command: LauncherCommand | undefined;
66
+ let commandArgs: string[] = [];
62
67
  let piSubcommand: PiSubcommand | undefined;
63
68
  const passthrough: string[] = [];
64
69
 
@@ -108,6 +113,38 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
108
113
  i += 1;
109
114
  continue;
110
115
  }
116
+ if (arg.startsWith("--package-root=")) {
117
+ const value = arg.slice("--package-root=".length);
118
+ if (value.length === 0) {
119
+ error = "--package-root requires a non-empty path argument";
120
+ continue;
121
+ }
122
+ packageRoot = value;
123
+ continue;
124
+ }
125
+ if (arg === "--package-root") {
126
+ const value = argv[i + 1];
127
+ if (value === undefined || value.length === 0) {
128
+ error = "--package-root requires a non-empty path argument";
129
+ if (value !== undefined) i += 1;
130
+ continue;
131
+ }
132
+ packageRoot = value;
133
+ i += 1;
134
+ continue;
135
+ }
136
+ // Unlike `home`, `setup` is not restricted to argv[0]: it accepts the
137
+ // home selectors (--link, --isolated, --home <dir>) ahead of it, same
138
+ // as a pi subcommand would, so it provisions whichever home those
139
+ // selectors resolve to. It is only recognised as the FIRST non-flag
140
+ // token — once a pi subcommand (or any other passthrough token) has
141
+ // already started, a later "setup" is just an ordinary passthrough
142
+ // argument, same as "home" is.
143
+ if (arg === "setup" && command === undefined && passthrough.length === 0) {
144
+ command = "setup";
145
+ commandArgs = argv.slice(i + 1);
146
+ break;
147
+ }
111
148
  if (passthrough.length === 0 && isPiSubcommand(arg)) {
112
149
  piSubcommand = arg;
113
150
  }
@@ -124,7 +161,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
124
161
  }
125
162
  }
126
163
 
127
- return { link, isolated, home, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
164
+ return { link, isolated, home, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error };
128
165
  }
129
166
 
130
167
  // --- home resolution -------------------------------------------------------
@@ -177,6 +214,20 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome {
177
214
  return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" };
178
215
  }
179
216
 
217
+ // The flags that reproduce `home`'s resolved mode on a later `gentle-shell
218
+ // <flags> ...` invocation — used by remediation messages (e.g. "run
219
+ // `gentle-shell <flags> remove <source>`") so they point at the exact home
220
+ // setup provisioned instead of silently defaulting to the isolated home.
221
+ // Mirrors the three ResolvedHome modes one-to-one: "link" needs --link
222
+ // (PI_CODING_AGENT_DIR-derived dirs aren't reproducible as a literal path),
223
+ // "path" needs its --home <dir>, and "isolated" needs nothing since it's
224
+ // gentle-shell's own default when no selector is given.
225
+ export function homeSelectorFlags(home: ResolvedHome): string[] {
226
+ if (home.mode === "link") return ["--link"];
227
+ if (home.mode === "path") return ["--home", home.dir];
228
+ return [];
229
+ }
230
+
180
231
  export function launcherConfigPath(homedir: string): string {
181
232
  return join(homedir, ".gentle-shell", "config.json");
182
233
  }
@@ -205,6 +256,88 @@ export function parseLauncherConfig(text: string): LauncherConfig | undefined {
205
256
  return { mode: "path", dir: home };
206
257
  }
207
258
 
259
+ // --- provisioning marker (S7 auto-provision) --------------------------------
260
+
261
+ // Raw config.json shape as actually stored on disk: a plain object that may
262
+ // carry `home` (see LauncherConfig above), `provisioned`, and any other key
263
+ // a future feature adds. Unlike parseLauncherConfig's discriminated
264
+ // LauncherConfig, these helpers operate on (and return) the whole object so
265
+ // a write never drops a field it does not itself understand — notably
266
+ // another home's provisioned marker when `gentle-shell home ...` persists a
267
+ // mode change.
268
+ export type RawLauncherConfig = Record<string, unknown>;
269
+
270
+ // Tolerant like parseLauncherConfig: a missing, malformed, or foreign
271
+ // config.json resolves to an empty object rather than throwing, so a caller
272
+ // can always merge into (and write back) whatever it finds.
273
+ export function parseRawLauncherConfig(text: string | undefined): RawLauncherConfig {
274
+ if (text === undefined) return {};
275
+ let parsed: unknown;
276
+ try {
277
+ parsed = JSON.parse(text);
278
+ } catch {
279
+ return {};
280
+ }
281
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
282
+ return parsed as RawLauncherConfig;
283
+ }
284
+
285
+ export interface ProvisionedEntry {
286
+ gentleAi: string;
287
+ // Optional: a marker written before gentle-pi version tracking (S8) has
288
+ // no `gentlePi` field at all. needsProvisioning below treats that
289
+ // omission as "needs provisioning" rather than trusting or crashing on it.
290
+ gentlePi?: string;
291
+ at: string;
292
+ }
293
+
294
+ function isProvisionedEntry(value: unknown): value is ProvisionedEntry {
295
+ if (typeof value !== "object" || value === null) return false;
296
+ const record = value as Record<string, unknown>;
297
+ if (typeof record.gentleAi !== "string" || typeof record.at !== "string") return false;
298
+ return record.gentlePi === undefined || typeof record.gentlePi === "string";
299
+ }
300
+
301
+ // Tolerant read of config.provisioned: a missing, non-object, or malformed
302
+ // map (or a malformed individual entry) is dropped rather than thrown, same
303
+ // tolerance policy as parseLauncherConfig above.
304
+ function provisionedMap(config: RawLauncherConfig): Record<string, ProvisionedEntry> {
305
+ const value = config.provisioned;
306
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
307
+ const map: Record<string, ProvisionedEntry> = {};
308
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
309
+ if (isProvisionedEntry(entry)) map[key] = entry;
310
+ }
311
+ return map;
312
+ }
313
+
314
+ // The provisioning record for `homeDir` (the caller passes a realpath, so
315
+ // two different-looking paths to the same home never diverge), or undefined
316
+ // when that home has never been auto- or manually provisioned.
317
+ export function provisionedEntry(config: RawLauncherConfig, homeDir: string): ProvisionedEntry | undefined {
318
+ return provisionedMap(config)[homeDir];
319
+ }
320
+
321
+ // True when `homeDir` has never been provisioned, was provisioned with a
322
+ // gentle-ai pin other than `pin`, or was provisioned against a gentle-pi
323
+ // other than `gentlePiVersion` (the running launcher's own version, from its
324
+ // package.json) — the signal bin/gentle-shell.mjs uses to decide whether a
325
+ // plain launch should run the setup flow automatically before starting pi.
326
+ // A marker written before gentle-pi version tracking existed has no
327
+ // `gentlePi` field, which never strictly-equals a real version string, so it
328
+ // always counts as needing provisioning too — see ProvisionedEntry above.
329
+ export function needsProvisioning(config: RawLauncherConfig, homeDir: string, pin: string, gentlePiVersion: string): boolean {
330
+ const entry = provisionedEntry(config, homeDir);
331
+ return entry === undefined || entry.gentleAi !== pin || entry.gentlePi !== gentlePiVersion;
332
+ }
333
+
334
+ // Returns a new config object recording `homeDir` as provisioned at `pin`
335
+ // and `gentlePiVersion`, preserving every other key — including every other
336
+ // home's provisioned entry — unchanged. Never mutates `config`.
337
+ export function recordProvisioned(config: RawLauncherConfig, homeDir: string, pin: string, gentlePiVersion: string, now: string): RawLauncherConfig {
338
+ return { ...config, provisioned: { ...provisionedMap(config), [homeDir]: { gentleAi: pin, gentlePi: gentlePiVersion, at: now } } };
339
+ }
340
+
208
341
  // --- pi runtime resolution ---------------------------------------------------
209
342
 
210
343
  export type PiRuntimeKind = "env" | "bundled" | "path";
@@ -275,6 +408,25 @@ export function checkPiVersion(output: string, minimum: string = MIN_PI_VERSION)
275
408
  return { ok: true, version };
276
409
  }
277
410
 
411
+ // --- setup subcommand's gentle-ai pin gate -----------------------------------
412
+
413
+ // The first gentle-ai release that honors PI_CODING_AGENT_DIR in its own
414
+ // `install --agent pi` provisioning. `gentle-shell setup` spawns the
415
+ // package-local pinned gentle-ai with PI_CODING_AGENT_DIR set to the
416
+ // resolved home; an older pin ignores that variable and silently provisions
417
+ // the caller's real ~/.pi/agent instead, so setup must refuse to run it.
418
+ export const MIN_SETUP_GENTLE_AI_VERSION = "3.6.0";
419
+
420
+ export function isSetupCapablePin(version: string, minimum: string = MIN_SETUP_GENTLE_AI_VERSION): boolean {
421
+ const match = VERSION_PATTERN.exec(version);
422
+ if (!match) return false;
423
+ const minimumMatch = VERSION_PATTERN.exec(minimum);
424
+ if (!minimumMatch) throw new Error(`invalid minimum version "${minimum}"`);
425
+ const found: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])];
426
+ const wanted: [number, number, number] = [Number(minimumMatch[1]), Number(minimumMatch[2]), Number(minimumMatch[3])];
427
+ return compareVersions(found, wanted) >= 0;
428
+ }
429
+
278
430
  // --- packaging drift guard -----------------------------------------------------
279
431
 
280
432
  export interface PackageJsonPeerShape {
@@ -307,36 +459,406 @@ export function checkPeerVersionPin(packageJson: PackageJsonPeerShape, peerName:
307
459
  return { ok: true, pinned };
308
460
  }
309
461
 
310
- // --- settings.json detection ---------------------------------------------------
462
+ // --- settings.json package declaration detection --------------------------
311
463
 
312
- function packageEntryDeclaresGentlePi(entry: unknown): boolean {
313
- const declares = (value: unknown): boolean => typeof value === "string" && (value === "npm:gentle-pi" || value.startsWith("npm:gentle-pi@"));
314
- if (declares(entry)) return true;
315
- if (entry !== null && typeof entry === "object") return declares((entry as Record<string, unknown>).source);
316
- return false;
464
+ // Matches the raw git URL forms pi accepts without a `git:` prefix.
465
+ const GIT_URL_PATTERN = /^(?:https?|ssh|git):\/\//;
466
+
467
+ function entrySource(entry: unknown): string | undefined {
468
+ if (typeof entry === "string") return entry;
469
+ if (entry !== null && typeof entry === "object") {
470
+ const source = (entry as Record<string, unknown>).source;
471
+ if (typeof source === "string") return source;
472
+ }
473
+ return undefined;
317
474
  }
318
475
 
319
- export function settingsDeclareGentlePi(settingsText: string | undefined): boolean {
320
- if (settingsText === undefined) return false;
476
+ export type PackageSourceKind = "npm" | "git" | "path";
477
+
478
+ // A settings `packages` entry is npm- or git-sourced only via an explicit
479
+ // `npm:`/`git:` prefix or a bare git URL; every other source (relative or
480
+ // absolute) is a local path, per pi's own package-source rules.
481
+ export function packageSourceKind(source: string): PackageSourceKind {
482
+ if (source.startsWith("npm:")) return "npm";
483
+ if (source.startsWith("git:")) return "git";
484
+ if (GIT_URL_PATTERN.test(source)) return "git";
485
+ return "path";
486
+ }
487
+
488
+ function npmSourceDeclaresGentlePi(source: string): boolean {
489
+ return source === "npm:gentle-pi" || source.startsWith("npm:gentle-pi@");
490
+ }
491
+
492
+ // npm:<name> or npm:<name>@<version>, tolerating a scoped `@scope/name`: only
493
+ // the first `@` *after* the leading scope marker starts a version suffix.
494
+ function npmPackageName(source: string): string {
495
+ const spec = source.slice("npm:".length);
496
+ if (spec.startsWith("@")) {
497
+ const versionAt = spec.indexOf("@", 1);
498
+ return versionAt === -1 ? spec : spec.slice(0, versionAt);
499
+ }
500
+ const versionAt = spec.indexOf("@");
501
+ return versionAt === -1 ? spec : spec.slice(0, versionAt);
502
+ }
503
+
504
+ function parseSettingsPackages(settingsText: string | undefined): unknown[] | undefined {
505
+ if (settingsText === undefined) return undefined;
321
506
  let parsed: unknown;
322
507
  try {
323
508
  parsed = JSON.parse(settingsText);
324
509
  } catch {
325
- return false;
510
+ return undefined;
326
511
  }
327
- if (typeof parsed !== "object" || parsed === null) return false;
512
+ if (typeof parsed !== "object" || parsed === null) return undefined;
328
513
  const packages = (parsed as Record<string, unknown>).packages;
329
- if (!Array.isArray(packages)) return false;
514
+ return Array.isArray(packages) ? packages : undefined;
515
+ }
516
+
517
+ function packageEntryDeclaresGentlePi(entry: unknown): boolean {
518
+ const source = entrySource(entry);
519
+ return source !== undefined && packageSourceKind(source) === "npm" && npmSourceDeclaresGentlePi(source);
520
+ }
521
+
522
+ // Deprecated: recognises only an `npm:gentle-pi` declaration. Kept as a thin
523
+ // compatibility wrapper over the pre-existing behaviour for any caller that
524
+ // only cares about the npm case; findGentlePiDeclaration below also detects
525
+ // a path package whose own package.json names it "gentle-pi".
526
+ export function settingsDeclareGentlePi(settingsText: string | undefined): boolean {
527
+ const packages = parseSettingsPackages(settingsText);
528
+ if (packages === undefined) return false;
330
529
  return packages.some(packageEntryDeclaresGentlePi);
331
530
  }
332
531
 
532
+ // gentle-ai's own managed Pi stack still installs
533
+ // npm:@juicesharp/rpiv-ask-user-question, which conflicts with gentle-pi's
534
+ // first-party ask_user_question tool: Pi tool names are exclusive, so a
535
+ // second provider for the same name fails the whole load (see
536
+ // extensions/ask-user-question.ts). Tracked upstream as gentle-ai #4820 and
537
+ // gentle-shell #1277; the gentle-ai fix lands separately, so `gentle-shell
538
+ // setup` (bin/gentle-shell.mjs) must remove it from the provisioned home
539
+ // itself.
540
+ //
541
+ // gentle-ai's managed Pi stack also always declares npm:gentle-pi itself.
542
+ // That declaration must never survive setup either, for an unrelated reason:
543
+ // this launcher always loads its own gentle-pi (its own package root, or a
544
+ // take-over), never the one gentle-ai's stack installs, so leaving the
545
+ // declaration in place would silently let the home drift onto whatever
546
+ // gentle-pi npm last installed — or, for a developer running from a source
547
+ // checkout, onto the published npm package — instead of the running
548
+ // launcher's own copy. See docs/readme-reference.md's "setup" section.
549
+ //
550
+ // Table of every package `setup` removes after gentle-ai finishes, so a
551
+ // future addition only needs a new row here.
552
+ const POST_INSTALL_REMOVAL_PACKAGES: readonly { readonly name: string; readonly source: string }[] = [
553
+ { name: "@juicesharp/rpiv-ask-user-question", source: "npm:@juicesharp/rpiv-ask-user-question" },
554
+ { name: "gentle-pi", source: "npm:gentle-pi" },
555
+ ];
556
+
557
+ // The known removal sources, exposed so a `--dry-run` caller can report what
558
+ // setup would remove *if* gentle-ai's install declares it, without reading
559
+ // settings.json itself: a dry run writes nothing, so settings.json
560
+ // afterwards would only reflect whatever pre-existed the run, not what the
561
+ // (skipped) install would have declared. See runPostInstallCleanup in
562
+ // bin/gentle-shell.mjs.
563
+ export const POST_INSTALL_REMOVAL_SOURCES: readonly string[] = POST_INSTALL_REMOVAL_PACKAGES.map((entry) => entry.source);
564
+
565
+ // Scans a settings.json `packages` list (same string/object-source parsing
566
+ // as settingsDeclareGentlePi/findGentlePiDeclaration above) for any entry
567
+ // whose npm package name matches POST_INSTALL_REMOVAL_PACKAGES, at any
568
+ // version spec. Returns each match's canonical unversioned source, deduped,
569
+ // in the order those packages first appear in `packages` — never the
570
+ // declared (possibly versioned) source text, since the caller always removes
571
+ // the bare package.
572
+ export function postInstallRemovals(settingsText: string | undefined): string[] {
573
+ const packages = parseSettingsPackages(settingsText);
574
+ if (packages === undefined) return [];
575
+
576
+ const found: string[] = [];
577
+ for (const entry of packages) {
578
+ const source = entrySource(entry);
579
+ if (source === undefined || packageSourceKind(source) !== "npm") continue;
580
+ const name = npmPackageName(source);
581
+ const match = POST_INSTALL_REMOVAL_PACKAGES.find((candidate) => candidate.name === name);
582
+ if (match !== undefined && !found.includes(match.source)) found.push(match.source);
583
+ }
584
+ return found;
585
+ }
586
+
587
+ export type GentlePiDeclaration = { kind: "npm" } | { kind: "path"; dir: string };
588
+
589
+ export interface FindGentlePiDeclarationOptions {
590
+ agentDir: string;
591
+ // Injected fs reader: returns <dir>/package.json's "name" field, or
592
+ // undefined when the file is missing, unreadable, or has no string name.
593
+ readPackageName: (dir: string) => string | undefined;
594
+ }
595
+
596
+ // Detects a settings.json `packages` entry that already loads gentle-pi,
597
+ // either as `npm:gentle-pi[@version]` or as a local path (string or object
598
+ // `source`) whose own package.json declares `"name": "gentle-pi"`. Path
599
+ // entries are resolved relative to `opts.agentDir`, matching how pi itself
600
+ // resolves a settings-relative local path.
601
+ export function findGentlePiDeclaration(settingsText: string | undefined, opts: FindGentlePiDeclarationOptions): GentlePiDeclaration | undefined {
602
+ const packages = parseSettingsPackages(settingsText);
603
+ if (packages === undefined) return undefined;
604
+
605
+ for (const entry of packages) {
606
+ const source = entrySource(entry);
607
+ if (source === undefined) continue;
608
+ const kind = packageSourceKind(source);
609
+ if (kind === "npm" && npmSourceDeclaresGentlePi(source)) return { kind: "npm" };
610
+ if (kind === "path") {
611
+ const dir = resolvePath(opts.agentDir, source);
612
+ if (opts.readPackageName(dir) === "gentle-pi") return { kind: "path", dir };
613
+ }
614
+ }
615
+ return undefined;
616
+ }
617
+
618
+ // --- take-over decision ---------------------------------------------------
619
+
620
+ export interface DecideTakeOverInput {
621
+ declaration: GentlePiDeclaration | undefined;
622
+ realPackageRoot: string;
623
+ // realpath of the declared path dir, when declaration.kind === "path".
624
+ // Falls back to the raw declared dir when the caller could not realpath
625
+ // it (for example the directory does not exist).
626
+ realDeclaredDir?: string;
627
+ // True when the user passed --package-root explicitly: forces a
628
+ // take-over even for a matching npm declaration, so a different
629
+ // checkout can always be tested on demand.
630
+ packageRootExplicit: boolean;
631
+ }
632
+
633
+ export function decideTakeOver(input: DecideTakeOverInput): boolean {
634
+ if (input.packageRootExplicit) return true;
635
+ if (input.declaration === undefined) return false;
636
+ if (input.declaration.kind === "npm") return false;
637
+ const realDeclaredDir = input.realDeclaredDir ?? input.declaration.dir;
638
+ return realDeclaredDir !== input.realPackageRoot;
639
+ }
640
+
641
+ // --- other-package injection planning --------------------------------------
642
+
643
+ export interface OtherPackageInjectionsInput {
644
+ settingsText: string | undefined;
645
+ agentDir: string;
646
+ // The gentle-pi declaration being taken over: its own entry is excluded
647
+ // from the result, since it is injected separately as the launcher's
648
+ // own packageRoot.
649
+ skip: GentlePiDeclaration;
650
+ // Existence check for each resolved package directory, injected so this
651
+ // function stays pure and unit-testable without a real filesystem. A
652
+ // declared package whose directory does not exist (a hand-edited
653
+ // settings.json, a failed or interrupted `pi install`, or an npm store
654
+ // laid out somewhere other than <agentDir>/npm/node_modules) is skipped
655
+ // with a warning instead of being handed to pi as an unresolvable `-e`,
656
+ // which pi's module loader fails on with "Cannot find module" (R3-001).
657
+ // Defaults to always-true so a caller that only cares about the pure
658
+ // string resolution (most existing unit tests) does not need to supply
659
+ // a filesystem stub.
660
+ isDirectory?: (dir: string) => boolean;
661
+ // Realpath resolver applied to a settings path entry's resolved
662
+ // directory before comparing it against `skip`. bin/gentle-shell.mjs's
663
+ // --package-root take-over passes `skip.dir` as an already-realpath'd
664
+ // directory; without also realpath'ing the settings entry here, a
665
+ // settings path entry reaching that same physical directory through a
666
+ // symlink is not recognised as the package being taken over and gets
667
+ // re-injected as a second, redundant -e for it
668
+ // (R4-forced-root-symlink-double-injection). Defaults to identity so
669
+ // this function stays pure and existing callers keep comparing raw
670
+ // strings.
671
+ realpath?: (dir: string) => string;
672
+ }
673
+
674
+ export interface OtherPackageInjections {
675
+ paths: string[];
676
+ warnings: string[];
677
+ }
678
+
679
+ function entryFilterKeys(entry: unknown): string[] {
680
+ if (entry === null || typeof entry !== "object") return [];
681
+ const record = entry as Record<string, unknown>;
682
+ const keys: string[] = [];
683
+ if ("extensions" in record) keys.push("extensions");
684
+ if ("autoload" in record) keys.push("autoload");
685
+ return keys;
686
+ }
687
+
688
+ // Plans the `-e <dir>` flags a take-over must add for every OTHER settings
689
+ // package once `--no-extensions` drops normal settings-driven extension
690
+ // discovery. git-sourced packages are skipped (their install directory is
691
+ // not derivable without pi's own package manager) with a warning; object
692
+ // entries carrying `extensions`/`autoload` filters are still included, with
693
+ // a warning that the take-over cannot honour those filters (their skills,
694
+ // prompts, and themes still load through ordinary settings discovery, which
695
+ // --no-extensions does not affect).
696
+ export function otherPackageInjections(input: OtherPackageInjectionsInput): OtherPackageInjections {
697
+ const paths: string[] = [];
698
+ const warnings: string[] = [];
699
+ const packages = parseSettingsPackages(input.settingsText);
700
+ if (packages === undefined) return { paths, warnings };
701
+ const isDirectory = input.isDirectory ?? (() => true);
702
+ const realpath = input.realpath ?? ((dir: string) => dir);
703
+
704
+ for (const entry of packages) {
705
+ const source = entrySource(entry);
706
+ if (source === undefined) continue;
707
+ const kind = packageSourceKind(source);
708
+
709
+ // Skip every gentle-pi entry unconditionally, not only the one
710
+ // matching `skip`'s kind: settings can carry more than one gentle-pi
711
+ // declaration (for example an npm:gentle-pi entry alongside the path
712
+ // declaration actually being taken over), and re-injecting any of
713
+ // them as an "other package" would double-load gentle-pi extensions.
714
+ if (kind === "npm" && npmSourceDeclaresGentlePi(source)) continue;
715
+ if (kind === "path") {
716
+ const dir = resolvePath(input.agentDir, source);
717
+ // Compared through realpath on BOTH sides (not the raw resolved
718
+ // strings): skip.dir may already be a realpath itself
719
+ // (bin/gentle-shell.mjs's --package-root take-over) or may not be
720
+ // (a plain settings.json declaration), so only comparing one side
721
+ // through realpath would break whichever case does not match that
722
+ // assumption. Realpath'ing both keeps the exact-match case
723
+ // (skip.dir derived from the very same source) trivially correct
724
+ // while also recognising a settings entry that reaches the same
725
+ // physical directory as skip through a symlink.
726
+ if (input.skip.kind === "path" && realpath(dir) === realpath(input.skip.dir)) continue;
727
+ }
728
+
729
+ if (kind === "git") {
730
+ warnings.push(
731
+ `gentle-shell: skipping git-sourced package "${source}" during takeover (its install directory is not derivable without pi's own package manager).`,
732
+ );
733
+ continue;
734
+ }
735
+
736
+ const filters = entryFilterKeys(entry);
737
+ if (filters.length > 0) {
738
+ warnings.push(
739
+ `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.`,
740
+ );
741
+ }
742
+
743
+ const dir = kind === "npm" ? join(input.agentDir, "npm", "node_modules", npmPackageName(source)) : resolvePath(input.agentDir, source);
744
+ if (!isDirectory(dir)) {
745
+ warnings.push(`gentle-shell: skipping declared package "${source}": ${dir} is not a directory`);
746
+ continue;
747
+ }
748
+ paths.push(dir);
749
+ }
750
+ return { paths, warnings };
751
+ }
752
+
753
+ // --- loose extension discovery ----------------------------------------------
754
+
755
+ export interface LooseExtensionFsEntry {
756
+ name: string;
757
+ isFile: boolean;
758
+ isDirectory: boolean;
759
+ }
760
+
761
+ export interface LooseExtensionFs {
762
+ // Lists dir's direct children with cheap type info per entry. A throwing
763
+ // readdir (missing or unreadable dir) is treated the same as an empty
764
+ // directory by discoverLooseExtensionEntries.
765
+ readdir: (dir: string) => LooseExtensionFsEntry[];
766
+ // Existence check used only for a child subdirectory's index.ts/index.js.
767
+ exists: (path: string) => boolean;
768
+ }
769
+
770
+ // scripts/build-runtime-modules.mjs rewrites every occurrence of a dot, the
771
+ // letters ts, and an immediately following closing quote (single or double)
772
+ // to end in mjs instead, when it generates runtime/gentle-shell-launcher.mjs
773
+ // — a plain `.replace(/\.ts(["'])/g, ...)` that cannot tell an import
774
+ // specifier from an ordinary string literal. Any other string ending the
775
+ // same way — a dot, the letters ts, and a closing quote right after — would
776
+ // get silently corrupted into the mjs form in the generated runtime module,
777
+ // so the three constants below are built by concatenation instead of
778
+ // written as literals that would trigger the same rewrite.
779
+ const TS_EXTENSION = `.t${"s"}`;
780
+ const INDEX_TS_FILENAME = `index${TS_EXTENSION}`;
781
+ const DECLARATION_FILE_SUFFIX = `.d${TS_EXTENSION}`;
782
+ const LOOSE_EXTENSION_FILE_PATTERN = /\.(?:ts|js|mjs)$/;
783
+
784
+ function isLooseExtensionFile(name: string): boolean {
785
+ if (name.startsWith(".")) return false;
786
+ if (name.endsWith(DECLARATION_FILE_SUFFIX)) return false;
787
+ return LOOSE_EXTENSION_FILE_PATTERN.test(name);
788
+ }
789
+
790
+ // Mirrors pi's own discoverExtensionsInDir (packages/coding-agent/src/core/
791
+ // extensions/loader.ts): direct *.ts/*.js/*.mjs files, plus <subdir>/index.ts
792
+ // (falling back to <subdir>/index.js) for a child directory that has one. No
793
+ // recursion beyond that one level, matching pi's own rule that a more complex
794
+ // nested package must use a package.json manifest instead.
795
+ //
796
+ // Unlike pi's own scan, hidden entries (dotfiles, and hidden subdirectories)
797
+ // and *.d.ts files are deliberately excluded here: pi's `-e <file>` flag hands
798
+ // the path straight to its module loader with no directory-discovery pass of
799
+ // its own (see buildPiInvocation's takeOver branch), so a hidden file or a
800
+ // type-only declaration file was never a runnable extension and would only
801
+ // surface a confusing "Cannot find module"/empty-module error once injected.
802
+ //
803
+ // Returns already-resolved absolute file paths, sorted by name so the result
804
+ // (and therefore -e ordering) does not depend on the host filesystem's
805
+ // unspecified readdir order.
806
+ export function discoverLooseExtensionEntries(dir: string, fs: LooseExtensionFs): string[] {
807
+ let entries: LooseExtensionFsEntry[];
808
+ try {
809
+ entries = fs.readdir(dir);
810
+ } catch {
811
+ return [];
812
+ }
813
+
814
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
815
+ const discovered: string[] = [];
816
+
817
+ for (const entry of sorted) {
818
+ if (entry.name.startsWith(".")) continue;
819
+
820
+ if (entry.isFile) {
821
+ if (isLooseExtensionFile(entry.name)) discovered.push(join(dir, entry.name));
822
+ continue;
823
+ }
824
+
825
+ if (!entry.isDirectory) continue;
826
+ const childDir = join(dir, entry.name);
827
+ const indexTs = join(childDir, INDEX_TS_FILENAME);
828
+ const indexJs = join(childDir, "index.js");
829
+ if (fs.exists(indexTs)) discovered.push(indexTs);
830
+ else if (fs.exists(indexJs)) discovered.push(indexJs);
831
+ }
832
+
833
+ return discovered;
834
+ }
835
+
333
836
  // --- pi invocation builder ---------------------------------------------------
334
837
 
335
838
  export interface BuildPiInvocationInput {
336
839
  runtime: PiRuntime;
337
840
  home: ResolvedHome;
338
841
  packageRoot: string;
339
- settingsDeclareGentlePi: boolean;
842
+ declaration: GentlePiDeclaration | undefined;
843
+ // True when the target settings already declare a *different* gentle-pi
844
+ // than this launcher's own packageRoot (or --package-root forces it):
845
+ // the launcher takes over the pi invocation instead of deferring to the
846
+ // declared package.
847
+ takeOver: boolean;
848
+ // Directories for every OTHER settings package, from otherPackageInjections.
849
+ // Only consulted when takeOver is true.
850
+ otherPackagePaths: string[];
851
+ // Already-resolved loose extension FILE paths (never directories) that
852
+ // normal pi discovery would otherwise have picked up from
853
+ // <agentDir>/extensions and the project-local <cwd>/.pi/extensions before
854
+ // --no-extensions drops that discovery — see discoverLooseExtensionEntries.
855
+ // Only consulted when takeOver is true. The caller resolves the actual
856
+ // file list per candidate directory (or, when a candidate directory is
857
+ // itself a self-contained extension — its own index.ts/index.js, or a
858
+ // pi package manifest at its root — passes that directory through
859
+ // unchanged instead, since pi's own module loader resolves that case
860
+ // directly).
861
+ looseExtensionEntries?: string[];
340
862
  passthrough: string[];
341
863
  // Set when parseLauncherArgs recognised passthrough[0] as one of
342
864
  // PI_SUBCOMMANDS. pi dispatches install/remove/uninstall/update/list/
@@ -352,31 +874,76 @@ export interface PiInvocation {
352
874
  env: Record<string, string | undefined>;
353
875
  }
354
876
 
355
- // The `-e/--theme/--skill/--prompt-template` injection is skipped when the
356
- // caller already confirmed the target settings.json declares the package
357
- // (the `--link` case with a pi-managed install), or when passthrough[0] is
358
- // one of pi's own subcommands: pi dispatches install/remove/uninstall/
359
- // update/list/config/auth on argv[0] before it even parses flags, so any
360
- // injected flag ahead of it stops pi from recognising its subcommand at
361
- // all — this is exactly the observed 2026-09-22 bug where `gentle-shell
362
- // install npm:x` opened an interactive pi session instead of running the
363
- // package manager. Isolated and path homes never declare the package, so
364
- // callers pass `settingsDeclareGentlePi: false` for those and the
365
- // injection always happens there, unless a pi subcommand is set.
877
+ function packageRootAssetArgs(packageRoot: string): string[] {
878
+ return ["--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")];
879
+ }
880
+
881
+ function packageRootInjectionArgs(packageRoot: string): string[] {
882
+ return ["-e", packageRoot, ...packageRootAssetArgs(packageRoot)];
883
+ }
884
+
885
+ // Four cases, checked in this order — `piSubcommand` first, then `takeOver`:
886
+ // - piSubcommand: pi dispatches install/remove/uninstall/update/list/
887
+ // config/auth on argv[0] before it even parses flags, so any injected
888
+ // -e/--theme/--skill/--prompt-template flag ahead of it stops pi from
889
+ // recognising its subcommand at all — this is exactly the observed
890
+ // 2026-09-22 bug where `gentle-shell install npm:x` opened an
891
+ // interactive pi session instead of running the package manager. No
892
+ // injection of any kind (including a take-over's --no-extensions and
893
+ // other-package/loose-extension -e flags) may precede it.
894
+ // - takeOver: the target settings declare a *different* gentle-pi, or
895
+ // --package-root forced a takeover regardless of any declaration. This
896
+ // must win over the next two cases even when there is no declaration to
897
+ // report, or the plain branch would silently drop --no-extensions and
898
+ // the other-package injections while bin/gentle-shell.mjs still prints
899
+ // the "taking over" message. `--no-extensions` drops normal
900
+ // settings-driven extension discovery, so it is replaced by an explicit
901
+ // `-e <dir>` for every OTHER settings package (skills/prompts/themes
902
+ // for those packages still load through ordinary settings discovery,
903
+ // which --no-extensions does not affect), then an explicit `-e <file>`
904
+ // for every loose extension entry normal discovery would otherwise have
905
+ // found under <agentDir>/extensions and the project-local
906
+ // .pi/extensions, and finally this launcher's own packageRoot injected
907
+ // last so it wins any conflict. Every -e path is injected at most once
908
+ // (R3-001): a loose entry that duplicates an other-package path, or
909
+ // repeats within looseExtensionEntries itself, is skipped rather than
910
+ // loaded twice.
911
+ // - Not takeOver, no declaration: inject this launcher's own packageRoot,
912
+ // exactly as when nothing else in settings loads gentle-pi.
913
+ // - Not takeOver, with a declaration: no injection at all — the target
914
+ // settings already load a gentle-pi the launcher accepts as-is (the
915
+ // `--link` case with a pi-managed install matching this launcher).
366
916
  export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation {
367
917
  const args = [...input.runtime.args];
368
- if (input.piSubcommand === undefined && !input.settingsDeclareGentlePi) {
369
- args.push(
370
- "-e",
371
- input.packageRoot,
372
- "--theme",
373
- join(input.packageRoot, "themes"),
374
- "--skill",
375
- join(input.packageRoot, "skills"),
376
- "--prompt-template",
377
- join(input.packageRoot, "prompts"),
378
- );
918
+
919
+ if (input.piSubcommand !== undefined) {
920
+ // No injection at all: pi must see the bare subcommand as argv[0].
921
+ } else if (input.takeOver) {
922
+ args.push("--no-extensions");
923
+ const injected = new Set<string>();
924
+ for (const otherPath of input.otherPackagePaths) {
925
+ if (injected.has(otherPath)) continue;
926
+ injected.add(otherPath);
927
+ args.push("-e", otherPath);
928
+ }
929
+ for (const entry of input.looseExtensionEntries ?? []) {
930
+ if (injected.has(entry)) continue;
931
+ injected.add(entry);
932
+ args.push("-e", entry);
933
+ }
934
+ // R3-003: the launcher's own package root must also be checked
935
+ // against the dedupe set instead of being appended unconditionally,
936
+ // or a settings package/loose entry that resolves to the same
937
+ // directory as --package-root would be injected twice.
938
+ if (!injected.has(input.packageRoot)) {
939
+ injected.add(input.packageRoot);
940
+ args.push("-e", input.packageRoot);
941
+ }
942
+ args.push(...packageRootAssetArgs(input.packageRoot));
943
+ } else if (input.declaration === undefined) {
944
+ args.push(...packageRootInjectionArgs(input.packageRoot));
379
945
  }
946
+
380
947
  args.push(...input.passthrough);
381
948
 
382
949
  return {
@@ -406,6 +973,20 @@ export function quoteForCmdExe(token: string): string {
406
973
  return `"${token.replace(/"/g, '\\"')}"`;
407
974
  }
408
975
 
976
+ const POSIX_SHELL_SPECIAL_CHARS = /[\s"'`\\$&|;<>(){}*?[\]!#~]/;
977
+
978
+ // POSIX/bash single-quote shell quoting for a copy-pasteable command
979
+ // bin/gentle-shell.mjs prints to stderr (e.g. the setup remediation
980
+ // command): wraps a token in single quotes when it is empty or contains
981
+ // whitespace or a shell metacharacter, escaping an embedded single quote as
982
+ // `'\''` (close quote, escaped literal quote, reopen quote) — inside single
983
+ // quotes nothing else needs escaping, unlike cmd.exe's `"`-based quoting
984
+ // (quoteForCmdExe above).
985
+ export function shellQuote(value: string): string {
986
+ if (value.length > 0 && !POSIX_SHELL_SPECIAL_CHARS.test(value)) return value;
987
+ return `'${value.replace(/'/g, "'\\''")}'`;
988
+ }
989
+
409
990
  export interface PlanSpawnInput {
410
991
  command: string;
411
992
  args: string[];
@@ -426,6 +1007,115 @@ export function planSpawn(input: PlanSpawnInput): SpawnPlan {
426
1007
  return { command, args, shell: false };
427
1008
  }
428
1009
 
1010
+ // --- JSON field restore --------------------------------------------------
1011
+
1012
+ // Detects the indentation unit and trailing-newline presence of a JSON text,
1013
+ // so restoreJsonField below can re-serialize as close to the original
1014
+ // formatting as practical instead of imposing its own. `indent` is
1015
+ // `undefined` for compact (no-whitespace) JSON, matching what
1016
+ // `JSON.stringify(value)` (no third argument) produces.
1017
+ function detectJsonFormatting(text: string): { indent: string | undefined; trailingNewline: boolean } {
1018
+ const match = text.match(/\{\r?\n([ \t]+)/);
1019
+ return { indent: match ? match[1] : undefined, trailingNewline: text.endsWith("\n") };
1020
+ }
1021
+
1022
+ function jsonValuesEqual(a: unknown, b: unknown): boolean {
1023
+ return JSON.stringify(a) === JSON.stringify(b);
1024
+ }
1025
+
1026
+ // Pure JSON merge: restores `field` in `currentText` back to whatever it was
1027
+ // in `originalText`, keeping every other field exactly as `currentText` left
1028
+ // it, and formatting the result to match `originalText`'s indentation and
1029
+ // trailing newline. Used by bin/gentle-shell.mjs's setup flow to restore
1030
+ // `managed_asset_digest` in the user's shared `~/.gentle-ai/state.json` after
1031
+ // the pinned gentle-ai spawn rewrites it (the same shared-file problem
1032
+ // persona.json has — see sharedPersonaPath/snapshotFile/restoreFile in
1033
+ // bin/gentle-shell.mjs — but state.json also carries fields the pinned
1034
+ // gentle-ai is supposed to update, like installed_agents, so this restores
1035
+ // only the one field instead of the whole file).
1036
+ //
1037
+ // Returns the new text, or `undefined` when either text fails to parse as a
1038
+ // JSON object, or the field's presence and value are already identical on
1039
+ // both sides (nothing to restore). Never called by the caller when
1040
+ // `originalText` comes from a file that did not exist before the spawn —
1041
+ // there is nothing to restore a nonexistent file back to.
1042
+ export function restoreJsonField(originalText: string, currentText: string, field: string): string | undefined {
1043
+ let originalValue: unknown;
1044
+ let currentValue: unknown;
1045
+ try {
1046
+ originalValue = JSON.parse(originalText);
1047
+ currentValue = JSON.parse(currentText);
1048
+ } catch {
1049
+ return undefined;
1050
+ }
1051
+ if (
1052
+ typeof originalValue !== "object" ||
1053
+ originalValue === null ||
1054
+ Array.isArray(originalValue) ||
1055
+ typeof currentValue !== "object" ||
1056
+ currentValue === null ||
1057
+ Array.isArray(currentValue)
1058
+ ) {
1059
+ return undefined;
1060
+ }
1061
+ const originalObj = originalValue as Record<string, unknown>;
1062
+ const currentObj = currentValue as Record<string, unknown>;
1063
+ const hadField = Object.prototype.hasOwnProperty.call(originalObj, field);
1064
+ const hasFieldNow = Object.prototype.hasOwnProperty.call(currentObj, field);
1065
+ const unchanged = hadField === hasFieldNow && (!hadField || jsonValuesEqual(originalObj[field], currentObj[field]));
1066
+ if (unchanged) return undefined;
1067
+
1068
+ let restored: Record<string, unknown>;
1069
+ if (hadField) {
1070
+ restored = { ...currentObj, [field]: originalObj[field] };
1071
+ } else {
1072
+ restored = { ...currentObj };
1073
+ delete restored[field];
1074
+ }
1075
+
1076
+ const { indent, trailingNewline } = detectJsonFormatting(originalText);
1077
+ const serialized = JSON.stringify(restored, null, indent);
1078
+ return trailingNewline ? `${serialized}\n` : serialized;
1079
+ }
1080
+
1081
+ // Pure JSON merge: forces `field` in `currentText` to `value`, but only when
1082
+ // `originalText` (the state from before whatever wrote `currentText`) did not
1083
+ // declare that field at all — never overriding a value the original already
1084
+ // had, in either direction. Keeps every other field exactly as `currentText`
1085
+ // left it, and formats the result to match `currentText`'s own indentation
1086
+ // and trailing newline (unlike restoreJsonField above, which matches the
1087
+ // *original*'s formatting — here `currentText` is what the other writer just
1088
+ // produced, so its own convention is respected instead of imposed on).
1089
+ // Used by bin/gentle-shell.mjs's setup flow so a home gentle-shell provisions
1090
+ // ends up with the maintainer's default theme unless the home (or the user)
1091
+ // already had an opinion about it, even when gentle-ai's own managed install
1092
+ // writes a *different* default theme into settings.json.
1093
+ //
1094
+ // Returns the new text, or `undefined` when either text fails to parse as a
1095
+ // JSON object, the original text already declared `field` (nothing to
1096
+ // force), or the current value already equals `value` (nothing to change).
1097
+ export function forceJsonFieldIfAbsentInOriginal(originalText: string, currentText: string, field: string, value: unknown): string | undefined {
1098
+ let originalValue: unknown;
1099
+ let currentValue: unknown;
1100
+ try {
1101
+ originalValue = JSON.parse(originalText);
1102
+ currentValue = JSON.parse(currentText);
1103
+ } catch {
1104
+ return undefined;
1105
+ }
1106
+ if (typeof originalValue !== "object" || originalValue === null || Array.isArray(originalValue)) return undefined;
1107
+ if (typeof currentValue !== "object" || currentValue === null || Array.isArray(currentValue)) return undefined;
1108
+ const originalObj = originalValue as Record<string, unknown>;
1109
+ const currentObj = currentValue as Record<string, unknown>;
1110
+ if (Object.prototype.hasOwnProperty.call(originalObj, field)) return undefined;
1111
+ if (jsonValuesEqual(currentObj[field], value)) return undefined;
1112
+
1113
+ const forced = { ...currentObj, [field]: value };
1114
+ const { indent, trailingNewline } = detectJsonFormatting(currentText);
1115
+ const serialized = JSON.stringify(forced, null, indent);
1116
+ return trailingNewline ? `${serialized}\n` : serialized;
1117
+ }
1118
+
429
1119
  // --- reporting ---------------------------------------------------------------
430
1120
 
431
1121
  export interface DescribeVersionInput {
@@ -446,6 +1136,7 @@ export function helpText(): string {
446
1136
  return [
447
1137
  "Usage: gentle-shell [options] [-- pi-args...]",
448
1138
  " gentle-shell home [link|isolated|<path>]",
1139
+ " gentle-shell [home selectors] setup [--dry-run]",
449
1140
  "",
450
1141
  "Opens pi with the Gentle Shell package loaded, without touching your",
451
1142
  "vanilla pi installation.",
@@ -454,11 +1145,17 @@ export function helpText(): string {
454
1145
  " --link Use your existing pi agent home (never edits its settings.json).",
455
1146
  " --isolated Use the dedicated ~/.gentle-shell/agent home (default).",
456
1147
  " --home <path> Use a custom agent home directory.",
1148
+ " --package-root <dir> Force this directory as the gentle-pi package to load, taking over",
1149
+ " from any conflicting package the target settings.json already declares.",
457
1150
  " --help, -h Show this help text.",
458
1151
  " --version Show gentle-shell, pi, and home version information.",
459
1152
  "",
460
1153
  "Commands:",
461
1154
  " home Print or persist the effective home mode (link, isolated, or a path).",
1155
+ " setup Provision the resolved home with the gentle-ai companion packages",
1156
+ " (runs the package-local gentle-ai 'install --agent pi --scope global').",
1157
+ " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector",
1158
+ " (--link, --isolated, --home <dir>) before it.",
462
1159
  "",
463
1160
  "Managing packages:",
464
1161
  " gentle-shell install npm:<pkg> Run pi's own 'install' against the resolved home.",