gentle-pi 3.5.1 → 3.7.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.
Files changed (35) hide show
  1. package/README.md +8 -1
  2. package/assets/orchestrator-delegation.md +2 -0
  3. package/bin/gentle-shell.mjs +900 -23
  4. package/docs/gentle-shell.md +5 -4
  5. package/docs/readme-reference.md +53 -39
  6. package/extensions/gentle-agents.ts +89 -22
  7. package/extensions/gentle-ai.ts +27 -48
  8. package/lib/agents-runner.ts +9 -0
  9. package/lib/foreign-target-grants.ts +32 -0
  10. package/lib/gentle-shell-launcher.ts +314 -2
  11. package/lib/inprocess-reviewer.ts +54 -6
  12. package/lib/native-review-cli.ts +16 -0
  13. package/lib/session-change-capture.ts +10 -1
  14. package/package.json +1 -1
  15. package/runtime/gentle-shell-launcher.mjs +313 -1
  16. package/runtime/native-review-cli.mjs +16 -0
  17. package/scripts/gentle-ai-installer.mjs +10 -10
  18. package/scripts/install-tui-mode-setting.mjs +21 -2
  19. package/scripts/verify-package-files.mjs +2 -2
  20. package/tests/agents-runner.test.ts +22 -0
  21. package/tests/foreign-target-grants.test.ts +58 -0
  22. package/tests/gentle-agents.test.ts +362 -2
  23. package/tests/gentle-ai-binary.test.ts +1 -1
  24. package/tests/gentle-ai-installer.test.ts +54 -49
  25. package/tests/gentle-ai.test.ts +147 -2
  26. package/tests/gentle-shell-bin.test.ts +1739 -4
  27. package/tests/gentle-shell-launcher.test.ts +394 -0
  28. package/tests/inprocess-reviewer.test.ts +179 -0
  29. package/tests/install-tui-mode-setting.test.ts +22 -4
  30. package/tests/native-review-capability-contract.test.ts +34 -1
  31. package/tests/odd-runtime-delegation-gate.test.ts +18 -197
  32. package/tests/package-manifest.test.ts +6 -6
  33. package/tests/runtime-harness.mjs +1 -2
  34. package/tests/session-change-capture.test.ts +12 -1
  35. package/lib/odd-runtime-delegation-gate.ts +0 -88
@@ -1,8 +1,21 @@
1
1
  import assert from "node:assert/strict";
2
- import { spawnSync } from "node:child_process";
3
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ rmSync,
12
+ statSync,
13
+ symlinkSync,
14
+ utimesSync,
15
+ writeFileSync,
16
+ } from "node:fs";
4
17
  import { tmpdir } from "node:os";
5
- import { dirname, join } from "node:path";
18
+ import { delimiter, dirname, join } from "node:path";
6
19
  import { fileURLToPath } from "node:url";
7
20
  import test from "node:test";
8
21
 
@@ -16,6 +29,21 @@ const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url);
16
29
  const binPath = fileURLToPath(binUrl);
17
30
  const packageRoot = dirname(dirname(binPath));
18
31
 
32
+ // The launcher's own gentle-pi version, exactly as `ownPackageVersion()` in
33
+ // bin/gentle-shell.mjs reads it (package.json at packageRoot) — used to
34
+ // assert the post-install gentle-pi removal message and the provisioning
35
+ // marker's `gentlePi` field without hardcoding this package's own version.
36
+ function ownGentlePiVersion(): string {
37
+ return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
38
+ }
39
+
40
+ // GENTLE_SHELL_NO_AUTO_SETUP=1 is in the base fixture env so every test that
41
+ // is not itself about auto-provisioning (S7) keeps today's plain-launch
42
+ // behavior: without it, every fixture-driven test against a fresh isolated
43
+ // home would trigger a real `gentle-ai install --agent pi` (network, tens of
44
+ // seconds, mutating state) unless it also happened to set
45
+ // GENTLE_SHELL_GENTLE_AI_BIN. The auto-provision test section below opts
46
+ // back in per test via enableAutoProvision.
19
47
  function fixture(t: test.TestContext) {
20
48
  const root = mkdtempSync(join(tmpdir(), "gentle-shell-bin-"));
21
49
  t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -30,17 +58,38 @@ function fixture(t: test.TestContext) {
30
58
  USERPROFILE: home,
31
59
  GENTLE_SHELL_HOME: gentleShellHome,
32
60
  GENTLE_SHELL_PI: piScript,
61
+ GENTLE_SHELL_NO_AUTO_SETUP: "1",
33
62
  };
34
63
  return { root, home, gentleShellHome, piScript, env };
35
64
  }
36
65
 
