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