import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; // Integration tests for the thin bin/gentle-shell.mjs entry: they run the real // file via spawnSync with an isolated HOME and a fake pi script standing in for // the real @earendil-works/pi-coding-agent runtime, so the launcher's own logic // (already covered at the unit level in tests/gentle-shell-launcher.test.ts) // gets exercised end-to-end through real argv, env, and child-process wiring. const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url); const binPath = fileURLToPath(binUrl); const packageRoot = dirname(dirname(binPath)); test("Herdr activity is discoverable through the isolated launcher package", async () => { const { buildPiInvocation } = await import("../lib/gentle-shell-launcher.ts"); const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); assert.ok(manifest.pi.extensions.includes("./extensions")); assert.ok(existsSync(join(packageRoot, "extensions", "gentle-herdr-activity.ts"))); for (const takeOver of [false, true]) { const invocation = buildPiInvocation({ runtime: { kind: "path", command: "fake-pi", args: [] }, home: { mode: "isolated", source: "default", dir: "/fake/home" }, packageRoot, declaration: undefined, takeOver, otherPackagePaths: [], passthrough: [], baseEnv: {}, homedir: "/fake", cwd: "/fake/cwd", }); assert.ok(invocation.args.includes(packageRoot)); assert.equal(invocation.env.PI_CODING_AGENT_DIR, "/fake/home"); } }); test("real adjacent Pi resolves through its public entry without PATH or a runtime override", (t) => { const f = fixture(t); const result = spawnSync(process.execPath, [binPath, "--version"], { env: { HOME: f.home, USERPROFILE: f.home, PATH: "", GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", }); assert.equal(result.status, 0, result.stderr); // The adjacent peer is the release the open development range resolved, // not the manifest specifier. const installed: string = JSON.parse(readFileSync(join(packageRoot, "node_modules", "@earendil-works", "pi-coding-agent", "package.json"), "utf8")).version; assert.match(result.stdout, new RegExp(`pi ${installed.replace(/\./g, "\\.")}\\b`)); assert.equal(existsSync(join(f.home, ".gentle-shell", "agent")), false); }); // A private copy places the launcher's resolution root outside this checkout, // so absent-peer fallback is exercised without moving any real dependency. function standaloneLauncher(t: test.TestContext) { const f = fixture(t); const root = join(f.root, "standalone"); mkdirSync(join(root, "bin"), { recursive: true }); writeFileSync(join(root, "bin", "gentle-shell.mjs"), readFileSync(binPath)); writeFileSync(join(root, "package.json"), readFileSync(join(packageRoot, "package.json"))); for (const dir of ["runtime", "scripts"]) symlinkSync(join(packageRoot, dir), join(root, dir), "junction"); const env = { HOME: f.home, USERPROFILE: f.home, PATH: `${root}${delimiter}${dirname(process.execPath)}`, GENTLE_SHELL_NO_AUTO_SETUP: "1", // Keep executable discovery isolated without making cmd shims unlaunchable. ...(process.platform === "win32" ? { ComSpec: process.env.ComSpec, SystemRoot: process.env.SystemRoot } : {}), }; return { ...f, root, env, launcher: join(root, "bin", "gentle-shell.mjs") }; } test("a genuinely absent adjacent peer falls back to PATH", (t) => { const f = standaloneLauncher(t); writePathPiScript(f.root, "1.2.1"); const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /pi 1\.2\.1/); }); test("malformed adjacent metadata never silently falls back to PATH; env override still wins", (t) => { const f = standaloneLauncher(t); const peer = join(f.root, "node_modules", "@earendil-works", "pi-coding-agent"); mkdirSync(join(peer, "dist", "bundle"), { recursive: true }); writeFileSync(join(peer, "dist", "index.js"), ""); writePiScript(join(peer, "dist", "bundle", "cli.js"), "1.2.0"); const fallback = writePathPiScript(f.root, "1.2.1"); const base = { name: "@earendil-works/pi-coding-agent", type: "module", exports: { ".": { import: "./dist/index.js" } }, bin: { pi: "dist/bundle/cli.js" } }; for (const metadata of [{ ...base, name: "impostor" }, { ...base, bin: { pi: "../outside.js" } }, { ...base, bin: {} }, "malformed"]) { writeFileSync(join(peer, "package.json"), metadata === "malformed" ? "{" : JSON.stringify(metadata)); const result = spawnSync(process.execPath, [f.launcher, "--version"], { env: f.env, encoding: "utf8" }); assert.equal(result.status, 1); assert.equal(result.stdout, ""); } writeFileSync(join(peer, "package.json"), JSON.stringify({ ...base, name: "impostor" })); const override = spawnSync(process.execPath, [f.launcher, "--version"], { env: { ...f.env, GENTLE_SHELL_PI: fallback }, encoding: "utf8" }); assert.equal(override.status, 0, override.stderr); assert.match(override.stdout, /pi 1\.2\.1/); }); // The launcher's own gentle-pi version, exactly as `ownPackageVersion()` in // bin/gentle-shell.mjs reads it (package.json at packageRoot) — used to // assert the post-install gentle-pi removal message and the provisioning // marker's `gentlePi` field without hardcoding this package's own version. function ownGentlePiVersion(): string { return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version; } // GENTLE_SHELL_NO_AUTO_SETUP=1 is in the base fixture env so every test that // is not itself about auto-provisioning (S7) keeps today's plain-launch // behavior: without it, every fixture-driven test against a fresh isolated // home would trigger a real `gentle-ai install --agent pi` (network, tens of // seconds, mutating state) unless it also happened to set // GENTLE_SHELL_GENTLE_AI_BIN. The auto-provision test section below opts // back in per test via enableAutoProvision. function fixture(t: test.TestContext) { const root = mkdtempSync(join(tmpdir(), "gentle-shell-bin-")); t.after(() => rmSync(root, { recursive: true, force: true })); const home = join(root, "home"); mkdirSync(home, { recursive: true }); const piScript = join(root, "fake-pi.mjs"); writePiScript(piScript, "1.2.0"); const gentleShellHome = join(root, "gentle-shell-home"); const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home, GENTLE_SHELL_HOME: gentleShellHome, GENTLE_SHELL_PI: piScript, GENTLE_SHELL_NO_AUTO_SETUP: "1", }; // A suite launched from inside a Gentle Shell session inherits the outer // session's recorded user Pi home, which --link now honours first (#2015). delete env.GENTLE_SHELL_USER_PI_HOME; return { root, home, gentleShellHome, piScript, env }; } // Removes the fixture's default GENTLE_SHELL_NO_AUTO_SETUP=1 opt-out, for a // test that specifically exercises auto-provisioning (S7). function enableAutoProvision(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const { GENTLE_SHELL_NO_AUTO_SETUP, ...rest } = env; return rest; } // `removeExitCode` controls only the `pi remove ...` branch exercised by the // setup-subcommand conflict-cleanup tests below; every other pi invocation // (including the version probe) keeps exiting 0, unchanged for every // existing caller that does not pass it. function writePiScript(path: string, version: string, removeExitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "const args = process.argv.slice(2);", `if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`, "if (args[0] === 'remove') {", " console.log(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,", " }));", ` process.exit(${removeExitCode});`, "}", "console.log(JSON.stringify({", " args,", " PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,", " GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,", " GENTLE_SHELL_USER_PI_HOME: process.env.GENTLE_SHELL_USER_PI_HOME,", " GENTLE_SHELL_CHILD_PACKAGE_INJECTION: process.env.GENTLE_SHELL_CHILD_PACKAGE_INJECTION,", "}));", "process.exit(0);", "", ].join("\n"), ); chmodSync(path, 0o755); } // Match the real platform lookup: POSIX accepts the shebang executable, // Windows discovers a .cmd shim via PATHEXT. Bundled JS peers stay unchanged. function writePathPiScript(root: string, version: string): string { const script = join(root, process.platform === "win32" ? "pi-fixture.mjs" : "pi"); writePiScript(script, version); if (process.platform !== "win32") return script; const command = join(root, "pi.cmd"); writeFileSync(command, `@echo off\r\n"${process.execPath}" "${script}" %*\r\nexit /b %errorlevel%\r\n`); return command; } function run(env: NodeJS.ProcessEnv, args: string[], options: { cwd?: string } = {}) { return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env, ...options }); } // Herdr tests model terminal/socket metadata only: no socket is opened and the // real managed bridge is never imported. The fake pi records launch resources. function herdrFixture(t: test.TestContext) { const f = fixture(t); const bridge = join(f.home, ".pi", "agent", "extensions", "herdr-agent-state.ts"); mkdirSync(dirname(bridge), { recursive: true }); writeFileSync(bridge, "export default function () {}\n"); const socket = join(f.root, "mock-herdr.sock"); const preload = join(f.root, "terminal-and-socket.cjs"); writeFileSync(preload, [ "const fs = require('node:fs');", "const { syncBuiltinESMExports } = require('node:module');", "const originalStat = fs.statSync;", "fs.statSync = (path, ...args) => path === process.env.HERDR_SOCKET_PATH", " ? { isSocket: () => process.env.TEST_SOCKET_VALID === '1' } : originalStat(path, ...args);", "process.stdin.isTTY = process.env.TEST_STDIN_TTY === '1';", "process.stdout.isTTY = process.env.TEST_STDOUT_TTY === '1';", "require('node:net').createConnection = () => { throw new Error('Live socket forbidden'); };", "syncBuiltinESMExports();", ].join("\n")); const env: NodeJS.ProcessEnv = { ...f.env, HERDR_ENV: "1", HERDR_SOCKET_PATH: socket, HERDR_PANE_ID: "mock-pane", TEST_SOCKET_VALID: "1", TEST_STDIN_TTY: "1", TEST_STDOUT_TTY: "1", }; delete env.GENTLE_PI_AGENTS_CHILD; // Keep parent agent-home inheritance out of every portable test fixture. delete env.PI_CODING_AGENT_DIR; const launch = (args: string[] = [], overrides: NodeJS.ProcessEnv = {}) => { const result = spawnSync(process.execPath, ["--require", preload, binPath, ...args], { encoding: "utf8", env: { ...env, ...overrides }, cwd: f.root, }); assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout).args as string[]; }; return { ...f, bridge, env, launch }; } function extensionPaths(args: string[]): string[] { return args.flatMap((arg, i) => arg === "-e" || arg === "--extension" ? [args[i + 1]] : []); } test("Herdr isolated TUI launch explicitly loads the canonical managed bridge", (t) => { const f = herdrFixture(t); assert.ok(extensionPaths(f.launch()).includes(realpathSync(f.bridge))); assert.equal(readFileSync(f.bridge, "utf8"), "export default function () {}\n"); }); test("Herdr prefers the incoming agent-home bridge and falls back to the conventional home", (t) => { const f = herdrFixture(t); const previousHome = join(f.root, "previous-agent"); const previousBridge = join(previousHome, "extensions", "herdr-agent-state.ts"); mkdirSync(dirname(previousBridge), { recursive: true }); writeFileSync(previousBridge, "export default function () {}\n"); const previousPaths = extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: previousHome })); assert.ok(previousPaths.includes(realpathSync(previousBridge))); assert.ok(!previousPaths.includes(realpathSync(f.bridge))); assert.ok(extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: f.gentleShellHome })).includes(realpathSync(f.bridge))); const selectedBridge = join(f.gentleShellHome, "extensions", "herdr-agent-state.ts"); mkdirSync(dirname(selectedBridge), { recursive: true }); writeFileSync(selectedBridge, "export default function () {}\n"); const selectedPaths = extensionPaths(f.launch([], { PI_CODING_AGENT_DIR: previousHome })); assert.ok(selectedPaths.includes(realpathSync(selectedBridge))); assert.ok(!selectedPaths.includes(realpathSync(previousBridge))); assert.ok(!selectedPaths.includes(realpathSync(f.bridge))); }); test("Herdr bridge injection respects disabled extensions, headless modes and child launches", (t) => { const f = herdrFixture(t); for (const args of [["--no-extensions"], ["-ne"], ["-p", "prompt"], ["--print", "prompt"], ["--mode", "rpc"], ["--mode", "json"], ["--export", "session.jsonl"], ["--list-models"], ["--link"], ["--home", f.gentleShellHome], ["list"]]) { assert.ok(!extensionPaths(f.launch(args)).includes(realpathSync(f.bridge)), JSON.stringify(args)); } for (const overrides of [{ HERDR_ENV: "0" }, { HERDR_ENV: undefined }, { HERDR_PANE_ID: "" }, { HERDR_SOCKET_PATH: "" }, { TEST_SOCKET_VALID: "0" }, { TEST_STDIN_TTY: "0" }, { TEST_STDOUT_TTY: "0" }, { GENTLE_PI_AGENTS_CHILD: "1" }]) { assert.ok(!extensionPaths(f.launch([], overrides)).includes(realpathSync(f.bridge)), JSON.stringify(overrides)); } assert.ok(extensionPaths(f.launch(["--mode", "text"])).includes(realpathSync(f.bridge))); }); test("Herdr absent bridge is nonfatal and explicit resources are preserved", (t) => { const f = herdrFixture(t); const absentHome = join(f.root, "empty-home"); mkdirSync(absentHome); assert.ok(!extensionPaths(f.launch([], { HOME: absentHome, USERPROFILE: absentHome })).includes(realpathSync(f.bridge))); const alias = join(f.root, "bridge-alias.ts"); symlinkSync(f.bridge, alias); const args = f.launch(["-e", alias]); assert.ok(extensionPaths(args).includes(alias)); assert.ok(extensionPaths(args).includes(realpathSync(f.bridge))); const disabled = f.launch(["--no-extensions", "-e", alias]); assert.ok(extensionPaths(disabled).includes(alias)); assert.ok(!extensionPaths(disabled).includes(realpathSync(f.bridge))); }); test("Pi real resource loader deduplicates the bridge across discovery, explicit files and package directories", async (t) => { const { DefaultResourceLoader, SettingsManager, createEventBus } = await import("@earendil-works/pi-coding-agent"); for (const scenario of ["discovered", "explicit", "directory"] as const) { const f = fixture(t); const agentDir = join(f.home, ".pi", "agent"); const bridge = join(agentDir, "extensions", "herdr-agent-state.ts"); mkdirSync(dirname(bridge), { recursive: true }); writeFileSync(bridge, "export default function (pi) { pi.events.emit('mock:bridge-loaded', {}); }\n"); const canonical = realpathSync(bridge); const alias = join(f.root, "alias.ts"); symlinkSync(bridge, alias); writeFileSync(join(agentDir, "package.json"), JSON.stringify({ pi: { extensions: ["./extensions/herdr-agent-state.ts"] } })); const eventBus = createEventBus(); let factoryCalls = 0; eventBus.on("mock:bridge-loaded", () => { factoryCalls++; }); const additionalExtensionPaths = scenario === "explicit" ? [alias, canonical, canonical] : scenario === "directory" ? [agentDir, canonical] : [canonical]; const loader = new DefaultResourceLoader({ cwd: f.root, agentDir, eventBus, settingsManager: SettingsManager.inMemory({ extensions: ["-builtin:mcp", "-builtin:llama.cpp", "-builtin:codemode", "-builtin:tool-search"] }), additionalExtensionPaths, noExtensions: scenario !== "discovered", noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, }); await loader.reload(); const loaded = loader.getExtensions(); assert.deepEqual(loaded.errors, [], scenario); assert.equal(loaded.extensions.length, 1, scenario); assert.equal(factoryCalls, 1, scenario); } }); // Test/development-only stub for the setup subcommand's gentle-ai binary: // records its argv and the environment gentle-shell sets around it, instead // of running the real package-local gentle-ai (whose supply-chain integrity // checks a test cannot cheaply satisfy). Pointed to via GENTLE_SHELL_GENTLE_AI_BIN, // documented as test/development-only in docs/readme-reference.md. function writeGentleAiScript(path: string, exitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "const args = process.argv.slice(2);", "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,", "}));", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only stub for the setup subcommand's gentle-ai binary // that also records each invocation's args to `counterPath` (one JSON line // per run), so an auto-provision test can prove the flow ran exactly once // (or not at all) across several gentle-shell invocations, independent of // what lands on stdout/stderr. function writeGentleAiScriptCountingRuns(path: string, counterPath: string, exitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "import { appendFileSync } from 'node:fs';", "const args = process.argv.slice(2);", `appendFileSync(${JSON.stringify(counterPath)}, JSON.stringify(args) + '\\n');`, "process.stdout.write(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only stub for setup's self-heal installer seam // (GENTLE_SHELL_GENTLE_AI_INSTALLER): writes a working gentle-ai stub at // process.env.GENTLE_SHELL_GENTLE_AI_BIN — the same path setup itself // resolved the missing binary from — so "the installer created the pinned // binary" is provable by setup's own retry check finding it, exactly like a // real `node scripts/install-gentle-ai.mjs` run would leave a real binary at // gentleAiBinaryPath(). function writeInstallerScriptThatCreatesTheBinary(path: string) { writeFileSync( path, [ "#!/usr/bin/env node", "import { writeFileSync, chmodSync, mkdirSync } from 'node:fs';", "import { dirname } from 'node:path';", "const target = process.env.GENTLE_SHELL_GENTLE_AI_BIN;", "mkdirSync(dirname(target), { recursive: true });", "writeFileSync(target, [", " '#!/usr/bin/env node',", " 'const args = process.argv.slice(2);',", " '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 }));',", " 'process.exit(0);',", " '',", "].join('\\n'));", "chmodSync(target, 0o755);", "process.exit(0);", "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only stub for setup's self-heal installer seam that // deliberately never creates the binary, simulating a real installer run // that failed (e.g. a network or verification failure) — setup's retry // check must still find the binary missing afterward. function writeInstallerScriptThatFails(path: string) { writeFileSync(path, ["#!/usr/bin/env node", "process.stderr.write('simulated installer failure\\n');", "process.exit(1);", ""].join("\n")); chmodSync(path, 0o755); } // Test/development-only stub for the setup subcommand's gentle-ai binary, // simulating the real gentle-ai's managed Pi stack declaring the conflicting // npm:@juicesharp/rpiv-ask-user-question package (gentle-ai #4820, // gentle-shell #1277) into the provisioned home's settings.json, the same // file the isolated-home bootstrap already created before `setup` spawns // this stub. Its own stdout line ends with "\n" so a test can tell it apart // from a later `pi remove` line on the same inherited stdout. function writeGentleAiScriptDeclaringConflict(path: string, exitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "import { readFileSync, writeFileSync } from 'node:fs';", "import { join } from 'node:path';", "const args = process.argv.slice(2);", "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');", "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));", "settings.packages = ['npm:@juicesharp/rpiv-ask-user-question@1.2.3'];", "writeFileSync(settingsPath, JSON.stringify(settings));", "process.stdout.write(JSON.stringify({ args }) + '\\n');", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only stub for the setup subcommand's gentle-ai binary // that also rewrites the provisioned home's own settings.json `theme` field, // simulating gentle-ai's managed Pi install writing its own theme into // settings.json — the case gentle-shell's own theme-restore logic // (bin/gentle-shell.mjs's runSetupFlow, enforceDefaultThemeField) must undo. function writeGentleAiScriptSettingTheme(path: string, theme: string, exitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "import { readFileSync, writeFileSync } from 'node:fs';", "import { join } from 'node:path';", "const args = process.argv.slice(2);", "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');", "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));", `settings.theme = ${JSON.stringify(theme)};`, "writeFileSync(settingsPath, JSON.stringify(settings));", "process.stdout.write(JSON.stringify({ args }) + '\\n');", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only stub for the setup subcommand's gentle-ai binary, // simulating the real gentle-ai's managed Pi stack always declaring // npm:gentle-pi itself into the provisioned home's settings.json (the // problem this fix addresses: the home must never keep running that // declared copy instead of the launcher's own). `extraSources` lets a test // also declare the conflicting rpiv package alongside it, in the given // order, to prove both post-install removals run together. function writeGentleAiScriptDeclaringGentlePi(path: string, extraSources: string[] = [], exitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "import { readFileSync, writeFileSync } from 'node:fs';", "import { join } from 'node:path';", "const args = process.argv.slice(2);", "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');", "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));", `settings.packages = [...${JSON.stringify(extraSources)}, 'npm:gentle-pi@3.5.1'];`, "writeFileSync(settingsPath, JSON.stringify(settings));", "process.stdout.write(JSON.stringify({ args }) + '\\n');", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only fake pi runtime whose `remove ` branch also // rewrites the target home's settings.json, dropping the matching `packages` // entry — unlike the plain `writePiScript` stub above (which only records // the invocation), this models what a real `pi remove` does to settings.json // closely enough to prove a later plain launch sees the declaration gone and // resumes the launcher's own injection. function writePiScriptEditingSettingsOnRemove(path: string, version = "1.2.0") { writeFileSync( path, [ "#!/usr/bin/env node", "import { readFileSync, writeFileSync } from 'node:fs';", "import { join } from 'node:path';", "const args = process.argv.slice(2);", `if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`, "if (args[0] === 'remove') {", " const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');", " const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));", " const target = args[1];", " settings.packages = (settings.packages || []).filter((entry) => {", " const source = typeof entry === 'string' ? entry : entry.source;", " return source !== target && !source.startsWith(target + '@');", " });", " writeFileSync(settingsPath, JSON.stringify(settings));", " console.log(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,", " }));", " process.exit(0);", "}", "console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));", "process.exit(0);", "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only stub for the setup subcommand's gentle-ai binary // that also rewrites the shared Pi persona file at // `$HOME/.pi/gentle-ai/persona.json` to the default preset, exactly like the // real gentle-ai does regardless of `PI_CODING_AGENT_DIR` (gentle-ai's // `PiPersonaConfigPath` always resolves against the OS home). Reads // `homedir()` inside the child so it honors the fixture's isolated `HOME`, // never `PI_CODING_AGENT_DIR` — that mismatch is exactly the bug the // snapshot/restore wrap in `runSetupFlow` guards against. function writeGentleAiScriptRewritingPersona(path: string, exitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "import { writeFileSync, mkdirSync } from 'node:fs';", "import { homedir } from 'node:os';", "import { join } from 'node:path';", "const personaDir = join(homedir(), '.pi', 'gentle-ai');", "mkdirSync(personaDir, { recursive: true });", "writeFileSync(join(personaDir, 'persona.json'), JSON.stringify({ mode: 'gentleman' }));", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } // Test/development-only stub for the setup subcommand's gentle-ai binary // that also rewrites the shared `$HOME/.gentle-ai/state.json` the way the // real gentle-ai does on every install: it changes managed_asset_digest (the // field this fix restores) and one unrelated field (installed_agents), so a // test can prove the digest gets restored while the unrelated field is left // exactly as the child wrote it. `create` controls whether it merges into an // existing state.json or writes a brand-new one (for the "no pre-existing // file" case, where the stub's file must be left alone entirely). function writeGentleAiScriptRewritingManagedAssetDigest(path: string, opts: { digest?: string; create?: boolean } = {}, exitCode = 0) { const digest = opts.digest ?? "digest-from-pinned-gentle-ai"; const create = opts.create ?? false; writeFileSync( path, [ "#!/usr/bin/env node", "import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';", "import { homedir } from 'node:os';", "import { join } from 'node:path';", "const stateDir = join(homedir(), '.gentle-ai');", "mkdirSync(stateDir, { recursive: true });", "const statePath = join(stateDir, 'state.json');", `const create = ${JSON.stringify(create)};`, "const state = !create && existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : {};", `state.managed_asset_digest = ${JSON.stringify(digest)};`, "state.installed_agents = [...(state.installed_agents || []), 'claude'];", "writeFileSync(statePath, JSON.stringify(state, null, 2) + '\\n');", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } test("--help exits 0 and prints usage", (t) => { const f = fixture(t); const result = run(f.env, ["--help"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /Usage: gentle-shell/); }); test("home with no args prints the default isolated home", (t) => { const f = fixture(t); const result = run(f.env, ["home"]); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout.trim(), `isolated ${f.gentleShellHome}`); }); test("home link persists and a later home reflects it", (t) => { const f = fixture(t); const save = run(f.env, ["home", "link"]); assert.equal(save.status, 0, save.stderr); const configPath = join(f.home, ".gentle-shell", "config.json"); assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { home: "link" }); const check = run(f.env, ["home"]); assert.equal(check.status, 0, check.stderr); assert.match(check.stdout, /^link /); }); test("home persists a custom directory", (t) => { const f = fixture(t); const target = join(f.root, "custom-home"); const save = run(f.env, ["home", target]); assert.equal(save.status, 0, save.stderr); const configPath = join(f.home, ".gentle-shell", "config.json"); assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { home: target }); const check = run(f.env, ["home"]); assert.equal(check.stdout.trim(), `path ${target}`); }); // Test/development-only: GENTLE_SHELL_CONFIG overrides the launcher // config.json path (normally /.gentle-shell/config.json), so a test // or a field run against the real HOME (for example the packed-artifact E2E // script, which needs `--link` probes against the real pi home) never // touches the real ~/.gentle-shell/config.json. Documented as // test/development-only in docs/readme-reference.md. test("GENTLE_SHELL_CONFIG redirects the config.json path used by 'home' and auto-provisioning", (t) => { const f = fixture(t); const overridePath = join(f.root, "alt-config.json"); const env = { ...f.env, GENTLE_SHELL_CONFIG: overridePath }; const save = run(env, ["home", "link"]); assert.equal(save.status, 0, save.stderr); assert.deepEqual(JSON.parse(readFileSync(overridePath, "utf8")), { home: "link" }); assert.equal(existsSync(join(f.home, ".gentle-shell", "config.json")), false); const check = run(env, ["home"]); assert.match(check.stdout, /^link /); }); test("first isolated run bootstraps the home, writes fullscreen, and prints the hint once", (t) => { const f = fixture(t); assert.equal(existsSync(f.gentleShellHome), false); const first = run(f.env, []); assert.equal(first.status, 0, first.stderr); assert.match(first.stderr, /using a separate home at/); assert.match(first.stderr, /gentle-shell --link/); const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8")); assert.equal(settings.tuiMode, "fullscreen"); const second = run(f.env, []); assert.equal(second.status, 0, second.stderr); assert.doesNotMatch(second.stderr, /using a separate home at/); }); // --- home import-pi ---------------------------------------------------------- function importPiFixture(t: test.TestContext) { const f = fixture(t); const env = { ...f.env, PI_CODING_AGENT_DIR: undefined }; const piHome = join(f.home, ".pi", "agent"); mkdirSync(piHome, { recursive: true }); writeFileSync(join(piHome, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", refresh: "dummy-refresh" } }), { mode: 0o600 }); writeFileSync(join(piHome, "mcp.json"), JSON.stringify({ mcpServers: {} })); writeFileSync(join(piHome, "settings.json"), JSON.stringify({ defaultProvider: "anthropic", defaultModel: "m", packages: ["npm:foreign"], extensions: ["./x.ts"] })); writeFileSync(join(piHome, "mcp-cache.json"), "{}"); return { ...f, env, piHome }; } test("first isolated run without a pi home keeps the plain --link hint", (t) => { const f = fixture(t); const first = run({ ...f.env, PI_CODING_AGENT_DIR: undefined }, []); assert.equal(first.status, 0, first.stderr); assert.match(first.stderr, /Run 'gentle-shell --link' to reuse your pi sign-ins and chats\.\n/); assert.doesNotMatch(first.stderr, /import-pi/); }); test("first isolated run with a pi sign-in also mentions home import-pi", (t) => { const f = importPiFixture(t); const first = run(f.env, []); assert.equal(first.status, 0, first.stderr); assert.match(first.stderr, /'gentle-shell --link' to reuse your pi sign-ins and chats, or 'gentle-shell home import-pi'/); }); test("--help lists home import-pi", (t) => { const f = fixture(t); const result = run(f.env, ["--help"]); assert.match(result.stdout, /gentle-shell \[home selectors\] home import-pi \[--dry-run\]/); }); test("home import-pi copies sign-ins, MCP servers and default model into the isolated home only", (t) => { const f = importPiFixture(t); const result = run(f.env, ["home", "import-pi"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /auth\.json: copied \(0600\)/); assert.match(result.stdout, /settings\.json defaultProvider: copied/); assert.doesNotMatch(result.stdout + result.stderr, /dummy-refresh/); assert.ok(existsSync(join(f.gentleShellHome, "auth.json"))); assert.equal(existsSync(join(f.gentleShellHome, "mcp-cache.json")), false); if (process.platform !== "win32") assert.equal(statSync(join(f.gentleShellHome, "auth.json")).mode & 0o777, 0o600); const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8")); assert.equal(settings.defaultProvider, "anthropic"); assert.equal(settings.tuiMode, "fullscreen", "the home is bootstrapped as a normal first launch would"); assert.equal(settings.packages, undefined); assert.equal(settings.extensions, undefined); assert.ok(existsSync(join(f.gentleShellHome, ".gentle-shell-home"))); const again = run(f.env, ["home", "import-pi"]); assert.equal(again.status, 0, again.stderr); assert.match(again.stdout, /auth\.json: already present, kept/); assert.match(again.stdout, /Nothing to import/); }); test("home import-pi --dry-run prints the plan and creates nothing", (t) => { const f = importPiFixture(t); const result = run(f.env, ["home", "import-pi", "--dry-run"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /auth\.json: would copy/); assert.equal(existsSync(f.gentleShellHome), false); assert.equal(existsSync(join(f.home, ".gentle-shell")), false); }); test("home import-pi refuses link mode and a target that is the pi home itself", (t) => { const f = importPiFixture(t); const before = readFileSync(join(f.piHome, "settings.json"), "utf8"); for (const argv of [["--link", "home", "import-pi"], ["--home", f.piHome, "home", "import-pi"]]) { const result = run(f.env, argv); assert.equal(result.status, 1, argv.join(" ")); assert.match(result.stderr, /gentle-shell home import-pi: .*nothing to import/); } assert.equal(run(f.env, ["home", "link"]).status, 0); assert.equal(run(f.env, ["home", "import-pi"]).status, 1); assert.equal(readFileSync(join(f.piHome, "settings.json"), "utf8"), before); assert.equal(run(f.env, ["home", "import-pi", "--force"]).status, 2); }); test("a --home filled by home import-pi is still auto-provisioned as gentle-shell's own", (t) => { const f = importPiFixture(t); const fresh = join(f.root, "fresh-home"); const empty = join(f.root, "empty-home"); mkdirSync(empty); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); for (const [index, target] of [fresh, empty].entries()) { const imported = run(f.env, ["--home", target, "home", "import-pi"]); assert.equal(imported.status, 0, imported.stderr); assert.match(imported.stdout, /auth\.json: copied/); const launch = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(launch.status, 0, launch.stderr); assert.doesNotMatch(launch.stderr, /already has content and was not set up by gentle-shell/); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, index + 1, `auto-provisioning ran for ${target}`); } }); test("home import-pi exits 1 and names the item when a write fails", { skip: process.platform === "win32" }, (t) => { const f = importPiFixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); writeFileSync(join(f.gentleShellHome, "settings.json"), "{}"); writeFileSync(join(f.gentleShellHome, ".gentle-shell-home"), "{}"); chmodSync(f.gentleShellHome, 0o500); let result; try { result = run(f.env, ["home", "import-pi"]); } finally { chmodSync(f.gentleShellHome, 0o700); // before the fixture's own cleanup } assert.equal(result.status, 1); assert.match(result.stdout, /auth\.json: failed/); }); test("forwarded args reach pi after the injected extension flags, in order", (t) => { const f = fixture(t); const result = run(f.env, ["--mode", "rpc", "-p", "hi"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "-e", packageRoot, "--mode", "rpc", "-p", "hi", ]); assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome); }); // #1690: the spawned pi must carry the launcher's own -e set so the subagent // runner can forward it to delegated children; a stale inherited value is replaced. test("an isolated launch without a gentle-pi declaration signals its package injection to pi", (t) => { const f = fixture(t); const stale = JSON.stringify({ version: 1, noExtensions: true, extensionPaths: [join(f.root, "outer")] }); for (const inherited of [undefined, stale]) { const result = run({ ...f.env, GENTLE_SHELL_CHILD_PACKAGE_INJECTION: inherited }, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args.slice(0, 2), ["-e", packageRoot]); assert.deepEqual(JSON.parse(payload.GENTLE_SHELL_CHILD_PACKAGE_INJECTION), { version: 1, noExtensions: false, extensionPaths: [packageRoot] }); } }); test("an isolated launch keeps its own agent home and carries the user's original Pi home, even when nested", (t) => { const f = fixture(t); const base = { ...f.env }; delete base.PI_CODING_AGENT_DIR; delete base.GENTLE_SHELL_USER_PI_HOME; const launch = (overrides: NodeJS.ProcessEnv, args: string[] = []) => { const result = run({ ...base, ...overrides }, [...args, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); }; const conventional = launch({}); assert.equal(conventional.PI_CODING_AGENT_DIR, f.gentleShellHome); assert.equal(conventional.GENTLE_SHELL_USER_PI_HOME, join(f.home, ".pi", "agent")); const customHome = join(f.root, "custom-pi"); const custom = launch({ PI_CODING_AGENT_DIR: customHome }); assert.equal(custom.PI_CODING_AGENT_DIR, f.gentleShellHome); assert.equal(custom.GENTLE_PI_AGENT_HOME, f.gentleShellHome); assert.equal(custom.GENTLE_SHELL_USER_PI_HOME, customHome); // A gentle-shell started from inside a Gentle Shell session inherits both variables. const nested = launch({ PI_CODING_AGENT_DIR: custom.PI_CODING_AGENT_DIR, GENTLE_SHELL_USER_PI_HOME: custom.GENTLE_SHELL_USER_PI_HOME }); assert.equal(nested.PI_CODING_AGENT_DIR, f.gentleShellHome); assert.equal(nested.GENTLE_SHELL_USER_PI_HOME, customHome); const linked = launch({ PI_CODING_AGENT_DIR: customHome }, ["--link"]); assert.equal(linked.PI_CODING_AGENT_DIR, customHome); assert.equal(linked.GENTLE_SHELL_USER_PI_HOME, customHome); // --link from inside an isolated session reuses the preserved original home (#2015). const nestedLinked = launch({ PI_CODING_AGENT_DIR: f.gentleShellHome, GENTLE_SHELL_USER_PI_HOME: customHome }, ["--link"]); assert.equal(nestedLinked.PI_CODING_AGENT_DIR, customHome); assert.equal(nestedLinked.GENTLE_SHELL_USER_PI_HOME, customHome); assert.equal(existsSync(join(customHome, "sessions")), false); }); test("--link skips injection and leaves settings.json byte-identical when it already declares gentle-pi", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const settingsPath = join(piAgentDir, "settings.json"); const settingsText = '{"packages":["npm:gentle-pi"],"theme":"kept"}'; writeFileSync(settingsPath, settingsText); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--mode", "rpc"]); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); assert.equal(payload.PI_CODING_AGENT_DIR, piAgentDir); assert.equal(existsSync(join(piAgentDir, ".gentle-shell")), false); }); // --- injection skip for isolated/--home homes once they declare gentle-pi (S3) --- // // `gentle-shell setup` installs npm:gentle-pi into the home's settings.json; // once that declaration exists, isolated and --home homes must stop // injecting the launcher's own copy on top of it, the same way --link // already does. Homes without a declaration keep the plain injection. test("an isolated home whose settings.json declares npm:gentle-pi gets no injection", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["npm:gentle-pi@3.5.1"], tuiMode: "fullscreen" })); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--mode", "rpc"]); assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome); }); test("an isolated home whose settings.json does not declare gentle-pi keeps the plain injection", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" })); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "-e", packageRoot, "--mode", "rpc", ]); }); test("an isolated home whose settings.json declares a path-based gentle-pi takes over, same as --link", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"], tuiMode: "fullscreen" })); const result = run(f.env, ["--mode", "rpc"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /taking over gentle-pi from/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", packageRoot, "--mode", "rpc", ]); }); test("gentle-shell list forwards to pi as a bare subcommand, with no injected extension flags", (t) => { const f = fixture(t); const result = run(f.env, ["list"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["list"]); assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome); }); test("gentle-shell install npm: forwards the subcommand and its argument verbatim", (t) => { const f = fixture(t); const result = run(f.env, ["install", "npm:pi-btw"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "npm:pi-btw"]); assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome); }); // #2140: pi dispatches `mcp` on argv[0] only, so an injected -e ahead of it // turned `gentle-shell mcp list` into an interactive prompt. test("gentle-shell mcp add -- forwards to pi's own mcp verbatim, with no injected extension flags", (t) => { const f = fixture(t); const result = run(f.env, ["mcp", "add", "demo", "--", "npx", "-y", "demo-server"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["mcp", "add", "demo", "--", "npx", "-y", "demo-server"]); assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome); }); test("a too-old pi exits 1 naming both versions", (t) => { const f = fixture(t); writePiScript(f.piScript, "0.80.0"); const result = run(f.env, []); assert.equal(result.status, 1); assert.match(result.stderr, /0\.80\.0/); assert.match(result.stderr, /1\.2\.0/); }); test("--version prints three lines", (t) => { const f = fixture(t); const result = run(f.env, ["--version"]); assert.equal(result.status, 0, result.stderr); const lines = result.stdout.trim().split("\n"); assert.equal(lines.length, 3); assert.match(lines[0], /^gentle-shell /); assert.match(lines[1], /^pi 1\.2\.0$/); assert.match(lines[2], /^home isolated /); }); // --- setup subcommand ------------------------------------------------------ test("gentle-shell setup provisions the resolved home through the pinned gentle-ai binary", (t) => { const f = fixture(t); assert.equal(existsSync(f.gentleShellHome), false); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /provisioning/); assert.match(result.stderr, new RegExp(f.gentleShellHome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]); assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome); assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome); // First the temporary `pi` that runs the resolved runtime, then the runtime's own folder. const [launcher, runtimeDir] = payload.PATH.split(delimiter); assert.match(launcher, /gentle-shell-pi-/, payload.PATH); assert.equal(runtimeDir, dirname(f.piScript), payload.PATH); // Home resolution runs exactly as a normal run: the isolated home gets // created with its bootstrap TUI setting, same as a plain `gentle-shell`. const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8")); assert.equal(settings.tuiMode, "fullscreen"); }); test("gentle-shell setup forwards --dry-run to gentle-ai", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup", "--dry-run"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global", "--dry-run"]); }); test("gentle-shell setup accepts a home selector before it and provisions that home", (t) => { const f = fixture(t); const target = join(f.root, "custom-home"); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["--home", target, "setup"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]); assert.equal(payload.PI_CODING_AGENT_DIR, target); assert.equal(payload.GENTLE_PI_AGENT_HOME, target); assert.equal(existsSync(join(target, "settings.json")), true); }); test("gentle-shell setup passes through the gentle-ai exit code", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript, 3); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup"]); assert.equal(result.status, 3); }); // Windows cannot execute a shebang fixture directly; use the launcher's // existing .cmd support without changing the runtime under test. function setupMCPExecutable(path: string): string { chmodSync(path, 0o755); if (process.platform !== "win32") return path; const wrapper = `${path}.cmd`; writeFileSync(wrapper, `@echo off\r\n"${process.execPath}" "${path}" %*\r\n`); return wrapper; } test("setup MCP migration warns without re-enabling configured servers", async (t) => { for (const mode of ["link", "path"] as const) { await t.test(mode, (t) => { const f = fixture(t); const target = mode === "link" ? join(f.home, ".pi", "agent") : f.gentleShellHome; mkdirSync(join(target, "npm"), { recursive: true }); const settingsPath = join(target, "settings.json"); const settings = { theme: "kanagawa", packages: ["npm:pi-mcp-adapter", "npm:other"], extensions: ["-builtin:mcp", "user-extension.ts"] }; writeFileSync(settingsPath, JSON.stringify(settings)); const npmPath = join(target, "npm", "package.json"); writeFileSync(npmPath, JSON.stringify({ dependencies: { "pi-mcp-adapter": "^3.0.0", other: "1.0.0" } })); const mcpPath = join(target, "mcp.json"); const mcpText = '{"mcpServers":{"context7":{"command":"npx","args":["context7-mcp"]}}}'; writeFileSync(mcpPath, mcpText); const installer = join(f.root, "retire-adapter.mjs"); writeFileSync(installer, [ "#!/usr/bin/env node", 'import { readFileSync, writeFileSync } from "node:fs";', 'import { join } from "node:path";', 'const home = process.env.PI_CODING_AGENT_DIR;', 'const settingsPath = join(home, "settings.json");', 'const settings = JSON.parse(readFileSync(settingsPath, "utf8"));', 'settings.packages = settings.packages.filter(p => p !== "npm:pi-mcp-adapter");', 'writeFileSync(settingsPath, JSON.stringify(settings));', 'const npmPath = join(home, "npm", "package.json");', 'const manifest = JSON.parse(readFileSync(npmPath, "utf8"));', 'delete manifest.dependencies["pi-mcp-adapter"];', 'writeFileSync(npmPath, JSON.stringify(manifest));', 'console.log("installed");', ].join("\n")); assert.equal(readFileSync(installer, "utf8").split("\n")[0], "#!/usr/bin/env node"); const args = mode === "link" ? ["--link", "setup"] : ["--home", target, "setup"]; const result = run({ ...f.env, PI_CODING_AGENT_DIR: target, GENTLE_SHELL_PI: setupMCPExecutable(f.piScript), GENTLE_SHELL_GENTLE_AI_BIN: setupMCPExecutable(installer) }, args); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "installed\n"); assert.equal(result.stderr, `gentle-shell: provisioning ${target} with the gentle-ai companion packages\n` + `gentle-shell: WARNING: Pi built-in MCP is disabled in ${settingsPath}; pi-mcp-adapter is absent or inactive, so servers in ${mcpPath} will not load. If you want these servers enabled, remove -builtin:mcp from extensions in ${settingsPath}, then restart Pi. If MCP is intentionally disabled, keep the setting.\n`); assert.deepEqual(JSON.parse(readFileSync(settingsPath, "utf8")), { ...settings, packages: ["npm:other"] }); assert.deepEqual(JSON.parse(readFileSync(npmPath, "utf8")), { dependencies: { other: "1.0.0" } }); assert.equal(readFileSync(mcpPath, "utf8"), mcpText); }); } }); test("setup MCP migration keeps enabled, unconfigured and dry-run homes unchanged", async (t) => { for (const scenario of ["enabled", "unconfigured", "dry-run", "active-adapter"] as const) { await t.test(scenario, (t) => { const f = fixture(t); const target = join(f.home, ".pi", "agent"); mkdirSync(target, { recursive: true }); const settingsPath = join(target, "settings.json"); const settingsText = JSON.stringify({ extensions: scenario === "enabled" ? [] : ["-builtin:mcp"], packages: scenario === "active-adapter" ? ["npm:pi-mcp-adapter@3.0.0"] : [] }); writeFileSync(settingsPath, settingsText); const mcpPath = join(target, "mcp.json"); const mcpText = JSON.stringify({ mcpServers: scenario === "unconfigured" ? {} : { context7: { command: "npx" } } }); writeFileSync(mcpPath, mcpText); const installer = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(installer); const dryRun = scenario === "dry-run"; const result = run({ ...f.env, PI_CODING_AGENT_DIR: target, GENTLE_SHELL_PI: setupMCPExecutable(f.piScript), GENTLE_SHELL_GENTLE_AI_BIN: setupMCPExecutable(installer) }, ["--link", "setup", ...(dryRun ? ["--dry-run"] : [])]); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout).args, ["install", "--agent", "pi", "--scope", "global", ...(dryRun ? ["--dry-run"] : [])]); assert.doesNotMatch(result.stderr, /WARNING:|built-in MCP/); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); assert.equal(readFileSync(mcpPath, "utf8"), mcpText); }); } }); test("setup MCP migration warns when adapter resource loading is disabled", async (t) => { for (const adapter of [ { source: "npm:pi-mcp-adapter", autoload: false }, { source: "npm:pi-mcp-adapter", autoload: false, extensions: ["!**"] }, { source: "npm:pi-mcp-adapter", extensions: ["!**"] }, { source: "npm:pi-mcp-adapter", extensions: ["index.ts"] }, ]) { await t.test(JSON.stringify(adapter), (t) => { const f = fixture(t); const target = join(f.home, ".pi", "agent"); mkdirSync(target, { recursive: true }); const settingsPath = join(target, "settings.json"); const uncertain = "extensions" in adapter && adapter.extensions.includes("index.ts"); const settingsText = JSON.stringify({ extensions: ["-builtin:mcp"], packages: [adapter] }); writeFileSync(settingsPath, settingsText); const mcpPath = join(target, "mcp.json"); const mcpText = '{"mcpServers":{"context7":{"command":"npx"}}}'; writeFileSync(mcpPath, mcpText); const installer = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(installer); const result = run({ ...f.env, PI_CODING_AGENT_DIR: target, GENTLE_SHELL_PI: setupMCPExecutable(f.piScript), GENTLE_SHELL_GENTLE_AI_BIN: setupMCPExecutable(installer) }, ["--link", "setup"]); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout).args, ["install", "--agent", "pi", "--scope", "global"]); const diagnosis = uncertain ? "pi-mcp-adapter activity cannot be confirmed from its extension filters, so servers in " + mcpPath + " may not load" : "pi-mcp-adapter is absent or inactive, so servers in " + mcpPath + " will not load"; assert.equal(result.stderr, `gentle-shell: provisioning ${target} with the gentle-ai companion packages\n` + `gentle-shell: WARNING: Pi built-in MCP is disabled in ${settingsPath}; ${diagnosis}. If you want these servers enabled, remove -builtin:mcp from extensions in ${settingsPath}, then restart Pi. If MCP is intentionally disabled, keep the setting.\n`); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); assert.equal(readFileSync(mcpPath, "utf8"), mcpText); }); } }); // --- setup subcommand's self-heal for a missing package-local binary ------- // // `npm install -g ` on a machine whose npm config disables lifecycle // scripts (`ignore-scripts=true`) never runs the package's own postinstall, // so .gentle-ai/v/gentle-ai is missing even though the package itself // installed fine. `gentle-shell setup` self-heals by running the installer // in place before giving up. GENTLE_SHELL_GENTLE_AI_INSTALLER is a // test/development-only override for the installer script path, next to // GENTLE_SHELL_GENTLE_AI_BIN; documented as test/development-only in // docs/readme-reference.md. test("gentle-shell setup installs the missing package-local gentle-ai via the installer seam and then continues", (t) => { const f = fixture(t); const missingBinary = join(f.root, "does-not-exist", "gentle-ai"); const installerScript = join(f.root, "fake-installer-creates-binary.mjs"); writeInstallerScriptThatCreatesTheBinary(installerScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: missingBinary, GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0", }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.match( result.stderr, /gentle-shell: the package-local gentle-ai v3\.6\.0 is missing \(npm lifecycle scripts may be disabled\); installing it now/, ); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]); assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome); assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome); }); test("gentle-shell setup exits 1 with an actionable message when the pinned gentle-ai binary is still missing after the installer runs", (t) => { const f = fixture(t); const missingBinary = join(f.root, "does-not-exist", "gentle-ai"); const installerScript = join(f.root, "fake-installer-fails.mjs"); writeInstallerScriptThatFails(installerScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: missingBinary, GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript }; const result = run(env, ["setup"]); assert.equal(result.status, 1); assert.match(result.stderr, /gentle-shell: the package-local gentle-ai v.+ is missing \(npm lifecycle scripts may be disabled\); installing it now/); assert.match(result.stderr, /package-local-binary-missing/); assert.match(result.stderr, new RegExp(missingBinary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); test("gentle-shell setup skips the self-heal install and exits 1 when GENTLE_PI_SKIP_GENTLE_AI_INSTALL is set", (t) => { const f = fixture(t); const missingBinary = join(f.root, "does-not-exist", "gentle-ai"); // This installer would prove itself by creating the binary if it ever ran; // it must not run at all when the skip variable is set. const installerScript = join(f.root, "installer-should-not-run.mjs"); writeInstallerScriptThatCreatesTheBinary(installerScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: missingBinary, GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript, GENTLE_PI_SKIP_GENTLE_AI_INSTALL: "1", }; const result = run(env, ["setup"]); assert.equal(result.status, 1); assert.match(result.stderr, /package-local-binary-missing/); assert.match(result.stderr, /GENTLE_PI_SKIP_GENTLE_AI_INSTALL/); assert.doesNotMatch(result.stderr, /installing it now/); assert.equal(existsSync(missingBinary), false); }); // --- setup subcommand's gentle-ai pin gate (R3/R4 advisory findings) ------- // // `gentle-shell setup` points PI_CODING_AGENT_DIR at the resolved home // before spawning the package-local pinned gentle-ai; only gentle-ai >= // 3.6.0 honors that variable in its own provisioning. An older pin would // silently provision the caller's real ~/.pi/agent instead, so setup must // refuse to spawn it. GENTLE_SHELL_GENTLE_AI_PIN is a test/development-only // override for the reported pin, next to GENTLE_SHELL_GENTLE_AI_BIN; // documented as test/development-only in docs/readme-reference.md. test("gentle-shell setup refuses to run an older-than-3.6.0 pin and spawns nothing", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.5.0" }; const result = run(env, ["setup"]); assert.equal(result.status, 1); 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/); // The isolated-home bootstrap runs before command dispatch on every run // (same as a successful setup); the pin gate only refuses to spawn // gentle-ai, so no gentle-ai invocation is recorded on stdout. assert.equal(result.stdout, ""); }); test("gentle-shell setup proceeds when the reported pin is exactly 3.6.0", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]); }); // --- setup subcommand conflict cleanup (gentle-ai #4820 / gentle-shell #1277) --- // // gentle-ai's managed Pi stack still installs npm:@juicesharp/rpiv-ask-user-question, // which conflicts with gentle-pi's own first-party ask_user_question tool // (Pi refuses two providers for the same tool name). Until the gentle-ai fix // lands, `gentle-shell setup` removes the conflicting package itself once it // finds gentle-ai declared it in the provisioned home's settings.json. test("gentle-shell setup removes the conflicting ask-user-question package gentle-ai declared", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptDeclaringConflict(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.match( result.stderr, /gentle-shell: removing npm:@juicesharp\/rpiv-ask-user-question from .+: gentle-pi ships ask_user_question and Pi refuses two providers \(gentle-ai #4820\)/, ); const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0); assert.equal(lines.length, 2, result.stdout); assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]); const removePayload = JSON.parse(lines[1]); assert.deepEqual(removePayload.args, ["remove", "npm:@juicesharp/rpiv-ask-user-question"]); assert.equal(removePayload.PI_CODING_AGENT_DIR, f.gentleShellHome); assert.equal(removePayload.GENTLE_PI_AGENT_HOME, f.gentleShellHome); assert.ok(removePayload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), removePayload.PATH); }); test("gentle-shell setup runs no pi remove when gentle-ai did not declare the conflicting package", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /removing npm:/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]); }); // --- setup subcommand keeps the home on the launcher's own gentle-pi ------- // // gentle-ai's managed Pi stack always declares npm:gentle-pi itself into the // provisioned home's settings.json. That declaration must never survive // setup: this launcher always loads its own gentle-pi, so leaving it in // place would let the home drift onto whatever gentle-pi npm last installed // (or, worse, onto the published npm package instead of a developer's source // checkout) instead of the running launcher's own copy. test("gentle-shell setup removes npm:gentle-pi that gentle-ai declared, naming this launcher's own version", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptDeclaringGentlePi(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.match( result.stderr, new RegExp( `gentle-shell: removing npm:gentle-pi from .+: this launcher loads its own gentle-pi ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}, so the home always matches it`, ), ); const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0); assert.equal(lines.length, 2, result.stdout); assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]); const removePayload = JSON.parse(lines[1]); assert.deepEqual(removePayload.args, ["remove", "npm:gentle-pi"]); assert.equal(removePayload.PI_CODING_AGENT_DIR, f.gentleShellHome); assert.equal(removePayload.GENTLE_PI_AGENT_HOME, f.gentleShellHome); assert.ok(removePayload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), removePayload.PATH); }); test("gentle-shell setup removes both the conflicting rpiv package and npm:gentle-pi, in declaration order", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptDeclaringGentlePi(gentleAiScript, ["npm:@juicesharp/rpiv-ask-user-question@1.2.3"]); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0); assert.equal(lines.length, 3, result.stdout); assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]); assert.deepEqual(JSON.parse(lines[1]).args, ["remove", "npm:@juicesharp/rpiv-ask-user-question"]); assert.deepEqual(JSON.parse(lines[2]).args, ["remove", "npm:gentle-pi"]); }); // A --dry-run gentle-ai install writes nothing, so reading settings.json // afterwards would only ever report whatever pre-existed the run (e.g. the // isolated-home bootstrap this fixture's fake-gentle-ai script never // touches), never what the skipped install would have declared. Setup must // therefore print the pending-removal message unconditionally, without // reading settings.json — proven here with a fresh home that never declares // the conflicting package at all. test("gentle-shell setup --dry-run prints the pending removal unconditionally, without reading settings.json", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const result = run(env, ["setup", "--dry-run"]); assert.equal(result.status, 0, result.stderr); assert.match( result.stderr, /gentle-shell: setup would then remove npm:@juicesharp\/rpiv-ask-user-question if the install declares it \(gentle-ai #4820\)/, ); assert.match( result.stderr, new RegExp( `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`, ), ); assert.doesNotMatch(result.stderr, /gentle-shell: removing/); const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0); assert.equal(lines.length, 1, result.stdout); assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global", "--dry-run"]); }); test("a later normal launch injects the launcher's own package root once gentle-pi is removed from settings.json", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptDeclaringGentlePi(gentleAiScript); writePiScriptEditingSettingsOnRemove(f.piScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const setupResult = run(env, ["setup"]); assert.equal(setupResult.status, 0, setupResult.stderr); const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8")); assert.deepEqual(settings.packages, []); const result = run(env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "-e", packageRoot, "--mode", "rpc", ]); }); test("gentle-shell setup propagates a non-zero pi remove exit code with an actionable message", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptDeclaringConflict(gentleAiScript); const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs"); writePiScript(failingPiScript, "1.2.0", 7); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript }; const result = run(env, ["setup"]); assert.equal(result.status, 7); assert.match( result.stderr, /gentle-shell: could not remove npm:@juicesharp\/rpiv-ask-user-question; run `gentle-shell remove npm:@juicesharp\/rpiv-ask-user-question` before starting/, ); }); // The remediation message must include whatever home selector setup was run // with, so a copy-pasted `gentle-shell remove ` doesn't silently // fall back to the isolated default and miss the package in the home the // user actually set up (gentle-shell #1277 follow-up). test("gentle-shell setup --home includes --home in the failing-removal remediation command", (t) => { const f = fixture(t); const target = join(f.root, "custom-home"); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptDeclaringConflict(gentleAiScript); const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs"); writePiScript(failingPiScript, "1.2.0", 7); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript }; const result = run(env, ["--home", target, "setup"]); assert.equal(result.status, 7); assert.match( result.stderr, new RegExp( `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`, ), ); }); // A --home path containing a space (or another shell metacharacter) must be // single-quoted in the remediation command, or a copy-pasted // `gentle-shell --home remove ` silently splits into extra // shell words instead of naming the actual home setup provisioned // (gentle-shell #1277 follow-up). test("gentle-shell setup shell-quotes a --home path containing a space in the failing-removal remediation command", (t) => { const f = fixture(t); const target = join(f.root, "custom home"); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptDeclaringConflict(gentleAiScript); const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs"); writePiScript(failingPiScript, "1.2.0", 7); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript }; const result = run(env, ["--home", target, "setup"]); assert.equal(result.status, 7); assert.match( result.stderr, new RegExp( `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`, ), ); }); // --- persona snapshot/restore ----------------------------------------------- // // gentle-ai's persona file always lives at the shared `~/.pi/gentle-ai/persona.json` // regardless of `PI_CODING_AGENT_DIR` (see docs/readme-reference.md's setup // "Known limitation"), so `setup` snapshots it before spawning gentle-ai and // restores it afterward — in every mode gentle-ai gets spawned in: manual, // `--dry-run`, and automatic first-run provisioning. function personaPathFor(f: { home: string }) { return join(f.home, ".pi", "gentle-ai", "persona.json"); } test("gentle-shell setup restores the user's Pi persona file when gentle-ai rewrites it", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingPersona(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const personaPath = personaPathFor(f); mkdirSync(dirname(personaPath), { recursive: true }); const originalBytes = JSON.stringify({ mode: "neutral" }); writeFileSync(personaPath, originalBytes); chmodSync(personaPath, 0o600); const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.equal(readFileSync(personaPath, "utf8"), originalBytes); assert.equal(statSync(personaPath).mode & 0o777, 0o600); assert.match( result.stderr, new RegExp( `gentle-shell: kept your Pi persona unchanged \\(gentle-ai rewrote ${personaPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}; tracked upstream\\)`, ), ); }); test("gentle-shell setup removes a Pi persona file gentle-ai created where none existed", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingPersona(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const personaPath = personaPathFor(f); assert.equal(existsSync(personaPath), false); const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(personaPath), false); assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/); }); test("gentle-shell setup prints no persona notice when gentle-ai leaves the persona file alone", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const personaPath = personaPathFor(f); mkdirSync(dirname(personaPath), { recursive: true }); writeFileSync(personaPath, JSON.stringify({ mode: "neutral" })); const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /kept your Pi persona unchanged/); }); test("gentle-shell setup restores the persona file even when gentle-ai exits non-zero after rewriting it", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingPersona(gentleAiScript, 3); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const personaPath = personaPathFor(f); mkdirSync(dirname(personaPath), { recursive: true }); const originalBytes = JSON.stringify({ mode: "neutral" }); writeFileSync(personaPath, originalBytes); const result = run(env, ["setup"]); assert.equal(result.status, 3); assert.equal(readFileSync(personaPath, "utf8"), originalBytes); assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/); }); test("gentle-shell setup --dry-run also restores the persona file gentle-ai rewrites", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingPersona(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const personaPath = personaPathFor(f); mkdirSync(dirname(personaPath), { recursive: true }); const originalBytes = JSON.stringify({ mode: "neutral" }); writeFileSync(personaPath, originalBytes); const result = run(env, ["setup", "--dry-run"]); assert.equal(result.status, 0, result.stderr); assert.equal(readFileSync(personaPath, "utf8"), originalBytes); assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/); }); test("automatic first-run provisioning also restores the persona file gentle-ai rewrites", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingPersona(gentleAiScript); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const personaPath = personaPathFor(f); mkdirSync(dirname(personaPath), { recursive: true }); const originalBytes = JSON.stringify({ mode: "neutral" }); writeFileSync(personaPath, originalBytes); const result = run(env, []); assert.equal(result.status, 0, result.stderr); assert.equal(readFileSync(personaPath, "utf8"), originalBytes); assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/); }); // --- managed-asset digest snapshot/restore ---------------------------------- // // gentle-ai tracks whether its own binary's managed-asset bundle matches the // shared `~/.gentle-ai/state.json`'s managed_asset_digest field, regardless // of which home it was installing into. The pinned package-local gentle-ai // this setup flow spawns writes its own digest into that same shared file, // so afterward the user's own (unrelated, on-PATH) gentle-ai reports its // managed assets as outdated and demands `gentle-ai sync`, even though // nothing about the user's install changed. `setup` restores just that field // afterward — never the whole file, unlike persona.json, since state.json // also carries fields (like installed_agents) the pinned gentle-ai is // supposed to update. function stateJsonPathFor(f: { home: string }) { return join(f.home, ".gentle-ai", "state.json"); } test("gentle-shell setup restores managed_asset_digest while keeping other state.json fields as the child left them", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" }); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const statePath = stateJsonPathFor(f); mkdirSync(dirname(statePath), { recursive: true }); writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`); const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); const state = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(state.managed_asset_digest, "users-own-digest"); assert.deepEqual(state.installed_agents, ["pi", "claude"]); assert.match( result.stderr, new RegExp( `gentle-shell: kept your Gentle AI managed-asset record unchanged \\(the pinned gentle-ai rewrote ${statePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}; tracked upstream\\)`, ), ); }); test("gentle-shell setup prints no managed-asset notice when the digest is unchanged", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "same-digest" }); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const statePath = stateJsonPathFor(f); mkdirSync(dirname(statePath), { recursive: true }); writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "same-digest", installed_agents: ["pi"] }, null, 2)}\n`); const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /kept your Gentle AI managed-asset record unchanged/); }); test("gentle-shell setup leaves a state.json the pinned gentle-ai created where none existed before", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai", create: true }); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const statePath = stateJsonPathFor(f); assert.equal(existsSync(statePath), false); const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); const state = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(state.managed_asset_digest, "digest-from-pinned-gentle-ai"); assert.doesNotMatch(result.stderr, /kept your Gentle AI managed-asset record unchanged/); }); test("gentle-shell setup --dry-run also restores managed_asset_digest gentle-ai rewrites", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" }); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; const statePath = stateJsonPathFor(f); mkdirSync(dirname(statePath), { recursive: true }); writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`); const result = run(env, ["setup", "--dry-run"]); assert.equal(result.status, 0, result.stderr); const state = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(state.managed_asset_digest, "users-own-digest"); assert.match(result.stderr, /kept your Gentle AI managed-asset record unchanged/); }); test("automatic first-run provisioning also restores managed_asset_digest gentle-ai rewrites", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" }); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const statePath = stateJsonPathFor(f); mkdirSync(dirname(statePath), { recursive: true }); writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`); const result = run(env, []); assert.equal(result.status, 0, result.stderr); const state = JSON.parse(readFileSync(statePath, "utf8")); assert.equal(state.managed_asset_digest, "users-own-digest"); assert.match(result.stderr, /kept your Gentle AI managed-asset record unchanged/); }); // --- automatic first-run provisioning (S7) --------------------------------- // // A plain `gentle-shell` in an isolated or `--home` home now runs the same // flow as `gentle-shell setup` automatically, before launching pi, when the // home has never been provisioned or was provisioned with a different // gentle-ai pin. GENTLE_SHELL_GENTLE_AI_PIN pins the reported pin to a fixed // value so these tests are independent of the real installed pin. test("first launch auto-provisions the isolated home, writes the marker, then launches pi; stdout carries only pi's output", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--mode", "rpc", "-p", "hi"]); assert.equal(result.status, 0, result.stderr); // The flow ran exactly once, before pi. const runs = readFileSync(counterPath, "utf8").trim().split("\n"); assert.equal(runs.length, 1, counterPath); assert.deepEqual(JSON.parse(runs[0]), ["install", "--agent", "pi", "--scope", "global"]); // stdout carries only the final pi invocation's own output (the flow's // stdout and stderr, and the launcher's own notices, all went to stderr). const stdoutLines = result.stdout.trim().split("\n").filter((line) => line.length > 0); assert.equal(stdoutLines.length, 1, result.stdout); const payload = JSON.parse(stdoutLines[0]); assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]); 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\)/); assert.match(result.stderr, new RegExp(JSON.stringify({ args: ["install", "--agent", "pi", "--scope", "global"], PI_CODING_AGENT_DIR: f.gentleShellHome }).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8")); const homeKey = realpathSync(f.gentleShellHome); assert.equal(config.provisioned[homeKey].gentleAi, "3.6.0"); assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion()); assert.equal(typeof config.provisioned[homeKey].at, "string"); assert.equal(existsSync(join(f.gentleShellHome, ".gentle-shell-setup.lock")), false); }); test("a second launch against an already-provisioned home skips the flow entirely", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const first = run(env, []); assert.equal(first.status, 0, first.stderr); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1); const second = run(env, []); assert.equal(second.status, 0, second.stderr); assert.doesNotMatch(second.stderr, /gentle-shell: first run in/); assert.doesNotMatch(second.stderr, /gentle-ai pin changed/); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "gentle-ai must not run a second time"); }); test("a changed gentle-ai pin re-runs the flow and updates the marker", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const baseEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }); const first = run({ ...baseEnv, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }, []); assert.equal(first.status, 0, first.stderr); const second = run({ ...baseEnv, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.1" }, []); assert.equal(second.status, 0, second.stderr); assert.match(second.stderr, /gentle-shell: gentle-ai pin changed \(3\.6\.0 -> 3\.6\.1\): updating/); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2); const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8")); const homeKey = realpathSync(f.gentleShellHome); assert.equal(config.provisioned[homeKey].gentleAi, "3.6.1"); assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion()); }); // A marker written before gentle-pi version tracking existed (S8) has no // `gentlePi` field: needsProvisioning must never trust that omission as a // match, so the next launch re-provisions and backfills the field. test("a marker without a gentlePi field re-runs the flow and backfills it", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const first = run(env, []); assert.equal(first.status, 0, first.stderr); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1); const configPath = join(f.home, ".gentle-shell", "config.json"); const homeKey = realpathSync(f.gentleShellHome); const staleConfig = JSON.parse(readFileSync(configPath, "utf8")); delete staleConfig.provisioned[homeKey].gentlePi; writeFileSync(configPath, JSON.stringify(staleConfig)); const second = run(env, []); assert.equal(second.status, 0, second.stderr); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2, "gentle-ai must re-run once the marker predates gentlePi tracking"); const config = JSON.parse(readFileSync(configPath, "utf8")); assert.equal(config.provisioned[homeKey].gentleAi, "3.6.0"); assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion()); }); test("a changed gentle-pi version re-runs the flow and prints the gentle-pi-changed line", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const first = run(env, []); assert.equal(first.status, 0, first.stderr); const configPath = join(f.home, ".gentle-shell", "config.json"); const homeKey = realpathSync(f.gentleShellHome); const staleConfig = JSON.parse(readFileSync(configPath, "utf8")); staleConfig.provisioned[homeKey].gentlePi = "0.0.0-previous-launcher"; writeFileSync(configPath, JSON.stringify(staleConfig)); const second = run(env, []); assert.equal(second.status, 0, second.stderr); assert.match(second.stderr, new RegExp(`gentle-shell: gentle-pi changed \\(0\\.0\\.0-previous-launcher -> ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\): updating`)); assert.doesNotMatch(second.stderr, /gentle-ai pin changed/); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2); const config = JSON.parse(readFileSync(configPath, "utf8")); assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion()); }); test("a failing auto-provision flow warns, still launches pi, and writes no marker", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript, 5); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.match( result.stderr, /gentle-shell: automatic setup failed \(exit 5\); starting anyway and retrying next run\. Run `gentle-shell setup` to see the full output\./, ); // pi still launches, with today's plain injection (the home never got a // gentle-pi declaration since the flow failed). const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args.slice(-2), ["--mode", "rpc"]); const configPath = join(f.home, ".gentle-shell", "config.json"); if (existsSync(configPath)) { const config = JSON.parse(readFileSync(configPath, "utf8")); assert.equal(config.provisioned, undefined); } }); test("a failing auto-provision flow names the --home selector in its retry remediation", (t) => { const f = fixture(t); const target = join(f.root, "custom-home"); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript, 5); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.match( result.stderr, new RegExp(`Run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} setup\` to see the full output\\.`), ); }); test("GENTLE_SHELL_NO_AUTO_SETUP=1 skips auto-provisioning entirely", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_NO_AUTO_SETUP: "1" }; const result = run(env, []); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(counterPath), false); const configPath = join(f.home, ".gentle-shell", "config.json"); assert.equal(existsSync(configPath), false); }); test("--link never auto-provisions", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, PI_CODING_AGENT_DIR: piAgentDir }); const result = run(env, ["--link", "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(counterPath), false); assert.equal(existsSync(join(piAgentDir, ".gentle-shell-setup.lock")), false); }); test("a pi subcommand (list) never auto-provisions", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }); const result = run(env, ["list"]); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(counterPath), false); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["list"]); }); test("a fresh concurrent lock skips auto-provisioning for this run, without removing the lock", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); const lockPath = join(f.gentleShellHome, ".gentle-shell-setup.lock"); writeFileSync(lockPath, ""); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }); const result = run(env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(counterPath), false); assert.match(result.stderr, /already provisioning/); assert.equal(existsSync(lockPath), true); }); test("a stale lock (older than 15 minutes) is removed and auto-provisioning proceeds", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); const lockPath = join(f.gentleShellHome, ".gentle-shell-setup.lock"); writeFileSync(lockPath, ""); const staleTime = new Date(Date.now() - 16 * 60 * 1000); utimesSync(lockPath, staleTime, staleTime); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1); assert.equal(existsSync(lockPath), false, "the lock must be released once the flow completes"); }); // --- auto-provisioning only touches homes gentle-shell owns (R1-001) ------- test("a --home pointing at an existing non-empty, unmarked directory is never auto-provisioned", (t) => { const f = fixture(t); const target = join(f.root, "existing-pi-home"); mkdirSync(target, { recursive: true }); writeFileSync(join(target, "settings.json"), JSON.stringify({})); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(counterPath), false, "gentle-ai must never run against a foreign, unmarked home"); assert.match( result.stderr, new RegExp(`gentle-shell: ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} already has content and was not set up by gentle-shell`), ); assert.match(result.stderr, new RegExp(`run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} setup\` to provision it`)); }); test("an empty or nonexistent --home is still auto-provisioned", (t) => { const f = fixture(t); const target = join(f.root, "brand-new-home"); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "a fresh --home must still be auto-provisioned"); }); // #2106: both installers finish with `gentle-shell setup`; the first launch // after it must not install the companion packages again. // Gentle AI requires a `pi` on its PATH and runs `pi install npm:gentle-pi`. // When gentle-shell runs a Pi that is not itself on PATH (the adjacent // package, or GENTLE_SHELL_PI), setup must give Gentle AI that same Pi. test("setup gives Gentle AI a `pi` on its PATH that runs the same Pi gentle-shell resolved", { skip: process.platform === "win32" }, (t) => { const f = fixture(t); const probe = join(f.root, "pi-from-gentle-ai.txt"); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "import { spawnSync } from 'node:child_process';", "import { writeFileSync } from 'node:fs';", "const pi = spawnSync('pi', ['--version'], { encoding: 'utf8' });", `writeFileSync(${JSON.stringify(probe)}, JSON.stringify({ status: pi.status, error: pi.error?.code ?? null, stdout: pi.stdout }));`, "process.exit(0);", ""].join("\n"), { mode: 0o755 }); // No `pi` anywhere on the user's PATH: only node and the system tools. const temp = join(f.root, "tmp"); mkdirSync(temp); const env = { ...f.env, TMPDIR: temp, PATH: [dirname(process.execPath), "/usr/bin", "/bin"].join(":"), GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(readFileSync(probe, "utf8")), { status: 0, error: null, stdout: "1.2.0\n" }); // The launcher is temporary: nothing is left behind in the temp folder it used. assert.deepEqual(readdirSync(temp), []); }); test("setup without a usable temp folder runs as before, without the temporary pi", { skip: process.platform === "win32" }, (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptCountingRuns(gentleAiScript, join(f.root, "runs.log")); const env = { ...f.env, TMPDIR: join(f.root, "missing-tmp"), GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /mkdtemp|gentle-shell-pi-/); }); test("a successful manual setup records the isolated home, so the first launch does not set it up again", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const setup = run(env, ["setup"]); assert.equal(setup.status, 0, setup.stderr); const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8")); const homeKey = realpathSync(f.gentleShellHome); assert.equal(config.provisioned[homeKey].gentleAi, "3.6.0"); assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion()); const launch = run(env, ["--mode", "rpc"]); assert.equal(launch.status, 0, launch.stderr); assert.doesNotMatch(launch.stderr, /first run in/); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "gentle-ai ran only for the manual setup"); }); test("manual setup records nothing for a dry run, a failed setup or pi's own default agent home", (t) => { const f = fixture(t); const configPath = join(f.home, ".gentle-shell", "config.json"); const provisioned = () => (existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")).provisioned ?? {} : {}); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const failingScript = join(f.root, "failing-gentle-ai.mjs"); writeGentleAiScriptCountingRuns(gentleAiScript, join(f.root, "runs.log")); writeGentleAiScriptCountingRuns(failingScript, join(f.root, "failed-runs.log"), 3); const env = { ...f.env, PI_CODING_AGENT_DIR: undefined, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }; assert.equal(run(env, ["setup", "--dry-run"]).status, 0); assert.deepEqual(provisioned(), {}, "dry run"); assert.notEqual(run({ ...env, GENTLE_SHELL_GENTLE_AI_BIN: failingScript }, ["setup"]).status, 0); assert.deepEqual(provisioned(), {}, "failed setup"); const defaultPiHome = join(f.home, ".pi", "agent"); const own = run(env, ["--home", defaultPiHome, "setup"]); assert.equal(own.status, 0, own.stderr); assert.deepEqual(provisioned(), {}, "pi's own default agent home is never recorded, so it is never auto-provisioned later"); }); test("a --home equal to pi's own default agent home is never auto-provisioned, even when empty", (t) => { const f = fixture(t); const defaultPiHome = join(f.home, ".pi", "agent"); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); const counterPath = join(f.root, "gentle-ai-runs.log"); writeGentleAiScriptCountingRuns(gentleAiScript, counterPath); // Exercise the default Pi home, not an ambient PI_CODING_AGENT_DIR override. const env = enableAutoProvision({ ...f.env, PI_CODING_AGENT_DIR: undefined, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", defaultPiHome, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(counterPath), false, "gentle-ai must never run against pi's own default agent home"); assert.match(result.stderr, /never auto-provisions it/); }); // --- --home ownership marker and retry after a failed first attempt (R3-001) ----- test("a freshly bootstrapped isolated or --home directory gets a gentle-shell ownership marker file", (t) => { const f = fixture(t); const target = join(f.root, "marked-home"); const result = run(f.env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const marker = JSON.parse(readFileSync(join(target, ".gentle-shell-home"), "utf8")); assert.equal(marker.createdBy, "gentle-shell"); assert.equal(marker.version, ownGentlePiVersion()); }); test("a --home directory gentle-shell itself bootstrapped is retried after its first auto-provision attempt fails", (t) => { const f = fixture(t); const target = join(f.root, "retry-home"); const failingGentleAiScript = join(f.root, "fake-gentle-ai-fails.mjs"); writeGentleAiScript(failingGentleAiScript, 1); const firstEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: failingGentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const first = run(firstEnv, ["--home", target, "--mode", "rpc"]); assert.equal(first.status, 0, first.stderr); assert.match(first.stderr, /gentle-shell: automatic setup failed/); assert.ok(existsSync(join(target, "settings.json")), "bootstrap must have seeded settings.json before the failed attempt"); assert.ok(existsSync(join(target, ".gentle-shell-home")), "bootstrap must mark the home as gentle-shell-owned"); const counterPath = join(f.root, "gentle-ai-runs.log"); const succeedingGentleAiScript = join(f.root, "fake-gentle-ai-succeeds.mjs"); writeGentleAiScriptCountingRuns(succeedingGentleAiScript, counterPath); const secondEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: succeedingGentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const second = run(secondEnv, ["--home", target, "--mode", "rpc"]); assert.equal(second.status, 0, second.stderr); assert.doesNotMatch(second.stderr, /already has content and was not set up by gentle-shell/); assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "the retry must actually run gentle-ai against the previously-failed home"); }); // --- auto-provisioning interrupts, resilience, timeouts, atomic writes ----- // Waits until `child`'s stderr has emitted a line matching `pattern`, so a // test can send a signal only once the flow has actually started (never // racing the signal against the child process not existing yet). function waitForStderrMatch(child, pattern) { return new Promise((resolve, reject) => { let buffer = ""; const onData = (chunk) => { buffer += chunk.toString("utf8"); if (pattern.test(buffer)) { child.stderr.off("data", onData); resolve(buffer); } }; child.stderr.on("data", onData); child.once("error", reject); }); } function waitForExit(child: ReturnType): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { return new Promise((resolve) => { child.once("exit", (code, signal) => resolve({ code, signal })); }); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // Unique token the stub gentle-ai below writes to its own stderr (relayed // through this launcher's inherited stdio, see buildSetupEnv's stdio wiring) // the instant it starts, before it sleeps. Waiting for this — instead of the // launcher's own "provisioning" stderr line, which is written before the // child is even spawned and proves nothing about the child or spawnAndWait's // signal-forwarding listeners being ready — is deterministic: it can only // appear once the child process actually exists and spawnAndWait has // resolved its Promise executor synchronously (spawn + signal handler // registration, no `await` in between), so no fixed settle delay is needed // before sending the real signal. const CHILD_READY_TOKEN = "GENTLE_SHELL_TEST_CHILD_READY"; test("SIGINT during automatic provisioning kills the child and exits 130 without launching pi", { timeout: 10000 }, async (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs"); writeFileSync( gentleAiScript, ["#!/usr/bin/env node", `process.stderr.write(${JSON.stringify(CHILD_READY_TOKEN)} + "\\n");`, "await new Promise((resolve) => setTimeout(resolve, 20000));", "process.exit(0);", ""].join( "\n", ), ); chmodSync(gentleAiScript, 0o755); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const child = spawn(process.execPath, [binPath, "--mode", "rpc", "-p", "hi"], { env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }); await waitForStderrMatch(child, new RegExp(CHILD_READY_TOKEN)); child.kill("SIGINT"); const { code, signal } = await waitForExit(child); assert.equal(signal, null, "the launcher process itself must exit normally, not be killed by the signal"); assert.equal(code, 130); assert.equal(stdout.trim(), "", "pi must never launch once an auto-provision spawn was interrupted"); }); 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) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai-self-kill.mjs"); writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "process.kill(process.pid, 'SIGKILL');", ""].join("\n")); chmodSync(gentleAiScript, 0o755); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--mode", "rpc", "-p", "hi"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /gentle-shell: automatic setup failed/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]); }); test( "an unexpected error writing the provisioning marker (e.g. an unwritable config directory) still launches pi", { skip: process.platform === "win32" ? "chmod-based write restriction is not portable to Windows" : false }, (t) => { const f = fixture(t); // The config.json path itself does not exist yet (loadConfig's and // maybeAutoProvisionHome's own initial read both tolerate ENOENT, so // they succeed normally and reach the install); only its parent // directory is read+execute but not write, which lets writeRawConfig's // later mkdirSync — run only after a successful install, when it // writes the S7 marker — fail with EACCES instead of silently // succeeding. This isolates the failure to the write step inside // maybeAutoProvisionHome, the one this fix wraps in try/catch. const restrictedRoot = join(f.root, "locked-config-root"); mkdirSync(restrictedRoot, { recursive: true }); const configPath = join(restrictedRoot, ".gentle-shell", "config.json"); chmodSync(restrictedRoot, 0o500); // No permission restore needed: restrictedRoot stays empty (its own // .gentle-shell subdirectory never gets created), and removing an // empty directory only requires write permission on its *parent* // (f.root, unaffected), not on the directory's own mode. const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0", GENTLE_SHELL_CONFIG: configPath, }); const result = run(env, ["--mode", "rpc", "-p", "hi"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /gentle-shell: automatic setup failed unexpectedly/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]); }, ); test("a persona snapshot read failure is a non-fatal warning in both manual and automatic modes", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); // A directory where persona.json should be: readFileSync throws EISDIR // (not ENOENT), the exact non-fatal case snapshotFile must not propagate. const personaPath = personaPathFor(f); mkdirSync(personaPath, { recursive: true }); const manual = run({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }, ["setup"]); assert.equal(manual.status, 0, manual.stderr); assert.match(manual.stderr, /gentle-shell: could not snapshot your Pi persona file/); const auto = run( enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.1" }), ["--mode", "rpc", "-p", "hi"], ); assert.equal(auto.status, 0, auto.stderr); assert.match(auto.stderr, /gentle-shell: could not snapshot your Pi persona file/); const payload = JSON.parse(auto.stdout); assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]); }); test("automatic setup failure includes the underlying message on the line after the generic failure line", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); // A pin below MIN_SETUP_GENTLE_AI_VERSION makes runSetupFlow fail early // with a concrete `message`, before ever spawning the stub. const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.5.0" }); const result = run(env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const lines = result.stderr.trim().split("\n"); const failureIndex = lines.findIndex((line) => /gentle-shell: automatic setup failed/.test(line)); assert.notEqual(failureIndex, -1, result.stderr); assert.match(lines[failureIndex + 1], /gentle-shell: setup needs the package-local gentle-ai v3\.6\.0 or newer \(pinned: 3\.5\.0\)/); }); test("a hung child during automatic provisioning is killed after the timeout ceiling, treated as a failure, and pi still launches", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs"); writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "await new Promise((resolve) => setTimeout(resolve, 60000));", "process.exit(0);", ""].join("\n")); chmodSync(gentleAiScript, 0o755); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0", GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "300", }); const started = Date.now(); const result = run(env, ["--mode", "rpc", "-p", "hi"]); const elapsed = Date.now() - started; assert.equal(result.status, 0, result.stderr); assert.ok(elapsed < 15000, `expected the hung child to be killed quickly, took ${elapsed}ms`); assert.match(result.stderr, /gentle-shell: automatic setup failed/); // The message reports the *effective* ceiling (GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS=300 // above), not the hardcoded production default: 300ms is not a whole number // of minutes, so it renders in seconds. assert.match(result.stderr, /timed out after 0\.3 seconds/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]); }); test("a hung 'pi remove' during post-install cleanup is killed after the timeout ceiling and reports it in seconds too", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai-declares-conflict.mjs"); writeGentleAiScriptDeclaringConflict(gentleAiScript); const hangingPiScript = join(f.root, "fake-pi-hangs-on-remove.mjs"); writeFileSync( hangingPiScript, [ "#!/usr/bin/env node", "const args = process.argv.slice(2);", 'if (args.includes("--version")) { console.log("1.2.0"); process.exit(0); }', "if (args[0] === 'remove') { await new Promise((resolve) => setTimeout(resolve, 60000)); process.exit(0); }", "console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));", "process.exit(0);", "", ].join("\n"), ); chmodSync(hangingPiScript, 0o755); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0", GENTLE_SHELL_PI: hangingPiScript, GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "2000", }); const started = Date.now(); const result = run(env, ["--mode", "rpc", "-p", "hi"]); const elapsed = Date.now() - started; assert.equal(result.status, 0, result.stderr); assert.ok(elapsed < 15000, `expected the hung 'pi remove' to be killed quickly, took ${elapsed}ms`); assert.match(result.stderr, /gentle-shell: pi remove npm:@juicesharp\/rpiv-ask-user-question timed out after 2 seconds/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]); }); test("manual setup never times out even past the auto-mode ceiling override", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs"); writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "await new Promise((resolve) => setTimeout(resolve, 300));", "process.exit(0);", ""].join("\n")); chmodSync(gentleAiScript, 0o755); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "50" }; const result = run(env, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /timed out/); }); test("the provisioning marker write leaves no stray temp file behind", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScriptCountingRuns(gentleAiScript, join(f.root, "gentle-ai-runs.log")); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, []); assert.equal(result.status, 0, result.stderr); const configDir = join(f.home, ".gentle-shell"); assert.deepEqual(readdirSync(configDir), ["config.json"]); }); // --- default Gentleman-Cute theme ------------------------------------------- test("a freshly bootstrapped home defaults to the Gentleman-Cute theme", (t) => { const f = fixture(t); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8")); assert.equal(settings.theme, "Gentleman-Cute"); }); test("automatic setup never overwrites a theme the home already declared", (t) => { const f = fixture(t); const target = join(f.root, "themed-home"); mkdirSync(target, { recursive: true }); writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen", theme: "rose" })); writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() })); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8")); assert.equal(settings.theme, "rose"); }); test("gentle-shell --link setup never touches the home's theme", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent-link"); mkdirSync(piAgentDir, { recursive: true }); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" })); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "setup"]); assert.equal(result.status, 0, result.stderr); const settings = JSON.parse(readFileSync(join(piAgentDir, "settings.json"), "utf8")); assert.equal(settings.theme, undefined, "link mode must never gain a theme from gentle-shell's own default-theme logic"); }); test("automatic setup replaces a theme gentle-ai wrote into a home that had none with the default Gentle Shell theme", (t) => { const f = fixture(t); const target = join(f.root, "theme-home"); mkdirSync(target, { recursive: true }); writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" })); writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() })); const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs"); writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa"); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8")); assert.equal(settings.theme, "Gentleman-Cute"); }); // R3-theme-enforcement-dead-on-fresh-home: on a brand-new home, the isolated // bootstrap (installIsolatedTuiModeSetting) already writes "theme": // "Gentleman-Cute" into settings.json *before* auto-provisioning's gentle-ai // spawn ever runs — so the snapshot runSetupFlow takes right before that // spawn already has a theme, and a naive "only act when the home had no // theme before the spawn" check would never fire, letting gentle-ai's own // theme silently win on every freshly bootstrapped home. test("automatic setup restores the bootstrapped default theme when gentle-ai overwrites it on a fresh home", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs"); writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa"); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8")); assert.equal(settings.theme, "Gentleman-Cute"); }); test("automatic setup restores the home's own theme when gentle-ai overwrites it", (t) => { const f = fixture(t); const target = join(f.root, "themed-home-to-restore"); mkdirSync(target, { recursive: true }); writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen", theme: "dracula" })); writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() })); const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs"); writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa"); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8")); assert.equal(settings.theme, "dracula"); }); // --- builtin codemode exclusion ---------------------------------------------- // // gentle-pi registers its own decorated codemode tool, so Pi's replaceable // builtin codemode always loses to it and Pi prints a startup warning. The // only per-builtin opt-out Pi supports is a `-builtin:codemode` entry in the // settings `extensions` array, which every normal launch ensures in a home // gentle-shell owns — never in --link, a foreign --home, pi's own default // agent home, a pi subcommand, or `setup --dry-run`. const CODEMODE_EXCLUSION = "-builtin:codemode"; function settingsText(dir: string): string { return readFileSync(join(dir, "settings.json"), "utf8"); } test("a fresh isolated home excludes Pi's builtin codemode, and a second launch changes nothing", (t) => { const f = fixture(t); const first = run(f.env, ["--mode", "rpc"]); assert.equal(first.status, 0, first.stderr); const settings = JSON.parse(settingsText(f.gentleShellHome)); assert.deepEqual(settings.extensions, [CODEMODE_EXCLUSION]); assert.equal(settings.tuiMode, "fullscreen"); assert.equal(settings.theme, "Gentleman-Cute"); const before = settingsText(f.gentleShellHome); const second = run(f.env, ["--mode", "rpc"]); assert.equal(second.status, 0, second.stderr); assert.equal(settingsText(f.gentleShellHome), before); assert.doesNotMatch(second.stderr, /builtin codemode/); }); test("an existing isolated home gains the exclusion after its own extension entries, keeping every other key and its formatting", (t) => { for (const [indent, newline] of [[4, "\n"], [undefined, ""]] as const) { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); const original = { tuiMode: "fullscreen", theme: "rose", extensions: ["./ext/a.ts", "!./ext/b.ts"], packages: ["npm:pi-btw"] }; writeFileSync(join(f.gentleShellHome, "settings.json"), `${JSON.stringify(original, null, indent)}${newline}`); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); const expected = { ...original, extensions: [...original.extensions, CODEMODE_EXCLUSION] }; assert.equal(settingsText(f.gentleShellHome), `${JSON.stringify(expected, null, indent)}${newline}`); assert.match(result.stderr, /builtin codemode/); } }); test("an explicit user entry for builtin:codemode is left byte-identical", (t) => { for (const entry of ["+builtin:codemode", "!builtin:codemode", "builtin:codemode", CODEMODE_EXCLUSION]) { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); const text = JSON.stringify({ tuiMode: "fullscreen", extensions: ["./ext/a.ts", entry] }); writeFileSync(join(f.gentleShellHome, "settings.json"), text); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(settingsText(f.gentleShellHome), text, entry); } }); test("a malformed or unexpected settings.json is never overwritten and pi still starts", (t) => { for (const text of ["{ not json", "[]", '{"extensions":"oops"}']) { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); writeFileSync(join(f.gentleShellHome, "settings.json"), text); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout).args.slice(-2), ["--mode", "rpc"]); assert.equal(settingsText(f.gentleShellHome), text); } }); test("an owned home without settings.json is not given one", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(existsSync(join(f.gentleShellHome, "settings.json")), false); }); test("--link never gains the exclusion", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent-link"); mkdirSync(piAgentDir, { recursive: true }); const text = JSON.stringify({ tuiMode: "fullscreen" }); writeFileSync(join(piAgentDir, "settings.json"), text); const result = run({ ...f.env, PI_CODING_AGENT_DIR: piAgentDir }, ["--link", "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(settingsText(piAgentDir), text); }); test("a foreign --home and pi's own default agent home are never edited, while a bootstrapped --home is", (t) => { const f = fixture(t); const foreign = join(f.root, "existing-pi-home"); const defaultPiHome = join(f.home, ".pi", "agent"); const text = JSON.stringify({ tuiMode: "fullscreen" }); for (const dir of [foreign, defaultPiHome]) { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "settings.json"), text); const result = run({ ...f.env, PI_CODING_AGENT_DIR: undefined }, ["--home", dir, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.equal(settingsText(dir), text, dir); } const bootstrapped = join(f.root, "brand-new-home"); const result = run(f.env, ["--home", bootstrapped, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(settingsText(bootstrapped)).extensions, [CODEMODE_EXCLUSION]); }); test("a pi subcommand and setup --dry-run never edit the home's settings", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); const text = JSON.stringify({ tuiMode: "fullscreen" }); writeFileSync(join(f.gentleShellHome, "settings.json"), text); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeGentleAiScript(gentleAiScript); const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }; for (const args of [["list"], ["setup", "--dry-run"]]) { const result = run(env, args); assert.equal(result.status, 0, result.stderr); assert.equal(settingsText(f.gentleShellHome), text, args.join(" ")); } }); test("the exclusion is ensured after automatic setup rewrites settings.json", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai-drops-extensions.mjs"); writeFileSync( gentleAiScript, [ "#!/usr/bin/env node", "import { readFileSync, writeFileSync } from 'node:fs';", "import { join } from 'node:path';", "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');", "const { extensions, ...settings } = JSON.parse(readFileSync(settingsPath, 'utf8'));", "writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\\n');", "process.exit(0);", "", ].join("\n"), ); chmodSync(gentleAiScript, 0o755); mkdirSync(f.gentleShellHome, { recursive: true }); writeFileSync(join(f.gentleShellHome, "settings.json"), `${JSON.stringify({ tuiMode: "fullscreen", extensions: [CODEMODE_EXCLUSION] }, null, 2)}\n`); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(settingsText(f.gentleShellHome)).extensions, [CODEMODE_EXCLUSION]); }); // --- atomic writes keep symlinked config files intact ------------------------ // // Dotfile managers (Nix home-manager, stow, ...) ship settings.json and // config.json as symlinks. Every atomic launcher write must rename onto the // link's real target, so the link survives and the target gets the new // bytes with its own permission bits. // File symlinks need extra privileges on Windows; skip where unavailable. const fileSymlinkSkip = (() => { const dir = mkdtempSync(join(tmpdir(), "gentle-shell-symlink-probe-")); try { writeFileSync(join(dir, "target"), ""); symlinkSync(join(dir, "target"), join(dir, "link"), "file"); return false; } catch { return "file symlinks are unavailable here"; } finally { rmSync(dir, { recursive: true, force: true }); } })(); function symlinkedFile(linkPath: string, targetPath: string, text: string, mode: number) { mkdirSync(dirname(linkPath), { recursive: true }); mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, text); chmodSync(targetPath, mode); symlinkSync(targetPath, linkPath, "file"); } function assertStillLinkedTo(linkPath: string, targetPath: string, mode: number) { assert.ok(lstatSync(linkPath).isSymbolicLink(), `${linkPath} is still a symlink`); assert.equal(realpathSync(linkPath), realpathSync(targetPath)); if (process.platform !== "win32") assert.equal(statSync(targetPath).mode & 0o777, mode); for (const dir of [dirname(linkPath), dirname(targetPath)]) { assert.deepEqual(readdirSync(dir).filter((name) => name.endsWith(".tmp")), [], `no temp file left in ${dir}`); } } test("the builtin codemode exclusion writes through a symlinked settings.json", { skip: fileSymlinkSkip }, (t) => { const f = fixture(t); const linkPath = join(f.gentleShellHome, "settings.json"); const targetPath = join(f.root, "dotfiles", "settings.json"); symlinkedFile(linkPath, targetPath, JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }), 0o640); const result = run(f.env, ["--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assertStillLinkedTo(linkPath, targetPath, 0o640); assert.deepEqual(JSON.parse(readFileSync(targetPath, "utf8")), { tuiMode: "fullscreen", theme: "rose", extensions: [CODEMODE_EXCLUSION] }); }); test("the automatic setup theme restore writes through a symlinked settings.json", { skip: fileSymlinkSkip }, (t) => { const f = fixture(t); const target = join(f.root, "linked-themed-home"); const linkPath = join(target, "settings.json"); const targetPath = join(f.root, "dotfiles", "themed-settings.json"); symlinkedFile(linkPath, targetPath, JSON.stringify({ tuiMode: "fullscreen", theme: "dracula" }), 0o600); writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() })); const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs"); writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa"); const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }); const result = run(env, ["--home", target, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assertStillLinkedTo(linkPath, targetPath, 0o600); assert.equal(JSON.parse(readFileSync(targetPath, "utf8")).theme, "dracula"); }); test("'home' persistence writes through a symlinked config.json", { skip: fileSymlinkSkip }, (t) => { const f = fixture(t); const linkPath = join(f.root, "config", "config.json"); const targetPath = join(f.root, "dotfiles", "config.json"); symlinkedFile(linkPath, targetPath, "{}\n", 0o600); const result = run({ ...f.env, GENTLE_SHELL_CONFIG: linkPath }, ["home", "link"]); assert.equal(result.status, 0, result.stderr); assertStillLinkedTo(linkPath, targetPath, 0o600); assert.deepEqual(JSON.parse(readFileSync(targetPath, "utf8")), { home: "link" }); }); test("a dangling config.json symlink is replaced in place, never followed to create its target", { skip: fileSymlinkSkip }, (t) => { const f = fixture(t); const linkPath = join(f.root, "config", "config.json"); const missingTarget = join(f.root, "missing", "config.json"); mkdirSync(dirname(linkPath), { recursive: true }); symlinkSync(missingTarget, linkPath, "file"); const result = run({ ...f.env, GENTLE_SHELL_CONFIG: linkPath }, ["home", "link"]); assert.equal(result.status, 0, result.stderr); assert.equal(lstatSync(linkPath).isSymbolicLink(), false); assert.deepEqual(JSON.parse(readFileSync(linkPath, "utf8")), { home: "link" }); assert.equal(existsSync(dirname(missingTarget)), false); }); // --- --package-root silently ignored in a declared non-link home ---------- test("--package-root warns and is ignored when an isolated home already declares gentle-pi", (t) => { const f = fixture(t); mkdirSync(f.gentleShellHome, { recursive: true }); writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["npm:gentle-pi@3.5.1"], tuiMode: "fullscreen" })); const forcedRoot = join(f.root, "forced-root"); mkdirSync(forcedRoot, { recursive: true }); const result = run(f.env, ["--package-root", forcedRoot, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.match( result.stderr, new RegExp( `--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`, ), ); assert.doesNotMatch(result.stderr, /taking over gentle-pi from/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--mode", "rpc"]); }); test("--package-root prints no warning when the home does not declare gentle-pi", (t) => { const f = fixture(t); const forcedRoot = join(f.root, "forced-root"); mkdirSync(forcedRoot, { recursive: true }); const result = run(f.env, ["--package-root", forcedRoot, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /--package-root only forces a take-over/); }); test("--help mentions the setup subcommand", (t) => { const f = fixture(t); const result = run(f.env, ["--help"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /\bsetup\b/); }); test("--help lists the mcp subcommand", (t) => { const f = fixture(t); const result = run(f.env, ["--help"]); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /gentle-shell mcp /); }); // --- --link takeover of a conflicting path package ----------------------- // // Regression coverage for the settings.json shape that triggered the tool // conflict bug: gentle-pi declared as a relative *path* package (not // npm:gentle-pi) alongside another package. findGentlePiDeclaration now // recognises that path declaration by reading its package.json "name", and // the launcher takes over the pi invocation instead of also injecting its // own -e, which used to load two copies of gentle-pi side by side. test("--link takes over a path-declared conflicting gentle-pi: --no-extensions, the other package's dir, then this launcher's own -e, settings byte-identical", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); // "npm:some-other" must actually be installed under // /npm/node_modules for it to be re-injected (R3-001): a // declared-but-missing package dir is now skipped with a warning instead. mkdirSync(join(piAgentDir, "npm", "node_modules", "some-other"), { recursive: true }); const settingsPath = join(piAgentDir, "settings.json"); const settingsText = JSON.stringify({ packages: ["npm:some-other", "../other-gentle-pi"] }); writeFileSync(settingsPath, settingsText); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--mode", "rpc"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /taking over gentle-pi from/); assert.match(result.stderr, /other-gentle-pi/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", join(piAgentDir, "npm", "node_modules", "some-other"), "-e", packageRoot, "--mode", "rpc", ]); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); assert.equal(payload.PI_CODING_AGENT_DIR, piAgentDir); }); test("--link install npm:x with a path-declared conflicting gentle-pi in settings forwards the bare subcommand, no take-over", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); const settingsPath = join(piAgentDir, "settings.json"); const settingsText = JSON.stringify({ packages: ["npm:some-other", "../other-gentle-pi"] }); writeFileSync(settingsPath, settingsText); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "install", "npm:x"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /taking over gentle-pi from/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["install", "npm:x"]); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); assert.equal(payload.PI_CODING_AGENT_DIR, piAgentDir); }); test("--link does not take over a git-sourced other package: it is skipped with a warning, not injected", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); const settingsPath = join(piAgentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ packages: ["git:github.com/foo/bar", "../other-gentle-pi"] })); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /skipping git-sourced package/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]); }); test("--package-root forces a takeover even when settings already declare a matching npm:gentle-pi", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const settingsPath = join(piAgentDir, "settings.json"); const settingsText = JSON.stringify({ packages: ["npm:gentle-pi"] }); writeFileSync(settingsPath, settingsText); const forcedRoot = join(f.root, "forced-root"); mkdirSync(forcedRoot, { recursive: true }); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--package-root", forcedRoot], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /taking over gentle-pi from npm:gentle-pi/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--no-extensions", "-e", forcedRoot]); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); }); test("--package-root forces a takeover even with no gentle-pi declaration at all: --no-extensions and the other packages are still injected", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); // "npm:some-other" must actually be installed under // /npm/node_modules for it to be re-injected (R3-001). mkdirSync(join(piAgentDir, "npm", "node_modules", "some-other"), { recursive: true }); const settingsPath = join(piAgentDir, "settings.json"); const settingsText = JSON.stringify({ packages: ["npm:some-other"] }); writeFileSync(settingsPath, settingsText); const forcedRoot = join(f.root, "forced-root"); mkdirSync(forcedRoot, { recursive: true }); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--package-root", forcedRoot], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /taking over gentle-pi from the requested package root/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", join(piAgentDir, "npm", "node_modules", "some-other"), "-e", forcedRoot, ]); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); }); test("--link take-over with --package-root injects exactly one -e when a settings path entry reaches the same physical directory through a symlink (R4-forced-root-symlink-double-injection)", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const packagesDir = join(piAgentDir, "packages"); const realOtherDir = join(packagesDir, "real-other"); mkdirSync(realOtherDir, { recursive: true }); const linkOtherDir = join(packagesDir, "link-other"); symlinkSync(realOtherDir, linkOtherDir, "dir"); // settings declares the SYMLINK path; --package-root names the REAL path // directly. Both spellings reach the same physical directory, so it must // be injected exactly once instead of twice (once as an "other package" // via the symlink, once as this launcher's own package root). const settingsPath = join(piAgentDir, "settings.json"); writeFileSync(settingsPath, JSON.stringify({ packages: [join("packages", "link-other")] })); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--package-root", realOtherDir], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.equal(payload.args.filter((arg: string) => arg === "-e").length, 1); assert.deepEqual(payload.args, [ "--no-extensions", "-e", realOtherDir, ]); }); // --- loose extension files re-injected during a take-over ----------------- // // Regression coverage for R4-003/R3-003 (and the follow-up bug it left // behind): --no-extensions drops pi's normal settings-driven extension // discovery, which also happens to be how pi finds loose (non-package) // extensions under /extensions and the project-local // /.pi/extensions. Re-injecting those two directories wholesale as // `-e ` does not work: pi's `-e` flag hands the path straight to its // module loader (no directory-discovery pass), so a bare directory holding // only loose files fails with "Cannot find module ...". A take-over must // instead discover each loose file the same way pi's own directory scan // would (see discoverLooseExtensionEntries) and inject it individually. test("--link take-over re-injects loose extension files, one -e per discovered file, from /extensions and the project-local .pi/extensions", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); const settingsPath = join(piAgentDir, "settings.json"); const settingsText = JSON.stringify({ packages: ["../other-gentle-pi"] }); writeFileSync(settingsPath, settingsText); // /extensions: a.ts, b.js, c.ts.bak-pre-fullscreen-fix (skipped, // suffix doesn't end in .ts/.js/.mjs), sub/index.ts, .hidden.ts (skipped). const looseAgentExtensions = join(piAgentDir, "extensions"); mkdirSync(join(looseAgentExtensions, "sub"), { recursive: true }); writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};"); writeFileSync(join(looseAgentExtensions, "b.js"), "export default () => {};"); writeFileSync(join(looseAgentExtensions, "gentle-agent-state.ts.bak-pre-fullscreen-fix"), "stale backup"); writeFileSync(join(looseAgentExtensions, ".hidden.ts"), "export default () => {};"); writeFileSync(join(looseAgentExtensions, "sub", "index.ts"), "export default () => {};"); const projectDir = join(f.root, "project"); const looseProjectExtensions = join(projectDir, ".pi", "extensions"); mkdirSync(looseProjectExtensions, { recursive: true }); writeFileSync(join(looseProjectExtensions, "project-ext.mjs"), "export default () => {};"); // The launcher derives this dir from process.cwd() inside the spawned // child, which resolves symlinks (e.g. macOS's /tmp -> /private/tmp); // realpath the expectation the same way so the two agree everywhere. const resolvedLooseProjectExtensions = join(realpathSync(projectDir), ".pi", "extensions"); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--mode", "rpc"], { cwd: projectDir }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /taking over gentle-pi from/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", join(looseAgentExtensions, "a.ts"), "-e", join(looseAgentExtensions, "b.js"), "-e", join(looseAgentExtensions, "sub", "index.ts"), "-e", join(resolvedLooseProjectExtensions, "project-ext.mjs"), "-e", packageRoot, "--mode", "rpc", ]); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); }); test("--link take-over injects a root-level index.ts as its own loose file entry, alongside a sibling loose file (R4-loose-index-collapses-sibling-extensions)", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); const looseAgentExtensions = join(piAgentDir, "extensions"); mkdirSync(looseAgentExtensions, { recursive: true }); writeFileSync(join(looseAgentExtensions, "index.ts"), "export default () => {};"); // A root-level index.ts is just another loose file, not a marker that // collapses the whole directory into a single -e : pi's own // discovery loads every direct *.ts/*.js/*.mjs file individually, so a // sibling like extra.ts must keep loading too instead of being dropped. writeFileSync(join(looseAgentExtensions, "extra.ts"), "export default () => {};"); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", join(looseAgentExtensions, "extra.ts"), "-e", join(looseAgentExtensions, "index.ts"), "-e", packageRoot, ]); }); test("--link take-over injects a subdirectory's own index.ts entry point even when the loose extensions dir has no other direct files", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); const looseAgentExtensions = join(piAgentDir, "extensions"); const subDir = join(looseAgentExtensions, "sub"); mkdirSync(subDir, { recursive: true }); writeFileSync(join(subDir, "index.ts"), "export default () => {};"); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", join(subDir, "index.ts"), "-e", packageRoot, ]); }); test("--link take-over omits -e flags for loose extension dirs that do not exist", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]); }); // --- R3-001/R4-takeover-injects-unverified-package-dirs ------------------- // // A settings.json package that is declared but not actually installed on // disk (hand-edited file, a failed or interrupted `pi install`, an npm store // laid out anywhere other than /npm/node_modules) must not be // handed to pi as an unresolvable -e: pi's module loader fails on it with // "Cannot find module", which would break every take-over launch against a // partially-installed home. It is filtered the same way loose extension // candidates already are, with one stderr warning naming the source and the // resolved path, and the launch still succeeds. test("--link take-over skips a declared package directory that is not installed, warns, and still launches; an installed one is still injected", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); // "npm:some-other" is declared but never installed under // /npm/node_modules, so its resolved directory does not exist. // "npm:installed-other" IS installed, so it must still be injected. const installedOtherDir = join(piAgentDir, "npm", "node_modules", "installed-other"); mkdirSync(installedOtherDir, { recursive: true }); const settingsPath = join(piAgentDir, "settings.json"); const settingsText = JSON.stringify({ packages: ["npm:some-other", "npm:installed-other", "../other-gentle-pi"] }); writeFileSync(settingsPath, settingsText); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--mode", "rpc"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /skipping declared package "npm:some-other"/); assert.match(result.stderr, /is not a directory/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", installedOtherDir, "-e", packageRoot, "--mode", "rpc", ]); assert.equal(readFileSync(settingsPath, "utf8"), settingsText); }); test("--link take-over injects the launcher's own package root once when --package-root names a directory settings also declare as a plain path entry", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const forcedRoot = join(f.root, "forced-root"); mkdirSync(forcedRoot, { recursive: true }); const settingsPath = join(piAgentDir, "settings.json"); // The forced root is also declared as an ordinary (non-gentle-pi) path // package, so otherPackageInjections would resolve it to the same // directory as --package-root. const settingsText = JSON.stringify({ packages: ["../forced-root"] }); writeFileSync(settingsPath, settingsText); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link", "--package-root", forcedRoot], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); const eFlags = payload.args.filter((arg: string, index: number) => payload.args[index - 1] === "-e"); assert.deepEqual(eFlags, [forcedRoot]); assert.deepEqual(payload.args, [ "--no-extensions", "-e", forcedRoot, ]); }); test("--isolated --package-root produces the plain injection (no --no-extensions): the take-over path is gated on link mode", (t) => { const f = fixture(t); const forcedRoot = join(f.root, "forced-root"); mkdirSync(forcedRoot, { recursive: true }); const result = run(f.env, ["--isolated", "--package-root", forcedRoot, "--mode", "rpc"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /taking over gentle-pi from/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "-e", forcedRoot, "--mode", "rpc", ]); }); test("--package-root naming a directory that does not exist fails with a clear error instead of launching", (t) => { const f = fixture(t); const missingRoot = join(f.root, "does-not-exist"); const result = run(f.env, ["--package-root", missingRoot]); assert.notEqual(result.status, 0); assert.match(result.stderr, /--package-root/); assert.match(result.stderr, /does not exist|not a directory/); }); // --- R4-loose-extension-enumeration-fails-silently ------------------------- test("--link take-over warns once when a loose extensions directory cannot be read, instead of failing silently", { skip: process.platform === "win32" || process.getuid?.() === 0 ? "mode bits do not block readdir here" : false }, (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); const looseAgentExtensions = join(piAgentDir, "extensions"); mkdirSync(looseAgentExtensions, { recursive: true }); writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};"); chmodSync(looseAgentExtensions, 0o000); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); // Restore permissions immediately, before any assertion can throw and // skip cleanup: fixture()'s own t.after (registered before this test body // runs) removes f.root recursively, which requires read/execute // permission on every subdirectory, including this one. chmodSync(looseAgentExtensions, 0o755); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /could not read loose extension directory/); assert.match(result.stderr, /extensions/); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]); }); // --- R3-004: loose-extensions manifest branch coverage ---------------------- test("--link take-over falls through to per-file discovery when a loose dir's package.json declares an empty pi.extensions array", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); const looseAgentExtensions = join(piAgentDir, "extensions"); mkdirSync(looseAgentExtensions, { recursive: true }); writeFileSync(join(looseAgentExtensions, "package.json"), JSON.stringify({ pi: { extensions: [] } })); writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};"); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", join(looseAgentExtensions, "a.ts"), "-e", packageRoot, ]); }); test("--link take-over treats a malformed package.json as no manifest and falls through to per-file discovery", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); const looseAgentExtensions = join(piAgentDir, "extensions"); mkdirSync(looseAgentExtensions, { recursive: true }); writeFileSync(join(looseAgentExtensions, "package.json"), "{ not valid json"); writeFileSync(join(looseAgentExtensions, "a.ts"), "export default () => {};"); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, [ "--no-extensions", "-e", join(looseAgentExtensions, "a.ts"), "-e", packageRoot, ]); }); // --- R4-takeover-leaves-two-gentle-pi-skill-sets-loaded --------------------- test("--link take-over stderr message also notes that the taken-over declaration's skills, prompts, and themes still load alongside this launcher's", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); assert.match(result.stderr, /taking over gentle-pi from/); assert.match(result.stderr, /skills, prompts, and themes/); }); test("--link take-over treats a file named `extensions` as not a loose extension dir", (t) => { const f = fixture(t); const piAgentDir = join(f.root, "pi-agent"); mkdirSync(piAgentDir, { recursive: true }); const otherGentlePiDir = join(f.root, "other-gentle-pi"); mkdirSync(otherGentlePiDir, { recursive: true }); writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" })); writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"] })); writeFileSync(join(piAgentDir, "extensions"), "not a directory"); const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir }; const result = run(env, ["--link"], { cwd: f.root }); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout); assert.deepEqual(payload.args, ["--no-extensions", "-e", packageRoot]); }); // --- resume-hint handoff (lib/gentle-shell-resume-hint.ts) ---------------------- // A stand-in pi that writes a resume handoff like extensions/resume-hint.ts // does, prints pi's own exit hint, and exits with the given code. function writeHandoffPiScript(path: string, exitCode = 0) { writeFileSync( path, [ "#!/usr/bin/env node", "const { writeFileSync } = require('node:fs');", "const args = process.argv.slice(2);", "if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }", "const handoff = process.env.GENTLE_SHELL_RESUME_HANDOFF;", "if (handoff) writeFileSync(handoff, JSON.stringify({ sessionId: 'abc' }));", "if (process.env.PI_STUB_PRINT_ENV) console.log(JSON.stringify({ args, handoff }));", "process.stdout.write('To resume this session: pi --session abc\\n');", `process.exit(${exitCode});`, "", ].join("\n"), ); chmodSync(path, 0o755); } test("interactive launch hands pi a private resume handoff and cleans it up", (t) => { const f = fixture(t); const piScript = join(f.root, "handoff-pi.cjs"); writeHandoffPiScript(piScript); const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, []); assert.equal(result.status, 0, result.stderr); const lines = result.stdout.trim().split("\n"); const { handoff } = JSON.parse(lines[0]); assert.equal(typeof handoff, "string"); assert.match(handoff, /gentle-shell-resume-/); assert.equal(existsSync(dirname(handoff)), false, "handoff dir must be removed after pi exits"); // Not a TTY: like pi's own hint, the gentle-shell line is not printed. assert.deepEqual(lines.slice(1), ["To resume this session: pi --session abc"]); }); test("pi subcommands get no resume handoff", (t) => { const f = fixture(t); const piScript = join(f.root, "handoff-pi.cjs"); writeHandoffPiScript(piScript); const result = run({ ...f.env, GENTLE_SHELL_PI: piScript, PI_STUB_PRINT_ENV: "1" }, ["list"]); assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout.trim().split("\n")[0]).handoff, undefined); }); // The hint is only printed to a real TTY, so drive the launcher through a // pseudo-terminal. Python's pty module is the portable POSIX way to get one // without a native dependency; skipped where it is unavailable. const hasPythonPty = process.platform !== "win32" && spawnSync("python3", ["-c", "import pty"], { stdio: "ignore" }).status === 0; const PTY_RUNNER = [ "import fcntl, os, pty, struct, subprocess, sys, termios", "m, s = pty.openpty()", "fcntl.ioctl(s, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 120, 0, 0))", "p = subprocess.Popen(sys.argv[1:], stdin=s, stdout=s, stderr=s)", "os.close(s)", "out = b''", // PTY_SIGNAL_AFTER: once this text appears, send PTY_SIGNAL to the launcher. "after = os.environ.get('PTY_SIGNAL_AFTER', '').encode()", "while True:", " try: d = os.read(m, 4096)", " except OSError: break", " if not d: break", " out += d", " if after and after in out:", " after = b''", " os.kill(p.pid, int(os.environ['PTY_SIGNAL']))", "sys.stdout.write(out.decode())", "sys.exit(p.wait())", ].join("\n"); function runInPty(env: NodeJS.ProcessEnv, args: string[], expectedStatus = 0): string { // Pin the color environment so the launcher's hasColors() check does not // depend on the machine running the tests; a test can still override it. const { NO_COLOR, FORCE_COLOR, NODE_DISABLE_COLORS, ...rest } = env; const colorEnv = { ...rest, TERM: "xterm-256color", ...(env.PTY_NO_COLOR ? { NO_COLOR: "1" } : {}) }; // Bounded so a stand-in pi that never exits fails the test instead of hanging CI. const result = spawnSync("python3", ["-c", PTY_RUNNER, process.execPath, binPath, ...args], { encoding: "utf8", env: colorEnv, timeout: 30_000 }); assert.equal(result.error, undefined, String(result.error)); assert.equal(result.status, expectedStatus, result.stdout + result.stderr); return result.stdout; } const PI_HINT = "To resume this session: pi --session abc\r\n"; const GENTLE_HINT = "\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session abc\r\n"; test("on a TTY the launcher appends a gentle-shell resume line below pi's hint", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { const f = fixture(t); const piScript = join(f.root, "tty-pi.cjs"); writeHandoffPiScript(piScript); const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]); assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out)); }); test("on a TTY the gentle-shell line still follows a non-zero pi exit, keeping the code", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { const f = fixture(t); const piScript = join(f.root, "tty-pi.cjs"); writeHandoffPiScript(piScript, 3); const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"], 3); assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out)); }); // A stand-in pi that writes the handoff and then waits: it quits with its // own hint on SIGHUP/SIGTERM, but survives SIGINT (like an interrupted turn) // and quits only on the next line of input. function writeWaitingPiScript(path: string) { writeFileSync( path, [ "#!/usr/bin/env node", "const { writeFileSync } = require('node:fs');", "if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }", "writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc' }));", "const quit = () => { process.stdout.write('To resume this session: pi --session abc\\n'); process.exit(0); };", "process.on('SIGHUP', quit);", "process.on('SIGTERM', quit);", "process.on('SIGINT', () => { process.stdout.write('interrupted\\n'); setTimeout(quit, 50); });", "process.stdout.write('ready\\n');", "setInterval(() => {}, 1000);", "", ].join("\n"), ); chmodSync(path, 0o755); } test("on a TTY the launcher prints nothing extra after the terminal hangs up", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { const f = fixture(t); const piScript = join(f.root, "tty-pi.cjs"); writeWaitingPiScript(piScript); const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "1" }, ["--link"]); // pi quit cleanly with its own hint; only the gentle-shell line is withheld. assert.ok(out.endsWith(PI_HINT), JSON.stringify(out)); assert.equal(out.includes("gentle-shell --"), false, JSON.stringify(out)); }); test("on a TTY a forwarded SIGINT that pi survives does not silence the gentle-shell line", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { const f = fixture(t); const piScript = join(f.root, "tty-pi.cjs"); writeWaitingPiScript(piScript); const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_SIGNAL_AFTER: "ready", PTY_SIGNAL: "2" }, ["--link"]); assert.ok(out.includes("interrupted"), JSON.stringify(out)); assert.ok(out.endsWith(PI_HINT + GENTLE_HINT), JSON.stringify(out)); }); test("on a TTY a cross-project session resumes by its session file", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { const f = fixture(t); const sessionFile = join(f.root, "other project", "session.jsonl"); const piScript = join(f.root, "tty-pi.cjs"); writeFileSync( piScript, [ "#!/usr/bin/env node", "const { writeFileSync } = require('node:fs');", "if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }", `writeFileSync(process.env.GENTLE_SHELL_RESUME_HANDOFF, JSON.stringify({ sessionId: 'abc', sessionFile: ${JSON.stringify(sessionFile)} }));`, "process.stdout.write('To resume this session: pi --session abc\\n');", "", ].join("\n"), ); chmodSync(piScript, 0o755); const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript }, ["--link"]); assert.ok( out.endsWith(`${PI_HINT}\u001b[2mTo resume in gentle-shell:\u001b[22m gentle-shell --link --session '${sessionFile}'\r\n`), JSON.stringify(out), ); }); test("on a TTY without colors the gentle-shell line has no ANSI styling", { skip: !hasPythonPty && "needs python3 pty" }, (t) => { const f = fixture(t); const piScript = join(f.root, "tty-pi.cjs"); writeHandoffPiScript(piScript); const out = runInPty({ ...f.env, GENTLE_SHELL_PI: piScript, PTY_NO_COLOR: "1" }, ["--link"]); assert.ok(out.endsWith(`${PI_HINT}To resume in gentle-shell: gentle-shell --link --session abc\r\n`), JSON.stringify(out)); }); // #2143: a Gentle AI that honors GENTLE_AI_PI_EXCLUDE_PACKAGES (gentle-ai#5503) // never declares npm:gentle-pi, so setup neither runs `pi remove` nor prints // the contradictory "removing npm:gentle-pi" line. test("gentle-shell setup asks Gentle AI to skip npm:gentle-pi, so nothing is installed only to be removed", (t) => { const f = fixture(t); const gentleAiScript = join(f.root, "fake-gentle-ai.mjs"); writeFileSync(gentleAiScript, [ "#!/usr/bin/env node", "import { readFileSync, writeFileSync } from 'node:fs';", "import { join } from 'node:path';", "const excluded = (process.env.GENTLE_AI_PI_EXCLUDE_PACKAGES ?? '').split(',').map((entry) => entry.trim());", "const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');", "const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));", "settings.packages = ['npm:gentle-pi', 'npm:pi-btw'].filter((source) => !excluded.includes(source));", "writeFileSync(settingsPath, JSON.stringify(settings));", "process.stdout.write(JSON.stringify({ args: process.argv.slice(2), excluded: process.env.GENTLE_AI_PI_EXCLUDE_PACKAGES ?? null }) + '\\n');", "", ].join("\n")); chmodSync(gentleAiScript, 0o755); const result = run({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }, ["setup"]); assert.equal(result.status, 0, result.stderr); assert.doesNotMatch(result.stderr, /removing npm:gentle-pi/); const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0); assert.equal(lines.length, 1, "no pi remove ran: " + result.stdout); assert.deepEqual(JSON.parse(lines[0]), { args: ["install", "--agent", "pi", "--scope", "global"], excluded: "npm:gentle-pi" }); });