37
- function writePiScript(path: string, version: string) {
66
+ // Removes the fixture's default GENTLE_SHELL_NO_AUTO_SETUP=1 opt-out, for a
67
+ // test that specifically exercises auto-provisioning (S7).
68
+ function enableAutoProvision(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
69
+ const { GENTLE_SHELL_NO_AUTO_SETUP, ...rest } = env;
70
+ return rest;
71
+ }
72
+
73
+ // `removeExitCode` controls only the `pi remove ...` branch exercised by the
74
+ // setup-subcommand conflict-cleanup tests below; every other pi invocation
75
+ // (including the version probe) keeps exiting 0, unchanged for every
76
+ // existing caller that does not pass it.
77
+ function writePiScript(path: string, version: string, removeExitCode = 0) {
38
78
  writeFileSync(
39
79
  path,
40
80
  [
41
81
  "#!/usr/bin/env node",
42
82
  "const args = process.argv.slice(2);",
43
83
  `if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`,
84
+ "if (args[0] === 'remove') {",
85
+ " console.log(JSON.stringify({",
86
+ " args,",
87
+ " PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
88
+ " GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
89
+ " PATH: process.env.PATH,",
90
+ " }));",
91
+ ` process.exit(${removeExitCode});`,
92
+ "}",
44
93
  "console.log(JSON.stringify({",
45
94
  " args,",
46
95
  " PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
@@ -57,6 +106,269 @@ function run(env: NodeJS.ProcessEnv, args: string[], options: { cwd?: string } =
57
106
  return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env, ...options });
58
107
  }
59
108
 
109
+ // Test/development-only stub for the setup subcommand's gentle-ai binary:
110
+ // records its argv and the environment gentle-shell sets around it, instead
111
+ // of running the real package-local gentle-ai (whose supply-chain integrity
112
+ // checks a test cannot cheaply satisfy). Pointed to via GENTLE_SHELL_GENTLE_AI_BIN,
113
+ // documented as test/development-only in docs/readme-reference.md.
114
+ function writeGentleAiScript(path: string, exitCode = 0) {
115
+ writeFileSync(
116
+ path,
117
+ [
118
+ "#!/usr/bin/env node",
119
+ "const args = process.argv.slice(2);",
120
+ "process.stdout.write(JSON.stringify({",
121
+ " args,",
122
+ " PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
123
+ " GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
124
+ " PATH: process.env.PATH,",
125
+ "}));",
126
+ `process.exit(${exitCode});`,
127
+ "",
128
+ ].join("\n"),
129
+ );
130
+ chmodSync(path, 0o755);
131
+ }
132
+
133
+ // Test/development-only stub for the setup subcommand's gentle-ai binary
134
+ // that also records each invocation's args to `counterPath` (one JSON line
135
+ // per run), so an auto-provision test can prove the flow ran exactly once
136
+ // (or not at all) across several gentle-shell invocations, independent of
137
+ // what lands on stdout/stderr.
138
+ function writeGentleAiScriptCountingRuns(path: string, counterPath: string, exitCode = 0) {
139
+ writeFileSync(
140
+ path,
141
+ [
142
+ "#!/usr/bin/env node",
143
+ "import { appendFileSync } from 'node:fs';",
144
+ "const args = process.argv.slice(2);",
145
+ `appendFileSync(${JSON.stringify(counterPath)}, JSON.stringify(args) + '\\n');`,
146
+ "process.stdout.write(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
147
+ `process.exit(${exitCode});`,
148
+ "",
149
+ ].join("\n"),
150
+ );
151
+ chmodSync(path, 0o755);
152
+ }
153
+
154
+ // Test/development-only stub for setup's self-heal installer seam
155
+ // (GENTLE_SHELL_GENTLE_AI_INSTALLER): writes a working gentle-ai stub at
156
+ // process.env.GENTLE_SHELL_GENTLE_AI_BIN — the same path setup itself
157
+ // resolved the missing binary from — so "the installer created the pinned
158
+ // binary" is provable by setup's own retry check finding it, exactly like a
159
+ // real `node scripts/install-gentle-ai.mjs` run would leave a real binary at
160
+ // gentleAiBinaryPath().
161
+ function writeInstallerScriptThatCreatesTheBinary(path: string) {
162
+ writeFileSync(
163
+ path,
164
+ [
165
+ "#!/usr/bin/env node",
166
+ "import { writeFileSync, chmodSync, mkdirSync } from 'node:fs';",
167
+ "import { dirname } from 'node:path';",
168
+ "const target = process.env.GENTLE_SHELL_GENTLE_AI_BIN;",
169
+ "mkdirSync(dirname(target), { recursive: true });",
170
+ "writeFileSync(target, [",
171
+ " '#!/usr/bin/env node',",
172
+ " 'const args = process.argv.slice(2);',",
173
+ " 'process.stdout.write(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME, PATH: process.env.PATH }));',",
174
+ " 'process.exit(0);',",
175
+ " '',",
176
+ "].join('\\n'));",
177
+ "chmodSync(target, 0o755);",
178
+ "process.exit(0);",
179
+ "",
180
+ ].join("\n"),
181
+ );
182
+ chmodSync(path, 0o755);
183
+ }
184
+
185
+ // Test/development-only stub for setup's self-heal installer seam that
186
+ // deliberately never creates the binary, simulating a real installer run
187
+ // that failed (e.g. a network or verification failure) — setup's retry
188
+ // check must still find the binary missing afterward.
189
+ function writeInstallerScriptThatFails(path: string) {
190
+ writeFileSync(path, ["#!/usr/bin/env node", "process.stderr.write('simulated installer failure\\n');", "process.exit(1);", ""].join("\n"));
191
+ chmodSync(path, 0o755);
192
+ }
193
+
194
+ // Test/development-only stub for the setup subcommand's gentle-ai binary,
195
+ // simulating the real gentle-ai's managed Pi stack declaring the conflicting
196
+ // npm:@juicesharp/rpiv-ask-user-question package (gentle-ai #4820,
197
+ // gentle-shell #1277) into the provisioned home's settings.json, the same
198
+ // file the isolated-home bootstrap already created before `setup` spawns
199
+ // this stub. Its own stdout line ends with "\n" so a test can tell it apart
200
+ // from a later `pi remove` line on the same inherited stdout.
201
+ function writeGentleAiScriptDeclaringConflict(path: string, exitCode = 0) {
202
+ writeFileSync(
203
+ path,
204
+ [
205
+ "#!/usr/bin/env node",
206
+ "import { readFileSync, writeFileSync } from 'node:fs';",
207
+ "import { join } from 'node:path';",
208
+ "const args = process.argv.slice(2);",
209
+ "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
210
+ "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
211
+ "settings.packages = ['npm:@juicesharp/rpiv-ask-user-question@1.2.3'];",
212
+ "writeFileSync(settingsPath, JSON.stringify(settings));",
213
+ "process.stdout.write(JSON.stringify({ args }) + '\\n');",
214
+ `process.exit(${exitCode});`,
215
+ "",
216
+ ].join("\n"),
217
+ );
218
+ chmodSync(path, 0o755);
219
+ }
220
+
221
+ // Test/development-only stub for the setup subcommand's gentle-ai binary
222
+ // that also rewrites the provisioned home's own settings.json `theme` field,
223
+ // simulating gentle-ai's managed Pi install writing its own theme into
224
+ // settings.json — the case gentle-shell's own theme-restore logic
225
+ // (bin/gentle-shell.mjs's runSetupFlow, enforceDefaultThemeField) must undo.
226
+ function writeGentleAiScriptSettingTheme(path: string, theme: string, exitCode = 0) {
227
+ writeFileSync(
228
+ path,
229
+ [
230
+ "#!/usr/bin/env node",
231
+ "import { readFileSync, writeFileSync } from 'node:fs';",
232
+ "import { join } from 'node:path';",
233
+ "const args = process.argv.slice(2);",
234
+ "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
235
+ "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
236
+ `settings.theme = ${JSON.stringify(theme)};`,
237
+ "writeFileSync(settingsPath, JSON.stringify(settings));",
238
+ "process.stdout.write(JSON.stringify({ args }) + '\\n');",
239
+ `process.exit(${exitCode});`,
240
+ "",
241
+ ].join("\n"),
242
+ );
243
+ chmodSync(path, 0o755);
244
+ }
245
+
246
+ // Test/development-only stub for the setup subcommand's gentle-ai binary,
247
+ // simulating the real gentle-ai's managed Pi stack always declaring
248
+ // npm:gentle-pi itself into the provisioned home's settings.json (the
249
+ // problem this fix addresses: the home must never keep running that
250
+ // declared copy instead of the launcher's own). `extraSources` lets a test
251
+ // also declare the conflicting rpiv package alongside it, in the given
252
+ // order, to prove both post-install removals run together.
253
+ function writeGentleAiScriptDeclaringGentlePi(path: string, extraSources: string[] = [], exitCode = 0) {
254
+ writeFileSync(
255
+ path,
256
+ [
257
+ "#!/usr/bin/env node",
258
+ "import { readFileSync, writeFileSync } from 'node:fs';",
259
+ "import { join } from 'node:path';",
260
+ "const args = process.argv.slice(2);",
261
+ "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
262
+ "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
263
+ `settings.packages = [...${JSON.stringify(extraSources)}, 'npm:gentle-pi@3.5.1'];`,
264
+ "writeFileSync(settingsPath, JSON.stringify(settings));",
265
+ "process.stdout.write(JSON.stringify({ args }) + '\\n');",
266
+ `process.exit(${exitCode});`,
267
+ "",
268
+ ].join("\n"),
269
+ );
270
+ chmodSync(path, 0o755);
271
+ }
272
+
273
+ // Test/development-only fake pi runtime whose `remove <source>` branch also
274
+ // rewrites the target home's settings.json, dropping the matching `packages`
275
+ // entry — unlike the plain `writePiScript` stub above (which only records
276
+ // the invocation), this models what a real `pi remove` does to settings.json
277
+ // closely enough to prove a later plain launch sees the declaration gone and
278
+ // resumes the launcher's own injection.
279
+ function writePiScriptEditingSettingsOnRemove(path: string, version = "0.85.1") {
280
+ writeFileSync(
281
+ path,
282
+ [
283
+ "#!/usr/bin/env node",
284
+ "import { readFileSync, writeFileSync } from 'node:fs';",
285
+ "import { join } from 'node:path';",
286
+ "const args = process.argv.slice(2);",
287
+ `if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`,
288
+ "if (args[0] === 'remove') {",
289
+ " const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
290
+ " const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
291
+ " const target = args[1];",
292
+ " settings.packages = (settings.packages || []).filter((entry) => {",
293
+ " const source = typeof entry === 'string' ? entry : entry.source;",
294
+ " return source !== target && !source.startsWith(target + '@');",
295
+ " });",
296
+ " writeFileSync(settingsPath, JSON.stringify(settings));",
297
+ " console.log(JSON.stringify({",
298
+ " args,",
299
+ " PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
300
+ " GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
301
+ " PATH: process.env.PATH,",
302
+ " }));",
303
+ " process.exit(0);",
304
+ "}",
305
+ "console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
306
+ "process.exit(0);",
307
+ "",
308
+ ].join("\n"),
309
+ );
310
+ chmodSync(path, 0o755);
311
+ }
312
+
313
+ // Test/development-only stub for the setup subcommand's gentle-ai binary
314
+ // that also rewrites the shared Pi persona file at
315
+ // `$HOME/.pi/gentle-ai/persona.json` to the default preset, exactly like the
316
+ // real gentle-ai does regardless of `PI_CODING_AGENT_DIR` (gentle-ai's
317
+ // `PiPersonaConfigPath` always resolves against the OS home). Reads
318
+ // `homedir()` inside the child so it honors the fixture's isolated `HOME`,
319
+ // never `PI_CODING_AGENT_DIR` — that mismatch is exactly the bug the
320
+ // snapshot/restore wrap in `runSetupFlow` guards against.
321
+ function writeGentleAiScriptRewritingPersona(path: string, exitCode = 0) {
322
+ writeFileSync(
323
+ path,
324
+ [
325
+ "#!/usr/bin/env node",
326
+ "import { writeFileSync, mkdirSync } from 'node:fs';",
327
+ "import { homedir } from 'node:os';",
328
+ "import { join } from 'node:path';",
329
+ "const personaDir = join(homedir(), '.pi', 'gentle-ai');",
330
+ "mkdirSync(personaDir, { recursive: true });",
331
+ "writeFileSync(join(personaDir, 'persona.json'), JSON.stringify({ mode: 'gentleman' }));",
332
+ `process.exit(${exitCode});`,
333
+ "",
334
+ ].join("\n"),
335
+ );
336
+ chmodSync(path, 0o755);
337
+ }
338
+
339
+ // Test/development-only stub for the setup subcommand's gentle-ai binary
340
+ // that also rewrites the shared `$HOME/.gentle-ai/state.json` the way the
341
+ // real gentle-ai does on every install: it changes managed_asset_digest (the
342
+ // field this fix restores) and one unrelated field (installed_agents), so a
343
+ // test can prove the digest gets restored while the unrelated field is left
344
+ // exactly as the child wrote it. `create` controls whether it merges into an
345
+ // existing state.json or writes a brand-new one (for the "no pre-existing
346
+ // file" case, where the stub's file must be left alone entirely).
347
+ function writeGentleAiScriptRewritingManagedAssetDigest(path: string, opts: { digest?: string; create?: boolean } = {}, exitCode = 0) {
348
+ const digest = opts.digest ?? "digest-from-pinned-gentle-ai";
349
+ const create = opts.create ?? false;
350
+ writeFileSync(
351
+ path,
352
+ [
353
+ "#!/usr/bin/env node",
354
+ "import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';",
355
+ "import { homedir } from 'node:os';",
356
+ "import { join } from 'node:path';",
357
+ "const stateDir = join(homedir(), '.gentle-ai');",
358
+ "mkdirSync(stateDir, { recursive: true });",
359
+ "const statePath = join(stateDir, 'state.json');",
360
+ `const create = ${JSON.stringify(create)};`,
361
+ "const state = !create && existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : {};",
362
+ `state.managed_asset_digest = ${JSON.stringify(digest)};`,
363
+ "state.installed_agents = [...(state.installed_agents || []), 'claude'];",
364
+ "writeFileSync(statePath, JSON.stringify(state, null, 2) + '\\n');",
365
+ `process.exit(${exitCode});`,
366
+ "",
367
+ ].join("\n"),
368
+ );
369
+ chmodSync(path, 0o755);
370
+ }
371
+
60
372
  test("--help exits 0 and prints usage", (t) => {
61
373
  const f = fixture(t);
62
374
  const result = run(f.env, ["--help"]);
@@ -93,6 +405,26 @@ test("home <path> persists a custom directory", (t) => {
93
405
  assert.equal(check.stdout.trim(), `path ${target}`);
94
406
  });
95
407
 
408
+ // Test/development-only: GENTLE_SHELL_CONFIG overrides the launcher
409
+ // config.json path (normally <homedir>/.gentle-shell/config.json), so a test
410
+ // or a field run against the real HOME (for example the packed-artifact E2E
411
+ // script, which needs `--link` probes against the real pi home) never
412
+ // touches the real ~/.gentle-shell/config.json. Documented as
413
+ // test/development-only in docs/readme-reference.md.
414
+ test("GENTLE_SHELL_CONFIG redirects the config.json path used by 'home' and auto-provisioning", (t) => {
415
+ const f = fixture(t);
416
+ const overridePath = join(f.root, "alt-config.json");
417
+ const env = { ...f.env, GENTLE_SHELL_CONFIG: overridePath };
418
+
419
+ const save = run(env, ["home", "link"]);
420
+ assert.equal(save.status, 0, save.stderr);
421
+ assert.deepEqual(JSON.parse(readFileSync(overridePath, "utf8")), { home: "link" });
422
+ assert.equal(existsSync(join(f.home, ".gentle-shell", "config.json")), false);
423
+
424
+ const check = run(env, ["home"]);
425
+ assert.match(check.stdout, /^link /);
426
+ });
427
+
96
428
  test("first isolated run bootstraps the home, writes fullscreen, and prints the hint once", (t) => {
97
429
  const f = fixture(t);
98
430
  assert.equal(existsSync(f.gentleShellHome), false);
@@ -149,6 +481,74 @@ test("--link skips injection and leaves settings.json byte-identical when it alr
149
481
  assert.equal(existsSync(join(piAgentDir, ".gentle-shell")), false);
150
482
  });
151
483
 
484
+ // --- injection skip for isolated/--home homes once they declare gentle-pi (S3) ---
485
+ //
486
+ // `gentle-shell setup` installs npm:gentle-pi into the home's settings.json;
487
+ // once that declaration exists, isolated and --home homes must stop
488
+ // injecting the launcher's own copy on top of it, the same way --link
489
+ // already does. Homes without a declaration keep the plain injection.
490
+
491
+ test("an isolated home whose settings.json declares npm:gentle-pi gets no injection", (t) => {
492
+ const f = fixture(t);
493
+ mkdirSync(f.gentleShellHome, { recursive: true });
494
+ writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["npm:gentle-pi@3.5.1"], tuiMode: "fullscreen" }));
495
+
496
+ const result = run(f.env, ["--mode", "rpc"]);
497
+ assert.equal(result.status, 0, result.stderr);
498
+ const payload = JSON.parse(result.stdout);
499
+ assert.deepEqual(payload.args, ["--mode", "rpc"]);
500
+ assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
501
+ });
502
+
503
+ test("an isolated home whose settings.json does not declare gentle-pi keeps the plain injection", (t) => {
504
+ const f = fixture(t);
505
+ mkdirSync(f.gentleShellHome, { recursive: true });
506
+ writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
507
+
508
+ const result = run(f.env, ["--mode", "rpc"]);
509
+ assert.equal(result.status, 0, result.stderr);
510
+ const payload = JSON.parse(result.stdout);
511
+ assert.deepEqual(payload.args, [
512
+ "-e",
513
+ packageRoot,
514
+ "--theme",
515
+ join(packageRoot, "themes"),
516
+ "--skill",
517
+ join(packageRoot, "skills"),
518
+ "--prompt-template",
519
+ join(packageRoot, "prompts"),
520
+ "--mode",
521
+ "rpc",
522
+ ]);
523
+ });
524
+
525
+ test("an isolated home whose settings.json declares a path-based gentle-pi takes over, same as --link", (t) => {
526
+ const f = fixture(t);
527
+ mkdirSync(f.gentleShellHome, { recursive: true });
528
+ const otherGentlePiDir = join(f.root, "other-gentle-pi");
529
+ mkdirSync(otherGentlePiDir, { recursive: true });
530
+ writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
531
+ writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"], tuiMode: "fullscreen" }));
532
+
533
+ const result = run(f.env, ["--mode", "rpc"], { cwd: f.root });
534
+ assert.equal(result.status, 0, result.stderr);
535
+ assert.match(result.stderr, /taking over gentle-pi from/);
536
+ const payload = JSON.parse(result.stdout);
537
+ assert.deepEqual(payload.args, [
538
+ "--no-extensions",
539
+ "-e",
540
+ packageRoot,
541
+ "--theme",
542
+ join(packageRoot, "themes"),
543
+ "--skill",
544
+ join(packageRoot, "skills"),
545
+ "--prompt-template",
546
+ join(packageRoot, "prompts"),
547
+ "--mode",
548
+ "rpc",
549
+ ]);
550
+ });
551
+
152
552
  test("gentle-shell list forwards to pi as a bare subcommand, with no injected extension flags", (t) => {
153
553
  const f = fixture(t);
154
554
  const result = run(f.env, ["list"]);
@@ -187,6 +587,1341 @@ test("--version prints three lines", (t) => {
187
587
  assert.match(lines[2], /^home isolated /);
188
588
  });
189
589
 
590
+ // --- setup subcommand ------------------------------------------------------
591
+
592
+ test("gentle-shell setup provisions the resolved home through the pinned gentle-ai binary", (t) => {
593
+ const f = fixture(t);
594
+ assert.equal(existsSync(f.gentleShellHome), false);
595
+
596
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
597
+ writeGentleAiScript(gentleAiScript);
598
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
599
+
600
+ const result = run(env, ["setup"]);
601
+ assert.equal(result.status, 0, result.stderr);
602
+ assert.match(result.stderr, /provisioning/);
603
+ assert.match(result.stderr, new RegExp(f.gentleShellHome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
604
+
605
+ const payload = JSON.parse(result.stdout);
606
+ assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
607
+ assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
608
+ assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
609
+ assert.ok(payload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), payload.PATH);
610
+
611
+ // Home resolution runs exactly as a normal run: the isolated home gets
612
+ // created with its bootstrap TUI setting, same as a plain `gentle-shell`.
613
+ const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
614
+ assert.equal(settings.tuiMode, "fullscreen");
615
+ });
616
+
617
+ test("gentle-shell setup forwards --dry-run to gentle-ai", (t) => {
618
+ const f = fixture(t);
619
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
620
+ writeGentleAiScript(gentleAiScript);
621
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
622
+
623
+ const result = run(env, ["setup", "--dry-run"]);
624
+ assert.equal(result.status, 0, result.stderr);
625
+ const payload = JSON.parse(result.stdout);
626
+ assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global", "--dry-run"]);
627
+ });
628
+
629
+ test("gentle-shell setup accepts a home selector before it and provisions that home", (t) => {
630
+ const f = fixture(t);
631
+ const target = join(f.root, "custom-home");
632
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
633
+ writeGentleAiScript(gentleAiScript);
634
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
635
+
636
+ const result = run(env, ["--home", target, "setup"]);
637
+ assert.equal(result.status, 0, result.stderr);
638
+ const payload = JSON.parse(result.stdout);
639
+ assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
640
+ assert.equal(payload.PI_CODING_AGENT_DIR, target);
641
+ assert.equal(payload.GENTLE_PI_AGENT_HOME, target);
642
+ assert.equal(existsSync(join(target, "settings.json")), true);
643
+ });
644
+
645
+ test("gentle-shell setup passes through the gentle-ai exit code", (t) => {
646
+ const f = fixture(t);
647
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
648
+ writeGentleAiScript(gentleAiScript, 3);
649
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
650
+
651
+ const result = run(env, ["setup"]);
652
+ assert.equal(result.status, 3);
653
+ });
654
+
655
+ // --- setup subcommand's self-heal for a missing package-local binary -------
656
+ //
657
+ // `npm install -g <tarball>` on a machine whose npm config disables lifecycle
658
+ // scripts (`ignore-scripts=true`) never runs the package's own postinstall,
659
+ // so .gentle-ai/v<pin>/gentle-ai is missing even though the package itself
660
+ // installed fine. `gentle-shell setup` self-heals by running the installer
661
+ // in place before giving up. GENTLE_SHELL_GENTLE_AI_INSTALLER is a
662
+ // test/development-only override for the installer script path, next to
663
+ // GENTLE_SHELL_GENTLE_AI_BIN; documented as test/development-only in
664
+ // docs/readme-reference.md.
665
+
666
+ test("gentle-shell setup installs the missing package-local gentle-ai via the installer seam and then continues", (t) => {
667
+ const f = fixture(t);
668
+ const missingBinary = join(f.root, "does-not-exist", "gentle-ai");
669
+ const installerScript = join(f.root, "fake-installer-creates-binary.mjs");
670
+ writeInstallerScriptThatCreatesTheBinary(installerScript);
671
+ const env = {
672
+ ...f.env,
673
+ GENTLE_SHELL_GENTLE_AI_BIN: missingBinary,
674
+ GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript,
675
+ GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
676
+ };
677
+
678
+ const result = run(env, ["setup"]);
679
+ assert.equal(result.status, 0, result.stderr);
680
+ assert.match(
681
+ result.stderr,
682
+ /gentle-shell: the package-local gentle-ai v3\.6\.0 is missing \(npm lifecycle scripts may be disabled\); installing it now/,
683
+ );
684
+ const payload = JSON.parse(result.stdout);
685
+ assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
686
+ assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
687
+ assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
688
+ });
689
+
690
+ test("gentle-shell setup exits 1 with an actionable message when the pinned gentle-ai binary is still missing after the installer runs", (t) => {
691
+ const f = fixture(t);
692
+ const missingBinary = join(f.root, "does-not-exist", "gentle-ai");
693
+ const installerScript = join(f.root, "fake-installer-fails.mjs");
694
+ writeInstallerScriptThatFails(installerScript);
695
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: missingBinary, GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript };
696
+
697
+ const result = run(env, ["setup"]);
698
+ assert.equal(result.status, 1);
699
+ assert.match(result.stderr, /gentle-shell: the package-local gentle-ai v.+ is missing \(npm lifecycle scripts may be disabled\); installing it now/);
700
+ assert.match(result.stderr, /package-local-binary-missing/);
701
+ assert.match(result.stderr, new RegExp(missingBinary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
702
+ });
703
+
704
+ test("gentle-shell setup skips the self-heal install and exits 1 when GENTLE_PI_SKIP_GENTLE_AI_INSTALL is set", (t) => {
705
+ const f = fixture(t);
706
+ const missingBinary = join(f.root, "does-not-exist", "gentle-ai");
707
+ // This installer would prove itself by creating the binary if it ever ran;
708
+ // it must not run at all when the skip variable is set.
709
+ const installerScript = join(f.root, "installer-should-not-run.mjs");
710
+ writeInstallerScriptThatCreatesTheBinary(installerScript);
711
+ const env = {
712
+ ...f.env,
713
+ GENTLE_SHELL_GENTLE_AI_BIN: missingBinary,
714
+ GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript,
715
+ GENTLE_PI_SKIP_GENTLE_AI_INSTALL: "1",
716
+ };
717
+
718
+ const result = run(env, ["setup"]);
719
+ assert.equal(result.status, 1);
720
+ assert.match(result.stderr, /package-local-binary-missing/);
721
+ assert.match(result.stderr, /GENTLE_PI_SKIP_GENTLE_AI_INSTALL/);
722
+ assert.doesNotMatch(result.stderr, /installing it now/);
723
+ assert.equal(existsSync(missingBinary), false);
724
+ });
725
+
726
+ // --- setup subcommand's gentle-ai pin gate (R3/R4 advisory findings) -------
727
+ //
728
+ // `gentle-shell setup` points PI_CODING_AGENT_DIR at the resolved home
729
+ // before spawning the package-local pinned gentle-ai; only gentle-ai >=
730
+ // 3.6.0 honors that variable in its own provisioning. An older pin would
731
+ // silently provision the caller's real ~/.pi/agent instead, so setup must
732
+ // refuse to spawn it. GENTLE_SHELL_GENTLE_AI_PIN is a test/development-only
733
+ // override for the reported pin, next to GENTLE_SHELL_GENTLE_AI_BIN;
734
+ // documented as test/development-only in docs/readme-reference.md.
735
+
736
+ test("gentle-shell setup refuses to run an older-than-3.6.0 pin and spawns nothing", (t) => {
737
+ const f = fixture(t);
738
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
739
+ writeGentleAiScript(gentleAiScript);
740
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.5.0" };
741
+
742
+ const result = run(env, ["setup"]);
743
+ assert.equal(result.status, 1);
744
+ assert.match(result.stderr, /gentle-shell: setup needs the package-local gentle-ai v3\.6\.0 or newer \(pinned: 3\.5\.0\); this build cannot provision a home without touching ~\/\.pi\/agent/);
745
+ // The isolated-home bootstrap runs before command dispatch on every run
746
+ // (same as a successful setup); the pin gate only refuses to spawn
747
+ // gentle-ai, so no gentle-ai invocation is recorded on stdout.
748
+ assert.equal(result.stdout, "");
749
+ });
750
+
751
+ test("gentle-shell setup proceeds when the reported pin is exactly 3.6.0", (t) => {
752
+ const f = fixture(t);
753
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
754
+ writeGentleAiScript(gentleAiScript);
755
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" };
756
+
757
+ const result = run(env, ["setup"]);
758
+ assert.equal(result.status, 0, result.stderr);
759
+ const payload = JSON.parse(result.stdout);
760
+ assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
761
+ });
762
+
763
+ // --- setup subcommand conflict cleanup (gentle-ai #4820 / gentle-shell #1277) ---
764
+ //
765
+ // gentle-ai's managed Pi stack still installs npm:@juicesharp/rpiv-ask-user-question,
766
+ // which conflicts with gentle-pi's own first-party ask_user_question tool
767
+ // (Pi refuses two providers for the same tool name). Until the gentle-ai fix
768
+ // lands, `gentle-shell setup` removes the conflicting package itself once it
769
+ // finds gentle-ai declared it in the provisioned home's settings.json.
770
+
771
+ test("gentle-shell setup removes the conflicting ask-user-question package gentle-ai declared", (t) => {
772
+ const f = fixture(t);
773
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
774
+ writeGentleAiScriptDeclaringConflict(gentleAiScript);
775
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
776
+
777
+ const result = run(env, ["setup"]);
778
+ assert.equal(result.status, 0, result.stderr);
779
+ assert.match(
780
+ result.stderr,
781
+ /gentle-shell: removing npm:@juicesharp\/rpiv-ask-user-question from .+: gentle-pi ships ask_user_question and Pi refuses two providers \(gentle-ai #4820\)/,
782
+ );
783
+
784
+ const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
785
+ assert.equal(lines.length, 2, result.stdout);
786
+ assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]);
787
+ const removePayload = JSON.parse(lines[1]);
788
+ assert.deepEqual(removePayload.args, ["remove", "npm:@juicesharp/rpiv-ask-user-question"]);
789
+ assert.equal(removePayload.PI_CODING_AGENT_DIR, f.gentleShellHome);
790
+ assert.equal(removePayload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
791
+ assert.ok(removePayload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), removePayload.PATH);
792
+ });
793
+
794
+ test("gentle-shell setup runs no pi remove when gentle-ai did not declare the conflicting package", (t) => {
795
+ const f = fixture(t);
796
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
797
+ writeGentleAiScript(gentleAiScript);
798
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
799
+
800
+ const result = run(env, ["setup"]);
801
+ assert.equal(result.status, 0, result.stderr);
802
+ assert.doesNotMatch(result.stderr, /removing npm:/);
803
+ const payload = JSON.parse(result.stdout);
804
+ assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
805
+ });
806
+
807
+ // --- setup subcommand keeps the home on the launcher's own gentle-pi -------
808
+ //
809
+ // gentle-ai's managed Pi stack always declares npm:gentle-pi itself into the
810
+ // provisioned home's settings.json. That declaration must never survive
811
+ // setup: this launcher always loads its own gentle-pi, so leaving it in
812
+ // place would let the home drift onto whatever gentle-pi npm last installed
813
+ // (or, worse, onto the published npm package instead of a developer's source
814
+ // checkout) instead of the running launcher's own copy.
815
+
816
+ test("gentle-shell setup removes npm:gentle-pi that gentle-ai declared, naming this launcher's own version", (t) => {
817
+ const f = fixture(t);
818
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
819
+ writeGentleAiScriptDeclaringGentlePi(gentleAiScript);
820
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
821
+
822
+ const result = run(env, ["setup"]);
823
+ assert.equal(result.status, 0, result.stderr);
824
+ assert.match(
825
+ result.stderr,
826
+ new RegExp(
827
+ `gentle-shell: removing npm:gentle-pi from .+: this launcher loads its own gentle-pi ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}, so the home always matches it`,
828
+ ),
829
+ );
830
+
831
+ const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
832
+ assert.equal(lines.length, 2, result.stdout);
833
+ assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]);
834
+ const removePayload = JSON.parse(lines[1]);
835
+ assert.deepEqual(removePayload.args, ["remove", "npm:gentle-pi"]);
836
+ assert.equal(removePayload.PI_CODING_AGENT_DIR, f.gentleShellHome);
837
+ assert.equal(removePayload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
838
+ assert.ok(removePayload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), removePayload.PATH);
839
+ });
840
+
841
+ test("gentle-shell setup removes both the conflicting rpiv package and npm:gentle-pi, in declaration order", (t) => {
842
+ const f = fixture(t);
843
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
844
+ writeGentleAiScriptDeclaringGentlePi(gentleAiScript, ["npm:@juicesharp/rpiv-ask-user-question@1.2.3"]);
845
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
846
+
847
+ const result = run(env, ["setup"]);
848
+ assert.equal(result.status, 0, result.stderr);
849
+
850
+ const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
851
+ assert.equal(lines.length, 3, result.stdout);
852
+ assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]);
853
+ assert.deepEqual(JSON.parse(lines[1]).args, ["remove", "npm:@juicesharp/rpiv-ask-user-question"]);
854
+ assert.deepEqual(JSON.parse(lines[2]).args, ["remove", "npm:gentle-pi"]);
855
+ });
856
+
857
+ // A --dry-run gentle-ai install writes nothing, so reading settings.json
858
+ // afterwards would only ever report whatever pre-existed the run (e.g. the
859
+ // isolated-home bootstrap this fixture's fake-gentle-ai script never
860
+ // touches), never what the skipped install would have declared. Setup must
861
+ // therefore print the pending-removal message unconditionally, without
862
+ // reading settings.json — proven here with a fresh home that never declares
863
+ // the conflicting package at all.
864
+ test("gentle-shell setup --dry-run prints the pending removal unconditionally, without reading settings.json", (t) => {
865
+ const f = fixture(t);
866
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
867
+ writeGentleAiScript(gentleAiScript);
868
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
869
+
870
+ const result = run(env, ["setup", "--dry-run"]);
871
+ assert.equal(result.status, 0, result.stderr);
872
+ assert.match(
873
+ result.stderr,
874
+ /gentle-shell: setup would then remove npm:@juicesharp\/rpiv-ask-user-question if the install declares it \(gentle-ai #4820\)/,
875
+ );
876
+ assert.match(
877
+ result.stderr,
878
+ new RegExp(
879
+ `gentle-shell: setup would then remove npm:gentle-pi if the install declares it: this launcher loads its own gentle-pi ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}, so the home always matches it`,
880
+ ),
881
+ );
882
+ assert.doesNotMatch(result.stderr, /gentle-shell: removing/);
883
+
884
+ const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
885
+ assert.equal(lines.length, 1, result.stdout);
886
+ assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global", "--dry-run"]);
887
+ });
888
+
889
+ test("a later normal launch injects the launcher's own package root once gentle-pi is removed from settings.json", (t) => {
890
+ const f = fixture(t);
891
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
892
+ writeGentleAiScriptDeclaringGentlePi(gentleAiScript);
893
+ writePiScriptEditingSettingsOnRemove(f.piScript);
894
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
895
+
896
+ const setupResult = run(env, ["setup"]);
897
+ assert.equal(setupResult.status, 0, setupResult.stderr);
898
+ const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
899
+ assert.deepEqual(settings.packages, []);
900
+
901
+ const result = run(env, ["--mode", "rpc"]);
902
+ assert.equal(result.status, 0, result.stderr);
903
+ const payload = JSON.parse(result.stdout);
904
+ assert.deepEqual(payload.args, [
905
+ "-e",
906
+ packageRoot,
907
+ "--theme",
908
+ join(packageRoot, "themes"),
909
+ "--skill",
910
+ join(packageRoot, "skills"),
911
+ "--prompt-template",
912
+ join(packageRoot, "prompts"),
913
+ "--mode",
914
+ "rpc",
915
+ ]);
916
+ });
917
+
918
+ test("gentle-shell setup propagates a non-zero pi remove exit code with an actionable message", (t) => {
919
+ const f = fixture(t);
920
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
921
+ writeGentleAiScriptDeclaringConflict(gentleAiScript);
922
+ const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
923
+ writePiScript(failingPiScript, "0.85.1", 7);
924
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
925
+
926
+ const result = run(env, ["setup"]);
927
+ assert.equal(result.status, 7);
928
+ assert.match(
929
+ result.stderr,
930
+ /gentle-shell: could not remove npm:@juicesharp\/rpiv-ask-user-question; run `gentle-shell remove npm:@juicesharp\/rpiv-ask-user-question` before starting/,
931
+ );
932
+ });
933
+
934
+ // The remediation message must include whatever home selector setup was run
935
+ // with, so a copy-pasted `gentle-shell remove <source>` doesn't silently
936
+ // fall back to the isolated default and miss the package in the home the
937
+ // user actually set up (gentle-shell #1277 follow-up).
938
+ test("gentle-shell setup --home <dir> includes --home <dir> in the failing-removal remediation command", (t) => {
939
+ const f = fixture(t);
940
+ const target = join(f.root, "custom-home");
941
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
942
+ writeGentleAiScriptDeclaringConflict(gentleAiScript);
943
+ const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
944
+ writePiScript(failingPiScript, "0.85.1", 7);
945
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
946
+
947
+ const result = run(env, ["--home", target, "setup"]);
948
+ assert.equal(result.status, 7);
949
+ assert.match(
950
+ result.stderr,
951
+ new RegExp(
952
+ `gentle-shell: could not remove npm:@juicesharp/rpiv-ask-user-question; run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} remove npm:@juicesharp/rpiv-ask-user-question\` before starting`,
953
+ ),
954
+ );
955
+ });
956
+
957
+ // A --home path containing a space (or another shell metacharacter) must be
958
+ // single-quoted in the remediation command, or a copy-pasted
959
+ // `gentle-shell --home <dir> remove <source>` silently splits into extra
960
+ // shell words instead of naming the actual home setup provisioned
961
+ // (gentle-shell #1277 follow-up).
962
+ test("gentle-shell setup shell-quotes a --home path containing a space in the failing-removal remediation command", (t) => {
963
+ const f = fixture(t);
964
+ const target = join(f.root, "custom home");
965
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
966
+ writeGentleAiScriptDeclaringConflict(gentleAiScript);
967
+ const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
968
+ writePiScript(failingPiScript, "0.85.1", 7);
969
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
970
+
971
+ const result = run(env, ["--home", target, "setup"]);
972
+ assert.equal(result.status, 7);
973
+ assert.match(
974
+ result.stderr,
975
+ new RegExp(
976
+ `gentle-shell: could not remove npm:@juicesharp/rpiv-ask-user-question; run \`gentle-shell --home '${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}' remove npm:@juicesharp/rpiv-ask-user-question\` before starting`,
977
+ ),
978
+ );
979
+ });
980
+
981
+ // --- persona snapshot/restore -----------------------------------------------
982
+ //
983
+ // gentle-ai's persona file always lives at the shared `~/.pi/gentle-ai/persona.json`
984
+ // regardless of `PI_CODING_AGENT_DIR` (see docs/readme-reference.md's setup
985
+ // "Known limitation"), so `setup` snapshots it before spawning gentle-ai and
986
+ // restores it afterward — in every mode gentle-ai gets spawned in: manual,
987
+ // `--dry-run`, and automatic first-run provisioning.
988
+
989
+ function personaPathFor(f: { home: string }) {
990
+ return join(f.home, ".pi", "gentle-ai", "persona.json");
991
+ }
992
+
993
+ test("gentle-shell setup restores the user's Pi persona file when gentle-ai rewrites it", (t) => {
994
+ const f = fixture(t);
995
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
996
+ writeGentleAiScriptRewritingPersona(gentleAiScript);
997
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
998
+
999
+ const personaPath = personaPathFor(f);
1000
+ mkdirSync(dirname(personaPath), { recursive: true });
1001
+ const originalBytes = JSON.stringify({ mode: "neutral" });
1002
+ writeFileSync(personaPath, originalBytes);
1003
+ chmodSync(personaPath, 0o600);
1004
+
1005
+ const result = run(env, ["setup"]);
1006
+ assert.equal(result.status, 0, result.stderr);
1007
+ assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
1008
+ assert.equal(statSync(personaPath).mode & 0o777, 0o600);
1009
+ assert.match(
1010
+ result.stderr,
1011
+ new RegExp(
1012
+ `gentle-shell: kept your Pi persona unchanged \\(gentle-ai rewrote ${personaPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}; tracked upstream\\)`,
1013
+ ),
1014
+ );
1015
+ });
1016
+
1017
+ test("gentle-shell setup removes a Pi persona file gentle-ai created where none existed", (t) => {
1018
+ const f = fixture(t);
1019
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1020
+ writeGentleAiScriptRewritingPersona(gentleAiScript);
1021
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1022
+
1023
+ const personaPath = personaPathFor(f);
1024
+ assert.equal(existsSync(personaPath), false);
1025
+
1026
+ const result = run(env, ["setup"]);
1027
+ assert.equal(result.status, 0, result.stderr);
1028
+ assert.equal(existsSync(personaPath), false);
1029
+ assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
1030
+ });
1031
+
1032
+ test("gentle-shell setup prints no persona notice when gentle-ai leaves the persona file alone", (t) => {
1033
+ const f = fixture(t);
1034
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1035
+ writeGentleAiScript(gentleAiScript);
1036
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1037
+
1038
+ const personaPath = personaPathFor(f);
1039
+ mkdirSync(dirname(personaPath), { recursive: true });
1040
+ writeFileSync(personaPath, JSON.stringify({ mode: "neutral" }));
1041
+
1042
+ const result = run(env, ["setup"]);
1043
+ assert.equal(result.status, 0, result.stderr);
1044
+ assert.doesNotMatch(result.stderr, /kept your Pi persona unchanged/);
1045
+ });
1046
+
1047
+ test("gentle-shell setup restores the persona file even when gentle-ai exits non-zero after rewriting it", (t) => {
1048
+ const f = fixture(t);
1049
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1050
+ writeGentleAiScriptRewritingPersona(gentleAiScript, 3);
1051
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1052
+
1053
+ const personaPath = personaPathFor(f);
1054
+ mkdirSync(dirname(personaPath), { recursive: true });
1055
+ const originalBytes = JSON.stringify({ mode: "neutral" });
1056
+ writeFileSync(personaPath, originalBytes);
1057
+
1058
+ const result = run(env, ["setup"]);
1059
+ assert.equal(result.status, 3);
1060
+ assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
1061
+ assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
1062
+ });
1063
+
1064
+ test("gentle-shell setup --dry-run also restores the persona file gentle-ai rewrites", (t) => {
1065
+ const f = fixture(t);
1066
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1067
+ writeGentleAiScriptRewritingPersona(gentleAiScript);
1068
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1069
+
1070
+ const personaPath = personaPathFor(f);
1071
+ mkdirSync(dirname(personaPath), { recursive: true });
1072
+ const originalBytes = JSON.stringify({ mode: "neutral" });
1073
+ writeFileSync(personaPath, originalBytes);
1074
+
1075
+ const result = run(env, ["setup", "--dry-run"]);
1076
+ assert.equal(result.status, 0, result.stderr);
1077
+ assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
1078
+ assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
1079
+ });
1080
+
1081
+ test("automatic first-run provisioning also restores the persona file gentle-ai rewrites", (t) => {
1082
+ const f = fixture(t);
1083
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1084
+ writeGentleAiScriptRewritingPersona(gentleAiScript);
1085
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1086
+
1087
+ const personaPath = personaPathFor(f);
1088
+ mkdirSync(dirname(personaPath), { recursive: true });
1089
+ const originalBytes = JSON.stringify({ mode: "neutral" });
1090
+ writeFileSync(personaPath, originalBytes);
1091
+
1092
+ const result = run(env, []);
1093
+ assert.equal(result.status, 0, result.stderr);
1094
+ assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
1095
+ assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
1096
+ });
1097
+
1098
+ // --- managed-asset digest snapshot/restore ----------------------------------
1099
+ //
1100
+ // gentle-ai tracks whether its own binary's managed-asset bundle matches the
1101
+ // shared `~/.gentle-ai/state.json`'s managed_asset_digest field, regardless
1102
+ // of which home it was installing into. The pinned package-local gentle-ai
1103
+ // this setup flow spawns writes its own digest into that same shared file,
1104
+ // so afterward the user's own (unrelated, on-PATH) gentle-ai reports its
1105
+ // managed assets as outdated and demands `gentle-ai sync`, even though
1106
+ // nothing about the user's install changed. `setup` restores just that field
1107
+ // afterward — never the whole file, unlike persona.json, since state.json
1108
+ // also carries fields (like installed_agents) the pinned gentle-ai is
1109
+ // supposed to update.
1110
+
1111
+ function stateJsonPathFor(f: { home: string }) {
1112
+ return join(f.home, ".gentle-ai", "state.json");
1113
+ }
1114
+
1115
+ test("gentle-shell setup restores managed_asset_digest while keeping other state.json fields as the child left them", (t) => {
1116
+ const f = fixture(t);
1117
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1118
+ writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" });
1119
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1120
+
1121
+ const statePath = stateJsonPathFor(f);
1122
+ mkdirSync(dirname(statePath), { recursive: true });
1123
+ writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`);
1124
+
1125
+ const result = run(env, ["setup"]);
1126
+ assert.equal(result.status, 0, result.stderr);
1127
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
1128
+ assert.equal(state.managed_asset_digest, "users-own-digest");
1129
+ assert.deepEqual(state.installed_agents, ["pi", "claude"]);
1130
+ assert.match(
1131
+ result.stderr,
1132
+ new RegExp(
1133
+ `gentle-shell: kept your Gentle AI managed-asset record unchanged \\(the pinned gentle-ai rewrote ${statePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}; tracked upstream\\)`,
1134
+ ),
1135
+ );
1136
+ });
1137
+
1138
+ test("gentle-shell setup prints no managed-asset notice when the digest is unchanged", (t) => {
1139
+ const f = fixture(t);
1140
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1141
+ writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "same-digest" });
1142
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1143
+
1144
+ const statePath = stateJsonPathFor(f);
1145
+ mkdirSync(dirname(statePath), { recursive: true });
1146
+ writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "same-digest", installed_agents: ["pi"] }, null, 2)}\n`);
1147
+
1148
+ const result = run(env, ["setup"]);
1149
+ assert.equal(result.status, 0, result.stderr);
1150
+ assert.doesNotMatch(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
1151
+ });
1152
+
1153
+ test("gentle-shell setup leaves a state.json the pinned gentle-ai created where none existed before", (t) => {
1154
+ const f = fixture(t);
1155
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1156
+ writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai", create: true });
1157
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1158
+
1159
+ const statePath = stateJsonPathFor(f);
1160
+ assert.equal(existsSync(statePath), false);
1161
+
1162
+ const result = run(env, ["setup"]);
1163
+ assert.equal(result.status, 0, result.stderr);
1164
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
1165
+ assert.equal(state.managed_asset_digest, "digest-from-pinned-gentle-ai");
1166
+ assert.doesNotMatch(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
1167
+ });
1168
+
1169
+ test("gentle-shell setup --dry-run also restores managed_asset_digest gentle-ai rewrites", (t) => {
1170
+ const f = fixture(t);
1171
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1172
+ writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" });
1173
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
1174
+
1175
+ const statePath = stateJsonPathFor(f);
1176
+ mkdirSync(dirname(statePath), { recursive: true });
1177
+ writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`);
1178
+
1179
+ const result = run(env, ["setup", "--dry-run"]);
1180
+ assert.equal(result.status, 0, result.stderr);
1181
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
1182
+ assert.equal(state.managed_asset_digest, "users-own-digest");
1183
+ assert.match(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
1184
+ });
1185
+
1186
+ test("automatic first-run provisioning also restores managed_asset_digest gentle-ai rewrites", (t) => {
1187
+ const f = fixture(t);
1188
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1189
+ writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" });
1190
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1191
+
1192
+ const statePath = stateJsonPathFor(f);
1193
+ mkdirSync(dirname(statePath), { recursive: true });
1194
+ writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`);
1195
+
1196
+ const result = run(env, []);
1197
+ assert.equal(result.status, 0, result.stderr);
1198
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
1199
+ assert.equal(state.managed_asset_digest, "users-own-digest");
1200
+ assert.match(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
1201
+ });
1202
+
1203
+ // --- automatic first-run provisioning (S7) ---------------------------------
1204
+ //
1205
+ // A plain `gentle-shell` in an isolated or `--home` home now runs the same
1206
+ // flow as `gentle-shell setup` automatically, before launching pi, when the
1207
+ // home has never been provisioned or was provisioned with a different
1208
+ // gentle-ai pin. GENTLE_SHELL_GENTLE_AI_PIN pins the reported pin to a fixed
1209
+ // value so these tests are independent of the real installed pin.
1210
+
1211
+ test("first launch auto-provisions the isolated home, writes the marker, then launches pi; stdout carries only pi's output", (t) => {
1212
+ const f = fixture(t);
1213
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1214
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1215
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1216
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1217
+
1218
+ const result = run(env, ["--mode", "rpc", "-p", "hi"]);
1219
+ assert.equal(result.status, 0, result.stderr);
1220
+
1221
+ // The flow ran exactly once, before pi.
1222
+ const runs = readFileSync(counterPath, "utf8").trim().split("\n");
1223
+ assert.equal(runs.length, 1, counterPath);
1224
+ assert.deepEqual(JSON.parse(runs[0]), ["install", "--agent", "pi", "--scope", "global"]);
1225
+
1226
+ // stdout carries only the final pi invocation's own output (the flow's
1227
+ // stdout and stderr, and the launcher's own notices, all went to stderr).
1228
+ const stdoutLines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
1229
+ assert.equal(stdoutLines.length, 1, result.stdout);
1230
+ const payload = JSON.parse(stdoutLines[0]);
1231
+ assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
1232
+
1233
+ assert.match(result.stderr, /gentle-shell: first run in .+: installing the Gentle AI companion packages \(one time; set GENTLE_SHELL_NO_AUTO_SETUP=1 to skip\)/);
1234
+ assert.match(result.stderr, new RegExp(JSON.stringify({ args: ["install", "--agent", "pi", "--scope", "global"], PI_CODING_AGENT_DIR: f.gentleShellHome }).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
1235
+
1236
+ const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8"));
1237
+ const homeKey = realpathSync(f.gentleShellHome);
1238
+ assert.equal(config.provisioned[homeKey].gentleAi, "3.6.0");
1239
+ assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
1240
+ assert.equal(typeof config.provisioned[homeKey].at, "string");
1241
+ assert.equal(existsSync(join(f.gentleShellHome, ".gentle-shell-setup.lock")), false);
1242
+ });
1243
+
1244
+ test("a second launch against an already-provisioned home skips the flow entirely", (t) => {
1245
+ const f = fixture(t);
1246
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1247
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1248
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1249
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1250
+
1251
+ const first = run(env, []);
1252
+ assert.equal(first.status, 0, first.stderr);
1253
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1);
1254
+
1255
+ const second = run(env, []);
1256
+ assert.equal(second.status, 0, second.stderr);
1257
+ assert.doesNotMatch(second.stderr, /gentle-shell: first run in/);
1258
+ assert.doesNotMatch(second.stderr, /gentle-ai pin changed/);
1259
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "gentle-ai must not run a second time");
1260
+ });
1261
+
1262
+ test("a changed gentle-ai pin re-runs the flow and updates the marker", (t) => {
1263
+ const f = fixture(t);
1264
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1265
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1266
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1267
+ const baseEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript });
1268
+
1269
+ const first = run({ ...baseEnv, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }, []);
1270
+ assert.equal(first.status, 0, first.stderr);
1271
+
1272
+ const second = run({ ...baseEnv, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.1" }, []);
1273
+ assert.equal(second.status, 0, second.stderr);
1274
+ assert.match(second.stderr, /gentle-shell: gentle-ai pin changed \(3\.6\.0 -> 3\.6\.1\): updating/);
1275
+
1276
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2);
1277
+ const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8"));
1278
+ const homeKey = realpathSync(f.gentleShellHome);
1279
+ assert.equal(config.provisioned[homeKey].gentleAi, "3.6.1");
1280
+ assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
1281
+ });
1282
+
1283
+ // A marker written before gentle-pi version tracking existed (S8) has no
1284
+ // `gentlePi` field: needsProvisioning must never trust that omission as a
1285
+ // match, so the next launch re-provisions and backfills the field.
1286
+ test("a marker without a gentlePi field re-runs the flow and backfills it", (t) => {
1287
+ const f = fixture(t);
1288
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1289
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1290
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1291
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1292
+
1293
+ const first = run(env, []);
1294
+ assert.equal(first.status, 0, first.stderr);
1295
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1);
1296
+
1297
+ const configPath = join(f.home, ".gentle-shell", "config.json");
1298
+ const homeKey = realpathSync(f.gentleShellHome);
1299
+ const staleConfig = JSON.parse(readFileSync(configPath, "utf8"));
1300
+ delete staleConfig.provisioned[homeKey].gentlePi;
1301
+ writeFileSync(configPath, JSON.stringify(staleConfig));
1302
+
1303
+ const second = run(env, []);
1304
+ assert.equal(second.status, 0, second.stderr);
1305
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2, "gentle-ai must re-run once the marker predates gentlePi tracking");
1306
+
1307
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
1308
+ assert.equal(config.provisioned[homeKey].gentleAi, "3.6.0");
1309
+ assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
1310
+ });
1311
+
1312
+ test("a changed gentle-pi version re-runs the flow and prints the gentle-pi-changed line", (t) => {
1313
+ const f = fixture(t);
1314
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1315
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1316
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1317
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1318
+
1319
+ const first = run(env, []);
1320
+ assert.equal(first.status, 0, first.stderr);
1321
+
1322
+ const configPath = join(f.home, ".gentle-shell", "config.json");
1323
+ const homeKey = realpathSync(f.gentleShellHome);
1324
+ const staleConfig = JSON.parse(readFileSync(configPath, "utf8"));
1325
+ staleConfig.provisioned[homeKey].gentlePi = "0.0.0-previous-launcher";
1326
+ writeFileSync(configPath, JSON.stringify(staleConfig));
1327
+
1328
+ const second = run(env, []);
1329
+ assert.equal(second.status, 0, second.stderr);
1330
+ assert.match(second.stderr, new RegExp(`gentle-shell: gentle-pi changed \\(0\\.0\\.0-previous-launcher -> ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\): updating`));
1331
+ assert.doesNotMatch(second.stderr, /gentle-ai pin changed/);
1332
+
1333
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2);
1334
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
1335
+ assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
1336
+ });
1337
+
1338
+ test("a failing auto-provision flow warns, still launches pi, and writes no marker", (t) => {
1339
+ const f = fixture(t);
1340
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1341
+ writeGentleAiScript(gentleAiScript, 5);
1342
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1343
+
1344
+ const result = run(env, ["--mode", "rpc"]);
1345
+ assert.equal(result.status, 0, result.stderr);
1346
+ assert.match(
1347
+ result.stderr,
1348
+ /gentle-shell: automatic setup failed \(exit 5\); starting anyway and retrying next run\. Run `gentle-shell setup` to see the full output\./,
1349
+ );
1350
+ // pi still launches, with today's plain injection (the home never got a
1351
+ // gentle-pi declaration since the flow failed).
1352
+ const payload = JSON.parse(result.stdout);
1353
+ assert.deepEqual(payload.args.slice(-2), ["--mode", "rpc"]);
1354
+
1355
+ const configPath = join(f.home, ".gentle-shell", "config.json");
1356
+ if (existsSync(configPath)) {
1357
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
1358
+ assert.equal(config.provisioned, undefined);
1359
+ }
1360
+ });
1361
+
1362
+ test("a failing auto-provision flow names the --home selector in its retry remediation", (t) => {
1363
+ const f = fixture(t);
1364
+ const target = join(f.root, "custom-home");
1365
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1366
+ writeGentleAiScript(gentleAiScript, 5);
1367
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1368
+
1369
+ const result = run(env, ["--home", target, "--mode", "rpc"]);
1370
+ assert.equal(result.status, 0, result.stderr);
1371
+ assert.match(
1372
+ result.stderr,
1373
+ new RegExp(`Run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} setup\` to see the full output\\.`),
1374
+ );
1375
+ });
1376
+
1377
+ test("GENTLE_SHELL_NO_AUTO_SETUP=1 skips auto-provisioning entirely", (t) => {
1378
+ const f = fixture(t);
1379
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1380
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1381
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1382
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
1383
+
1384
+ const result = run(env, []);
1385
+ assert.equal(result.status, 0, result.stderr);
1386
+ assert.equal(existsSync(counterPath), false);
1387
+ const configPath = join(f.home, ".gentle-shell", "config.json");
1388
+ assert.equal(existsSync(configPath), false);
1389
+ });
1390
+
1391
+ test("--link never auto-provisions", (t) => {
1392
+ const f = fixture(t);
1393
+ const piAgentDir = join(f.root, "pi-agent");
1394
+ mkdirSync(piAgentDir, { recursive: true });
1395
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1396
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1397
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1398
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, PI_CODING_AGENT_DIR: piAgentDir });
1399
+
1400
+ const result = run(env, ["--link", "--mode", "rpc"]);
1401
+ assert.equal(result.status, 0, result.stderr);
1402
+ assert.equal(existsSync(counterPath), false);
1403
+ assert.equal(existsSync(join(piAgentDir, ".gentle-shell-setup.lock")), false);
1404
+ });
1405
+
1406
+ test("a pi subcommand (list) never auto-provisions", (t) => {
1407
+ const f = fixture(t);
1408
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1409
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1410
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1411
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript });
1412
+
1413
+ const result = run(env, ["list"]);
1414
+ assert.equal(result.status, 0, result.stderr);
1415
+ assert.equal(existsSync(counterPath), false);
1416
+ const payload = JSON.parse(result.stdout);
1417
+ assert.deepEqual(payload.args, ["list"]);
1418
+ });
1419
+
1420
+ test("a fresh concurrent lock skips auto-provisioning for this run, without removing the lock", (t) => {
1421
+ const f = fixture(t);
1422
+ mkdirSync(f.gentleShellHome, { recursive: true });
1423
+ const lockPath = join(f.gentleShellHome, ".gentle-shell-setup.lock");
1424
+ writeFileSync(lockPath, "");
1425
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1426
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1427
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1428
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript });
1429
+
1430
+ const result = run(env, ["--mode", "rpc"]);
1431
+ assert.equal(result.status, 0, result.stderr);
1432
+ assert.equal(existsSync(counterPath), false);
1433
+ assert.match(result.stderr, /already provisioning/);
1434
+ assert.equal(existsSync(lockPath), true);
1435
+ });
1436
+
1437
+ test("a stale lock (older than 15 minutes) is removed and auto-provisioning proceeds", (t) => {
1438
+ const f = fixture(t);
1439
+ mkdirSync(f.gentleShellHome, { recursive: true });
1440
+ const lockPath = join(f.gentleShellHome, ".gentle-shell-setup.lock");
1441
+ writeFileSync(lockPath, "");
1442
+ const staleTime = new Date(Date.now() - 16 * 60 * 1000);
1443
+ utimesSync(lockPath, staleTime, staleTime);
1444
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1445
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1446
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1447
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1448
+
1449
+ const result = run(env, ["--mode", "rpc"]);
1450
+ assert.equal(result.status, 0, result.stderr);
1451
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1);
1452
+ assert.equal(existsSync(lockPath), false, "the lock must be released once the flow completes");
1453
+ });
1454
+
1455
+ // --- auto-provisioning only touches homes gentle-shell owns (R1-001) -------
1456
+
1457
+ test("a --home pointing at an existing non-empty, unmarked directory is never auto-provisioned", (t) => {
1458
+ const f = fixture(t);
1459
+ const target = join(f.root, "existing-pi-home");
1460
+ mkdirSync(target, { recursive: true });
1461
+ writeFileSync(join(target, "settings.json"), JSON.stringify({}));
1462
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1463
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1464
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1465
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1466
+
1467
+ const result = run(env, ["--home", target, "--mode", "rpc"]);
1468
+ assert.equal(result.status, 0, result.stderr);
1469
+ assert.equal(existsSync(counterPath), false, "gentle-ai must never run against a foreign, unmarked home");
1470
+ assert.match(
1471
+ result.stderr,
1472
+ new RegExp(`gentle-shell: ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} already has content and was not set up by gentle-shell`),
1473
+ );
1474
+ assert.match(result.stderr, new RegExp(`run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} setup\` to provision it`));
1475
+ });
1476
+
1477
+ test("an empty or nonexistent --home is still auto-provisioned", (t) => {
1478
+ const f = fixture(t);
1479
+ const target = join(f.root, "brand-new-home");
1480
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1481
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1482
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1483
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1484
+
1485
+ const result = run(env, ["--home", target, "--mode", "rpc"]);
1486
+ assert.equal(result.status, 0, result.stderr);
1487
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "a fresh --home must still be auto-provisioned");
1488
+ });
1489
+
1490
+ test("a --home equal to pi's own default agent home is never auto-provisioned, even when empty", (t) => {
1491
+ const f = fixture(t);
1492
+ const defaultPiHome = join(f.home, ".pi", "agent");
1493
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1494
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1495
+ writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
1496
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1497
+
1498
+ const result = run(env, ["--home", defaultPiHome, "--mode", "rpc"]);
1499
+ assert.equal(result.status, 0, result.stderr);
1500
+ assert.equal(existsSync(counterPath), false, "gentle-ai must never run against pi's own default agent home");
1501
+ assert.match(result.stderr, /never auto-provisions it/);
1502
+ });
1503
+
1504
+ // --- --home ownership marker and retry after a failed first attempt (R3-001) -----
1505
+
1506
+ test("a freshly bootstrapped isolated or --home directory gets a gentle-shell ownership marker file", (t) => {
1507
+ const f = fixture(t);
1508
+ const target = join(f.root, "marked-home");
1509
+ const result = run(f.env, ["--home", target, "--mode", "rpc"]);
1510
+ assert.equal(result.status, 0, result.stderr);
1511
+ const marker = JSON.parse(readFileSync(join(target, ".gentle-shell-home"), "utf8"));
1512
+ assert.equal(marker.createdBy, "gentle-shell");
1513
+ assert.equal(marker.version, ownGentlePiVersion());
1514
+ });
1515
+
1516
+ test("a --home directory gentle-shell itself bootstrapped is retried after its first auto-provision attempt fails", (t) => {
1517
+ const f = fixture(t);
1518
+ const target = join(f.root, "retry-home");
1519
+ const failingGentleAiScript = join(f.root, "fake-gentle-ai-fails.mjs");
1520
+ writeGentleAiScript(failingGentleAiScript, 1);
1521
+ const firstEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: failingGentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1522
+
1523
+ const first = run(firstEnv, ["--home", target, "--mode", "rpc"]);
1524
+ assert.equal(first.status, 0, first.stderr);
1525
+ assert.match(first.stderr, /gentle-shell: automatic setup failed/);
1526
+ assert.ok(existsSync(join(target, "settings.json")), "bootstrap must have seeded settings.json before the failed attempt");
1527
+ assert.ok(existsSync(join(target, ".gentle-shell-home")), "bootstrap must mark the home as gentle-shell-owned");
1528
+
1529
+ const counterPath = join(f.root, "gentle-ai-runs.log");
1530
+ const succeedingGentleAiScript = join(f.root, "fake-gentle-ai-succeeds.mjs");
1531
+ writeGentleAiScriptCountingRuns(succeedingGentleAiScript, counterPath);
1532
+ const secondEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: succeedingGentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1533
+
1534
+ const second = run(secondEnv, ["--home", target, "--mode", "rpc"]);
1535
+ assert.equal(second.status, 0, second.stderr);
1536
+ assert.doesNotMatch(second.stderr, /already has content and was not set up by gentle-shell/);
1537
+ assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "the retry must actually run gentle-ai against the previously-failed home");
1538
+ });
1539
+
1540
+ // --- auto-provisioning interrupts, resilience, timeouts, atomic writes -----
1541
+
1542
+ // Waits until `child`'s stderr has emitted a line matching `pattern`, so a
1543
+ // test can send a signal only once the flow has actually started (never
1544
+ // racing the signal against the child process not existing yet).
1545
+ function waitForStderrMatch(child, pattern) {
1546
+ return new Promise((resolve, reject) => {
1547
+ let buffer = "";
1548
+ const onData = (chunk) => {
1549
+ buffer += chunk.toString("utf8");
1550
+ if (pattern.test(buffer)) {
1551
+ child.stderr.off("data", onData);
1552
+ resolve(buffer);
1553
+ }
1554
+ };
1555
+ child.stderr.on("data", onData);
1556
+ child.once("error", reject);
1557
+ });
1558
+ }
1559
+
1560
+ function waitForExit(child: ReturnType<typeof spawn>): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
1561
+ return new Promise((resolve) => {
1562
+ child.once("exit", (code, signal) => resolve({ code, signal }));
1563
+ });
1564
+ }
1565
+
1566
+ function delay(ms) {
1567
+ return new Promise((resolve) => setTimeout(resolve, ms));
1568
+ }
1569
+
1570
+ // Unique token the stub gentle-ai below writes to its own stderr (relayed
1571
+ // through this launcher's inherited stdio, see buildSetupEnv's stdio wiring)
1572
+ // the instant it starts, before it sleeps. Waiting for this — instead of the
1573
+ // launcher's own "provisioning" stderr line, which is written before the
1574
+ // child is even spawned and proves nothing about the child or spawnAndWait's
1575
+ // signal-forwarding listeners being ready — is deterministic: it can only
1576
+ // appear once the child process actually exists and spawnAndWait has
1577
+ // resolved its Promise executor synchronously (spawn + signal handler
1578
+ // registration, no `await` in between), so no fixed settle delay is needed
1579
+ // before sending the real signal.
1580
+ const CHILD_READY_TOKEN = "GENTLE_SHELL_TEST_CHILD_READY";
1581
+
1582
+ test("SIGINT during automatic provisioning kills the child and exits 130 without launching pi", { timeout: 10000 }, async (t) => {
1583
+ const f = fixture(t);
1584
+ const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs");
1585
+ writeFileSync(
1586
+ gentleAiScript,
1587
+ ["#!/usr/bin/env node", `process.stderr.write(${JSON.stringify(CHILD_READY_TOKEN)} + "\\n");`, "await new Promise((resolve) => setTimeout(resolve, 20000));", "process.exit(0);", ""].join(
1588
+ "\n",
1589
+ ),
1590
+ );
1591
+ chmodSync(gentleAiScript, 0o755);
1592
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1593
+
1594
+ const child = spawn(process.execPath, [binPath, "--mode", "rpc", "-p", "hi"], { env, stdio: ["ignore", "pipe", "pipe"] });
1595
+ let stdout = "";
1596
+ child.stdout.on("data", (chunk) => {
1597
+ stdout += chunk.toString("utf8");
1598
+ });
1599
+ t.after(() => {
1600
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
1601
+ });
1602
+
1603
+ await waitForStderrMatch(child, new RegExp(CHILD_READY_TOKEN));
1604
+ child.kill("SIGINT");
1605
+ const { code, signal } = await waitForExit(child);
1606
+
1607
+ assert.equal(signal, null, "the launcher process itself must exit normally, not be killed by the signal");
1608
+ assert.equal(code, 130);
1609
+ assert.equal(stdout.trim(), "", "pi must never launch once an auto-provision spawn was interrupted");
1610
+ });
1611
+
1612
+ test("a child that dies by a signal on its own during automatic provisioning is an ordinary failure, not a launcher interrupt: pi still launches", (t) => {
1613
+ const f = fixture(t);
1614
+ const gentleAiScript = join(f.root, "fake-gentle-ai-self-kill.mjs");
1615
+ writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "process.kill(process.pid, 'SIGKILL');", ""].join("\n"));
1616
+ chmodSync(gentleAiScript, 0o755);
1617
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1618
+
1619
+ const result = run(env, ["--mode", "rpc", "-p", "hi"]);
1620
+ assert.equal(result.status, 0, result.stderr);
1621
+ assert.match(result.stderr, /gentle-shell: automatic setup failed/);
1622
+ const payload = JSON.parse(result.stdout);
1623
+ assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
1624
+ });
1625
+
1626
+ test(
1627
+ "an unexpected error writing the provisioning marker (e.g. an unwritable config directory) still launches pi",
1628
+ { skip: process.platform === "win32" ? "chmod-based write restriction is not portable to Windows" : false },
1629
+ (t) => {
1630
+ const f = fixture(t);
1631
+ // The config.json path itself does not exist yet (loadConfig's and
1632
+ // maybeAutoProvisionHome's own initial read both tolerate ENOENT, so
1633
+ // they succeed normally and reach the install); only its parent
1634
+ // directory is read+execute but not write, which lets writeRawConfig's
1635
+ // later mkdirSync — run only after a successful install, when it
1636
+ // writes the S7 marker — fail with EACCES instead of silently
1637
+ // succeeding. This isolates the failure to the write step inside
1638
+ // maybeAutoProvisionHome, the one this fix wraps in try/catch.
1639
+ const restrictedRoot = join(f.root, "locked-config-root");
1640
+ mkdirSync(restrictedRoot, { recursive: true });
1641
+ const configPath = join(restrictedRoot, ".gentle-shell", "config.json");
1642
+ chmodSync(restrictedRoot, 0o500);
1643
+ // No permission restore needed: restrictedRoot stays empty (its own
1644
+ // .gentle-shell subdirectory never gets created), and removing an
1645
+ // empty directory only requires write permission on its *parent*
1646
+ // (f.root, unaffected), not on the directory's own mode.
1647
+
1648
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1649
+ writeGentleAiScript(gentleAiScript);
1650
+ const env = enableAutoProvision({
1651
+ ...f.env,
1652
+ GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript,
1653
+ GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
1654
+ GENTLE_SHELL_CONFIG: configPath,
1655
+ });
1656
+
1657
+ const result = run(env, ["--mode", "rpc", "-p", "hi"]);
1658
+ assert.equal(result.status, 0, result.stderr);
1659
+ assert.match(result.stderr, /gentle-shell: automatic setup failed unexpectedly/);
1660
+ const payload = JSON.parse(result.stdout);
1661
+ assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
1662
+ },
1663
+ );
1664
+
1665
+ test("a persona snapshot read failure is a non-fatal warning in both manual and automatic modes", (t) => {
1666
+ const f = fixture(t);
1667
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1668
+ writeGentleAiScript(gentleAiScript);
1669
+ // A directory where persona.json should be: readFileSync throws EISDIR
1670
+ // (not ENOENT), the exact non-fatal case snapshotFile must not propagate.
1671
+ const personaPath = personaPathFor(f);
1672
+ mkdirSync(personaPath, { recursive: true });
1673
+
1674
+ const manual = run({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }, ["setup"]);
1675
+ assert.equal(manual.status, 0, manual.stderr);
1676
+ assert.match(manual.stderr, /gentle-shell: could not snapshot your Pi persona file/);
1677
+
1678
+ const auto = run(
1679
+ enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.1" }),
1680
+ ["--mode", "rpc", "-p", "hi"],
1681
+ );
1682
+ assert.equal(auto.status, 0, auto.stderr);
1683
+ assert.match(auto.stderr, /gentle-shell: could not snapshot your Pi persona file/);
1684
+ const payload = JSON.parse(auto.stdout);
1685
+ assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
1686
+ });
1687
+
1688
+ test("automatic setup failure includes the underlying message on the line after the generic failure line", (t) => {
1689
+ const f = fixture(t);
1690
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1691
+ writeGentleAiScript(gentleAiScript);
1692
+ // A pin below MIN_SETUP_GENTLE_AI_VERSION makes runSetupFlow fail early
1693
+ // with a concrete `message`, before ever spawning the stub.
1694
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.5.0" });
1695
+
1696
+ const result = run(env, ["--mode", "rpc"]);
1697
+ assert.equal(result.status, 0, result.stderr);
1698
+ const lines = result.stderr.trim().split("\n");
1699
+ const failureIndex = lines.findIndex((line) => /gentle-shell: automatic setup failed/.test(line));
1700
+ assert.notEqual(failureIndex, -1, result.stderr);
1701
+ assert.match(lines[failureIndex + 1], /gentle-shell: setup needs the package-local gentle-ai v3\.6\.0 or newer \(pinned: 3\.5\.0\)/);
1702
+ });
1703
+
1704
+ test("a hung child during automatic provisioning is killed after the timeout ceiling, treated as a failure, and pi still launches", (t) => {
1705
+ const f = fixture(t);
1706
+ const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs");
1707
+ writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "await new Promise((resolve) => setTimeout(resolve, 60000));", "process.exit(0);", ""].join("\n"));
1708
+ chmodSync(gentleAiScript, 0o755);
1709
+ const env = enableAutoProvision({
1710
+ ...f.env,
1711
+ GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript,
1712
+ GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
1713
+ GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "300",
1714
+ });
1715
+
1716
+ const started = Date.now();
1717
+ const result = run(env, ["--mode", "rpc", "-p", "hi"]);
1718
+ const elapsed = Date.now() - started;
1719
+ assert.equal(result.status, 0, result.stderr);
1720
+ assert.ok(elapsed < 15000, `expected the hung child to be killed quickly, took ${elapsed}ms`);
1721
+ assert.match(result.stderr, /gentle-shell: automatic setup failed/);
1722
+ // The message reports the *effective* ceiling (GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS=300
1723
+ // above), not the hardcoded production default: 300ms is not a whole number
1724
+ // of minutes, so it renders in seconds.
1725
+ assert.match(result.stderr, /timed out after 0\.3 seconds/);
1726
+ const payload = JSON.parse(result.stdout);
1727
+ assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
1728
+ });
1729
+
1730
+ test("a hung 'pi remove' during post-install cleanup is killed after the timeout ceiling and reports it in seconds too", (t) => {
1731
+ const f = fixture(t);
1732
+ const gentleAiScript = join(f.root, "fake-gentle-ai-declares-conflict.mjs");
1733
+ writeGentleAiScriptDeclaringConflict(gentleAiScript);
1734
+ const hangingPiScript = join(f.root, "fake-pi-hangs-on-remove.mjs");
1735
+ writeFileSync(
1736
+ hangingPiScript,
1737
+ [
1738
+ "#!/usr/bin/env node",
1739
+ "const args = process.argv.slice(2);",
1740
+ 'if (args.includes("--version")) { console.log("0.85.1"); process.exit(0); }',
1741
+ "if (args[0] === 'remove') { await new Promise((resolve) => setTimeout(resolve, 60000)); process.exit(0); }",
1742
+ "console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
1743
+ "process.exit(0);",
1744
+ "",
1745
+ ].join("\n"),
1746
+ );
1747
+ chmodSync(hangingPiScript, 0o755);
1748
+ const env = enableAutoProvision({
1749
+ ...f.env,
1750
+ GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript,
1751
+ GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
1752
+ GENTLE_SHELL_PI: hangingPiScript,
1753
+ GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "2000",
1754
+ });
1755
+
1756
+ const started = Date.now();
1757
+ const result = run(env, ["--mode", "rpc", "-p", "hi"]);
1758
+ const elapsed = Date.now() - started;
1759
+ assert.equal(result.status, 0, result.stderr);
1760
+ assert.ok(elapsed < 15000, `expected the hung 'pi remove' to be killed quickly, took ${elapsed}ms`);
1761
+ assert.match(result.stderr, /gentle-shell: pi remove npm:@juicesharp\/rpiv-ask-user-question timed out after 2 seconds/);
1762
+ const payload = JSON.parse(result.stdout);
1763
+ assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
1764
+ });
1765
+
1766
+ test("manual setup never times out even past the auto-mode ceiling override", (t) => {
1767
+ const f = fixture(t);
1768
+ const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs");
1769
+ writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "await new Promise((resolve) => setTimeout(resolve, 300));", "process.exit(0);", ""].join("\n"));
1770
+ chmodSync(gentleAiScript, 0o755);
1771
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "50" };
1772
+
1773
+ const result = run(env, ["setup"]);
1774
+ assert.equal(result.status, 0, result.stderr);
1775
+ assert.doesNotMatch(result.stderr, /timed out/);
1776
+ });
1777
+
1778
+ test("the provisioning marker write leaves no stray temp file behind", (t) => {
1779
+ const f = fixture(t);
1780
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1781
+ writeGentleAiScriptCountingRuns(gentleAiScript, join(f.root, "gentle-ai-runs.log"));
1782
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1783
+
1784
+ const result = run(env, []);
1785
+ assert.equal(result.status, 0, result.stderr);
1786
+ const configDir = join(f.home, ".gentle-shell");
1787
+ assert.deepEqual(readdirSync(configDir), ["config.json"]);
1788
+ });
1789
+
1790
+ // --- default Gentleman-Cute theme -------------------------------------------
1791
+
1792
+ test("a freshly bootstrapped home defaults to the Gentleman-Cute theme", (t) => {
1793
+ const f = fixture(t);
1794
+ const result = run(f.env, ["--mode", "rpc"]);
1795
+ assert.equal(result.status, 0, result.stderr);
1796
+ const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
1797
+ assert.equal(settings.theme, "Gentleman-Cute");
1798
+ });
1799
+
1800
+ test("automatic setup never overwrites a theme the home already declared", (t) => {
1801
+ const f = fixture(t);
1802
+ const target = join(f.root, "themed-home");
1803
+ mkdirSync(target, { recursive: true });
1804
+ writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }));
1805
+ writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
1806
+
1807
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1808
+ writeGentleAiScript(gentleAiScript);
1809
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1810
+
1811
+ const result = run(env, ["--home", target, "--mode", "rpc"]);
1812
+ assert.equal(result.status, 0, result.stderr);
1813
+ const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8"));
1814
+ assert.equal(settings.theme, "rose");
1815
+ });
1816
+
1817
+ test("gentle-shell --link setup never touches the home's theme", (t) => {
1818
+ const f = fixture(t);
1819
+ const piAgentDir = join(f.root, "pi-agent-link");
1820
+ mkdirSync(piAgentDir, { recursive: true });
1821
+ writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
1822
+ const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
1823
+ writeGentleAiScript(gentleAiScript);
1824
+ const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, PI_CODING_AGENT_DIR: piAgentDir };
1825
+
1826
+ const result = run(env, ["--link", "setup"]);
1827
+ assert.equal(result.status, 0, result.stderr);
1828
+ const settings = JSON.parse(readFileSync(join(piAgentDir, "settings.json"), "utf8"));
1829
+ assert.equal(settings.theme, undefined, "link mode must never gain a theme from gentle-shell's own default-theme logic");
1830
+ });
1831
+
1832
+ test("automatic setup replaces a theme gentle-ai wrote into a home that had none with the default Gentle Shell theme", (t) => {
1833
+ const f = fixture(t);
1834
+ const target = join(f.root, "theme-home");
1835
+ mkdirSync(target, { recursive: true });
1836
+ writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
1837
+ writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
1838
+
1839
+ const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
1840
+ writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
1841
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1842
+
1843
+ const result = run(env, ["--home", target, "--mode", "rpc"]);
1844
+ assert.equal(result.status, 0, result.stderr);
1845
+ const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8"));
1846
+ assert.equal(settings.theme, "Gentleman-Cute");
1847
+ });
1848
+
1849
+ // R3-theme-enforcement-dead-on-fresh-home: on a brand-new home, the isolated
1850
+ // bootstrap (installIsolatedTuiModeSetting) already writes "theme":
1851
+ // "Gentleman-Cute" into settings.json *before* auto-provisioning's gentle-ai
1852
+ // spawn ever runs — so the snapshot runSetupFlow takes right before that
1853
+ // spawn already has a theme, and a naive "only act when the home had no
1854
+ // theme before the spawn" check would never fire, letting gentle-ai's own
1855
+ // theme silently win on every freshly bootstrapped home.
1856
+ test("automatic setup restores the bootstrapped default theme when gentle-ai overwrites it on a fresh home", (t) => {
1857
+ const f = fixture(t);
1858
+ const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
1859
+ writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
1860
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1861
+
1862
+ const result = run(env, ["--mode", "rpc"]);
1863
+ assert.equal(result.status, 0, result.stderr);
1864
+ const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
1865
+ assert.equal(settings.theme, "Gentleman-Cute");
1866
+ });
1867
+
1868
+ test("automatic setup restores the home's own theme when gentle-ai overwrites it", (t) => {
1869
+ const f = fixture(t);
1870
+ const target = join(f.root, "themed-home-to-restore");
1871
+ mkdirSync(target, { recursive: true });
1872
+ writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen", theme: "dracula" }));
1873
+ writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
1874
+
1875
+ const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
1876
+ writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
1877
+ const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
1878
+
1879
+ const result = run(env, ["--home", target, "--mode", "rpc"]);
1880
+ assert.equal(result.status, 0, result.stderr);
1881
+ const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8"));
1882
+ assert.equal(settings.theme, "dracula");
1883
+ });
1884
+
1885
+ // --- --package-root silently ignored in a declared non-link home ----------
1886
+
1887
+ test("--package-root warns and is ignored when an isolated home already declares gentle-pi", (t) => {
1888
+ const f = fixture(t);
1889
+ mkdirSync(f.gentleShellHome, { recursive: true });
1890
+ writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["npm:gentle-pi@3.5.1"], tuiMode: "fullscreen" }));
1891
+ const forcedRoot = join(f.root, "forced-root");
1892
+ mkdirSync(forcedRoot, { recursive: true });
1893
+
1894
+ const result = run(f.env, ["--package-root", forcedRoot, "--mode", "rpc"]);
1895
+ assert.equal(result.status, 0, result.stderr);
1896
+ assert.match(
1897
+ result.stderr,
1898
+ new RegExp(
1899
+ `--package-root only forces a take-over in --link mode; ${f.gentleShellHome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} declares gentle-pi, so the installed package is used and ${forcedRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} is ignored`,
1900
+ ),
1901
+ );
1902
+ assert.doesNotMatch(result.stderr, /taking over gentle-pi from/);
1903
+
1904
+ const payload = JSON.parse(result.stdout);
1905
+ assert.deepEqual(payload.args, ["--mode", "rpc"]);
1906
+ });
1907
+
1908
+ test("--package-root prints no warning when the home does not declare gentle-pi", (t) => {
1909
+ const f = fixture(t);
1910
+ const forcedRoot = join(f.root, "forced-root");
1911
+ mkdirSync(forcedRoot, { recursive: true });
1912
+
1913
+ const result = run(f.env, ["--package-root", forcedRoot, "--mode", "rpc"]);
1914
+ assert.equal(result.status, 0, result.stderr);
1915
+ assert.doesNotMatch(result.stderr, /--package-root only forces a take-over/);
1916
+ });
1917
+
1918
+ test("--help mentions the setup subcommand", (t) => {
1919
+ const f = fixture(t);
1920
+ const result = run(f.env, ["--help"]);
1921
+ assert.equal(result.status, 0, result.stderr);
1922
+ assert.match(result.stdout, /\bsetup\b/);
1923
+ });
1924
+
190
1925
  // --- --link takeover of a conflicting path package -----------------------
191
1926
  //
192
1927
  // Regression coverage for the settings.json shape that triggered the tool