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