gentle-pi 3.5.0 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
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,
@@ -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,6 +58,7 @@ 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;
@@ -108,6 +111,26 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
108
111
  i += 1;
109
112
  continue;
110
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
+ }
111
134
  if (passthrough.length === 0 && isPiSubcommand(arg)) {
112
135
  piSubcommand = arg;
113
136
  }
@@ -124,7 +147,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs {
124
147
  }
125
148
  }
126
149
 
127
- return { link, isolated, home, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
150
+ return { link, isolated, home, packageRoot, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
128
151
  }
129
152
 
130
153
  // --- home resolution -------------------------------------------------------
@@ -307,36 +330,351 @@ export function checkPeerVersionPin(packageJson: PackageJsonPeerShape, peerName:
307
330
  return { ok: true, pinned };
308
331
  }
309
332
 
310
- // --- settings.json detection ---------------------------------------------------
333
+ // --- settings.json package declaration detection --------------------------
311
334
 
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;
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;
317
345
  }
318
346
 
319
- export function settingsDeclareGentlePi(settingsText: string | undefined): boolean {
320
- if (settingsText === undefined) return false;
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;
321
377
  let parsed: unknown;
322
378
  try {
323
379
  parsed = JSON.parse(settingsText);
324
380
  } catch {
325
- return false;
381
+ return undefined;
326
382
  }
327
- if (typeof parsed !== "object" || parsed === null) return false;
383
+ if (typeof parsed !== "object" || parsed === null) return undefined;
328
384
  const packages = (parsed as Record<string, unknown>).packages;
329
- if (!Array.isArray(packages)) return false;
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;
330
400
  return packages.some(packageEntryDeclaresGentlePi);
331
401
  }
332
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
+
333
652
  // --- pi invocation builder ---------------------------------------------------
334
653
 
335
654
  export interface BuildPiInvocationInput {
336
655
  runtime: PiRuntime;
337
656
  home: ResolvedHome;
338
657
  packageRoot: string;
339
- settingsDeclareGentlePi: boolean;
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[];
340
678
  passthrough: string[];
341
679
  // Set when parseLauncherArgs recognised passthrough[0] as one of
342
680
  // PI_SUBCOMMANDS. pi dispatches install/remove/uninstall/update/list/
@@ -352,31 +690,76 @@ export interface PiInvocation {
352
690
  env: Record<string, string | undefined>;
353
691
  }
354
692
 
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.
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).
366
732
  export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation {
367
733
  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
- );
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));
379
761
  }
762
+
380
763
  args.push(...input.passthrough);
381
764
 
382
765
  return {
@@ -454,6 +837,8 @@ export function helpText(): string {
454
837
  " --link Use your existing pi agent home (never edits its settings.json).",
455
838
  " --isolated Use the dedicated ~/.gentle-shell/agent home (default).",
456
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.",
457
842
  " --help, -h Show this help text.",
458
843
  " --version Show gentle-shell, pi, and home version information.",
459
844
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "3.5.0",
3
+ "version": "3.5.1",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",