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