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.
- package/README.md +43 -41
- package/bin/gentle-shell.mjs +183 -8
- package/docs/readme-reference.md +63 -12
- package/lib/gentle-shell-launcher.ts +421 -36
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +420 -35
- package/tests/agents-rpc-publisher.test.ts +66 -0
- package/tests/gentle-agents.test.ts +46 -0
- package/tests/gentle-shell-bin.test.ts +651 -3
- package/tests/gentle-shell-launcher.test.ts +659 -10
- package/tests/package-manifest.test.ts +2 -2
|
@@ -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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return
|
|
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
|
|
320
|
-
|
|
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
|
|
381
|
+
return undefined;
|
|
326
382
|
}
|
|
327
|
-
if (typeof parsed !== "object" || parsed === null) return
|
|
383
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
328
384
|
const packages = (parsed as Record<string, unknown>).packages;
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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.
|
|
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",
|