diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 2ffcc0892..766212e64 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -40,6 +40,7 @@ import { forceJsonFieldIfAbsentInOriginal, helpText, homeSelectorFlags, + isolatedDir, isSetupCapablePin, launcherConfigPath, MIN_SETUP_GENTLE_AI_VERSION, @@ -62,6 +63,8 @@ import { separateHomeHint, shellQuote, standaloneConfigHome, + launchConfigHome, + baseConfigHome, userPiHome, } from "../runtime/gentle-shell-launcher.mjs"; import { formatImportPiReport, importPi } from "../runtime/gentle-shell-import-pi.mjs"; @@ -91,6 +94,7 @@ import { bundledUpdateNext, bundledUpdateNotice, bundledUpdatePlan, + bundledPiConfigDir, nodeBundledFs, parseBundledUpgradeArgs, resolveBundledInstall, @@ -105,6 +109,25 @@ import { } from "../runtime/gentle-shell-resume-hint.mjs"; import { bundledGentleAiPaths, GENTLE_AI_VERSION, gentleAiBinaryPath, gentleAiIsolationEnv, PackageLocalGentleAiBinaryMissingError } from "../runtime/gentle-ai-binary.mjs"; import { DEFAULT_THEME_NAME, installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs"; +import { + categoriesToSeed, + describeEnvScope, + ENV_SCOPE_FILE, + parseEnvScope, + parseScopeFlags, + planScopeChange, + seedOperations, + serializeEnvScope, + envDir, + envNameForDir, + envsRoot, + removeEnvFromConfig, + renameEnvInConfig, + setDefaultEnvInConfig, + summarizeEnv, + validateEnvName, +} from "../runtime/gentle-shell-envs.mjs"; +import { initialEnvsState, parseKeys, reduceEnvs, renderEnvsScreen } from "../runtime/gentle-shell-envs-tui.mjs"; import { artifactFor, engramPinVersion } from "../scripts/installer-downloads.mjs"; import { migrateBundledEngram, migrationPending, supportsBundledSessionOwned, MIGRATION_PENDING_NOTICE, MIGRATION_USAGE } from "../scripts/bundled-engram-migration.mjs"; import { shouldCheckStartupUpdate, findStartupUpdate } from "../runtime/gentle-shell-startup-update.mjs"; @@ -406,6 +429,7 @@ function emptyArgs() { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, @@ -689,6 +713,472 @@ async function handleImportPiCommand(args) { process.exitCode = result.failed ? 1 : 0; } +// --- named envs (`gentle-shell envs`, lib/gentle-shell-envs*.ts) ------------- + +// The env's own Gentle config categories (lib/gentle-config-scope.ts); a +// missing or unreadable scope file means it shares everything. +function readEnvScope(dir) { + return parseEnvScope(readTextOrUndefined(join(dir, ENV_SCOPE_FILE))); +} + +// The shared (base) Gentle config home, exactly as a Pi process launched in +// a named env resolves it without env scope: the bundled prefix's config, +// then GENTLE_PI_CONFIG_HOME, then the base isolated home's own folder +// (~/.gentle-shell/gentle-ai, #2201), then ~/.pi/gentle-ai. +function sharedConfigHome() { + const install = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs }); + if (install) return bundledPiConfigDir(install.root); + if (process.env.GENTLE_PI_CONFIG_HOME) return process.env.GENTLE_PI_CONFIG_HOME; + return baseConfigHome(process.env, homedir()) ?? join(homedir(), ".pi", "gentle-ai"); +} + +// Applies --own/--share/--from/--empty to one env: seeds the categories that +// become own (a copy from the base home or another env, or empty), then +// persists the scope. Sharing a category again keeps its own files on disk. +function applyEnvScope(root, name, flags) { + // Same guard as rename/delete: a managed, real env directory, and an own + // config folder that is never a link (seeding would write through it). + const dir = assertManagedEnvDir(root, name); + const ownDir = join(dir, "gentle-ai"); + let ownStat; + try { + ownStat = lstatSync(ownDir); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (ownStat !== undefined && (ownStat.isSymbolicLink() || !ownStat.isDirectory())) { + throw new Error(`refusing to touch ${ownDir}: not a real directory`); + } + const current = readEnvScope(dir); + const plan = planScopeChange({ current, own: flags.own, share: flags.share, seed: flags.empty ? { kind: "empty" } : { kind: "copy", from: flags.from } }); + let sourceDirFor = () => sharedConfigHome(); + if (flags.from !== undefined) { + const fromDir = envDir(root, flags.from); + if (!existsSync(fromDir)) throw Object.assign(new Error(`unknown env "${flags.from}"`), { usage: true }); + const fromOwn = readEnvScope(fromDir); + const shared = sharedConfigHome(); + sourceDirFor = (category) => (fromOwn.includes(category) ? join(fromDir, "gentle-ai") : shared); + } + const targetDir = join(dir, "gentle-ai"); + const seed = categoriesToSeed(plan.seed, targetDir, existsSync); + const createdTargetDir = seed.length > 0 && !existsSync(targetDir); + const copied = []; + try { + if (seed.length > 0) mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + for (const op of seedOperations({ categories: seed, mode: flags.empty ? "empty" : "copy", sourceDirFor, targetDir, exists: existsSync })) { + if (op.kind === "copy") { + copyFileSync(op.from, op.to); + copied.push(op.to); + } else rmSync(op.path, { force: true }); + } + writeFileAtomically(join(dir, ENV_SCOPE_FILE), serializeEnvScope(plan.next), { mode: 0o600 }); + } catch (error) { + // A seeded category had no own files before this call (categoriesToSeed), + // so what it copied is all there is: a partial copy left behind would + // read as a kept own copy next time and never be seeded again. + for (const path of copied) rmSync(path, { force: true }); + if (createdTargetDir) rmSync(targetDir, { recursive: true, force: true }); + throw error; + } + return plan; +} + +function currentEnvsRoot() { + return envsRoot(process.env, homedir()); +} + +function isRealDirectory(path) { + try { + return lstatSync(path).isDirectory(); + } catch { + return false; + } +} + +function listEnvNames(root) { + let entries; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + return entries + .filter((entry) => entry.isDirectory() && validateEnvName(entry.name) === undefined) + .map((entry) => entry.name) + .sort(); +} + +// Session files live in /sessions//*.jsonl; two levels +// are walked, never deeper, so a huge home cannot stall the manager. +function sessionStats(dir) { + let count = 0; + let last = 0; + const visit = (path, depth) => { + let entries; + try { + entries = readdirSync(path, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = join(path, entry.name); + if (entry.isDirectory() && depth < 2) visit(child, depth + 1); + else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + count += 1; + try { + last = Math.max(last, statSync(child).mtimeMs); + } catch { + // Vanished mid-walk: still counted, just undated. + } + } + } + }; + visit(join(dir, "sessions"), 1); + return { count, last: last > 0 ? new Date(last).toISOString() : undefined }; +} + +function readTextOrUndefined(path) { + try { + return readJsonIfExists(path); + } catch { + return undefined; + } +} + +// The base home first (the install's own `--isolated` home, exactly as the +// launcher resolves it), then the named envs. The default follows the same +// precedence a plain launch uses: a missing, malformed or "isolated" home +// setting opens the base home, "link" opens none of these, a path opens the +// env it names. +function loadEnvSummaries(root) { + const rawConfig = readRawConfig(resolveConfigPath()); + const persisted = loadConfig(); + const defaultReal = persisted?.mode === "path" ? safeRealpath(resolvePath(persisted.dir)) : undefined; + const summarize = (name, dir, extra) => { + const real = safeRealpath(dir); + const sessions = sessionStats(dir); + return summarizeEnv({ + name, + dir, + settingsText: readTextOrUndefined(join(dir, "settings.json")), + authText: readTextOrUndefined(join(dir, "auth.json")), + provisioned: provisionedEntry(rawConfig, real), + sessionCount: sessions.count, + lastSessionAt: sessions.last, + isDefault: defaultReal !== undefined && defaultReal === real, + ...extra, + }); + }; + const baseDir = resolvePath(isolatedDir(process.env, homedir())); + const base = summarize("base", baseDir, { base: true, ...(persisted === undefined || persisted.mode === "isolated" ? { isDefault: true } : {}) }); + return [base, ...listEnvNames(root).map((name) => summarize(name, envDir(root, name), { own: readEnvScope(envDir(root, name)) }))]; +} + +// The launcher selector that reproduces an entry: `--isolated` for the base +// home (never an env name, which may even be "base"), `--env ` otherwise. +function entrySelector(target) { + return target.base ? ["--isolated"] : ["--env", target.name]; +} + +// Same bootstrap a first `--home` launch performs, so the env is owned by +// gentle-shell and auto-provisions on its first launch. +async function createEnv(root, name) { + const dir = envDir(root, name); + if (existsSync(dir)) throw new Error(`env "${name}" already exists`); + mkdirSync(root, { recursive: true, mode: 0o700 }); + mkdirSync(dir, { mode: 0o700 }); + try { + await installIsolatedTuiModeSetting(dir); + writeHomeOwnershipMarker({ mode: "path", dir }); + } catch (error) { + // This call created `dir`: a half-bootstrapped env would read as "already + // exists" and, once non-empty and unmarked, as a foreign home --env never + // provisions. Remove it so `envs new` can simply be retried. + rmSync(dir, { recursive: true, force: true }); + throw error; + } + return dir; +} + +// Guards every destructive path: only a direct, validly named child of the +// envs root that is a real directory (not a symlink to elsewhere). +function assertManagedEnvDir(root, name) { + const dir = envDir(root, name); + if (envNameForDir(root, dir) !== name) throw new Error(`refusing to touch ${dir}: not a managed env`); + const stat = lstatSync(dir); + if (!stat.isDirectory()) throw new Error(`refusing to touch ${dir}: not a directory`); + return dir; +} + +function renameEnv(root, from, to) { + const oldDir = assertManagedEnvDir(root, from); + const newDir = envDir(root, to); + if (validateEnvName(to) !== undefined) throw new Error(validateEnvName(to)); + if (existsSync(newDir)) throw new Error(`env "${to}" already exists`); + const aliases = [...new Set([oldDir, safeRealpath(oldDir)])]; + renameSync(oldDir, newDir); + const configPath = resolveConfigPath(); + try { + writeRawConfig(configPath, renameEnvInConfig(readRawConfig(configPath), aliases, newDir, safeRealpath(newDir))); + } catch (error) { + // config.json still names the old dir (default home, setup record): put + // the env back there rather than leave a launch bootstrapping an empty + // home at the old path and the renamed env provisioning again. + renameSync(newDir, oldDir); + throw error; + } +} + +function deleteEnv(root, name) { + const dir = assertManagedEnvDir(root, name); + const aliases = [...new Set([dir, safeRealpath(dir)])]; + // config.json first: removing the folder cannot be undone, so a failed + // config write must leave the env on disk, never a default that names a + // missing folder (the next launch would recreate it empty). + const configPath = resolveConfigPath(); + writeRawConfig(configPath, removeEnvFromConfig(readRawConfig(configPath), aliases)); + rmSync(dir, { recursive: true, force: true }); +} + +function setDefaultEnv(root, name) { + const dir = assertManagedEnvDir(root, name); + const configPath = resolveConfigPath(); + writeRawConfig(configPath, setDefaultEnvInConfig(readRawConfig(configPath), dir)); +} + +// Runs this same launcher against one env (setup/install/auth) in the +// foreground, outside the manager's alternate screen. +function runLauncherForEnv(target, extraArgs) { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), ...entrySelector(target), ...extraArgs], { stdio: "inherit", env: process.env }); + return result.error ? 1 : (result.status ?? 1); +} + +function printEnvList(root) { + const entries = loadEnvSummaries(root); + // The default reads like the /gentle:profiles markers: `name (default)`. + const label = (env) => `${env.base ? "[base]" : env.name}${env.isDefault ? " (default)" : ""}`; + const width = Math.max(...entries.map((env) => label(env).length)); + for (const env of entries) { + process.stdout.write(`${label(env).padEnd(width)} ${env.dir} (${env.provisioned})\n`); + } + if (entries.length === 1) process.stdout.write(`No named envs in ${root} yet. Create one with 'gentle-shell envs new '.\n`); +} + +const ENVS_USAGE = "Usage: gentle-shell envs [list | new [scope] | config [scope]]\n envs open the env manager (TTY), or list envs\n envs list print the base home and every env; (default) marks what a plain launch opens\n envs new create an env without launching it\n envs config show or change which Gentle config an env owns\n scope: --own --share [--from | --empty]\n categories: models, persona, appearance, behavior; new own ones copy\n from the base home (or --from ), or start --empty"; + +// Returns the launcher selector to launch with, or undefined when nothing should launch. +async function handleEnvsCommand(commandArgs) { + const root = currentEnvsRoot(); + const [sub, ...rest] = commandArgs; + if (sub === "list" && rest.length === 0) { + printEnvList(root); + return undefined; + } + if (sub === "new" && rest.length >= 1) { + const [name, ...flagArgs] = rest; + const invalid = validateEnvName(name); + if (invalid !== undefined) fail(`gentle-shell: ${invalid}`, 2); + const flags = parseScopeFlags(flagArgs); + if (flags.error !== undefined) fail(`gentle-shell: ${flags.error}`, 2); + // A new env starts sharing everything: only --own makes the others meaningful. + if (flags.own.length === 0 && (flags.share.length > 0 || flags.from !== undefined || flags.empty)) { + fail("gentle-shell: --share, --from and --empty need --own on envs new", 2); + } + if (flags.from !== undefined && !existsSync(envDir(root, flags.from))) fail(`gentle-shell: unknown env "${flags.from}"`, 2); + let created; + try { + created = await createEnv(root, name); + const scope = flags.own.length > 0 ? applyEnvScope(root, name, flags) : undefined; + process.stdout.write(`Created env ${name} at ${created}\n`); + if (scope) process.stdout.write(`Config: ${describeEnvScope(scope.next)}\n`); + } catch (error) { + // All or nothing: an env whose scope could not be applied is removed again. + if (created !== undefined) rmSync(created, { recursive: true, force: true }); + fail(`gentle-shell: ${error.message}`, error.usage ? 2 : 1); + } + return undefined; + } + if (sub === "config" && rest.length >= 1) { + const [name, ...flagArgs] = rest; + const invalid = validateEnvName(name); + if (invalid !== undefined) fail(`gentle-shell: ${invalid}`, 2); + if (!existsSync(envDir(root, name))) fail(`gentle-shell: unknown env "${name}"`, 2); + const flags = parseScopeFlags(flagArgs); + if (flags.error !== undefined) fail(`gentle-shell: ${flags.error}`, 2); + try { + const own = flagArgs.length === 0 ? readEnvScope(envDir(root, name)) : applyEnvScope(root, name, flags).next; + process.stdout.write(`${name}: ${describeEnvScope(own)}\n`); + } catch (error) { + fail(`gentle-shell: ${error.message}`, error.usage ? 2 : 1); + } + return undefined; + } + if (sub === "--help" || sub === "-h") { + process.stdout.write(`${ENVS_USAGE}\n`); + return undefined; + } + if (sub !== undefined) fail(`${ENVS_USAGE}`, 2); + if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) { + printEnvList(root); + return undefined; + } + return runEnvsManager(root); +} + +// One alternate-screen session: draws `state`, feeds keys to the reducer and +// applies in-place effects through `apply`, and resolves with the first +// effect that needs the real terminal (quit, launch, setup, install, auth). +// The terminal is always restored, including when `apply` throws. +function envsScreenSession(initial, apply) { + const { stdin, stdout } = process; + // Gentle truecolor only where the terminal has it and NO_COLOR is unset; + // otherwise the same layout in monochrome. + const renderOptions = { + color: !process.env.NO_COLOR && stdout.hasColors?.(2 ** 24) === true, + version: ownPackageVersion(), + }; + return new Promise((resolve) => { + let state = initial; + const draw = () => { + // Some ptys report 0x0 until resized; fall back to a usable size. + const lines = renderEnvsScreen(state, stdout.columns || 80, stdout.rows || 24, renderOptions); + stdout.write(`\x1b[H${lines.map((line) => `${line}\x1b[K`).join("\r\n")}\x1b[J`); + }; + const finish = (effect) => { + finished = true; + stdin.off("data", onData); + stdout.off("resize", draw); + stdin.setRawMode(false); + stdin.pause(); + stdout.write("\x1b[?25h\x1b[?1049l"); + resolve({ state, effect }); + }; + // Input is handled strictly in order: a key pressed while `apply` is + // still writing to disk waits for it instead of racing it. + let queue = Promise.resolve(); + let finished = false; + const onData = (data) => { + queue = queue.then(() => handle(data)).catch((error) => { + state = { ...state, message: `Error: ${error.message}` }; + if (!finished) draw(); + }); + }; + const handle = async (data) => { + if (finished) return; + for (const key of parseKeys(data.toString("utf8"))) { + const step = reduceEnvs(state, key); + state = step.state; + if (step.effect === undefined) continue; + const kind = step.effect.kind; + if (kind === "create" || kind === "rename" || kind === "delete" || kind === "set-default" || kind === "scope") { + state = await apply(state, step.effect); + continue; + } + finish(step.effect); + return; + } + draw(); + }; + stdout.write("\x1b[?1049h\x1b[?25l"); + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", onData); + stdout.on("resize", draw); + draw(); + }); +} + +function waitForAnyKey(message) { + const { stdin, stdout } = process; + stdout.write(`\n${message}`); + return new Promise((resolve) => { + stdin.setRawMode(true); + stdin.resume(); + stdin.once("data", () => { + stdin.setRawMode(false); + stdin.pause(); + stdout.write("\n"); + resolve(); + }); + }); +} + +async function runEnvsManager(root) { + // Reloads from disk after every change so the view never drifts from it. + const reload = (message, selectName) => initialEnvsState(loadEnvSummaries(root), message, selectName); + const apply = async (state, effect) => { + try { + if (effect.kind === "create") { + const created = await createEnv(root, effect.name); + let scopeNote = ""; + try { + if (effect.own.length > 0) scopeNote = ` Config: ${describeEnvScope(applyEnvScope(root, effect.name, { own: effect.own, share: [], from: effect.from, empty: effect.empty === true }).next)}.`; + } catch (error) { + // All or nothing, like `envs new`: never leave a half-configured env. + rmSync(created, { recursive: true, force: true }); + throw error; + } + return reload(`Created ${effect.name}.${scopeNote} Press enter to launch it (first launch installs the companion packages).`, effect.name); + } + if (effect.kind === "scope") { + const plan = applyEnvScope(root, effect.name, { own: effect.own, share: effect.share, from: effect.from, empty: effect.empty === true }); + return reload(`${effect.name}: ${describeEnvScope(plan.next)}. Applies from its next session.`, effect.name); + } + if (effect.kind === "rename") { + renameEnv(root, effect.from, effect.to); + return reload(`Renamed ${effect.from} to ${effect.to}.`, effect.to); + } + if (effect.kind === "delete") { + deleteEnv(root, effect.name); + return reload(`Deleted ${effect.name}.`); + } + if (effect.base) { + // Exactly what `gentle-shell home isolated` persists. + const configPath = resolveConfigPath(); + writeRawConfig(configPath, { ...readRawConfig(configPath), home: "isolated" }); + return reload("The base home is now the default: a plain 'gentle-shell' opens it.", { base: true }); + } + setDefaultEnv(root, effect.name); + return reload(`${effect.name} is now the default: a plain 'gentle-shell' opens it.`, effect.name); + } catch (error) { + return { ...state, message: `Error: ${error.message}` }; + } + }; + + let state = reload(); + for (;;) { + const { state: last, effect } = await envsScreenSession(state, apply); + if (effect.kind === "quit") return undefined; + if (effect.kind === "launch") return entrySelector(effect); + const what = effect.base ? "the base home" : `env ${effect.name}`; + let code; + if (effect.kind === "setup") { + process.stdout.write(`gentle-shell: setting up ${what}; downloads can take a few minutes (Ctrl-C cancels)\n`); + code = runLauncherForEnv(effect, ["setup"]); + } else if (effect.kind === "install") { + process.stdout.write(`gentle-shell: installing ${effect.source} into ${what} (Ctrl-C cancels)\n`); + code = runLauncherForEnv(effect, ["install", effect.source]); + } else { + // Pi signs in from inside a session (/login); outside one it can + // only check readiness, which needs a provider per call. + const providers = last.envs.find((env) => (effect.base ? env.base === true : !env.base && env.name === effect.name))?.providers ?? []; + if (providers.length === 0) { + process.stdout.write(`No providers signed in to ${what}. Launch it and run /login inside the session.\n`); + code = 0; + } else { + code = 0; + for (const provider of providers) { + const providerCode = runLauncherForEnv(effect, ["auth", "check", "--provider", provider]); + if (providerCode !== 0) code = providerCode; + } + } + } + await waitForAnyKey(`${code === 0 ? "Done" : `Finished with exit code ${code}`}. Press any key to return to the env manager.`); + state = reload(code === 0 ? undefined : `Last action exited with code ${code}.`, effect.base ? { base: true } : effect.name); + } +} + // Test/development-only override for the setup subcommand's gentle-ai // executable path. Lets a test point at a stub script (or a deliberately // missing path) without touching the real pinned .gentle-ai/v/gentle-ai @@ -867,9 +1357,10 @@ function buildSetupEnv(home, runtime, piLauncherDir) { GENTLE_PI_AGENT_HOME: home.dir, PATH: [...(piLauncherDir ? [piLauncherDir] : []), dirname(runtime.command), process.env.PATH ?? ""].join(delimiter), }; - // #2201: the isolated home's own config folder, given to Gentle AI the - // bundled way (gentleAiIsolationEnv); never an outer session's marker. - const configHome = standaloneConfigHome(home); + // #2201: the isolated home's own config folder (shared by named envs), + // given to Gentle AI the bundled way (gentleAiIsolationEnv); never an + // outer session's marker. + const configHome = launchConfigHome(home, process.env, homedir()); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; return gentleAiIsolationEnv(env, { packageRoot }); @@ -922,7 +1413,7 @@ function sharedPersonaPath() { // does nothing. function setupPersonaPaths(home) { if (bundledInstall) return [join(bundledGentleAiPaths(bundledInstall.root, process.platform).piConfig, "persona.json"), sharedPersonaPath()]; - const configHome = standaloneConfigHome(home); + const configHome = launchConfigHome(home, process.env, homedir()); return configHome === undefined ? [sharedPersonaPath()] : [join(configHome, "persona.json"), sharedPersonaPath()]; } @@ -1896,6 +2387,15 @@ async function main() { else handleHomeCommand(args.commandArgs); return; } + if (args.command === "envs") { + const selector = await handleEnvsCommand(args.commandArgs); + if (selector === undefined) return; + // Re-enter the ordinary launch exactly as `gentle-shell --env ` + // (or `--isolated` for the base home), so startup checks, provisioning + // and resume hints all see that argv. + process.argv = [...process.argv.slice(0, 2), ...selector]; + return main(); + } // The verification input applies to `--version` only (scripts/bundled-install.mjs upgradeBundled). const verifying = args.version && args.command === undefined ? process.env[BUNDLED_VERIFY_ENV] : undefined; const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs, verifying }); @@ -1933,6 +2433,15 @@ async function main() { const config = loadConfig(); let home = resolveHome({ args, env: process.env, homedir: homedir(), config }); if (home.mode === "path") home = { ...home, dir: resolvePath(home.dir) }; + // An explicit --env never creates a home: a typo must not silently become + // a new, empty env. A persisted default keeps the ordinary bootstrap. + // Like the envs list, only a real directory (never a file or a symlink) + // counts as an env. + if (args.env !== undefined && !isRealDirectory(home.dir)) { + fail(`gentle-shell: unknown env "${args.env}" (${home.dir}).\nCreate it with 'gentle-shell envs new ${args.env}' or manage envs with 'gentle-shell envs'.`, 2); + } + // The env's own Gentle config categories travel with the launch. + if (home.env !== undefined) home = { ...home, ownConfig: readEnvScope(home.dir) }; // Version and help runs (Shell's own or forwarded to Pi) never probe Engram, // ensure the companion, print memory notices or touch provenance. @@ -2028,9 +2537,10 @@ async function main() { } // #2201: the standalone isolated home's own config folder, seeded once from - // the shared ~/.pi/gentle-ai. Not when an explicit GENTLE_PI_CONFIG_HOME - // decides the config home; never blocks the launch. - const configHome = bundled || process.env.GENTLE_PI_CONFIG_HOME ? undefined : standaloneConfigHome(home); + // the shared ~/.pi/gentle-ai, also when a named env sharing it launches + // first. Not when an explicit GENTLE_PI_CONFIG_HOME decides the config + // home; never blocks the launch. + const configHome = bundled || process.env.GENTLE_PI_CONFIG_HOME ? undefined : launchConfigHome(home, process.env, homedir()); if (configHome !== undefined) { try { const migration = migrateStandaloneConfig({ source: join(homedir(), ".pi", "gentle-ai"), target: configHome, fs: configMigrationFs }); diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 55a83a146..588490db5 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -44,9 +44,9 @@ At 140 columns or wider, fullscreen defaults to a live header over a transcript- ✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ``` -- The header carries live session data: session identity on the left (brand, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). The effort reads `auto` for NaN models that manage their reasoning depth themselves (DeepSeek V4 Flash, Qwen 3.8 Flash, MiMo), with a one-time notice explaining that the selected level does not change the depth. It never shows the working/thinking state or extension statuses — those stay in the prompt indicator and the Status surface. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand survives, and below that the header renders nothing. +- The header carries live session data: session identity on the left (brand, the named env when launched with `--env`, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). The effort reads `auto` for NaN models that manage their reasoning depth themselves (DeepSeek V4 Flash, Qwen 3.8 Flash, MiMo), with a one-time notice explaining that the selected level does not change the depth. It never shows the working/thinking state or extension statuses — those stay in the prompt indicator and the Status surface. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand and the env survive, and below that the header renders nothing. - The right rail scrolls **Status → Changes → TODO**. Cards cache their rendered content until their own digest or an explicit invalidation changes. `neon` keeps the rounded border and accent title; `float` uses a tone background, a full-height `▎` accent, one-column transparent margins, painted padding above and below, and a blank painted separator between heading and body. Warning/error/success panels retain their tone colors. -- The Status card carries Project (cwd, branch, session name, active profile), Changes, and Integrations (other extensions' statuses); its live digest detects changes without requiring an event. Model, effort, context, cost, and the per-model usage table live in the header instead — the header ticks every frame, so duplicating them in a card would just make that card repaint every frame too. +- The Status card carries Env (the named env, only when launched with `--env`, shown first), Project (cwd, branch, session name, active profile), Changes, and Integrations (other extensions' statuses); its live digest detects changes without requiring an event. Model, effort, context, cost, and the per-model usage table live in the header instead — the header ticks every frame, so duplicating them in a card would just make that card repaint every frame too. - Gentle Agents is not part of the rail in any mode: its one card stays above the editor, where it already lived, with fixed right-aligned columns for `model · effort`, tokens, cost, and elapsed, each sized to the widest value among the shown tasks — so the numbers line up vertically even when one row's values are much shorter than another's. A queued task fills only the elapsed column with the word `queued`, leaving the other columns blank rather than overwriting the row. - The sidebar reuses its last frame until something it paints changes, so silent frames stay cheap; a per-section cache means one card's changing digest (or the header's) never forces an unrelated card to redraw. - Header placement is configurable. Above input, the float header has a full-width background, painted padding above and below its content, and a transparent `▔` bottom edge. Below input, the float footer groups Changes (when the session's roots have uncommitted work), header data, and locally owned integration statuses under one upper edge; it never duplicates statuses owned by the rail. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index ce87aa438..5d7d94628 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -301,6 +301,7 @@ An orphan branch with commits and no parent has no branch point to name as `base ```bash gentle-shell [options] [-- pi-args...] gentle-shell home [link|isolated|] +gentle-shell envs [list | new [scope] | config [scope]] gentle-shell [home selectors] setup [--dry-run] gentle-shell upgrade [--channel release|main] gentle-shell upgrade --rollback @@ -314,12 +315,13 @@ gentle-shell self-uninstall [--dry-run] [--yes] [--include-shared] | `--link` | Home is your own pi home: `GENTLE_SHELL_USER_PI_HOME` when a Gentle Shell session recorded it (so `--link` run from inside an isolated session reaches your original pi, not the isolated home), otherwise `PI_CODING_AGENT_DIR` or `~/.pi/agent`. Reuses your existing pi sign-ins, models, and chats; never writes to its `settings.json`. | | `--isolated` | Home is `GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`, with its own settings in `~/.gentle-shell/gentle-ai` ([config home](#config-home)); a custom `GENTLE_SHELL_HOME` outside a `.gentle-shell` folder keeps `~/.pi/gentle-ai`. No credential seeding. Default when nothing else is configured. | | `--home ` | Home is the given directory. | +| `--env ` | Home is the named env `GENTLE_SHELL_ENVS_DIR/` (default `~/.gentle-shell/envs/`). The env must already exist; an unknown name exits 2 instead of creating a new home. See "Named envs" below. | | `--package-root ` | Force this directory as the gentle-pi package to load, taking over from any conflicting package the target `settings.json` already declares (see "Loading the package" below). | | `--help`, `-h` | Print usage (flags, commands, env vars) and exit 0. | | `--version` | Print `gentle-shell `, `pi `, and `home `, then exit 0. | | `--` | Everything after is forwarded to pi verbatim, even text that looks like a `gentle-shell` flag. | -`--link`, `--isolated`, and `--home` are mutually exclusive; combining two is a usage error, as is `--home` or `--home=` with an empty value. Effective-home precedence: an explicit flag wins, then the persisted `home` subcommand choice, then the `--isolated` default. Every argument gentle-shell does not recognize — `--mode rpc`, `-p "..."`, etc. — is forwarded to pi unchanged. +`--link`, `--isolated`, `--home`, and `--env` are mutually exclusive; combining two is a usage error, as is `--home` or `--home=` with an empty value. Effective-home precedence: an explicit flag wins, then the persisted `home` subcommand choice, then the `--isolated` default. Every argument gentle-shell does not recognize — `--mode rpc`, `-p "..."`, etc. — is forwarded to pi unchanged. ### Config home @@ -372,6 +374,50 @@ It never overwrites, never follows a symbolic link, never copies `packages`, `ex Tradeoff: `--link` shares one credential store with pi; `import-pi` duplicates it. A provider that rotates refresh tokens on use may sign out whichever copy refreshes second; run `/login` again in that home. +### Named envs (`envs` subcommand, `--env`) + +A named env is a separate agent home managed by gentle-shell under `~/.gentle-shell/envs//` (`GENTLE_SHELL_ENVS_DIR` overrides the root). Each env has its own sign-ins, models, packages, settings, sessions, and subagent history. The directory is the registry: there is no separate list. Names are 1-40 lowercase letters, digits, `.`, `_`, or `-`, starting with a letter or digit. + +| Command | Effect | +| --- | --- | +| `gentle-shell envs` | On a terminal, opens a full-screen env manager. Without a TTY, it behaves like `envs list`. | +| `gentle-shell envs list` | Prints the base home as `[base]`, then every env, each with its setup state; `(default)` after a name marks what a plain `gentle-shell` opens. | +| `gentle-shell envs new ` | Creates the env without launching it. Its first launch installs the companion packages, the same as any new `--home`. | +| `gentle-shell --env [...]` | Launches, sets up (`--env setup`), or runs pi subcommands (`--env install npm:x`) in that env. | + +The manager lists the install's own home first, as `Gentle Shell base`. It is the `--isolated` home (`GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`), the one Gentle Shell always used before envs, and nothing about it changes. Opening it is `--isolated`, and making it the default persists `"home": "isolated"`, exactly like `gentle-shell home isolated`. Its sessions carry no env label. It cannot be renamed or deleted from the manager, and an env may still be named `base`. The `(default)` marker, like the `(active)` marker in `/gentle:profiles`, names what a plain launch opens: the base home when no `home` is persisted (or it is `isolated` or malformed), the env a persisted path names, and nothing in the list for `link`. + +Manager keys: `↑`/`↓` or `j`/`k` select. `enter` launches the selected env. `n` creates an env (after its name, a config form opens; see below), `r` renames it, and `d` deletes it after you type its name. `s` makes it the default. `p` runs `setup`, `i` installs a package source, and `a` runs `pi auth check` for each signed-in provider. `c` opens the config form for the selected env. `q` or `esc` quits. The detail pane shows the path, the setup record, the session count and last session, the signed-in provider ids (never credential values), the config scope, and the declared packages. + +Inside a session, the header shows `env ` right after the brand, and it stays there as the header narrows. The Status card shows an `Env` group with the name, above `Project`. The launcher passes the name to Pi as `GENTLE_SHELL_ENV`, only for a named env, and clears any inherited value for every other home. The default env is stored as the ordinary persisted `home` path, so a plain `gentle-shell` opens it, and `gentle-shell home link|isolated|` replaces it. Renaming or deleting an env keeps `config.json` consistent: its setup record and default follow it or are removed. Envs are separate homes, not sandboxes. By default they share the repository you work in, Gentle's own configuration (see *Per-env Gentle configuration* below), the Engram memory, and `~/.gentle-ai/state.json`, like every other home. Named envs work the same in a bundled (standalone) install. There, the envs root shares `~/.gentle-shell` with the prefix and launches use the bundled Pi. Both `self-uninstall` variants list the envs root once, as "your named envs", under *Not touched*, and never remove it. They also refuse a plan that would remove anything inside it, for example a `GENTLE_SHELL_HOME` that points into an env. + +### Per-env Gentle configuration + +Like VS Code profiles, each named env chooses, per category, whether it shares Gentle's own configuration with the base home or keeps its own copy: + +| Category | What it covers | Files | +| --- | --- | --- | +| `models` | Per-agent model routing and `/gentle:profiles` | `models.json`, `profiles.json` and their exports | +| `persona` | el Gentleman's persona | `persona.json` | +| `appearance` | `/gentle:customize`: visual settings and profiles, cards, animations, banner, notifications, Vim | see `lib/gentle-config-scope.ts` | +| `behavior` | Permission mode, runtime guardrails, history capture, background subagents, double Esc, builtin codemode opt-out | see `lib/gentle-config-scope.ts` | + +Models and profiles stay together because a profile is a snapshot of the routing, and applying one rewrites `models.json`. The update channel and the dev-binary registration belong to the install and are always shared. Repository settings (`/.pi/gentle-ai/`) stay with the repository. + +A shared category follows the base home's configuration live: the bundled install's own config, else `GENTLE_PI_CONFIG_HOME`, else the base home's `~/.gentle-shell/gentle-ai` (the same folder `--isolated` uses since #2201), else `~/.pi/gentle-ai` when the base home has no folder of its own (a custom `GENTLE_SHELL_HOME`). An env launched before the base home ever was still gets that folder seeded once from `~/.pi/gentle-ai`. An own category lives in `/gentle-ai/`. When a category becomes own it is seeded once, by copying from the base home or from another env (that env's effective files, own or shared), or by starting empty with the defaults. A category that is already own is never wiped, and sharing it again keeps its files on disk for later. The choice is stored in `/.gentle-shell-env.json`. An env without that file (every env created before this feature) shares everything. + +```bash +gentle-shell envs new client --own persona,appearance # copies them from the base home +gentle-shell envs new lab --own all --empty # owns everything, starting from defaults +gentle-shell envs config client # describes the scope +gentle-shell envs config client --own models --from work # copies work's models & profiles +gentle-shell envs config client --share appearance # shares it again (own files kept) +``` + +In the manager, `n` opens the same choice as a form after the name: `↑`/`↓` move, `space` or `←`/`→` toggle a category or change the source of new own copies, `a` toggles all, `enter` creates (or applies, for `c` on an existing env), and `esc` cancels. Enter with the defaults shares everything, exactly as before. The base home has no scope: it holds the shared configuration. + +When an env owns categories, the launcher passes `GENTLE_SHELL_ENV_CONFIG_HOME` (`/gentle-ai`) and `GENTLE_SHELL_ENV_OWN_CONFIG` to Pi. They win over the bundled config and `GENTLE_PI_CONFIG_HOME` for those categories only. The launcher clears them for every other home, so a nested launch never inherits another env's scope. Scope changes apply from the env's next session. The Engram memory is not part of this yet: all envs still share it. + ### Managing packages `gentle-shell install npm:`, `gentle-shell remove ...`, `gentle-shell list`, `gentle-shell update ...`, `gentle-shell config`, and `gentle-shell auth ...` run pi's own commands against the resolved home — the `--isolated` home by default, or your own pi home with `--link`. A launcher flag before the subcommand (`--link`, `--isolated`, `--home `) still selects which home the subcommand runs against. Running `gentle-shell install npm:gentle-pi` inside the isolated home is unnecessary: the launcher already loads the Gentle Shell package itself (see "Loading the package" below). diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 20b31a67e..b87c16e00 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -538,7 +538,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = let ui: ExtensionContext["ui"] | undefined; let host: { requestRender(): void } | undefined; let sidebarTui: TUI | undefined; - const visualHome = gentlePiConfigHome(env); + const visualHome = gentlePiConfigHome(env, "appearance"); let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents; let stopVisualUpdates: (() => void) | undefined; let sessions: ExtensionContext["sessionManager"] | undefined; @@ -1638,7 +1638,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // bound session used to kill its own non-git writer mid-preparation because // admission still read only the pin/global layers). Slice 3b-ii: the same // read covers the frozen inherited profile and `follow` mode. - const profileConfigHome = gentlePiConfigHome(deps.env); + const profileConfigHome = gentlePiConfigHome(deps.env, "models"); const sessionProfile = resolveSessionProfile({ sessionId: ctx.sessionManager.getSessionId(), cwd: originalCwd, @@ -2357,7 +2357,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = freezeSessionProfileAtStartup({ sessionId: ctx.sessionManager.getSessionId(), cwd: ctx.sessionManager.getCwd(), - configHome: gentlePiConfigHome(deps.env), + configHome: gentlePiConfigHome(deps.env, "models"), resolveWorktree: deps.resolveWorktree, env: deps.env, }); diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 39edf9a2e..3d404c6a7 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -49,6 +49,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import { Key, Text, isKeyRelease, matchesKey, truncateToWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui"; import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts"; +import type { ConfigCategory } from "../lib/gentle-config-scope.ts"; import { BACKGROUND_SUBAGENTS_FILE, BACKGROUND_SUBAGENTS_SCHEMA, @@ -399,7 +400,7 @@ const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = { /** Write the global policy file, creating the config home when needed. */ function writeGlobalBackgroundSubagentsPolicy( policy: BackgroundSubagentsPolicy, - configHome: string = gentleAiConfigHome(), + configHome: string = gentleAiConfigHome("behavior"), ): string { const path = join(configHome, BACKGROUND_SUBAGENTS_FILE); mkdirSync(configHome, { recursive: true }); @@ -1738,7 +1739,7 @@ function loadRuntimeGuardrailsConfig( return { autonomousMode: true, guardedCommands: {} }; } - const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome(); + const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome("behavior"); const globalConfigPath = join(configHome, "runtime-guardrails.json"); const projectConfigPath = join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json"); @@ -2052,7 +2053,7 @@ function isReversibleYoloCommand(command: string, evaluation: GuardEvaluation, s /** Preserve explicitly configured confirmations for the waivable keys present, even when legacy env/autonomy resolution ignores them. */ function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey[], options: LoadGuardrailsOptions = {}): GuardAction | undefined { let restriction: GuardAction | undefined; - for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { + for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome("behavior"), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { try { if (!existsSync(path)) continue; const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); @@ -2106,7 +2107,7 @@ async function confirmCommand( // ask/block rule or the independent data-loss confirmation. if (autoGate && !evaluation.dataLoss) { let explicit = false; - for (const path of [join(gentleAiConfigHome(), "runtime-guardrails.json"), join(ctx.cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { + for (const path of [join(gentleAiConfigHome("behavior"), "runtime-guardrails.json"), join(ctx.cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { try { if (!existsSync(path)) continue; const settings: unknown = JSON.parse(readFileSync(path, "utf8")); @@ -2177,16 +2178,16 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function gentleAiConfigHome(): string { - return gentlePiConfigHome(); +function gentleAiConfigHome(category: ConfigCategory): string { + return gentlePiConfigHome(undefined, category); } function modelConfigPath(_cwd: string): string { - return join(gentleAiConfigHome(), "models.json"); + return join(gentleAiConfigHome("models"), "models.json"); } function modelExportPath(_cwd: string): string { - return join(gentleAiConfigHome(), "models.export.json"); + return join(gentleAiConfigHome("models"), "models.export.json"); } const MODEL_EXPORT_KIND = "gentle-pi.agent_model_routing"; @@ -2201,7 +2202,7 @@ function projectPersonaConfigPath(cwd: string): string { } function personaConfigPath(_cwd: string): string { - return join(gentleAiConfigHome(), "persona.json"); + return join(gentleAiConfigHome("persona"), "persona.json"); } function readPersonaFile(path: string): PersonaMode | undefined { @@ -2452,7 +2453,7 @@ async function readMaterializedRoutingEntryAsync( * would use -- there is no second precedence rule here. */ function pinnedEffectiveModelConfig(cwd: string): AgentModelConfig | undefined { - const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); + const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome("models") }); return resolution === undefined ? undefined : cloneModelConfig(resolution.modelProfiles); } @@ -3794,9 +3795,9 @@ interface CurrentProfileTarget { */ function resolveCurrentProfileTarget( cwd: string, - store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())), + store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome("models"))), ): CurrentProfileTarget | undefined { - const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); + const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome("models") }); if (pin) return { name: pin.profile, source: "pinned" }; if (store.status !== "valid" || store.file.active === undefined) return undefined; if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined; @@ -3815,7 +3816,7 @@ function describeCurrentProfileTarget(target: CurrentProfileTarget | undefined): * outcome here speaks only about the profile. */ function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, pi: ExtensionAPI): void { - const path = profilesFilePath(gentleAiConfigHome()); + const path = profilesFilePath(gentleAiConfigHome("models")); const read = readProfilesFileResult(path); if (read.status === "invalid") { ctx.ui.notify( @@ -3998,7 +3999,7 @@ function followRenamedPin( * names exactly what a launch would use. */ function profilePinScopeNote(cwd: string): string | undefined { - const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); + const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome("models") }); if (!resolution) return undefined; return sanitizeTerminalText( `This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove the pin with /gentle:profiles (p or P).`, @@ -4578,7 +4579,7 @@ async function runProfilesPanelAction( // pin wins, applying is repo-scoped: re-pin this clone and write no global // routing, no materialized stores, and no orchestrator. The committed // declaration is never rewritten behind a commit. - const pinResolution = resolveProfilePin({ cwd: ctx.cwd, configHome: gentleAiConfigHome() }); + const pinResolution = resolveProfilePin({ cwd: ctx.cwd, configHome: gentleAiConfigHome("models") }); if (pinResolution) { const localPath = pinResolution.status.localPath; let pinNote: string; @@ -5051,7 +5052,7 @@ async function runProfilesPanelAction( } case "export": { if (!hasOwnProfile(file.profiles, result.name)) return file; - const exportPath = profileExportPath(gentleAiConfigHome()); + const exportPath = profileExportPath(gentleAiConfigHome("models")); try { const text = serializeProfileExport(result.name, file.profiles[result.name]); await mkdir(dirname(exportPath), { recursive: true }); @@ -5063,7 +5064,7 @@ async function runProfilesPanelAction( return file; } case "import": { - const importPath = profileExportPath(gentleAiConfigHome()); + const importPath = profileExportPath(gentleAiConfigHome("models")); let text: string; try { text = await readFile(importPath, "utf8"); @@ -5113,7 +5114,7 @@ async function runProfilesPanelAction( /** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise { - const path = profilesFilePath(gentleAiConfigHome()); + const path = profilesFilePath(gentleAiConfigHome("models")); const read = readProfilesFileResult(path); if (read.status === "invalid") { ctx.ui.notify( @@ -9732,7 +9733,7 @@ function createGentleAiExtensionForTesting( return classifier.run(request); }, cancel: () => classifier?.cancel(), - }, { configHome: gentlePiConfigHome(permissionEnvironment) }); + }, { configHome: gentlePiConfigHome(permissionEnvironment, "behavior") }); const auto = autoPermissions; pi.on("tool_result", (event, ctx) => { if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.toolResult(event, ctx); diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 5996188e9..000d1924b 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -127,6 +127,7 @@ import { installSessionChangeCapture } from "../lib/session-change-capture.ts"; import { SelectionEngine } from "../lib/selection-engine.ts"; import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; import { buildNotificationRows } from "../lib/notification-customize.ts"; +import { activeEnvName } from "../lib/gentle-shell-envs.ts"; // Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the // status bar, the petal prompt, the working-tree changes widget and overlay, @@ -153,6 +154,8 @@ interface ShellBarComponent { interface BuildOptions { jobs?: RunningJobDisplay[]; profile?: string; + // Named env from the launcher (GENTLE_SHELL_ENV); omitted for other homes. + env?: string; home?: string; dirty?: number; usage?: ProviderUsage; @@ -182,7 +185,7 @@ export type ActiveProfileReader = (() => string | undefined) & { // Unbound reads retain the global file-identity cache, including atomic replacements. // Bound reads are snapshots: no filesystem or Git work is done during a frame. export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): ActiveProfileReader { - const path = profilesFilePath(gentlePiConfigHome(env)); + const path = profilesFilePath(gentlePiConfigHome(env, "models")); let fingerprint: string | undefined; let name: string | undefined; let bound = false; @@ -217,7 +220,7 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): const next = cwd === undefined ? global() : sessionProfileLabel(resolveSessionProfile({ sessionId, cwd, - configHome: gentlePiConfigHome(env), + configHome: gentlePiConfigHome(env, "models"), resolveWorktree: () => identity, env, })); @@ -321,6 +324,7 @@ export function buildShellBarModel( return { cwd: shortenHome(ctx.sessionManager.getCwd(), home), profile: options.profile, + ...(options.env ? { env: options.env } : {}), branch: footerData.getGitBranch(), dirty: options.dirty, sessionName: ctx.sessionManager.getSessionName(), @@ -355,6 +359,7 @@ export function createShellBarComponent( dirty: () => number | undefined = () => undefined, usage: () => ProviderUsage | undefined = () => undefined, presentation?: () => ReturnType["settings"], + env: () => string | undefined = () => undefined, ): ShellBarComponent { const unsubscribe = footerData.onBranchChange(() => { host.invalidateSidebar?.(); @@ -362,7 +367,7 @@ export function createShellBarComponent( }); return { render(width: number) { - return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.(), gaugePaint(ctx)); + return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage(), env: env() }), theme, width, presentation?.(), gaugePaint(ctx)); }, invalidate() {}, dispose() { @@ -1584,7 +1589,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }; // One spelling of the config home, so the pin resolver, the global profiles // store and the profile reader cannot drift onto two different stores. - const usageConfigHome = gentlePiConfigHome(env); + const usageConfigHome = gentlePiConfigHome(env, "models"); const usageFetchTimeout = usageFetchTimeoutMs(env); // The subagent routing in force for this session, through the same single // rule launches use (gentle-shell#1064): explicit Enter binding, then the @@ -1787,8 +1792,12 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // re-syncs this cache from disk first, so status, the no-argument toggle // direction, and the Esc gate always describe the same effective policy // even when another session or a hand edit changed the file mid-session. - const doubleEscCancelConfigHome = gentlePiConfigHome(env); - const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome }; + // Two config categories live side by side here (lib/gentle-config-scope.ts): + // behavior (double Esc, history capture) and appearance (animations, Vim, + // cards, visual settings and profiles, banner). A named env may own either. + const doubleEscCancelConfigHome = gentlePiConfigHome(env, "behavior"); + const appearanceConfigHome = gentlePiConfigHome(env, "appearance"); + const animationOptions = { gentlePiConfigHome: appearanceConfigHome }; let animationPolicy = resolveAnimationPolicy(animationOptions).policy; let vimPolicy = resolveVimPolicy(animationOptions).policy; // Conversation cards read the style from a process-wide slot; the saved @@ -1923,13 +1932,13 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p sidebarTui = tui; if (tui.terminal) sidebarState(tui).visibility = { todo: visualSettings.visibility.todo }; renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) }; - const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings); + const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings, () => activeEnvName(env)); // The Status card paints live session state that no event re-registers a // part for: model, effort, context, cost, session name and extension // statuses. The digest is what keeps the fullscreen memo honest, and it // rebuilds the model exactly as the narrow bottom bar does every frame. const footerModel = (): ShellBarModel => ({ - ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), jobs: runningJobs }), + ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), env: activeEnvName(env), jobs: runningJobs }), changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted }, review, }); @@ -2122,9 +2131,9 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p if (closeCustomize === close) closeCustomize = undefined; }; closeCustomize = close; - const home = { gentlePiConfigHome: doubleEscCancelConfigHome }; + const home = { gentlePiConfigHome: appearanceConfigHome }; const rows: CustomizeRow[] = []; - const bannerHome = doubleEscCancelConfigHome; + const bannerHome = appearanceConfigHome; let banner = await readBannerConfig(bannerHome).catch(error => { close(); throw error; }); if (closed) return; let activeTheme = ctx.ui.theme.name; @@ -2200,7 +2209,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p if (current.malformed || current.readError) throw new Error(`Cannot update unreadable or malformed visual settings: ${current.globalFile}`); writeVisualSettings(change(current.settings), home); refreshVisual(); - pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome }); + pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: appearanceConfigHome }); }; const visual = () => resolveVisualSettings(home).settings; const pending = "Preference saved and applied."; @@ -2388,7 +2397,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p try { writeVisualSettings(structuredClone(DEFAULT_VISUAL_SETTINGS), home); refreshVisual(); - pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome }); + pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: appearanceConfigHome }); changed.push("visual"); await writeBannerConfig({ ...DEFAULT_BANNER_CONFIG }, bannerHome); banner = { ...DEFAULT_BANNER_CONFIG }; @@ -2435,7 +2444,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p if (current.malformed || current.readError) throw new Error("Visual settings unreadable."); writeVisualSettings(profile.visual, home); refreshVisual(); - pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome }); + pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: appearanceConfigHome }); }); await step("banner", async () => { await readBannerConfigForEdit(bannerHome); await writeBannerConfig(profile.banner, bannerHome); banner = profile.banner; }); await step("animation", () => { writeAnimationPolicy(profile.animationPolicy, animationOptions); animationPolicy = profile.animationPolicy; prompt?.setAnimationPolicy(animationPolicy); }); diff --git a/extensions/history/index.ts b/extensions/history/index.ts index fbe401141..33d972895 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -154,7 +154,7 @@ export interface HistoryDeps { */ export function captureEnabled( env: NodeJS.ProcessEnv = process.env, - configHome: string = gentlePiConfigHome(env), + configHome: string = gentlePiConfigHome(env, "behavior"), ): boolean { return historyCaptureEnabled({ env, gentlePiConfigHome: configHome }); } @@ -1315,7 +1315,7 @@ export default function promptHistoryExtension( deps: HistoryDeps = {}, ): void { const env = deps.env ?? process.env; - const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env); + const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior"); // Per-prompt gate: re-read so a Customize toggle applies live. A delegated // child never captures: its prompt is a delegation brief, not user history, // and the parent owns the store's init and GC (gentle-shell#1690). diff --git a/extensions/startup-banner.ts b/extensions/startup-banner.ts index ea1aa5ffb..ecfd3f27a 100644 --- a/extensions/startup-banner.ts +++ b/extensions/startup-banner.ts @@ -7,6 +7,8 @@ import { join } from "node:path"; import { resolveAnimationPolicy } from "../lib/animation-policy.ts"; import { PI_SUBCOMMANDS } from "../lib/gentle-shell-launcher.ts"; import { gentlePiConfigHome } from "../lib/agent-home.ts"; +import { ROSE_ART as ROSE_LARGE_RAW } from "../lib/gentle-brand.ts"; +import type { ConfigCategory } from "../lib/gentle-config-scope.ts"; export type BannerColor = "pink" | "cyan" | "yellow" | "green"; @@ -42,35 +44,15 @@ const TEXT_LOGO = [ " ▒▒▒▒▒▒", ]; -const ROSE_LARGE_RAW = [ - " ⣠⣾⣷⣶⣦⣤⣤⣄⣠⣄⣀ ⢀⣀⣀", - " ⢀⣴⣿⣿⠿⣋⣭⣭⣯⣭⣍⣭⣿⣟⠛⠛⠿⣿⣷⣄", - " ⢀⣴⣾⡟⢻⣿⡟⠁⣼⣿⠏⣵⢻⣿⣻⣿⣿⢿⡻⣿⣿⣶⡌⢿⣿⣷⣦⣤⡄", - " ⣤⣶⣾⣿⣿⠏ ⠈⢿⣄ ⢹⣏⠠⠟⣾⣿⣿⣿⣿⣿⠷⣏⣼⠟⢡⣿⡟⠋⢻⣿⣿⡄", - " ⠈⣿⣿⣿⣿⡆ ⣽⢧⡘⠈⠳⣦⣍⠛⠛⢦⣉⣴⣛⣫⣭⣴⡟⠋ ⣾⣿⣿⡿", - " ⢀⠹⣿⣿⣿⣷⣤⡄ ⠋ ⠙⢆ ⣠⠴⠟⠛⣛⣛⣛⠟⠋⠁⠺⡇ ⣀⣴⣿⣿⡟⠁", - " ⠈⣀⠈⠛⠷⠿⣿⣿⣷⣤⣀ ⢠⠋ ⠈⠉⠉ ⣠⣴⣥⠾⠛⠉⣰⣿⣷", - " ⠹⣯⣝⠛⠛⠷⢶⣤⣤⣀ ⢀⡠⠖⠋⠉⢉⣀⣀⣴⣾⣿⠿⠟⠃", - " ⠘⠻⢿⣦⣄⡀ ⠉⠛⢦⠠⢊⠤⠴⢒⣛⣛⣩⣽⡿⠟⠁", - " ⠶⢶⣤⣄⡀⠨⠭⠽⠟⣓⢦⣀⠈⢇⡥⠖⠛⠋⠉⠉", - " ⠈⢷ ⠐⠂⢤⣽⣄ ⠰⡎⠙⠳⣄⡀ ⠈⢣⠘⢦⠋", - " ⠈⢳⣀⡒⠉⠉⣉⠙⡲⣽⣄ ⣏⠳⡄ ⠘⡇ ⡾⠁", - " ⠛⠻⢦⣄⣉⡁⣀⣀⣈⣙⣺⣌⡇⢠⢀⡇⡾", - " ⠈⠉ ⠈⠳⡄⣸⢱⠇", - " ⡷⠡⡯⢖⠉", - " ⢀⡴⢪⠔⣉⠔⠋", - " ⠐⠈", -]; - function rgb(r: number, g: number, b: number, text: string): string { return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; } -function gentleAiConfigHome(): string { - return gentlePiConfigHome(process.env); +function gentleAiConfigHome(category: ConfigCategory = "appearance"): string { + return gentlePiConfigHome(process.env, category); } -function bannerConfigPath(configHome = gentleAiConfigHome()): string { +function bannerConfigPath(configHome = gentleAiConfigHome("appearance")): string { return join(configHome, "banner.json"); } @@ -86,7 +68,7 @@ function normalizeBannerConfig(value: unknown): BannerConfig { // Modal mutations must not turn an unreadable or malformed existing file into defaults. // Legacy banner commands retain their original tolerant read behavior below. -export async function readBannerConfigForEdit(configHome = gentleAiConfigHome()): Promise { +export async function readBannerConfigForEdit(configHome = gentleAiConfigHome("appearance")): Promise { const path = bannerConfigPath(configHome); let raw: string; try { @@ -105,7 +87,7 @@ export async function readBannerConfigForEdit(configHome = gentleAiConfigHome()) return { showRose: config.showRose, showTextLogo: config.showTextLogo, color: config.color as BannerColor }; } -export async function readBannerConfig(configHome = gentleAiConfigHome()): Promise { +export async function readBannerConfig(configHome = gentleAiConfigHome("appearance")): Promise { try { return normalizeBannerConfig(JSON.parse(await readFile(bannerConfigPath(configHome), "utf8"))); } catch { @@ -113,7 +95,7 @@ export async function readBannerConfig(configHome = gentleAiConfigHome()): Promi } } -export async function writeBannerConfig(config: BannerConfig, configHome = gentleAiConfigHome()): Promise { +export async function writeBannerConfig(config: BannerConfig, configHome = gentleAiConfigHome("appearance")): Promise { const path = bannerConfigPath(configHome); await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8"); @@ -665,7 +647,7 @@ export default function (pi: ExtensionAPI) { // clearing stdout here would leave its previous-frame cache out of sync. const bannerConfig = await readBannerConfig(); const palette = BANNER_PALETTES[bannerConfig.color]; - const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW)); + const roseBase = padLines(normalizeAscii([...ROSE_LARGE_RAW])); const logoBase = padLines(TEXT_LOGO); void warmupLetterStrokes(); diff --git a/lib/agent-home.ts b/lib/agent-home.ts index d949dd9a9..e3b7fbc9e 100644 --- a/lib/agent-home.ts +++ b/lib/agent-home.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts"; +import { type ConfigCategory, envConfigHomeFor } from "./gentle-config-scope.ts"; export { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts"; @@ -18,8 +19,13 @@ export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env): // the standalone launcher's isolated home next to its agent dir (#2201). The rule // itself lives in resolveGentlePiConfigHome, a generated runtime module, so the // launcher and the Gentle AI process environment share it too. -export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env): string { - return resolveGentlePiConfigHome(env, homedir()); +// +// `category` names what the caller reads or writes (lib/gentle-config-scope.ts). +// In a named env that owns that category, it resolves to the env's own config +// home, ahead of that rule. Every other case, and install-level reads that pass +// no category, keep the rule. +export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env, category?: ConfigCategory): string { + return envConfigHomeFor(env, category) ?? resolveGentlePiConfigHome(env, homedir()); } // The agent home Pi runs with, only in a bundled Gentle Shell (S9): there the diff --git a/lib/animation-policy.ts b/lib/animation-policy.ts index 4d1eef856..a63b7eceb 100644 --- a/lib/animation-policy.ts +++ b/lib/animation-policy.ts @@ -26,7 +26,7 @@ export function parseAnimationPolicyFile(raw: string): AnimationPolicy | undefin } export function resolveAnimationPolicy(options: AnimationOptions = {}): AnimationResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "animations.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "animations.json"); try { const policy = parseAnimationPolicyFile(readFileSync(globalFile, "utf8")); return { policy: policy ?? "quality", source: SOURCE.GLOBAL, malformed: policy === undefined, globalFile }; @@ -38,7 +38,7 @@ export function resolveAnimationPolicy(options: AnimationOptions = {}): Animatio /** Same-directory rename prevents readers from seeing a partially written policy. */ export function writeAnimationPolicy(policy: AnimationPolicy, options: AnimationOptions = {}): string { - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "animations.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/auto-permission-runtime.ts b/lib/auto-permission-runtime.ts index d804f1ee0..e31bea1bb 100644 --- a/lib/auto-permission-runtime.ts +++ b/lib/auto-permission-runtime.ts @@ -235,7 +235,7 @@ export class AutoPermissionRuntime { options: {configHome?: string; retryDelaysMs?: number[]} = {}) { this.pi=pi;this.modes=modes;this.classifier=classifier; this.memory=new AutoPermissionMemory(()=>pi.getAllTools?.() ?? []); - this.configHome = resolve(options.configHome ?? gentlePiConfigHome()); + this.configHome = resolve(options.configHome ?? gentlePiConfigHome(undefined, "behavior")); this.retryDelaysMs = options.retryDelaysMs ?? PROVIDER_RETRY_DELAYS_MS; } private retryDelaysMs: number[]; diff --git a/lib/background-subagents-policy.ts b/lib/background-subagents-policy.ts index 51f7e03d5..664c10540 100644 --- a/lib/background-subagents-policy.ts +++ b/lib/background-subagents-policy.ts @@ -95,7 +95,7 @@ export function resolveBackgroundSubagentsPolicy( let projectFile = ""; let globalFile = ""; try { - const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior"); projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE); globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE); const projectFileExists = existsSync(projectFile); diff --git a/lib/builtin-codemode-optout.ts b/lib/builtin-codemode-optout.ts index 98e6651fb..cdc24bc1c 100644 --- a/lib/builtin-codemode-optout.ts +++ b/lib/builtin-codemode-optout.ts @@ -27,7 +27,7 @@ export type BuiltinCodemodeOptOutOutcome = export interface BuiltinCodemodeOptOutOptions { /** Pi agent dir whose settings.json receives the entry; defaults to getAgentDir(). */ agentDir?: string; - /** gentle-pi config home holding the remembered decline; defaults to gentlePiConfigHome(). */ + /** gentle-pi config home holding the remembered decline; defaults to gentlePiConfigHome(undefined, "behavior"). */ configHome?: string; /** Effective (merged) settings `extensions`, e.g. from pi.getSettings(). */ effectiveExtensions?: unknown; @@ -69,7 +69,7 @@ export function withBuiltinExtensionExcluded(settingsText: string, builtin = BUI return settingsText.endsWith("\n") ? `${serialized}\n` : serialized; } -export function builtinCodemodeOptOutStatePath(configHome = gentlePiConfigHome()): string { +export function builtinCodemodeOptOutStatePath(configHome = gentlePiConfigHome(undefined, "behavior")): string { return join(configHome, "builtin-codemode-optout.json"); } @@ -138,7 +138,7 @@ export async function offerBuiltinCodemodeOptOut(ctx: BuiltinCodemodeOptOutConte if (!ctx.hasUI || ctx.mode !== "tui") return "no-ui"; if (hasExplicitBuiltinEntry(options.effectiveExtensions)) return "explicit-entry"; const settingsPath = resolve(options.agentDir ?? getAgentDir(), "settings.json"); - const configHome = options.configHome ?? gentlePiConfigHome(); + const configHome = options.configHome ?? gentlePiConfigHome(undefined, "behavior"); const currentText = readIfExists(settingsPath); if (currentText === undefined) return "unwritable"; try { diff --git a/lib/card-content-policy.ts b/lib/card-content-policy.ts index ab6c912b9..9d0dd79a4 100644 --- a/lib/card-content-policy.ts +++ b/lib/card-content-policy.ts @@ -42,7 +42,7 @@ export function parseCardContentFile(raw: string): CardContent | undefined { } export function resolveCardContent(options: CardContentOptions = {}): CardContentResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), CARD_CONTENT_FILE); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), CARD_CONTENT_FILE); try { const content = parseCardContentFile(readFileSync(globalFile, "utf8")); return { content: content ?? CARD_CONTENT.DEFAULT, source: "global_file", malformed: content === undefined, globalFile }; @@ -54,7 +54,7 @@ export function resolveCardContent(options: CardContentOptions = {}): CardConten export function writeCardContent(content: CardContent, options: CardContentOptions = {}): string { if (!isCardContent(content)) throw new TypeError("Invalid card content"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const current = resolveCardContent({ gentlePiConfigHome: home }); if (current.malformed) throw new Error(`Cannot update malformed or unreadable card content preference: ${current.globalFile}`); const path = current.globalFile; diff --git a/lib/card-style-policy.ts b/lib/card-style-policy.ts index 773f87087..ca58b8d84 100644 --- a/lib/card-style-policy.ts +++ b/lib/card-style-policy.ts @@ -32,7 +32,7 @@ export function parseCardStyleFile(raw: string): CardStyle | undefined { } export function resolveCardStyle(options: CardStyleOptions = {}): CardStyleResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), CARD_STYLE_FILE); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), CARD_STYLE_FILE); try { const style = parseCardStyleFile(readFileSync(globalFile, "utf8")); return { style: style ?? CARD_STYLE.FLOAT, source: "global_file", malformed: style === undefined, globalFile }; @@ -44,7 +44,7 @@ export function resolveCardStyle(options: CardStyleOptions = {}): CardStyleResol export function writeCardStyle(style: CardStyle, options: CardStyleOptions = {}): string { if (!isCardStyle(style)) throw new TypeError("Invalid card style"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const current = resolveCardStyle({ gentlePiConfigHome: home }); if (current.malformed) throw new Error(`Cannot update malformed or unreadable card style preference: ${current.globalFile}`); const path = current.globalFile; diff --git a/lib/double-esc-cancel-policy.ts b/lib/double-esc-cancel-policy.ts index 1cb10b8ad..5eb6de518 100644 --- a/lib/double-esc-cancel-policy.ts +++ b/lib/double-esc-cancel-policy.ts @@ -89,7 +89,7 @@ export function resolveDoubleEscCancelPolicy( const envValue = env.GENTLE_PI_DOUBLE_ESC_CANCEL; let globalFile = ""; try { - const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env); + const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior"); globalFile = join(configHome, DOUBLE_ESC_CANCEL_FILE); const globalFileExists = existsSync(globalFile); if (globalFileExists) { @@ -130,7 +130,7 @@ export function writeDoubleEscCancelPolicy( policy: DoubleEscCancelPolicy, options: { gentlePiConfigHome?: string } = {}, ): string { - const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); const path = join(configHome, DOUBLE_ESC_CANCEL_FILE); mkdirSync(configHome, { recursive: true }); writeFileSync(path, `${JSON.stringify({ schema: DOUBLE_ESC_CANCEL_SCHEMA, policy }, null, 2)}\n`); diff --git a/lib/gentle-brand.ts b/lib/gentle-brand.ts new file mode 100644 index 000000000..41c9bd5a1 --- /dev/null +++ b/lib/gentle-brand.ts @@ -0,0 +1,44 @@ +// Gentle's visual identity, shared by the Pi startup banner and the launcher's +// pre-Pi screens (the envs gate). Pure data and ANSI helpers only: the +// launcher runs before any Pi package is loaded. + +// The braille rose of the startup banner. +export const ROSE_ART: readonly string[] = Object.freeze([ + " ⣠⣾⣷⣶⣦⣤⣤⣄⣠⣄⣀ ⢀⣀⣀", + " ⢀⣴⣿⣿⠿⣋⣭⣭⣯⣭⣍⣭⣿⣟⠛⠛⠿⣿⣷⣄", + " ⢀⣴⣾⡟⢻⣿⡟⠁⣼⣿⠏⣵⢻⣿⣻⣿⣿⢿⡻⣿⣿⣶⡌⢿⣿⣷⣦⣤⡄", + " ⣤⣶⣾⣿⣿⠏ ⠈⢿⣄ ⢹⣏⠠⠟⣾⣿⣿⣿⣿⣿⠷⣏⣼⠟⢡⣿⡟⠋⢻⣿⣿⡄", + " ⠈⣿⣿⣿⣿⡆ ⣽⢧⡘⠈⠳⣦⣍⠛⠛⢦⣉⣴⣛⣫⣭⣴⡟⠋ ⣾⣿⣿⡿", + " ⢀⠹⣿⣿⣿⣷⣤⡄ ⠋ ⠙⢆ ⣠⠴⠟⠛⣛⣛⣛⠟⠋⠁⠺⡇ ⣀⣴⣿⣿⡟⠁", + " ⠈⣀⠈⠛⠷⠿⣿⣿⣷⣤⣀ ⢠⠋ ⠈⠉⠉ ⣠⣴⣥⠾⠛⠉⣰⣿⣷", + " ⠹⣯⣝⠛⠛⠷⢶⣤⣤⣀ ⢀⡠⠖⠋⠉⢉⣀⣀⣴⣾⣿⠿⠟⠃", + " ⠘⠻⢿⣦⣄⡀ ⠉⠛⢦⠠⢊⠤⠴⢒⣛⣛⣩⣽⡿⠟⠁", + " ⠶⢶⣤⣄⡀⠨⠭⠽⠟⣓⢦⣀⠈⢇⡥⠖⠛⠋⠉⠉", + " ⠈⢷ ⠐⠂⢤⣽⣄ ⠰⡎⠙⠳⣄⡀ ⠈⢣⠘⢦⠋", + " ⠈⢳⣀⡒⠉⠉⣉⠙⡲⣽⣄ ⣏⠳⡄ ⠘⡇ ⡾⠁", + " ⠛⠻⢦⣄⣉⡁⣀⣀⣈⣙⣺⣌⡇⢠⢀⡇⡾", + " ⠈⠉ ⠈⠳⡄⣸⢱⠇", + " ⡷⠡⡯⢖⠉", + " ⢀⡴⢪⠔⣉⠔⠋", + " ⠐⠈", +]); + +export type Rgb = readonly [number, number, number]; + +// The banner's default pink palette (startup-banner BANNER_PALETTES.pink) plus +// the Gentle theme's muted and error tones. +export const GENTLE_COLORS = Object.freeze({ + rose: [255, 118, 195] as Rgb, + label: [200, 100, 160] as Rgb, + value: [255, 140, 210] as Rgb, + dimRose: [95, 30, 60] as Rgb, + muted: [92, 97, 112] as Rgb, + error: [203, 124, 148] as Rgb, +}); + +export const BRAND_NAME = "Gentle Shell"; +export const BRAND_GLYPH = "✿"; + +export function truecolor([r, g, b]: Rgb, text: string): string { + return text.length === 0 ? "" : `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; +} diff --git a/lib/gentle-config-scope.ts b/lib/gentle-config-scope.ts new file mode 100644 index 000000000..b23efc0d0 --- /dev/null +++ b/lib/gentle-config-scope.ts @@ -0,0 +1,65 @@ +import { isAbsolute } from "node:path"; + +// Per-env scope of Gentle's own configuration (the base home's config home, +// ~/.gentle-shell/gentle-ai or ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category +// whether it shares the base configuration or keeps its own copy. Pure data +// and env parsing only: shared by the launcher and the Pi extensions. + +export const CONFIG_CATEGORIES = Object.freeze(["models", "persona", "appearance", "behavior"] as const); +export type ConfigCategory = (typeof CONFIG_CATEGORIES)[number]; + +export function isConfigCategory(value: string): value is ConfigCategory { + return (CONFIG_CATEGORIES as readonly string[]).includes(value); +} + +// The files each category owns inside a config home. Models and profiles +// travel together: a profile is a snapshot of the routing, and applying one +// rewrites models.json, so splitting them would let an own profile overwrite +// a shared routing. +export const CONFIG_CATEGORY_FILES: Readonly> = Object.freeze({ + models: Object.freeze(["models.json", "models.export.json", "profiles.json", "profiles.export.json"]), + persona: Object.freeze(["persona.json"]), + appearance: Object.freeze([ + "visual-customization.json", + "visual-profiles.json", + "card-style.json", + "card-content.json", + "animations.json", + "banner.json", + "notifications.json", + "vim.json", + ]), + behavior: Object.freeze([ + "permission-mode.json", + "runtime-guardrails.json", + "history-capture.json", + "background-subagents.json", + "double-esc-cancel.json", + "builtin-codemode-optout.json", + ]), +}); + +// Files that belong to the install, never to an env: they always resolve to +// the shared config home. +export const INSTALL_CONFIG_FILES = Object.freeze(["channel.json", "dev-binary.json"]); + +// Set by the launcher on the Pi process of a named env that owns some +// categories, and cleared for every other home. +export const ENV_CONFIG_HOME_ENV = "GENTLE_SHELL_ENV_CONFIG_HOME"; +export const ENV_OWN_CONFIG_ENV = "GENTLE_SHELL_ENV_OWN_CONFIG"; + +// The categories this process keeps in its env config home. Fails closed to +// "nothing own" (the shared configuration, today's behavior) unless the env +// config home is an absolute path and the category is known. +export function ownConfigCategories(env: Record): readonly ConfigCategory[] { + const home = env[ENV_CONFIG_HOME_ENV]; + if (home === undefined || home.length === 0 || !isAbsolute(home)) return []; + const listed = (env[ENV_OWN_CONFIG_ENV] ?? "").split(",").map((value) => value.trim()); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +// The env config home when `category` is own in this process, else undefined. +export function envConfigHomeFor(env: Record, category: ConfigCategory | undefined): string | undefined { + if (category === undefined) return undefined; + return ownConfigCategories(env).includes(category) ? env[ENV_CONFIG_HOME_ENV] : undefined; +} diff --git a/lib/gentle-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts new file mode 100644 index 000000000..384e82a5a --- /dev/null +++ b/lib/gentle-shell-envs-tui.ts @@ -0,0 +1,499 @@ +import { BRAND_GLYPH, BRAND_NAME, GENTLE_COLORS, ROSE_ART, type Rgb, truecolor } from "./gentle-brand.ts"; +import { CONFIG_CATEGORIES, type ConfigCategory } from "./gentle-config-scope.ts"; +import { CONFIG_CATEGORY_LABELS, describeEnvScope, type EnvSummary, validateEnvName } from "./gentle-shell-envs.ts"; + +// The `gentle-shell envs` manager: a pure key parser, reducer and renderer. +// It is dependency-free on purpose (no pi-tui), because the launcher runs +// before any Pi runtime is resolved. bin/gentle-shell.mjs owns raw-mode +// stdin, the alternate screen, and carrying out the returned effects. + +export type EnvsKey = + | { name: "up" } + | { name: "down" } + | { name: "left" } + | { name: "right" } + | { name: "enter" } + | { name: "backspace" } + | { name: "escape" } + | { name: "ctrl-c" } + | { name: "char"; char: string }; + +// `base: true` targets the install's own home (`--isolated`), never an env: +// an env may even be named like the base entry. +export type EnvsEffect = + | { kind: "quit" } + | { kind: "launch"; name: string; base?: true } + | { kind: "set-default"; name: string; base?: true } + | { kind: "setup"; name: string; base?: true } + | { kind: "auth-check"; name: string; base?: true } + // own: categories the new env owns; from/empty: where their copies come + // from (the base home when neither is set). + | { kind: "create"; name: string; own: ConfigCategory[]; from?: string; empty?: true } + | { kind: "scope"; name: string; own: ConfigCategory[]; share: ConfigCategory[]; from?: string; empty?: true } + | { kind: "rename"; from: string; to: string } + | { kind: "delete"; name: string } + | { kind: "install"; name: string; source: string; base?: true }; + +type PromptKind = "create" | "rename" | "delete" | "install"; + +export interface EnvsPrompt { + kind: PromptKind; + label: string; + value: string; + // The env the prompt acts on (rename/delete/install). + target?: string; + targetBase?: boolean; + error?: string; +} + +// The VS Code-like config form: per category, shared with the base home or +// an own copy; plus where newly own copies come from. +export type ScopeSource = { kind: "base" } | { kind: "env"; name: string } | { kind: "empty" }; + +export interface EnvsScopeForm { + mode: "create" | "edit"; + name: string; + own: ConfigCategory[]; + // The scope the env had when the form opened (edit), [] for a new env. + initial: ConfigCategory[]; + source: ScopeSource; + // 0..3 the categories, 4 the source row. + cursor: number; +} + +export interface EnvsState { + envs: EnvSummary[]; + selected: number; + prompt?: EnvsPrompt; + scope?: EnvsScopeForm; + // One-line status shown above the key hints (results, hints). + message?: string; +} + +// `select` names an env, or `{ base: true }` for the base home: an env may +// share the base entry's display name, so the two are never matched by name. +export function initialEnvsState(envs: EnvSummary[], message?: string, select?: string | { base: true }): EnvsState { + const byName = select === undefined ? -1 + : typeof select === "string" ? envs.findIndex((e) => !e.base && e.name === select) + : envs.findIndex((e) => e.base === true); + const byDefault = envs.findIndex((e) => e.isDefault); + return { envs, selected: byName >= 0 ? byName : Math.max(0, byDefault), message }; +} + +// --- key parsing ------------------------------------------------------------- + +export function parseKeys(data: string): EnvsKey[] { + const keys: EnvsKey[] = []; + const chars = [...data]; + for (let i = 0; i < chars.length; i += 1) { + const c = chars[i]; + if (c === "\x1b") { + const next = chars[i + 1]; + if (next === "[" || next === "O") { + // CSI / SS3: parameters, then one final byte in @..~. + let j = i + 2; + while (j < chars.length && !/[@-~]/.test(chars[j])) j += 1; + const sequence = chars.slice(i + 2, j + 1).join(""); + if (sequence === "A") keys.push({ name: "up" }); + else if (sequence === "B") keys.push({ name: "down" }); + else if (sequence === "C") keys.push({ name: "right" }); + else if (sequence === "D") keys.push({ name: "left" }); + i = j; + continue; + } + keys.push({ name: "escape" }); + continue; + } + if (c === "\r" || c === "\n") keys.push({ name: "enter" }); + else if (c === "\x7f" || c === "\b") keys.push({ name: "backspace" }); + else if (c === "\x03") keys.push({ name: "ctrl-c" }); + else if (c >= " ") keys.push({ name: "char", char: c }); + } + return keys; +} + +// --- reducer ------------------------------------------------------------------- + +export interface EnvsStep { + state: EnvsState; + effect?: EnvsEffect; +} + +const EMPTY_HINT = "No env selected: press n to create one."; + +function openPrompt(state: EnvsState, prompt: EnvsPrompt): EnvsStep { + return { state: { ...state, prompt, message: undefined } }; +} + +function reducePrompt(state: EnvsState, prompt: EnvsPrompt, key: EnvsKey): EnvsStep { + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, prompt: undefined } }; + if (key.name === "backspace") return { state: { ...state, prompt: { ...prompt, value: [...prompt.value].slice(0, -1).join(""), error: undefined } } }; + if (key.name === "char") return { state: { ...state, prompt: { ...prompt, value: prompt.value + key.char, error: undefined } } }; + if (key.name !== "enter") return { state }; + + const fail = (error: string): EnvsStep => ({ state: { ...state, prompt: { ...prompt, error } } }); + const done = (effect: EnvsEffect): EnvsStep => ({ state: { ...state, prompt: undefined }, effect }); + const value = prompt.value.trim(); + const target = prompt.target ?? ""; + + if (prompt.kind === "create" || prompt.kind === "rename") { + if (prompt.kind === "rename" && value === target) return { state: { ...state, prompt: undefined } }; + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + if (state.envs.some((e) => !e.base && e.name === value)) return fail(`env "${value}" already exists`); + if (prompt.kind === "create") { + // Like a new VS Code profile: choose what to share before creating. + return { state: { ...state, prompt: undefined, scope: { mode: "create", name: value, own: [], initial: [], source: { kind: "base" }, cursor: 0 } } }; + } + return done({ kind: "rename", from: target, to: value }); + } + if (prompt.kind === "delete") { + if (value !== target) return fail(`type "${target}" to confirm, or esc to cancel`); + return done({ kind: "delete", name: target }); + } + if (value.length === 0) return fail("enter a package source, e.g. npm:pi-btw"); + return done(prompt.targetBase ? { kind: "install", name: target, source: value, base: true } : { kind: "install", name: target, source: value }); +} + +// Sources a newly own copy can come from: the base home, every other env, or empty. +function scopeSources(state: EnvsState, form: EnvsScopeForm): ScopeSource[] { + const envs = state.envs.filter((env) => !env.base && env.name !== form.name).map((env): ScopeSource => ({ kind: "env", name: env.name })); + return [{ kind: "base" }, ...envs, { kind: "empty" }]; +} + +function sameSource(a: ScopeSource, b: ScopeSource): boolean { + return a.kind === b.kind && (a.kind !== "env" || (b.kind === "env" && a.name === b.name)); +} + +function sourceFields(source: ScopeSource): { from?: string; empty?: true } { + return source.kind === "env" ? { from: source.name } : source.kind === "empty" ? { empty: true } : {}; +} + +const NOTHING_OWN = "Nothing to copy yet: mark a category as Own copy first (space)."; + +function reduceScope(state: EnvsState, form: EnvsScopeForm, key: EnvsKey): EnvsStep { + const set = (next: Partial): EnvsStep => ({ state: { ...state, message: undefined, scope: { ...form, ...next } } }); + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, scope: undefined } }; + if (key.name === "up") return set({ cursor: Math.max(0, form.cursor - 1) }); + if (key.name === "down") return set({ cursor: Math.min(CONFIG_CATEGORIES.length, form.cursor + 1) }); + const char = key.name === "char" ? key.char : undefined; + if (char === "a") return set({ own: form.own.length === CONFIG_CATEGORIES.length ? [] : [...CONFIG_CATEGORIES] }); + const toggle = char === " " || key.name === "left" || key.name === "right"; + if (toggle && form.cursor < CONFIG_CATEGORIES.length) { + const category = CONFIG_CATEGORIES[form.cursor]; + const own = form.own.includes(category) ? form.own.filter((c) => c !== category) : [...form.own, category]; + return set({ own: CONFIG_CATEGORIES.filter((c) => own.includes(c)) }); + } + if (toggle) { + // The source only matters for own copies: say so instead of cycling it silently. + if (form.own.length === 0) return { state: { ...state, message: NOTHING_OWN } }; + const sources = scopeSources(state, form); + const index = sources.findIndex((source) => sameSource(source, form.source)); + const step = key.name === "left" ? -1 : 1; + return set({ source: sources[(index + step + sources.length) % sources.length] }); + } + if (key.name !== "enter") return { state }; + const close = { ...state, scope: undefined }; + if (form.mode === "create") return { state: close, effect: { kind: "create", name: form.name, own: form.own, ...sourceFields(form.source) } }; + const share = form.initial.filter((c) => !form.own.includes(c)); + const added = form.own.filter((c) => !form.initial.includes(c)); + if (share.length === 0 && added.length === 0) { + return { state: { ...close, message: `No changes for ${form.name}: mark a category as Own copy, or share one again, to change it.` } }; + } + return { state: close, effect: { kind: "scope", name: form.name, own: added, share, ...(added.length > 0 ? sourceFields(form.source) : {}) } }; +} + +export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { + if (state.prompt !== undefined) return reducePrompt(state, state.prompt, key); + if (state.scope !== undefined) return reduceScope(state, state.scope, key); + + if (key.name === "ctrl-c" || key.name === "escape") return { state, effect: { kind: "quit" } }; + const last = state.envs.length - 1; + const char = key.name === "char" ? key.char : undefined; + if (key.name === "up" || char === "k") return { state: { ...state, selected: Math.max(0, state.selected - 1), message: undefined } }; + if (key.name === "down" || char === "j") return { state: { ...state, selected: Math.max(0, Math.min(last, state.selected + 1)), message: undefined } }; + if (char === "q") return { state, effect: { kind: "quit" } }; + if (char === "n") return openPrompt(state, { kind: "create", label: "New env name", value: "" }); + + const current = state.envs[state.selected]; + const scoped = key.name === "enter" || (char !== undefined && "sparidc".includes(char)); + if (!scoped) return { state }; + if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; + const name = current.name; + const base = current.base ? { base: true as const } : {}; + if (key.name === "enter") return { state, effect: { kind: "launch", name, ...base } }; + if (char === "s") return { state, effect: { kind: "set-default", name, ...base } }; + if (char === "p") return { state, effect: { kind: "setup", name, ...base } }; + if (char === "a") return { state, effect: { kind: "auth-check", name, ...base } }; + if (current.base && (char === "r" || char === "d")) { + return { state: { ...state, message: "The base home belongs to the install: it can't be renamed or deleted." } }; + } + if (char === "c") { + if (current.base) return { state: { ...state, message: "The base home holds the shared configuration every env can reuse; press c on an env to choose what it owns." } }; + const own = current.own ?? []; + return { state: { ...state, message: undefined, scope: { mode: "edit", name, own, initial: own, source: { kind: "base" }, cursor: 0 } } }; + } + if (char === "r") return openPrompt(state, { kind: "rename", label: `Rename "${name}" to`, value: name, target: name }); + if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${current.base ? "the base home" : name}"`, value: "", target: name, ...(current.base ? { targetBase: true } : {}) }); + return openPrompt(state, { kind: "delete", label: `Delete "${name}" and all its data? Type its name`, value: "", target: name }); +} + +// --- rendering ----------------------------------------------------------------- +// The envs gate is the first thing a user sees, so it speaks Gentle's visual +// language: the rose, `✿ Gentle Shell`, rounded `╭─ ✿ Title ─╮` cards like +// the Status card, the `❯` selection and rose key hints. Every line is +// exactly the terminal width at most, and the screen is exactly its height. + +export interface EnvsRenderOptions { + // Truecolor Gentle palette; false renders the same layout in monochrome. + color?: boolean; + // gentle-pi version shown next to the brand. + version?: string; +} + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; + +interface Paint { + rose: (text: string) => string; + label: (text: string) => string; + value: (text: string) => string; + frame: (text: string) => string; + muted: (text: string) => string; + error: (text: string) => string; + bold: (text: string) => string; +} + +function painter(color: boolean): Paint { + const tone = (rgb: Rgb) => (text: string) => (color ? truecolor(rgb, text) : text); + return { + rose: tone(GENTLE_COLORS.rose), + label: tone(GENTLE_COLORS.label), + value: tone(GENTLE_COLORS.value), + frame: tone(GENTLE_COLORS.dimRose), + muted: tone(GENTLE_COLORS.muted), + error: tone(GENTLE_COLORS.error), + bold: (text) => (text.length === 0 ? "" : `${BOLD}${text}\x1b[22m`), + }; +} + +// Visible width: the renderer only emits single-cell characters (ASCII, +// box drawing, braille, ✿ ❯ ·) outside of its own SGR sequences. +function visible(text: string): number { + return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; +} + +// Text from the user's own files (paths, package sources, provider ids) is +// shown as plain text: escape sequences and other control characters are +// dropped so a file can never move the cursor or retitle the terminal. +function plain(text: string): string { + return text + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "") + .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f]/g, ""); +} + +function fit(text: string, width: number): string { + if (width <= 0) return ""; + const chars = [...text]; + if (chars.length <= width) return text; + return width === 1 ? "…" : `${chars.slice(0, width - 1).join("")}…`; +} + +function pad(text: string, width: number): string { + const fitted = fit(text, width); + return fitted + " ".repeat(Math.max(0, width - [...fitted].length)); +} + +function center(styled: string, width: number): string { + const free = Math.max(0, width - visible(styled)); + return " ".repeat(Math.floor(free / 2)) + styled; +} + +interface CardRow { + // Plain text, fitted to the card's content width before styling. + text: string; + style?: (text: string) => string; +} + +// A rounded Gentle card exactly `width` columns wide and `height` rows tall. +function card(title: string, rows: CardRow[], width: number, height: number, paint: Paint): string[] { + if (width < 6 || height < 2) return Array.from({ length: Math.max(0, height) }, () => " ".repeat(Math.max(0, width))); + const inner = width - 4; + const heading = fit(`${BRAND_GLYPH} ${title}`, Math.max(1, width - 6)); + const top = `${paint.frame("╭─ ")}${paint.rose(paint.bold(heading))}${paint.frame(` ${"─".repeat(Math.max(0, width - 5 - [...heading].length))}╮`)}`; + const bottom = paint.frame(`╰${"─".repeat(width - 2)}╯`); + const body: string[] = []; + for (let index = 0; index < height - 2; index += 1) { + const row = rows[index]; + const text = pad(row?.text ?? "", inner); + body.push(`${paint.frame("│")} ${row?.style ? row.style(text) : text} ${paint.frame("│")}`); + } + return [top, ...body, bottom]; +} + +function detailRows(env: EnvSummary | undefined, inner: number, paint: Paint): CardRow[] { + if (env === undefined) return [ + { text: "No envs yet.", style: paint.bold }, + { text: "" }, + { text: "Press n to create your first env.", style: paint.label }, + ]; + const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`; + // Labels in the rose label tone, values plain: one styled span per row. + const field = (label: string, value: string): CardRow => { + const head = `${label.padEnd(10)}`; + const text = pad(`${head}${value}`, inner); + return { text, style: (fitted) => `${paint.label(fitted.slice(0, head.length))}${fitted.slice(head.length)}` }; + }; + return [ + ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), + ...(env.isDefault ? [{ text: "Default: a plain gentle-shell opens it", style: paint.rose }] : []), + field("Path", plain(env.dir)), + field("Setup", env.setUp === false ? "not set up yet" : plain(env.provisioned)), + field("Sessions", sessions), + field("Sign-ins", env.providers.length > 0 ? env.providers.map(plain).join(", ") : "none yet: /login inside the session"), + field("Config", env.base ? "the shared configuration envs can reuse" : describeEnvScope(env.own ?? [])), + field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), + ...env.packages.map((source) => ({ text: ` · ${plain(source)}`, style: paint.muted })), + ]; +} + +// The VS Code-like config form, drawn in place of the detail card. +const CATEGORY_TITLES: Readonly> = Object.freeze({ + models: "Models & profiles", + persona: "Persona", + appearance: "Appearance", + behavior: "Behavior", +}); + +function sourceLabel(source: ScopeSource): string { + return source.kind === "base" ? `${BRAND_NAME} base` : source.kind === "env" ? `env ${source.name}` : "Empty (defaults)"; +} + +function scopeRows(form: EnvsScopeForm, paint: Paint): CardRow[] { + const row = (selected: boolean, label: string, value: string, width = 20): CardRow => { + const text = `${selected ? "❯" : " "} ${label.padEnd(width)}${value}`; + return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; + }; + return [ + { text: "Mark categories as Own copy (space),", style: paint.label }, + { text: "then choose where their copies come from.", style: paint.label }, + { text: "" }, + ...CONFIG_CATEGORIES.map((category, index) => row(form.cursor === index, CATEGORY_TITLES[category], form.own.includes(category) ? "Own copy" : "Shared with base")), + { text: "" }, + form.own.length === 0 + // Inactive until something is own: nothing would be copied yet. + ? { ...row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", "— mark a category as Own copy first", 21), style: paint.muted } + : row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), + { text: "" }, + { text: "Shared follows the base home live; an own copy diverges.", style: paint.muted }, + ]; +} + +const SCOPE_HINTS: Array<[string, string]> = [["↑↓", "move"], ["space", "toggle"], ["←→", "change"], ["a", "all"], ["esc", "cancel"]]; + +const HINTS: Array<[string, string]> = [ + ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["c", "config"], ["q", "quit"], +]; + +// Wrapped, never truncated: every action stays discoverable at any width. +function hintLines(width: number, paint: Paint, hints: Array<[string, string]> = HINTS): string[] { + const lines: string[] = []; + let parts: string[] = []; + let used = 0; + for (const [key, action] of hints) { + const size = `${key} ${action}`.length; + const extra = (parts.length > 0 ? 3 : 0) + size; + if (parts.length > 0 && used + extra > width) { + lines.push(parts.join(paint.muted(" · "))); + parts = []; + used = 0; + } + parts.push(`${paint.rose(paint.bold(key))} ${paint.label(action)}`); + used += (parts.length > 1 ? 3 : 0) + size; + } + if (parts.length > 0) lines.push(parts.join(paint.muted(" · "))); + return lines; +} + +export function renderEnvsScreen(state: EnvsState, width: number, height: number, options: EnvsRenderOptions = {}): string[] { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const paint = painter(options.color === true); + + // Footer: status or prompt, then the key hints. + // Centered under the cards; the prompt is the one left-aligned exception. + const footer: string[] = []; + if (state.prompt !== undefined) { + const p = state.prompt; + footer.push(`${paint.rose(paint.bold("❯"))} ${paint.bold(fit(`${p.label}: ${p.value}`, Math.max(0, w - 3)))}${paint.rose("▏")}`); + footer.push(p.error === undefined ? center(paint.muted(fit("enter confirm · esc cancel", w)), w) : center(paint.error(fit(p.error, w)), w)); + } else if (state.scope !== undefined) { + const hints: Array<[string, string]> = [...SCOPE_HINTS.slice(0, 4), ["enter", state.scope.mode === "create" ? "create" : "apply"], SCOPE_HINTS[4]]; + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); + footer.push(...hintLines(Math.min(w, 110), paint, hints).map((line) => center(line, w))); + } else { + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); + footer.push(...hintLines(Math.min(w, 110), paint).map((line) => center(line, w))); + } + + // Header: the rose when there is room for it and the cards, then the brand. + const title = `${paint.rose(paint.bold(`${BRAND_GLYPH} ${BRAND_NAME}`))}${options.version ? paint.muted(` v${fit(options.version, 20)}`) : ""}`; + const subtitle = paint.label("Choose an environment to open"); + const header: string[] = []; + const art = ROSE_ART.map((line) => line.replace(/\s+$/u, "")); + const artWidth = Math.max(...art.map((line) => [...line].length)); + if (h >= art.length + 16 && w >= artWidth + 4) { + const lead = Math.floor((w - artWidth) / 2); + header.push(...art.map((line) => " ".repeat(lead) + paint.rose(line)), ""); + } + if (h >= 10) header.push(center(title, w), center(subtitle, w), ""); + else header.push(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w) === `${BRAND_GLYPH} ${BRAND_NAME}` ? title : paint.rose(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w))); + + // The cards fit their content (with a little air) instead of the whole + // screen, and the block is centered both ways: a gate, not a log. + const blockWidth = Math.min(w, 110); + const lead = " ".repeat(Math.floor((w - blockWidth) / 2)); + const selectedEnv = state.envs[state.selected]; + const sideBySide = blockWidth >= 56; + // Wide enough for the longest entry (marker and `(default)` included), within bounds. + const longest = Math.max(0, ...state.envs.map((env) => [...(env.base ? `${BRAND_NAME} base` : env.name)].length + (env.isDefault ? " (default)".length : 0))); + const listWidth = sideBySide ? Math.min(Math.max(20, Math.floor(blockWidth * 0.3), longest + 6), 40, blockWidth - 30) : blockWidth; + const detailWidth = blockWidth - listWidth - 1; + const form = state.scope; + const details = form !== undefined ? scopeRows(form, paint) : detailRows(selectedEnv, Math.max(1, detailWidth - 4), paint); + const contentRows = Math.max(state.envs.length || 1, sideBySide ? details.length : 0) + 2; + const available = Math.max(0, h - header.length - footer.length); + const bodyHeight = Math.min(available, Math.max(6, contentRows + 1)); + const body: string[] = []; + if (bodyHeight >= 3 && blockWidth >= 6) { + const visibleRows = bodyHeight - 2; + // Keep the selection visible when the list is taller than the card. + const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); + const listRows: CardRow[] = state.envs.slice(offset, offset + visibleRows).map((env, index) => { + const selected = index + offset === state.selected; + // The default reads like the /gentle:profiles markers: `name (default)`. + const text = `${selected ? "❯" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}${env.isDefault ? " (default)" : ""}`; + return { text, style: selected ? (fitted: string) => paint.rose(paint.bold(fitted)) : undefined }; + }); + if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); + const list = card("Envs", listRows, listWidth, bodyHeight, paint); + if (sideBySide) { + const title = form !== undefined ? `${form.mode === "create" ? "New env" : "Config"} · ${form.name}` : selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name; + const detail = card(title, details, detailWidth, bodyHeight, paint); + for (let row = 0; row < bodyHeight; row += 1) body.push(`${lead}${list[row]} ${detail[row]}`); + } else { + body.push(...list.map((line) => lead + line)); + } + } + if (state.prompt !== undefined) footer[0] = lead + footer[0]; + // One composition, centered vertically: brand, cards, status and hints. + const block = [...header, ...body, ...footer]; + const spare = Math.max(0, h - block.length); + const above = Math.floor(spare / 2); + const composed = [...Array.from({ length: above }, () => ""), ...block, ...Array.from({ length: spare - above }, () => "")]; + const clip = (line: string) => (visible(line) > w ? fit(line.replace(/\x1b\[[0-9;]*m/g, ""), w) : line); + const all = composed.length > h ? [...composed.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h) : composed; + return all.map(clip).map((line) => (line.includes("\x1b[") ? `${line}${RESET}` : line)); +} diff --git a/lib/gentle-shell-envs.ts b/lib/gentle-shell-envs.ts new file mode 100644 index 000000000..455a269c6 --- /dev/null +++ b/lib/gentle-shell-envs.ts @@ -0,0 +1,301 @@ +import { basename, dirname, join, resolve as resolvePath } from "node:path"; +import { CONFIG_CATEGORIES, CONFIG_CATEGORY_FILES, type ConfigCategory, isConfigCategory } from "./gentle-config-scope.ts"; + +// Named environments for the gentle-shell launcher: pure, side-effect-free +// helpers over injected env/config/file text. An env is an ordinary managed +// `--home` directory under the envs root; the directory itself is the +// registry, so there is no separate list to drift out of sync. + +export const ENVS_DIR_ENV = "GENTLE_SHELL_ENVS_DIR"; + +// Set by the launcher on the Pi process when the home is a named env, so the +// Gentle Shell UI can name it. Never set for any other home. +export const ENV_NAME_ENV = "GENTLE_SHELL_ENV"; + +// The active env's name for display, or undefined. Only a valid name is +// trusted: the value is shown in the UI, so anything else is ignored. +export function activeEnvName(env: Record): string | undefined { + const name = env[ENV_NAME_ENV]; + return name !== undefined && validateEnvName(name) === undefined ? name : undefined; +} + +const ENV_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/; + +// Lowercase only, so names stay identical on case-insensitive filesystems; +// the leading character excludes `.`, `..`, hidden dirs and flag-like names. +export function validateEnvName(name: string): string | undefined { + if (ENV_NAME_PATTERN.test(name)) return undefined; + return `invalid env name "${name}": use 1-40 lowercase letters, digits, ".", "_" or "-", starting with a letter or digit`; +} + +export function envsRoot(env: Record, homedir: string): string { + const override = env[ENVS_DIR_ENV]; + return override !== undefined && override.length > 0 ? override : join(homedir, ".gentle-shell", "envs"); +} + +export function envDir(root: string, name: string): string { + return join(root, name); +} + +// The env name a home directory stands for, or undefined when it is not a +// direct, validly named child of the envs root (any other --home path). +export function envNameForDir(root: string, dir: string): string | undefined { + const resolved = resolvePath(dir); + if (dirname(resolved) !== resolvePath(root)) return undefined; + const name = basename(resolved); + return validateEnvName(name) === undefined ? name : undefined; +} + +// --- config.json mutations --------------------------------------------------- +// All return a new object, keep unknown keys, and never mutate their input. +// `aliases` lists every spelling of the env dir (as given and its realpath), +// because provisioned markers are keyed by realpath. + +type RawConfig = Record; + +function provisionedRecord(config: RawConfig): Record | undefined { + const value = config.provisioned; + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined; +} + +export function setDefaultEnvInConfig(config: RawConfig, dir: string): RawConfig { + return { ...config, home: dir }; +} + +// `newProvisionedKey` is the realpath of `newDir` (provisioned markers are +// realpath-keyed); `home` keeps the plain path so it still names the env. +export function renameEnvInConfig(config: RawConfig, aliases: readonly string[], newDir: string, newProvisionedKey: string = newDir): RawConfig { + const next: RawConfig = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) next.home = newDir; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + const moved: Record = {}; + let marker: unknown; + for (const [key, value] of Object.entries(provisioned)) { + if (aliases.includes(key)) marker ??= value; + else moved[key] = value; + } + if (marker !== undefined) moved[newProvisionedKey] = marker; + next.provisioned = moved; + } + return next; +} + +export function removeEnvFromConfig(config: RawConfig, aliases: readonly string[]): RawConfig { + const next: RawConfig = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) delete next.home; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + next.provisioned = Object.fromEntries(Object.entries(provisioned).filter(([key]) => !aliases.includes(key))); + } + return next; +} + +// --- per-env info ------------------------------------------------------------ + +export interface EnvSummaryInput { + name: string; + dir: string; + isDefault: boolean; + settingsText?: string; + authText?: string; + provisioned?: { gentleAi: string; gentlePi?: string; at: string }; + sessionCount: number; + lastSessionAt?: string; + // The install's own home (`--isolated`), listed beside the named envs. + base?: boolean; + // Gentle config categories this env owns; omitted means none. + own?: readonly ConfigCategory[]; +} + +export interface EnvSummary { + name: string; + dir: string; + isDefault: boolean; + packages: string[]; + // Provider ids only: credential values never leave auth.json. + providers: string[]; + provisioned: string; + // False until gentle-shell provisioned this home; its first launch does. + setUp?: boolean; + sessionCount: number; + lastSessionAt?: string; + base?: boolean; + // Gentle config categories this env owns (lib/gentle-config-scope.ts). + own?: ConfigCategory[]; +} + +function parseObject(text: string | undefined): Record | undefined { + if (text === undefined) return undefined; + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : undefined; + } catch { + return undefined; + } +} + +function packageSources(settingsText: string | undefined): string[] { + const packages = parseObject(settingsText)?.packages; + if (!Array.isArray(packages)) return []; + const sources: string[] = []; + for (const entry of packages) { + if (typeof entry === "string") sources.push(entry); + else if (typeof entry === "object" && entry !== null && typeof (entry as { source?: unknown }).source === "string") { + sources.push((entry as { source: string }).source); + } + } + return sources; +} + +export function summarizeEnv(input: EnvSummaryInput): EnvSummary { + const p = input.provisioned; + const provisioned = + p === undefined ? "not provisioned" : `gentle-ai ${p.gentleAi} · gentle-pi ${p.gentlePi ?? "unknown"} · ${p.at.slice(0, 10)}`; + return { + name: input.name, + dir: input.dir, + isDefault: input.isDefault, + packages: packageSources(input.settingsText), + providers: Object.keys(parseObject(input.authText) ?? {}).sort(), + provisioned, + setUp: p !== undefined, + sessionCount: input.sessionCount, + lastSessionAt: input.lastSessionAt, + ...(input.base ? { base: true } : {}), + own: [...(input.own ?? [])], + }; +} + +// --- per-env config scope (lib/gentle-config-scope.ts) ------------------------ +// Like VS Code profiles: each category of Gentle's config is either shared +// with the base home (live) or owned by the env (its own copy in +// `/gentle-ai`). Only that persistent choice is stored; copying or +// starting empty is a one-time seed when a category becomes own. + +export const ENV_SCOPE_FILE = ".gentle-shell-env.json"; +const ENV_SCOPE_SCHEMA = 1; + +// Tolerant: a missing, malformed or unknown-schema file means "share +// everything", today's behavior for every env created before scopes existed. +export function parseEnvScope(text: string | undefined): ConfigCategory[] { + const own = parseObject(text); + if (own === undefined || own.schema !== ENV_SCOPE_SCHEMA || !Array.isArray(own.own)) return []; + const listed = own.own.filter((value): value is string => typeof value === "string"); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +export function serializeEnvScope(own: readonly ConfigCategory[]): string { + return `${JSON.stringify({ schema: ENV_SCOPE_SCHEMA, own: CONFIG_CATEGORIES.filter((category) => own.includes(category)) }, null, 2)}\n`; +} + +export interface ScopeFlags { + own: ConfigCategory[]; + share: ConfigCategory[]; + // Env to copy newly own categories from; undefined copies from the base home. + from?: string; + empty: boolean; + error?: string; +} + +function categoryList(value: string): { categories: ConfigCategory[]; error?: string } { + if (value === "all") return { categories: [...CONFIG_CATEGORIES] }; + const categories: ConfigCategory[] = []; + for (const raw of value.split(",").map((part) => part.trim()).filter((part) => part.length > 0)) { + if (!isConfigCategory(raw)) return { categories, error: `unknown config category "${raw}" (use ${CONFIG_CATEGORIES.join(", ")} or all)` }; + if (!categories.includes(raw)) categories.push(raw); + } + return { categories }; +} + +// `--own `, `--share `, `--from ` and `--empty`, as +// `--flag value` or `--flag=value`. +export function parseScopeFlags(args: readonly string[]): ScopeFlags { + const result: ScopeFlags = { own: [], share: [], from: undefined, empty: false }; + const fail = (error: string): ScopeFlags => ({ ...result, error }); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--empty") { + result.empty = true; + continue; + } + const match = /^--(own|share|from)(?:=(.*))?$/.exec(arg); + if (match === null) return fail(`unknown option ${arg}`); + const [, flag, inline] = match; + const value = inline ?? args[i + 1]; + if (inline === undefined) i += 1; + if (value === undefined || value.length === 0) return fail(`--${flag} needs a value`); + if (flag === "from") { + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + result.from = value; + continue; + } + const list = categoryList(value); + if (list.error !== undefined) return fail(list.error); + result[flag as "own" | "share"] = list.categories; + } + if (result.from !== undefined && result.empty) return fail("--from and --empty cannot be combined"); + const both = result.own.find((category) => result.share.includes(category)); + if (both !== undefined) return fail(`"${both}" cannot be both own and shared`); + return result; +} + +export type ScopeSeed = { kind: "copy"; from?: string } | { kind: "empty" }; + +export interface ScopeChangePlan { + next: ConfigCategory[]; + // Categories that become own now and need their one-time seed. + seed: ConfigCategory[]; +} + +export function planScopeChange(input: { current: readonly ConfigCategory[]; own: readonly ConfigCategory[]; share: readonly ConfigCategory[]; seed: ScopeSeed }): ScopeChangePlan { + const next = CONFIG_CATEGORIES.filter((category) => !input.share.includes(category) && (input.current.includes(category) || input.own.includes(category))); + return { next, seed: next.filter((category) => !input.current.includes(category)) }; +} + +export type SeedOperation = { kind: "copy"; from: string; to: string } | { kind: "remove"; path: string }; + +// The file operations that seed `categories` in `targetDir`: a copy of each +// file the source has, and a removal of each one it lacks (so the target +// starts from the source's defaults), or only removals when starting empty. +// Never touches a file outside these categories. +export function seedOperations(input: { + categories: readonly ConfigCategory[]; + mode: "copy" | "empty"; + sourceDirFor: (category: ConfigCategory) => string; + targetDir: string; + exists: (path: string) => boolean; +}): SeedOperation[] { + const operations: SeedOperation[] = []; + for (const category of input.categories) { + for (const file of CONFIG_CATEGORY_FILES[category]) { + const to = join(input.targetDir, file); + const from = join(input.sourceDirFor(category), file); + if (input.mode === "copy" && input.exists(from)) operations.push({ kind: "copy", from, to }); + else operations.push({ kind: "remove", path: to }); + } + } + return operations; +} + +// Categories that need a seed when they become own: a category whose own +// files were kept on disk when it was shared again is reused, never seeded +// over, so an earlier own copy is not lost. +export function categoriesToSeed(categories: readonly ConfigCategory[], targetDir: string, exists: (path: string) => boolean): ConfigCategory[] { + return categories.filter((category) => !CONFIG_CATEGORY_FILES[category].some((file) => exists(join(targetDir, file)))); +} + +export const CONFIG_CATEGORY_LABELS: Readonly> = Object.freeze({ + models: "models & profiles", + persona: "persona", + appearance: "appearance", + behavior: "behavior", +}); + +export function describeEnvScope(own: readonly ConfigCategory[]): string { + if (own.length === 0) return "shares everything with the base home"; + if (own.length === CONFIG_CATEGORIES.length) return "fully own (shares nothing)"; + const label = (list: readonly ConfigCategory[]) => list.map((category) => CONFIG_CATEGORY_LABELS[category]).join(", "); + return `own ${label(CONFIG_CATEGORIES.filter((c) => own.includes(c)))} · shares ${label(CONFIG_CATEGORIES.filter((c) => !own.includes(c)))}`; +} diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 37a47273e..5a6806128 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1,12 +1,16 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, type ChildPackageInjection } from "./child-package-injection.ts"; import { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts"; +import { ENV_NAME_ENV, envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.ts"; +import { type ConfigCategory, ENV_CONFIG_HOME_ENV, ENV_OWN_CONFIG_ENV } from "./gentle-config-scope.ts"; + +export { ENV_NAME_ENV }; // The gentle-shell launcher: pure, side-effect-free functions over injected // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process, // filesystem and child process so this module stays fully unit-testable. -export type LauncherCommand = "home" | "setup" | "upgrade" | "self-uninstall" | "memory"; +export type LauncherCommand = "home" | "envs" | "setup" | "upgrade" | "self-uninstall" | "memory"; // pi's own package-management subcommands (see pi's cli/args.ts printHelp // "Commands" list): each is dispatched by pi itself, before pi's own flag @@ -23,6 +27,8 @@ export interface ParsedLauncherArgs { link: boolean; isolated: boolean; home?: string; + // Named env selected with `--env ` (see lib/gentle-shell-envs.ts). + env?: string; packageRoot?: string; help: boolean; version: boolean; @@ -42,11 +48,12 @@ export interface ParsedLauncherArgs { // untouched as commandArgs — T2 owns interpreting `home link|isolated|`. // `self-uninstall` is not `uninstall`, which stays pi's own alias for `remove`. export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { - if (argv[0] === "home" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { + if (argv[0] === "home" || argv[0] === "envs" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { return { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, @@ -61,6 +68,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { let link = false; let isolated = false; let home: string | undefined; + let env: string | undefined; let packageRoot: string | undefined; let help = false; let version = false; @@ -119,6 +127,22 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { i += 1; continue; } + if (arg === "--env" || arg.startsWith("--env=")) { + const inline = arg.startsWith("--env="); + const value = inline ? arg.slice("--env=".length) : argv[i + 1]; + if (!inline && value !== undefined) i += 1; + if (value === undefined || value.length === 0) { + error = "--env requires a non-empty env name"; + continue; + } + const invalid = validateEnvName(value); + if (invalid !== undefined) { + error = invalid; + continue; + } + env = value; + continue; + } if (arg.startsWith("--package-root=")) { const value = arg.slice("--package-root=".length); if (value.length === 0) { @@ -166,7 +190,13 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { } if (error === undefined) { - if (link && isolated) { + if (env !== undefined && link) { + error = "--env cannot be combined with --link"; + } else if (env !== undefined && isolated) { + error = "--env cannot be combined with --isolated"; + } else if (env !== undefined && home !== undefined) { + error = "--env cannot be combined with --home"; + } else if (link && isolated) { error = "--link cannot be combined with --isolated"; } else if (link && home !== undefined) { error = "--link cannot be combined with --home"; @@ -175,7 +205,7 @@ export function parseLauncherArgs(argv: string[]): ParsedLauncherArgs { } } - return { link, isolated, home, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; + return { link, isolated, home, env, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; } // --- home resolution ------------------------------------------------------- @@ -187,6 +217,11 @@ export interface ResolvedHome { mode: HomeMode; dir: string; source: HomeSource; + // Set for a `path` home that is a named env, so hints reproduce `--env`. + env?: string; + // The Gentle config categories this env keeps in `/gentle-ai` + // instead of sharing the base configuration (lib/gentle-config-scope.ts). + ownConfig?: readonly ConfigCategory[]; } // A discriminated union instead of a plain `home: string` field: `resolveHome` @@ -237,6 +272,20 @@ export function standaloneConfigHome(home: ResolvedHome): string | undefined { return basename(dir) === "agent" && basename(parent) === ".gentle-shell" ? join(parent, "gentle-ai") : undefined; } +// The launcher config folder a launch of `home` uses (#2201): the isolated +// home's own, and for a named env the base isolated home's, since "shared" +// means shared with the base home. Undefined keeps ~/.pi/gentle-ai (--link, +// a plain --home, or a base home without its own folder). +export function launchConfigHome(home: ResolvedHome, env: Record, homedir: string): string | undefined { + if (home.mode === "isolated") return standaloneConfigHome(home); + return home.env === undefined ? undefined : baseConfigHome(env, homedir); +} + +// The base (isolated) home's own config folder, or undefined when it has none. +export function baseConfigHome(env: Record, homedir: string): string | undefined { + return standaloneConfigHome({ mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }); +} + // The filesystem behind migrateStandaloneConfig; bin/gentle-shell.mjs wires the real one. export interface ConfigMigrationFs { // lstat: a symbolic link is "other", never followed. @@ -299,12 +348,18 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome { if (args.link) return { mode: "link", dir: userPiHome(env, homedir), source: "flag" }; if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" }; - if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" }; + if (args.home !== undefined) { + // An explicit --home into the envs root is that env, labelled like --env. + const name = envNameForDir(envsRoot(env, homedir), args.home); + return name === undefined ? { mode: "path", dir: args.home, source: "flag" } : { mode: "path", dir: args.home, source: "flag", env: name }; + } + if (args.env !== undefined) return { mode: "path", dir: envDir(envsRoot(env, homedir), args.env), source: "flag", env: args.env }; if (config !== undefined) { if (config.mode === "link") return { mode: "link", dir: userPiHome(env, homedir), source: "config" }; if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" }; - return { mode: "path", dir: config.dir, source: "config" }; + const name = envNameForDir(envsRoot(env, homedir), config.dir); + return name === undefined ? { mode: "path", dir: config.dir, source: "config" } : { mode: "path", dir: config.dir, source: "config", env: name }; } return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }; @@ -320,6 +375,7 @@ export function resolveHome(input: ResolveHomeInput): ResolvedHome { // gentle-shell's own default when no selector is given. export function homeSelectorFlags(home: ResolvedHome): string[] { if (home.mode === "link") return ["--link"]; + if (home.mode === "path" && home.env !== undefined) return ["--env", home.env]; if (home.mode === "path") return ["--home", home.dir]; return []; } @@ -1086,12 +1142,28 @@ export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation { GENTLE_PI_AGENT_HOME: input.home.dir, [USER_PI_HOME_ENV]: userPiHome(input.baseEnv, input.homedir), }; - // #2201: only the standalone isolated home gets its own config folder; any - // other launch removes an inherited marker, so a nested `--link` launch from - // an isolated session reads ~/.pi/gentle-ai again. - const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); + // #2201: only the standalone isolated home, and the named envs that share + // it, get its own config folder; any other launch removes an inherited + // marker, so a nested `--link` launch from an isolated session reads + // ~/.pi/gentle-ai again. + const configHome = input.bundled === true ? undefined : launchConfigHome(input.home, input.baseEnv, input.homedir); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; + + // Exactly this home's env name: an inherited value (a gentle-shell started + // from inside another env's session) must never label a different home. + if (input.home.env === undefined) delete env[ENV_NAME_ENV]; + else env[ENV_NAME_ENV] = input.home.env; + // Same rule for the env's config scope: only an env that owns categories + // sets it, so an outer session's scope never reaches another home. + const ownConfig = input.home.env === undefined ? [] : (input.home.ownConfig ?? []); + if (ownConfig.length === 0) { + delete env[ENV_CONFIG_HOME_ENV]; + delete env[ENV_OWN_CONFIG_ENV]; + } else { + env[ENV_CONFIG_HOME_ENV] = join(input.home.dir, "gentle-ai"); + env[ENV_OWN_CONFIG_ENV] = ownConfig.join(","); + } if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); @@ -1290,6 +1362,7 @@ export function helpText(): string { "Usage: gentle-shell [options] [-- pi-args...]", " gentle-shell home [link|isolated|]", " gentle-shell [home selectors] home import-pi [--dry-run]", + " gentle-shell envs [list | new [scope] | config [scope]]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", @@ -1304,6 +1377,7 @@ export function helpText(): string { " --isolated Use the dedicated ~/.gentle-shell/agent home (default), with its own", " settings in ~/.gentle-shell/gentle-ai (copied once from ~/.pi/gentle-ai).", " --home Use a custom agent home directory.", + " --env Use the named env ~/.gentle-shell/envs/ (must already exist).", " --package-root Force this directory as the gentle-pi package to load, taking over", " from any conflicting package the target settings.json already declares.", " --help, -h Show this help text.", @@ -1316,6 +1390,12 @@ export function helpText(): string { " level when it has none. Never overwrites a file or setting, never copies", " packages, extensions or caches, and never changes your pi home.", " --dry-run only shows the plan. Refuses --link (nothing to import).", + " envs Manage named envs: on a terminal, a full-screen manager to create,", + " rename, delete, launch, set the default, set up, install into and", + " check sign-ins of each env. 'envs list' prints them, marking (default);", + " 'envs new ' creates one; 'envs config ' chooses which Gentle", + " config it owns (--own/--share models,persona,appearance,behavior).", + " Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector", @@ -1368,6 +1448,7 @@ export function helpText(): string { " inside a .gentle-shell folder, its settings live in the gentle-ai", " folder next to it; elsewhere they stay in ~/.pi/gentle-ai.", " GENTLE_PI_CONFIG_HOME Gentle Shell settings folder for every home, overriding the above.", + " GENTLE_SHELL_ENVS_DIR Directory holding named envs (default: ~/.gentle-shell/envs).", " PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.", "", "Every other argument is forwarded to pi unchanged.", diff --git a/lib/gentle-shell-uninstall.ts b/lib/gentle-shell-uninstall.ts index 267ee55e2..6b5b849b7 100644 --- a/lib/gentle-shell-uninstall.ts +++ b/lib/gentle-shell-uninstall.ts @@ -2,6 +2,9 @@ import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdir import { createInterface } from "node:readline"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { isolatedDir, launcherConfigPath, parseRawLauncherConfig, provisionedEntry, userPiHome } from "./gentle-shell-launcher.ts"; +import { envsRoot } from "./gentle-shell-envs.ts"; + +const NAMED_ENVS_LABEL = "your named envs"; // `gentle-shell self-uninstall`: removes what Gentle Shell itself created, then // the gentle-pi package with the package manager that owns it. Everything is @@ -119,6 +122,23 @@ function comparable(path: string, platform: string): string { return platform === "win32" ? path.toLowerCase() : path; } +/** + * Where each symlink on the way to `path` lives: the root's own link and any + * symlinked folder above it, each as its resolved parent plus its name. + * Removing a folder that holds one of these breaks the configured path even + * when the real target is guarded elsewhere. + */ +function symlinkEntries(path: string): string[] { + const entries: string[] = []; + let current = resolve(path); + for (let parent = dirname(current); parent !== current; current = parent, parent = dirname(current)) { + let link = false; + try { link = lstatSync(current).isSymbolicLink(); } catch { /* missing: nothing to break */ } + if (link) entries.push(join(canonical(parent), basename(current))); + } + return entries; +} + /** The real path, resolving the nearest existing ancestor when `path` does not exist. */ function canonical(path: string): string { const absolute = resolve(path); @@ -139,7 +159,8 @@ function canonical(path: string): string { /** True when `child` is `parent` itself or lies inside it. */ function within(parent: string, child: string, platform: string): boolean { const path = relative(comparable(parent, platform), comparable(child, platform)); - return path === "" || (!path.startsWith("..") && !isAbsolute(path)); + // Only a whole `..` segment is parent traversal; `..envs` is a child name. + return path === "" || (!/^\.\.(?:[\\/]|$)/.test(path) && !isAbsolute(path)); } type Inspection = "ok" | "missing" | "symlink" | "other"; @@ -343,6 +364,17 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, const seen = new Set([comparable(isolatedReal, platform), comparable(canonical(piHome), platform)]); plan.notTouched.push({ path: piHome, label: "your Pi home" }); + // Named envs are user data: their root is listed once, never each env as a + // custom --home home, and the overlap check below protects all of them. + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); + // A symlinked root still holds the user's envs: protect its real target too. + const namedEnvsState = inspect(namedEnvs, "dir"); + const namedEnvsReal = namedEnvsState === "ok" || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok") ? canonical(namedEnvs) : undefined; + if (namedEnvsReal !== undefined) { + seen.add(comparable(namedEnvsReal, platform)); + plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); + } const customHomes = [typeof launcherConfig.home === "string" && !["link", "isolated", ""].includes(launcherConfig.home) ? launcherConfig.home : undefined]; const provisioned = launcherConfig.provisioned; if (typeof provisioned === "object" && provisioned !== null && !Array.isArray(provisioned)) customHomes.push(...Object.keys(provisioned)); @@ -350,6 +382,7 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, if (home === undefined || !isAbsolute(home)) continue; const key = comparable(canonical(home), platform); if (seen.has(key)) continue; + if (namedEnvsReal !== undefined && within(namedEnvsReal, canonical(home), platform)) continue; seen.add(key); plan.notTouched.push({ path: home, label: "custom --home home" }); } @@ -358,8 +391,12 @@ async function planSelfUninstall(input: SelfUninstallInput, configHome: string, // Nothing is both removed and not touched: a removal that is, holds or lies // inside a path listed as not touched refuses the whole run. if (plan.refusals.length === 0) { + const namedEnvsLinks = namedEnvsReal !== undefined ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); + if (namedEnvsLinks.some((entry) => within(real, entry, platform))) { + plan.refusals.push(`${removal.path} would be removed, but it overlaps ${namedEnvs} (${NAMED_ENVS_LABEL}), which is not touched; refusing to remove anything.`); + } for (const kept of plan.notTouched) { if (kept.path === undefined) continue; const target = canonical(kept.path); @@ -690,8 +727,19 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan if (windows) consider(join(root, BUNDLED_WINDOWS_MARKER.name), "the bundled install's folder marker", "file", plan.own); consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); + const namedEnvsState = inspect(namedEnvs, "dir"); + const hasNamedEnvs = namedEnvsState === "ok"; + // A symlinked root still holds the user's envs: guard and list its real + // target too. Only a real folder counts as a known prefix entry; a link + // there stays foreign and so keeps the prefix. + const guardsNamedEnvs = hasNamedEnvs || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok"); const known = new Set([...BUNDLED_OWN.map((item) => item.name), ...(windows ? [BUNDLED_WINDOWS_MARKER.name] : []), - ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : [])]); + ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : []), + // Named envs are user data that may share the prefix folder: kept, and + // listed below instead of as an unknown entry. + ...(hasNamedEnvs && same(canonical(dirname(namedEnvs)), real) ? [basename(namedEnvs)] : [])]); for (const name of names) { if (known.has(name)) continue; foreign = true; @@ -699,13 +747,18 @@ function planBundledSelfUninstall(input: BundledSelfUninstallInput): BundledPlan } plan.removeRoot = !foreign; + if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); + const namedEnvsLinks = guardsNamedEnvs ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { - const reason = collision(canonical(removal.path), guards, platform); + const real = canonical(removal.path); + const reason = collision(real, guards, platform) + ?? (namedEnvsLinks.some((entry) => within(real, entry, platform)) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); } plan.notTouched.push({ path: piHome, label: "your Pi home" }, { path: join(homedir, ".pi", "gentle-ai"), label: "Gentle AI's configuration for Pi" }, { path: join(homedir, ".gentle-ai"), label: "Gentle AI's own state" }, { path: join(homedir, ".engram"), label: "Engram's memory" }); + if (guardsNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); if (env.GENTLE_SHELL_HOME && !same(canonical(env.GENTLE_SHELL_HOME), canonical(join(shellHome, "agent")))) { plan.notTouched.push({ path: env.GENTLE_SHELL_HOME, label: "the Gentle Shell home GENTLE_SHELL_HOME names" }); } diff --git a/lib/history-capture-policy.ts b/lib/history-capture-policy.ts index 3aa09bb83..74d565f22 100644 --- a/lib/history-capture-policy.ts +++ b/lib/history-capture-policy.ts @@ -46,7 +46,7 @@ export function parseHistoryCaptureFile(raw: string): HistoryCapturePolicy | und } export function resolveHistoryCapturePolicy(options: HistoryCaptureOptions = {}): HistoryCapturePolicyResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "history-capture.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"), "history-capture.json"); try { const policy = parseHistoryCaptureFile(readFileSync(globalFile, "utf8")); return { policy: policy ?? "off", source: "global_file", malformed: policy === undefined, globalFile }; @@ -59,7 +59,7 @@ export function resolveHistoryCapturePolicy(options: HistoryCaptureOptions = {}) /** Full resolution for status surfaces; always reads the preference. */ export function resolveHistoryCapture(options: { env?: NodeJS.ProcessEnv; gentlePiConfigHome?: string } = {}): HistoryCaptureResolution { const env = options.env ?? process.env; - const saved = resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env) }); + const saved = resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior") }); const envOverride = historyCaptureEnvOverride(env); return { enabled: (envOverride ?? saved.policy) === "on", @@ -76,12 +76,12 @@ export function historyCaptureEnabled(options: { env?: NodeJS.ProcessEnv; gentle const env = options.env ?? process.env; const override = historyCaptureEnvOverride(env); if (override) return override === "on"; - return resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env) }).policy === "on"; + return resolveHistoryCapturePolicy({ gentlePiConfigHome: options.gentlePiConfigHome ?? gentlePiConfigHome(env, "behavior") }).policy === "on"; } export function writeHistoryCapturePolicy(policy: HistoryCapturePolicy, options: HistoryCaptureOptions = {}): string { if (policy !== "on" && policy !== "off") throw new TypeError("Invalid history capture policy"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); const path = join(home, "history-capture.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/notification-policy.ts b/lib/notification-policy.ts index 0cf4f6aec..29ab3c666 100644 --- a/lib/notification-policy.ts +++ b/lib/notification-policy.ts @@ -119,7 +119,7 @@ export function restoreNotificationPreset(settings: NotificationSettings): Notif return { ...structuredClone(DEFAULT_NOTIFICATION_SETTINGS), enabled: settings.enabled }; } export function resolveNotificationSettings(options: NotificationOptions = {}): NotificationResolution { - const globalFile = join(options.gentlePiConfigHome || gentlePiConfigHome(), "notifications.json"); + const globalFile = join(options.gentlePiConfigHome || gentlePiConfigHome(undefined, "appearance"), "notifications.json"); try { const settings = parseNotificationSettingsFile((options.io ?? DEFAULT_IO).readFile(globalFile), options.pathFlavor); return { settings: settings ?? structuredClone(DEFAULT_NOTIFICATION_SETTINGS), source: "global_file", malformed: settings === undefined, readError: false, globalFile }; @@ -132,7 +132,7 @@ export function resolveNotificationSettings(options: NotificationOptions = {}): export function writeNotificationSettings(settings: NotificationSettings, options: NotificationOptions = {}): string { if (!isNotificationSettings(settings, options.pathFlavor)) throw new TypeError("Invalid notification settings"); const io = options.io ?? DEFAULT_IO; - const home = options.gentlePiConfigHome || gentlePiConfigHome(); + const home = options.gentlePiConfigHome || gentlePiConfigHome(undefined, "appearance"); const path = join(home, "notifications.json"); const temporary = `${path}.${randomUUID()}.tmp`; io.mkdir(home); diff --git a/lib/permission-mode-policy.ts b/lib/permission-mode-policy.ts index d432871ba..cdd060ec5 100644 --- a/lib/permission-mode-policy.ts +++ b/lib/permission-mode-policy.ts @@ -17,7 +17,7 @@ export function isPermissionMode(value: unknown): value is PermissionMode { } /** Never consult merged Pi settings or repository configuration. */ export function permissionModePolicyFile(options: PermissionModeOptions = {}): string { - return join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "permission-mode.json"); + return join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"), "permission-mode.json"); } export const permissionModePolicyPath = permissionModePolicyFile; export function parsePermissionModePolicyFile(raw: string): PermissionMode | undefined { @@ -43,7 +43,7 @@ export function writePermissionModePolicy(mode: PermissionMode, options: Permiss if (!isPermissionMode(mode)) throw new Error("Invalid permission mode"); const resolution = resolvePermissionModePolicy(options); if (resolution.malformed) throw new Error("Permission mode preference is malformed or unreadable; repair it before saving."); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "behavior"); const path = resolution.globalFile; const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index 56ce774da..16cfb35f8 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -22,6 +22,8 @@ export interface ShellBarModel { review?: ReviewSidebarSnapshot; jobs?: RunningJobDisplay[]; profile?: string; + // The named env this session runs in (gentle-shell --env), if any. + env?: string; changes?: { files: number; added: number; deleted: number; notice?: string }; cwd: string; branch: string | null; @@ -48,6 +50,7 @@ export interface ShellHeaderModel { modelId: string; effort: string | undefined; profile?: string; + env?: string; contextPercent: number | null; costTotal: number; subscription: boolean; @@ -60,6 +63,7 @@ export interface ShellHeaderModel { export function buildShellHeaderModel(model: ShellBarModel, showJobs = false): ShellHeaderModel { const { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage } = model; return { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage, + ...(model.env ? { env: model.env } : {}), ...(showJobs && model.jobs?.length ? { jobs: model.jobs.length } : {}) }; } @@ -177,6 +181,9 @@ function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation? const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status))); return [ ...(presentation?.density === "minimal" ? [] : [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND)]), + // As in the header: the env sits beside the brand and, being first, is + // never among the trailing segments a narrow line drops. + ...(model.env ? [`${theme.fg(ROLE.LABEL, "env")} ${theme.fg(ROLE.VALUE, theme.bold(sanitizeStatus(model.env)))}`] : []), location, ...(presentation?.visibility.modelDetails === false ? [] : [modelSegment]), ...(presentation?.visibility.usageCost === false ? [] : [context, cost, ...(usage ? [usage] : [])]), @@ -225,6 +232,8 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme // this event-driven card keeps only what a footer/model-switch event does // not already refresh every frame. const groups: Array<{ title: string; lines: string[] }> = [ + // The named env comes first: it decides which home this session writes to. + ...(model.env ? [{ title: "Env", lines: [value(sanitizeStatus(model.env))] }] : []), { title: "Project", lines: [ @@ -277,18 +286,21 @@ const HEADER_BRAND = "✿ Gentle Shell"; // alive as long as anything can still share the row with the right-aligned // counters. function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme, showModelDetails: boolean): string[][] { + // The env stays beside the brand in every stage: which home this session + // writes to matters more than any detail that gives way before it. const brand = theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); + const env = model.env ? [`${theme.fg(ROLE.LABEL, "env")} ${theme.fg(ROLE.VALUE, theme.bold(sanitizeStatus(model.env)))}`] : []; const location = locationSegment(model, theme); - if (!showModelDetails) return [[brand, location], [brand]]; + if (!showModelDetails) return [[brand, ...env, location], [brand, ...env]]; const withEffort = executionSegment(model.modelId, model.effort, theme); const modelOnly = executionSegment(model.modelId, undefined, theme); const withProfile = model.profile ? `${withEffort} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.MODEL, sanitizeStatus(model.profile))}` : withEffort; return [ - [brand, location, withProfile], - [brand, location, withEffort], - [brand, location, modelOnly], - [brand, modelOnly], - [brand], + [brand, ...env, location, withProfile], + [brand, ...env, location, withEffort], + [brand, ...env, location, modelOnly], + [brand, ...env, modelOnly], + [brand, ...env], ]; } @@ -412,7 +424,12 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num const usageStart = visibleWidth(left) + (targetWidth - visibleWidth(left) - visibleWidth(right)) + visibleWidth(ctxCost) + visibleWidth(` ${SHELL_BAR_SEPARATOR} `); return { text, usageSpan: { start: usageStart, end: usageStart + visibleWidth(usageText) } }; } - const brand = presentation?.density === "minimal" ? "" : theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); + // Narrower than brand plus env: the brand gives way, never the env, as in + // the compact bar. Without an env, or when even it does not fit, the brand + // (hidden in minimal density) is the last thing left. + const envOnly = model.env ? `${theme.fg(ROLE.LABEL, "env")} ${theme.fg(ROLE.VALUE, theme.bold(sanitizeStatus(model.env)))}` : ""; + const brand = envOnly && visibleWidth(envOnly) <= targetWidth ? envOnly + : presentation?.density === "minimal" ? "" : theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND)); if (jobs && visibleWidth(jobs) <= targetWidth) { const left = visibleWidth(brand) + RIGHT_PADDING + visibleWidth(jobs) <= targetWidth ? brand : ""; return { text: left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(jobs)) + jobs }; @@ -517,8 +534,12 @@ export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width segments = buildSegments(compactModel(model), theme, presentation); left = joinSegments(segments, theme); } + const brandThenEnv = Boolean(model.env) && presentation?.density !== "minimal"; while (segments.length > 1 && visibleWidth(left) > width) { - segments.pop(); + // Down to brand and env, the brand gives way: the env names the home + // this session writes to. + if (brandThenEnv && segments.length === 2) segments.shift(); + else segments.pop(); left = joinSegments(segments, theme); } return [truncateToWidth(left, width, "…")]; diff --git a/lib/vim-policy.ts b/lib/vim-policy.ts index 264e702e5..a3784adf4 100644 --- a/lib/vim-policy.ts +++ b/lib/vim-policy.ts @@ -24,7 +24,7 @@ export function parseVimPolicyFile(raw: string): VimPolicy | undefined { } export function resolveVimPolicy(options: VimOptions = {}): VimResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "vim.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "vim.json"); try { const policy = parseVimPolicyFile(readFileSync(globalFile, "utf8")); return { policy: policy ?? "off", source: "global_file", malformed: policy === undefined, globalFile }; @@ -35,7 +35,7 @@ export function resolveVimPolicy(options: VimOptions = {}): VimResolution { } export function writeVimPolicy(policy: VimPolicy, options: VimOptions = {}): string { - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "vim.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/visual-customization-policy.ts b/lib/visual-customization-policy.ts index c7f7626d7..c1e2243e1 100644 --- a/lib/visual-customization-policy.ts +++ b/lib/visual-customization-policy.ts @@ -93,7 +93,7 @@ export function parseVisualSettingsFile(raw: string): VisualSettings | undefined } catch { return undefined; } } export function resolveVisualSettings(options: VisualOptions = {}): VisualResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "visual-customization.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "visual-customization.json"); try { const settings = parseVisualSettingsFile(readFileSync(globalFile, "utf8")); return { settings: settings ?? structuredClone(DEFAULT_VISUAL_SETTINGS), source: "global_file", malformed: settings === undefined, readError: false, globalFile }; @@ -105,7 +105,7 @@ export function resolveVisualSettings(options: VisualOptions = {}): VisualResolu /** Same-directory rename keeps partial JSON invisible to readers. */ export function writeVisualSettings(settings: VisualSettings, options: VisualOptions = {}): string { if (!isVisualSettings(settings)) throw new TypeError("Invalid visual settings"); - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "visual-customization.json"); const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(home, { recursive: true }); diff --git a/lib/visual-profiles.ts b/lib/visual-profiles.ts index 253dff6a5..4588d9654 100644 --- a/lib/visual-profiles.ts +++ b/lib/visual-profiles.ts @@ -101,7 +101,7 @@ export function parseVisualProfilesFile(raw: string): VisualProfile[] | undefine } export function readVisualProfiles(options: Options = {}): VisualProfilesResolution { - const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "visual-profiles.json"); + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"), "visual-profiles.json"); let fd: number | undefined; try { try { @@ -153,7 +153,7 @@ function existing(options: Options): VisualProfile[] { } function write(profiles: VisualProfile[], options: Options): string { - const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(undefined, "appearance"); const path = join(home, "visual-profiles.json"); const data = `${JSON.stringify({ schema: VISUAL_PROFILES_SCHEMA, profiles })}\n`; if (Buffer.byteLength(data) > MAX_BYTES) { diff --git a/lib/yolo-session-policy.ts b/lib/yolo-session-policy.ts index b3dfc32e3..e189511d8 100644 --- a/lib/yolo-session-policy.ts +++ b/lib/yolo-session-policy.ts @@ -102,7 +102,7 @@ interface ModeIdentity { /** UI/slash authoring only. Defaults are read from the dedicated trusted user store. */ export function registerYoloSessionPolicy(pi: ExtensionAPI, env: NodeJS.ProcessEnv, options: { onChange?: () => void } = {}): YoloSessionController { - const store = { gentlePiConfigHome: gentlePiConfigHome(env) }; + const store = { gentlePiConfigHome: gentlePiConfigHome(env, "behavior") }; let generation = 0; let ownerGeneration = 0; let liveMode: PermissionMode = "normal"; diff --git a/odd/tasks/named-envs.md b/odd/tasks/named-envs.md new file mode 100644 index 000000000..7db3e91f7 --- /dev/null +++ b/odd/tasks/named-envs.md @@ -0,0 +1,33 @@ +# Named environments (`gentle-shell envs` / `--env`) + +## Objective +Let users keep several named agent homes ("envs"), pick one to open a session in (`gentle-shell --env ` or the `gentle-shell envs` manager), see which one is active, and choose per env which parts of Gentle's own configuration it shares with the base home, like VS Code profiles. + +## Problem and why +A separate home needs `--home ` every time, with no names, no overview of what each home holds, and no visible active home. Gentle's own configuration (`~/.pi/gentle-ai`) is always shared, so homes cannot differ in model profiles, persona, appearance or behavior. + +## Decisions +- Surface: `gentle-shell envs` (a full-screen manager on a TTY, a list otherwise), `envs list|new|config`, and the `--env ` launch flag. +- Storage: managed directories `~/.gentle-shell/envs//` (`GENTLE_SHELL_ENVS_DIR` overrides the root). The directory is the registry. Names are `[a-z0-9][a-z0-9._-]{0,39}`. +- Compatibility first: the install's own `--isolated` home stays the base home and is listed as `Gentle Shell base`. The default reuses the persisted `home` key (`isolated` or an env path). `--env` never creates an unknown env. +- The session shows the active env: `env ` in the header and an `Env` group above `Project` in Status, passed as `GENTLE_SHELL_ENV` only for named envs. +- Per-env Gentle configuration by category (`models` = routing + profiles, `persona`, `appearance`, `behavior`): each one is shared live with the base home, or an own copy in `/gentle-ai/` seeded once (copied from the base home or another env, or empty). Install-level files and repository settings stay shared. Envs without a scope file share everything. +- Out of scope: per-env Engram memory stays shared for now. + +## Delivery +Chained PRs on `main`: +1. `feat/named-envs-core`: `--env`, `envs list|new`, the session indicator, and uninstall protection (bundled and non-bundled). +2. `feat/named-envs-gate`: the `envs` manager with Gentle's visual identity, the base home entry, and `(default)` markers. +3. `feat/named-envs-config`: per-category config resolution (every config-home read names its category, enforced by a drift-guard test), `envs config`, and the VS Code-like form. + +Separate: `feat/installer-download-progress` (TTY download progress for the package-local Gentle AI binary). + +## Evidence +- Test-first for every unit: launcher parsing/resolution, env model, manager reducer/renderer, config scope, bin and bundled-prefix integration. +- Manual checks in a pty: create, default, rename, delete, open, install, the branded gate, the session indicator in a real Pi session, and the config form. +- Real macOS standalone install from the published installer, with these branches overlaid: env CLI, gate, session indicator, uninstall plan ("your named envs"), and an env owning `models` showing its own profile while the base home keeps the shared one. +- No new failures in the touched extension suites compared with `main` (the remaining failures are pre-existing). + +## Known gaps +- Windows is untested: the raw-mode manager, the bundled Windows prefix, and the envs root. +- Prompt history still ignores the active home (#1618), so history mixes across envs until that is fixed. diff --git a/runtime/gentle-brand.mjs b/runtime/gentle-brand.mjs new file mode 100644 index 000000000..f7d4ae8b2 --- /dev/null +++ b/runtime/gentle-brand.mjs @@ -0,0 +1,45 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +// Gentle's visual identity, shared by the Pi startup banner and the launcher's +// pre-Pi screens (the envs gate). Pure data and ANSI helpers only: the +// launcher runs before any Pi package is loaded. + +// The braille rose of the startup banner. +export const ROSE_ART = Object.freeze([ + " ⣠⣾⣷⣶⣦⣤⣤⣄⣠⣄⣀ ⢀⣀⣀", + " ⢀⣴⣿⣿⠿⣋⣭⣭⣯⣭⣍⣭⣿⣟⠛⠛⠿⣿⣷⣄", + " ⢀⣴⣾⡟⢻⣿⡟⠁⣼⣿⠏⣵⢻⣿⣻⣿⣿⢿⡻⣿⣿⣶⡌⢿⣿⣷⣦⣤⡄", + " ⣤⣶⣾⣿⣿⠏ ⠈⢿⣄ ⢹⣏⠠⠟⣾⣿⣿⣿⣿⣿⠷⣏⣼⠟⢡⣿⡟⠋⢻⣿⣿⡄", + " ⠈⣿⣿⣿⣿⡆ ⣽⢧⡘⠈⠳⣦⣍⠛⠛⢦⣉⣴⣛⣫⣭⣴⡟⠋ ⣾⣿⣿⡿", + " ⢀⠹⣿⣿⣿⣷⣤⡄ ⠋ ⠙⢆ ⣠⠴⠟⠛⣛⣛⣛⠟⠋⠁⠺⡇ ⣀⣴⣿⣿⡟⠁", + " ⠈⣀⠈⠛⠷⠿⣿⣿⣷⣤⣀ ⢠⠋ ⠈⠉⠉ ⣠⣴⣥⠾⠛⠉⣰⣿⣷", + " ⠹⣯⣝⠛⠛⠷⢶⣤⣤⣀ ⢀⡠⠖⠋⠉⢉⣀⣀⣴⣾⣿⠿⠟⠃", + " ⠘⠻⢿⣦⣄⡀ ⠉⠛⢦⠠⢊⠤⠴⢒⣛⣛⣩⣽⡿⠟⠁", + " ⠶⢶⣤⣄⡀⠨⠭⠽⠟⣓⢦⣀⠈⢇⡥⠖⠛⠋⠉⠉", + " ⠈⢷ ⠐⠂⢤⣽⣄ ⠰⡎⠙⠳⣄⡀ ⠈⢣⠘⢦⠋", + " ⠈⢳⣀⡒⠉⠉⣉⠙⡲⣽⣄ ⣏⠳⡄ ⠘⡇ ⡾⠁", + " ⠛⠻⢦⣄⣉⡁⣀⣀⣈⣙⣺⣌⡇⢠⢀⡇⡾", + " ⠈⠉ ⠈⠳⡄⣸⢱⠇", + " ⡷⠡⡯⢖⠉", + " ⢀⡴⢪⠔⣉⠔⠋", + " ⠐⠈", +]); + + + +// The banner's default pink palette (startup-banner BANNER_PALETTES.pink) plus +// the Gentle theme's muted and error tones. +export const GENTLE_COLORS = Object.freeze({ + rose: [255, 118, 195] , + label: [200, 100, 160] , + value: [255, 140, 210] , + dimRose: [95, 30, 60] , + muted: [92, 97, 112] , + error: [203, 124, 148] , +}); + +export const BRAND_NAME = "Gentle Shell"; +export const BRAND_GLYPH = "✿"; + +export function truecolor([r, g, b] , text ) { + return text.length === 0 ? "" : `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`; +} diff --git a/runtime/gentle-config-scope.mjs b/runtime/gentle-config-scope.mjs new file mode 100644 index 000000000..0f49da077 --- /dev/null +++ b/runtime/gentle-config-scope.mjs @@ -0,0 +1,66 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { isAbsolute } from "node:path"; + +// Per-env scope of Gentle's own configuration (the base home's config home, +// ~/.gentle-shell/gentle-ai or ~/.pi/gentle-ai). Like VS Code profiles, a named env chooses per category +// whether it shares the base configuration or keeps its own copy. Pure data +// and env parsing only: shared by the launcher and the Pi extensions. + +export const CONFIG_CATEGORIES = Object.freeze(["models", "persona", "appearance", "behavior"] ); + + +export function isConfigCategory(value ) { + return (CONFIG_CATEGORIES ).includes(value); +} + +// The files each category owns inside a config home. Models and profiles +// travel together: a profile is a snapshot of the routing, and applying one +// rewrites models.json, so splitting them would let an own profile overwrite +// a shared routing. +export const CONFIG_CATEGORY_FILES = Object.freeze({ + models: Object.freeze(["models.json", "models.export.json", "profiles.json", "profiles.export.json"]), + persona: Object.freeze(["persona.json"]), + appearance: Object.freeze([ + "visual-customization.json", + "visual-profiles.json", + "card-style.json", + "card-content.json", + "animations.json", + "banner.json", + "notifications.json", + "vim.json", + ]), + behavior: Object.freeze([ + "permission-mode.json", + "runtime-guardrails.json", + "history-capture.json", + "background-subagents.json", + "double-esc-cancel.json", + "builtin-codemode-optout.json", + ]), +}); + +// Files that belong to the install, never to an env: they always resolve to +// the shared config home. +export const INSTALL_CONFIG_FILES = Object.freeze(["channel.json", "dev-binary.json"]); + +// Set by the launcher on the Pi process of a named env that owns some +// categories, and cleared for every other home. +export const ENV_CONFIG_HOME_ENV = "GENTLE_SHELL_ENV_CONFIG_HOME"; +export const ENV_OWN_CONFIG_ENV = "GENTLE_SHELL_ENV_OWN_CONFIG"; + +// The categories this process keeps in its env config home. Fails closed to +// "nothing own" (the shared configuration, today's behavior) unless the env +// config home is an absolute path and the category is known. +export function ownConfigCategories(env ) { + const home = env[ENV_CONFIG_HOME_ENV]; + if (home === undefined || home.length === 0 || !isAbsolute(home)) return []; + const listed = (env[ENV_OWN_CONFIG_ENV] ?? "").split(",").map((value) => value.trim()); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +// The env config home when `category` is own in this process, else undefined. +export function envConfigHomeFor(env , category ) { + if (category === undefined) return undefined; + return ownConfigCategories(env).includes(category) ? env[ENV_CONFIG_HOME_ENV] : undefined; +} diff --git a/runtime/gentle-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs new file mode 100644 index 000000000..8cad68586 --- /dev/null +++ b/runtime/gentle-shell-envs-tui.mjs @@ -0,0 +1,500 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { BRAND_GLYPH, BRAND_NAME, GENTLE_COLORS, ROSE_ART, truecolor } from "./gentle-brand.mjs"; +import { CONFIG_CATEGORIES, } from "./gentle-config-scope.mjs"; +import { CONFIG_CATEGORY_LABELS, describeEnvScope, validateEnvName } from "./gentle-shell-envs.mjs"; + +// The `gentle-shell envs` manager: a pure key parser, reducer and renderer. +// It is dependency-free on purpose (no pi-tui), because the launcher runs +// before any Pi runtime is resolved. bin/gentle-shell.mjs owns raw-mode +// stdin, the alternate screen, and carrying out the returned effects. + + + + + + + + + + + + +// `base: true` targets the install's own home (`--isolated`), never an env: +// an env may even be named like the base entry. + + + + + + + + + + + + + + + + + + + + + + + + + + +// The VS Code-like config form: per category, shared with the base home or +// an own copy; plus where newly own copies come from. + + + + + + + + + + + + + + + + + + + + + + +// `select` names an env, or `{ base: true }` for the base home: an env may +// share the base entry's display name, so the two are never matched by name. +export function initialEnvsState(envs , message , select ) { + const byName = select === undefined ? -1 + : typeof select === "string" ? envs.findIndex((e) => !e.base && e.name === select) + : envs.findIndex((e) => e.base === true); + const byDefault = envs.findIndex((e) => e.isDefault); + return { envs, selected: byName >= 0 ? byName : Math.max(0, byDefault), message }; +} + +// --- key parsing ------------------------------------------------------------- + +export function parseKeys(data ) { + const keys = []; + const chars = [...data]; + for (let i = 0; i < chars.length; i += 1) { + const c = chars[i]; + if (c === "\x1b") { + const next = chars[i + 1]; + if (next === "[" || next === "O") { + // CSI / SS3: parameters, then one final byte in @..~. + let j = i + 2; + while (j < chars.length && !/[@-~]/.test(chars[j])) j += 1; + const sequence = chars.slice(i + 2, j + 1).join(""); + if (sequence === "A") keys.push({ name: "up" }); + else if (sequence === "B") keys.push({ name: "down" }); + else if (sequence === "C") keys.push({ name: "right" }); + else if (sequence === "D") keys.push({ name: "left" }); + i = j; + continue; + } + keys.push({ name: "escape" }); + continue; + } + if (c === "\r" || c === "\n") keys.push({ name: "enter" }); + else if (c === "\x7f" || c === "\b") keys.push({ name: "backspace" }); + else if (c === "\x03") keys.push({ name: "ctrl-c" }); + else if (c >= " ") keys.push({ name: "char", char: c }); + } + return keys; +} + +// --- reducer ------------------------------------------------------------------- + + + + + + +const EMPTY_HINT = "No env selected: press n to create one."; + +function openPrompt(state , prompt ) { + return { state: { ...state, prompt, message: undefined } }; +} + +function reducePrompt(state , prompt , key ) { + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, prompt: undefined } }; + if (key.name === "backspace") return { state: { ...state, prompt: { ...prompt, value: [...prompt.value].slice(0, -1).join(""), error: undefined } } }; + if (key.name === "char") return { state: { ...state, prompt: { ...prompt, value: prompt.value + key.char, error: undefined } } }; + if (key.name !== "enter") return { state }; + + const fail = (error ) => ({ state: { ...state, prompt: { ...prompt, error } } }); + const done = (effect ) => ({ state: { ...state, prompt: undefined }, effect }); + const value = prompt.value.trim(); + const target = prompt.target ?? ""; + + if (prompt.kind === "create" || prompt.kind === "rename") { + if (prompt.kind === "rename" && value === target) return { state: { ...state, prompt: undefined } }; + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + if (state.envs.some((e) => !e.base && e.name === value)) return fail(`env "${value}" already exists`); + if (prompt.kind === "create") { + // Like a new VS Code profile: choose what to share before creating. + return { state: { ...state, prompt: undefined, scope: { mode: "create", name: value, own: [], initial: [], source: { kind: "base" }, cursor: 0 } } }; + } + return done({ kind: "rename", from: target, to: value }); + } + if (prompt.kind === "delete") { + if (value !== target) return fail(`type "${target}" to confirm, or esc to cancel`); + return done({ kind: "delete", name: target }); + } + if (value.length === 0) return fail("enter a package source, e.g. npm:pi-btw"); + return done(prompt.targetBase ? { kind: "install", name: target, source: value, base: true } : { kind: "install", name: target, source: value }); +} + +// Sources a newly own copy can come from: the base home, every other env, or empty. +function scopeSources(state , form ) { + const envs = state.envs.filter((env) => !env.base && env.name !== form.name).map((env) => ({ kind: "env", name: env.name })); + return [{ kind: "base" }, ...envs, { kind: "empty" }]; +} + +function sameSource(a , b ) { + return a.kind === b.kind && (a.kind !== "env" || (b.kind === "env" && a.name === b.name)); +} + +function sourceFields(source ) { + return source.kind === "env" ? { from: source.name } : source.kind === "empty" ? { empty: true } : {}; +} + +const NOTHING_OWN = "Nothing to copy yet: mark a category as Own copy first (space)."; + +function reduceScope(state , form , key ) { + const set = (next ) => ({ state: { ...state, message: undefined, scope: { ...form, ...next } } }); + if (key.name === "escape" || key.name === "ctrl-c") return { state: { ...state, scope: undefined } }; + if (key.name === "up") return set({ cursor: Math.max(0, form.cursor - 1) }); + if (key.name === "down") return set({ cursor: Math.min(CONFIG_CATEGORIES.length, form.cursor + 1) }); + const char = key.name === "char" ? key.char : undefined; + if (char === "a") return set({ own: form.own.length === CONFIG_CATEGORIES.length ? [] : [...CONFIG_CATEGORIES] }); + const toggle = char === " " || key.name === "left" || key.name === "right"; + if (toggle && form.cursor < CONFIG_CATEGORIES.length) { + const category = CONFIG_CATEGORIES[form.cursor]; + const own = form.own.includes(category) ? form.own.filter((c) => c !== category) : [...form.own, category]; + return set({ own: CONFIG_CATEGORIES.filter((c) => own.includes(c)) }); + } + if (toggle) { + // The source only matters for own copies: say so instead of cycling it silently. + if (form.own.length === 0) return { state: { ...state, message: NOTHING_OWN } }; + const sources = scopeSources(state, form); + const index = sources.findIndex((source) => sameSource(source, form.source)); + const step = key.name === "left" ? -1 : 1; + return set({ source: sources[(index + step + sources.length) % sources.length] }); + } + if (key.name !== "enter") return { state }; + const close = { ...state, scope: undefined }; + if (form.mode === "create") return { state: close, effect: { kind: "create", name: form.name, own: form.own, ...sourceFields(form.source) } }; + const share = form.initial.filter((c) => !form.own.includes(c)); + const added = form.own.filter((c) => !form.initial.includes(c)); + if (share.length === 0 && added.length === 0) { + return { state: { ...close, message: `No changes for ${form.name}: mark a category as Own copy, or share one again, to change it.` } }; + } + return { state: close, effect: { kind: "scope", name: form.name, own: added, share, ...(added.length > 0 ? sourceFields(form.source) : {}) } }; +} + +export function reduceEnvs(state , key ) { + if (state.prompt !== undefined) return reducePrompt(state, state.prompt, key); + if (state.scope !== undefined) return reduceScope(state, state.scope, key); + + if (key.name === "ctrl-c" || key.name === "escape") return { state, effect: { kind: "quit" } }; + const last = state.envs.length - 1; + const char = key.name === "char" ? key.char : undefined; + if (key.name === "up" || char === "k") return { state: { ...state, selected: Math.max(0, state.selected - 1), message: undefined } }; + if (key.name === "down" || char === "j") return { state: { ...state, selected: Math.max(0, Math.min(last, state.selected + 1)), message: undefined } }; + if (char === "q") return { state, effect: { kind: "quit" } }; + if (char === "n") return openPrompt(state, { kind: "create", label: "New env name", value: "" }); + + const current = state.envs[state.selected]; + const scoped = key.name === "enter" || (char !== undefined && "sparidc".includes(char)); + if (!scoped) return { state }; + if (current === undefined) return { state: { ...state, message: EMPTY_HINT } }; + const name = current.name; + const base = current.base ? { base: true } : {}; + if (key.name === "enter") return { state, effect: { kind: "launch", name, ...base } }; + if (char === "s") return { state, effect: { kind: "set-default", name, ...base } }; + if (char === "p") return { state, effect: { kind: "setup", name, ...base } }; + if (char === "a") return { state, effect: { kind: "auth-check", name, ...base } }; + if (current.base && (char === "r" || char === "d")) { + return { state: { ...state, message: "The base home belongs to the install: it can't be renamed or deleted." } }; + } + if (char === "c") { + if (current.base) return { state: { ...state, message: "The base home holds the shared configuration every env can reuse; press c on an env to choose what it owns." } }; + const own = current.own ?? []; + return { state: { ...state, message: undefined, scope: { mode: "edit", name, own, initial: own, source: { kind: "base" }, cursor: 0 } } }; + } + if (char === "r") return openPrompt(state, { kind: "rename", label: `Rename "${name}" to`, value: name, target: name }); + if (char === "i") return openPrompt(state, { kind: "install", label: `Install into "${current.base ? "the base home" : name}"`, value: "", target: name, ...(current.base ? { targetBase: true } : {}) }); + return openPrompt(state, { kind: "delete", label: `Delete "${name}" and all its data? Type its name`, value: "", target: name }); +} + +// --- rendering ----------------------------------------------------------------- +// The envs gate is the first thing a user sees, so it speaks Gentle's visual +// language: the rose, `✿ Gentle Shell`, rounded `╭─ ✿ Title ─╮` cards like +// the Status card, the `❯` selection and rose key hints. Every line is +// exactly the terminal width at most, and the screen is exactly its height. + + + + + + + + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; + + + + + + + + + + + +function painter(color ) { + const tone = (rgb ) => (text ) => (color ? truecolor(rgb, text) : text); + return { + rose: tone(GENTLE_COLORS.rose), + label: tone(GENTLE_COLORS.label), + value: tone(GENTLE_COLORS.value), + frame: tone(GENTLE_COLORS.dimRose), + muted: tone(GENTLE_COLORS.muted), + error: tone(GENTLE_COLORS.error), + bold: (text) => (text.length === 0 ? "" : `${BOLD}${text}\x1b[22m`), + }; +} + +// Visible width: the renderer only emits single-cell characters (ASCII, +// box drawing, braille, ✿ ❯ ·) outside of its own SGR sequences. +function visible(text ) { + return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length; +} + +// Text from the user's own files (paths, package sources, provider ids) is +// shown as plain text: escape sequences and other control characters are +// dropped so a file can never move the cursor or retitle the terminal. +function plain(text ) { + return text + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "") + .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f]/g, ""); +} + +function fit(text , width ) { + if (width <= 0) return ""; + const chars = [...text]; + if (chars.length <= width) return text; + return width === 1 ? "…" : `${chars.slice(0, width - 1).join("")}…`; +} + +function pad(text , width ) { + const fitted = fit(text, width); + return fitted + " ".repeat(Math.max(0, width - [...fitted].length)); +} + +function center(styled , width ) { + const free = Math.max(0, width - visible(styled)); + return " ".repeat(Math.floor(free / 2)) + styled; +} + + + + + + + +// A rounded Gentle card exactly `width` columns wide and `height` rows tall. +function card(title , rows , width , height , paint ) { + if (width < 6 || height < 2) return Array.from({ length: Math.max(0, height) }, () => " ".repeat(Math.max(0, width))); + const inner = width - 4; + const heading = fit(`${BRAND_GLYPH} ${title}`, Math.max(1, width - 6)); + const top = `${paint.frame("╭─ ")}${paint.rose(paint.bold(heading))}${paint.frame(` ${"─".repeat(Math.max(0, width - 5 - [...heading].length))}╮`)}`; + const bottom = paint.frame(`╰${"─".repeat(width - 2)}╯`); + const body = []; + for (let index = 0; index < height - 2; index += 1) { + const row = rows[index]; + const text = pad(row?.text ?? "", inner); + body.push(`${paint.frame("│")} ${row?.style ? row.style(text) : text} ${paint.frame("│")}`); + } + return [top, ...body, bottom]; +} + +function detailRows(env , inner , paint ) { + if (env === undefined) return [ + { text: "No envs yet.", style: paint.bold }, + { text: "" }, + { text: "Press n to create your first env.", style: paint.label }, + ]; + const sessions = `${env.sessionCount} session${env.sessionCount === 1 ? "" : "s"}${env.lastSessionAt ? ` · last ${env.lastSessionAt.slice(0, 16).replace("T", " ")}` : ""}`; + // Labels in the rose label tone, values plain: one styled span per row. + const field = (label , value ) => { + const head = `${label.padEnd(10)}`; + const text = pad(`${head}${value}`, inner); + return { text, style: (fitted) => `${paint.label(fitted.slice(0, head.length))}${fitted.slice(head.length)}` }; + }; + return [ + ...(env.base ? [{ text: "The install's own home, as before envs (--isolated).", style: paint.label }] : []), + ...(env.isDefault ? [{ text: "Default: a plain gentle-shell opens it", style: paint.rose }] : []), + field("Path", plain(env.dir)), + field("Setup", env.setUp === false ? "not set up yet" : plain(env.provisioned)), + field("Sessions", sessions), + field("Sign-ins", env.providers.length > 0 ? env.providers.map(plain).join(", ") : "none yet: /login inside the session"), + field("Config", env.base ? "the shared configuration envs can reuse" : describeEnvScope(env.own ?? [])), + field("Packages", env.packages.length > 0 ? `${env.packages.length}` : env.setUp === false ? "installed when you first open it" : "none"), + ...env.packages.map((source) => ({ text: ` · ${plain(source)}`, style: paint.muted })), + ]; +} + +// The VS Code-like config form, drawn in place of the detail card. +const CATEGORY_TITLES = Object.freeze({ + models: "Models & profiles", + persona: "Persona", + appearance: "Appearance", + behavior: "Behavior", +}); + +function sourceLabel(source ) { + return source.kind === "base" ? `${BRAND_NAME} base` : source.kind === "env" ? `env ${source.name}` : "Empty (defaults)"; +} + +function scopeRows(form , paint ) { + const row = (selected , label , value , width = 20) => { + const text = `${selected ? "❯" : " "} ${label.padEnd(width)}${value}`; + return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; + }; + return [ + { text: "Mark categories as Own copy (space),", style: paint.label }, + { text: "then choose where their copies come from.", style: paint.label }, + { text: "" }, + ...CONFIG_CATEGORIES.map((category, index) => row(form.cursor === index, CATEGORY_TITLES[category], form.own.includes(category) ? "Own copy" : "Shared with base")), + { text: "" }, + form.own.length === 0 + // Inactive until something is own: nothing would be copied yet. + ? { ...row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", "— mark a category as Own copy first", 21), style: paint.muted } + : row(form.cursor === CONFIG_CATEGORIES.length, "New own copies from", sourceLabel(form.source), 21), + { text: "" }, + { text: "Shared follows the base home live; an own copy diverges.", style: paint.muted }, + ]; +} + +const SCOPE_HINTS = [["↑↓", "move"], ["space", "toggle"], ["←→", "change"], ["a", "all"], ["esc", "cancel"]]; + +const HINTS = [ + ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["c", "config"], ["q", "quit"], +]; + +// Wrapped, never truncated: every action stays discoverable at any width. +function hintLines(width , paint , hints = HINTS) { + const lines = []; + let parts = []; + let used = 0; + for (const [key, action] of hints) { + const size = `${key} ${action}`.length; + const extra = (parts.length > 0 ? 3 : 0) + size; + if (parts.length > 0 && used + extra > width) { + lines.push(parts.join(paint.muted(" · "))); + parts = []; + used = 0; + } + parts.push(`${paint.rose(paint.bold(key))} ${paint.label(action)}`); + used += (parts.length > 1 ? 3 : 0) + size; + } + if (parts.length > 0) lines.push(parts.join(paint.muted(" · "))); + return lines; +} + +export function renderEnvsScreen(state , width , height , options = {}) { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const paint = painter(options.color === true); + + // Footer: status or prompt, then the key hints. + // Centered under the cards; the prompt is the one left-aligned exception. + const footer = []; + if (state.prompt !== undefined) { + const p = state.prompt; + footer.push(`${paint.rose(paint.bold("❯"))} ${paint.bold(fit(`${p.label}: ${p.value}`, Math.max(0, w - 3)))}${paint.rose("▏")}`); + footer.push(p.error === undefined ? center(paint.muted(fit("enter confirm · esc cancel", w)), w) : center(paint.error(fit(p.error, w)), w)); + } else if (state.scope !== undefined) { + const hints = [...SCOPE_HINTS.slice(0, 4), ["enter", state.scope.mode === "create" ? "create" : "apply"], SCOPE_HINTS[4]]; + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); + footer.push(...hintLines(Math.min(w, 110), paint, hints).map((line) => center(line, w))); + } else { + footer.push(state.message === undefined ? "" : center(paint.value(fit(state.message, w)), w)); + footer.push(...hintLines(Math.min(w, 110), paint).map((line) => center(line, w))); + } + + // Header: the rose when there is room for it and the cards, then the brand. + const title = `${paint.rose(paint.bold(`${BRAND_GLYPH} ${BRAND_NAME}`))}${options.version ? paint.muted(` v${fit(options.version, 20)}`) : ""}`; + const subtitle = paint.label("Choose an environment to open"); + const header = []; + const art = ROSE_ART.map((line) => line.replace(/\s+$/u, "")); + const artWidth = Math.max(...art.map((line) => [...line].length)); + if (h >= art.length + 16 && w >= artWidth + 4) { + const lead = Math.floor((w - artWidth) / 2); + header.push(...art.map((line) => " ".repeat(lead) + paint.rose(line)), ""); + } + if (h >= 10) header.push(center(title, w), center(subtitle, w), ""); + else header.push(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w) === `${BRAND_GLYPH} ${BRAND_NAME}` ? title : paint.rose(fit(`${BRAND_GLYPH} ${BRAND_NAME}`, w))); + + // The cards fit their content (with a little air) instead of the whole + // screen, and the block is centered both ways: a gate, not a log. + const blockWidth = Math.min(w, 110); + const lead = " ".repeat(Math.floor((w - blockWidth) / 2)); + const selectedEnv = state.envs[state.selected]; + const sideBySide = blockWidth >= 56; + // Wide enough for the longest entry (marker and `(default)` included), within bounds. + const longest = Math.max(0, ...state.envs.map((env) => [...(env.base ? `${BRAND_NAME} base` : env.name)].length + (env.isDefault ? " (default)".length : 0))); + const listWidth = sideBySide ? Math.min(Math.max(20, Math.floor(blockWidth * 0.3), longest + 6), 40, blockWidth - 30) : blockWidth; + const detailWidth = blockWidth - listWidth - 1; + const form = state.scope; + const details = form !== undefined ? scopeRows(form, paint) : detailRows(selectedEnv, Math.max(1, detailWidth - 4), paint); + const contentRows = Math.max(state.envs.length || 1, sideBySide ? details.length : 0) + 2; + const available = Math.max(0, h - header.length - footer.length); + const bodyHeight = Math.min(available, Math.max(6, contentRows + 1)); + const body = []; + if (bodyHeight >= 3 && blockWidth >= 6) { + const visibleRows = bodyHeight - 2; + // Keep the selection visible when the list is taller than the card. + const offset = Math.max(0, Math.min(state.selected - visibleRows + 1, state.envs.length - visibleRows)); + const listRows = state.envs.slice(offset, offset + visibleRows).map((env, index) => { + const selected = index + offset === state.selected; + // The default reads like the /gentle:profiles markers: `name (default)`. + const text = `${selected ? "❯" : " "} ${env.base ? `${BRAND_NAME} base` : env.name}${env.isDefault ? " (default)" : ""}`; + return { text, style: selected ? (fitted ) => paint.rose(paint.bold(fitted)) : undefined }; + }); + if (state.envs.length === 0) listRows.push({ text: " (none)", style: paint.muted }); + const list = card("Envs", listRows, listWidth, bodyHeight, paint); + if (sideBySide) { + const title = form !== undefined ? `${form.mode === "create" ? "New env" : "Config"} · ${form.name}` : selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name; + const detail = card(title, details, detailWidth, bodyHeight, paint); + for (let row = 0; row < bodyHeight; row += 1) body.push(`${lead}${list[row]} ${detail[row]}`); + } else { + body.push(...list.map((line) => lead + line)); + } + } + if (state.prompt !== undefined) footer[0] = lead + footer[0]; + // One composition, centered vertically: brand, cards, status and hints. + const block = [...header, ...body, ...footer]; + const spare = Math.max(0, h - block.length); + const above = Math.floor(spare / 2); + const composed = [...Array.from({ length: above }, () => ""), ...block, ...Array.from({ length: spare - above }, () => "")]; + const clip = (line ) => (visible(line) > w ? fit(line.replace(/\x1b\[[0-9;]*m/g, ""), w) : line); + const all = composed.length > h ? [...composed.slice(0, Math.max(0, h - footer.length)), ...footer].slice(-h) : composed; + return all.map(clip).map((line) => (line.includes("\x1b[") ? `${line}${RESET}` : line)); +} diff --git a/runtime/gentle-shell-envs.mjs b/runtime/gentle-shell-envs.mjs new file mode 100644 index 000000000..832fb69da --- /dev/null +++ b/runtime/gentle-shell-envs.mjs @@ -0,0 +1,302 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { basename, dirname, join, resolve as resolvePath } from "node:path"; +import { CONFIG_CATEGORIES, CONFIG_CATEGORY_FILES, isConfigCategory } from "./gentle-config-scope.mjs"; + +// Named environments for the gentle-shell launcher: pure, side-effect-free +// helpers over injected env/config/file text. An env is an ordinary managed +// `--home` directory under the envs root; the directory itself is the +// registry, so there is no separate list to drift out of sync. + +export const ENVS_DIR_ENV = "GENTLE_SHELL_ENVS_DIR"; + +// Set by the launcher on the Pi process when the home is a named env, so the +// Gentle Shell UI can name it. Never set for any other home. +export const ENV_NAME_ENV = "GENTLE_SHELL_ENV"; + +// The active env's name for display, or undefined. Only a valid name is +// trusted: the value is shown in the UI, so anything else is ignored. +export function activeEnvName(env ) { + const name = env[ENV_NAME_ENV]; + return name !== undefined && validateEnvName(name) === undefined ? name : undefined; +} + +const ENV_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/; + +// Lowercase only, so names stay identical on case-insensitive filesystems; +// the leading character excludes `.`, `..`, hidden dirs and flag-like names. +export function validateEnvName(name ) { + if (ENV_NAME_PATTERN.test(name)) return undefined; + return `invalid env name "${name}": use 1-40 lowercase letters, digits, ".", "_" or "-", starting with a letter or digit`; +} + +export function envsRoot(env , homedir ) { + const override = env[ENVS_DIR_ENV]; + return override !== undefined && override.length > 0 ? override : join(homedir, ".gentle-shell", "envs"); +} + +export function envDir(root , name ) { + return join(root, name); +} + +// The env name a home directory stands for, or undefined when it is not a +// direct, validly named child of the envs root (any other --home path). +export function envNameForDir(root , dir ) { + const resolved = resolvePath(dir); + if (dirname(resolved) !== resolvePath(root)) return undefined; + const name = basename(resolved); + return validateEnvName(name) === undefined ? name : undefined; +} + +// --- config.json mutations --------------------------------------------------- +// All return a new object, keep unknown keys, and never mutate their input. +// `aliases` lists every spelling of the env dir (as given and its realpath), +// because provisioned markers are keyed by realpath. + + + +function provisionedRecord(config ) { + const value = config.provisioned; + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value ) : undefined; +} + +export function setDefaultEnvInConfig(config , dir ) { + return { ...config, home: dir }; +} + +// `newProvisionedKey` is the realpath of `newDir` (provisioned markers are +// realpath-keyed); `home` keeps the plain path so it still names the env. +export function renameEnvInConfig(config , aliases , newDir , newProvisionedKey = newDir) { + const next = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) next.home = newDir; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + const moved = {}; + let marker ; + for (const [key, value] of Object.entries(provisioned)) { + if (aliases.includes(key)) marker ??= value; + else moved[key] = value; + } + if (marker !== undefined) moved[newProvisionedKey] = marker; + next.provisioned = moved; + } + return next; +} + +export function removeEnvFromConfig(config , aliases ) { + const next = { ...config }; + if (typeof config.home === "string" && aliases.includes(config.home)) delete next.home; + const provisioned = provisionedRecord(config); + if (provisioned !== undefined) { + next.provisioned = Object.fromEntries(Object.entries(provisioned).filter(([key]) => !aliases.includes(key))); + } + return next; +} + +// --- per-env info ------------------------------------------------------------ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +function parseObject(text ) { + if (text === undefined) return undefined; + try { + const parsed = JSON.parse(text); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed ) : undefined; + } catch { + return undefined; + } +} + +function packageSources(settingsText ) { + const packages = parseObject(settingsText)?.packages; + if (!Array.isArray(packages)) return []; + const sources = []; + for (const entry of packages) { + if (typeof entry === "string") sources.push(entry); + else if (typeof entry === "object" && entry !== null && typeof (entry ).source === "string") { + sources.push((entry ).source); + } + } + return sources; +} + +export function summarizeEnv(input ) { + const p = input.provisioned; + const provisioned = + p === undefined ? "not provisioned" : `gentle-ai ${p.gentleAi} · gentle-pi ${p.gentlePi ?? "unknown"} · ${p.at.slice(0, 10)}`; + return { + name: input.name, + dir: input.dir, + isDefault: input.isDefault, + packages: packageSources(input.settingsText), + providers: Object.keys(parseObject(input.authText) ?? {}).sort(), + provisioned, + setUp: p !== undefined, + sessionCount: input.sessionCount, + lastSessionAt: input.lastSessionAt, + ...(input.base ? { base: true } : {}), + own: [...(input.own ?? [])], + }; +} + +// --- per-env config scope (lib/gentle-config-scope.ts) ------------------------ +// Like VS Code profiles: each category of Gentle's config is either shared +// with the base home (live) or owned by the env (its own copy in +// `/gentle-ai`). Only that persistent choice is stored; copying or +// starting empty is a one-time seed when a category becomes own. + +export const ENV_SCOPE_FILE = ".gentle-shell-env.json"; +const ENV_SCOPE_SCHEMA = 1; + +// Tolerant: a missing, malformed or unknown-schema file means "share +// everything", today's behavior for every env created before scopes existed. +export function parseEnvScope(text ) { + const own = parseObject(text); + if (own === undefined || own.schema !== ENV_SCOPE_SCHEMA || !Array.isArray(own.own)) return []; + const listed = own.own.filter((value) => typeof value === "string"); + return CONFIG_CATEGORIES.filter((category) => listed.includes(category)); +} + +export function serializeEnvScope(own ) { + return `${JSON.stringify({ schema: ENV_SCOPE_SCHEMA, own: CONFIG_CATEGORIES.filter((category) => own.includes(category)) }, null, 2)}\n`; +} + + + + + + + + + + +function categoryList(value ) { + if (value === "all") return { categories: [...CONFIG_CATEGORIES] }; + const categories = []; + for (const raw of value.split(",").map((part) => part.trim()).filter((part) => part.length > 0)) { + if (!isConfigCategory(raw)) return { categories, error: `unknown config category "${raw}" (use ${CONFIG_CATEGORIES.join(", ")} or all)` }; + if (!categories.includes(raw)) categories.push(raw); + } + return { categories }; +} + +// `--own `, `--share `, `--from ` and `--empty`, as +// `--flag value` or `--flag=value`. +export function parseScopeFlags(args ) { + const result = { own: [], share: [], from: undefined, empty: false }; + const fail = (error ) => ({ ...result, error }); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--empty") { + result.empty = true; + continue; + } + const match = /^--(own|share|from)(?:=(.*))?$/.exec(arg); + if (match === null) return fail(`unknown option ${arg}`); + const [, flag, inline] = match; + const value = inline ?? args[i + 1]; + if (inline === undefined) i += 1; + if (value === undefined || value.length === 0) return fail(`--${flag} needs a value`); + if (flag === "from") { + const invalid = validateEnvName(value); + if (invalid !== undefined) return fail(invalid); + result.from = value; + continue; + } + const list = categoryList(value); + if (list.error !== undefined) return fail(list.error); + result[flag ] = list.categories; + } + if (result.from !== undefined && result.empty) return fail("--from and --empty cannot be combined"); + const both = result.own.find((category) => result.share.includes(category)); + if (both !== undefined) return fail(`"${both}" cannot be both own and shared`); + return result; +} + + + + + + + + + +export function planScopeChange(input ) { + const next = CONFIG_CATEGORIES.filter((category) => !input.share.includes(category) && (input.current.includes(category) || input.own.includes(category))); + return { next, seed: next.filter((category) => !input.current.includes(category)) }; +} + + + +// The file operations that seed `categories` in `targetDir`: a copy of each +// file the source has, and a removal of each one it lacks (so the target +// starts from the source's defaults), or only removals when starting empty. +// Never touches a file outside these categories. +export function seedOperations(input + + + + + + ) { + const operations = []; + for (const category of input.categories) { + for (const file of CONFIG_CATEGORY_FILES[category]) { + const to = join(input.targetDir, file); + const from = join(input.sourceDirFor(category), file); + if (input.mode === "copy" && input.exists(from)) operations.push({ kind: "copy", from, to }); + else operations.push({ kind: "remove", path: to }); + } + } + return operations; +} + +// Categories that need a seed when they become own: a category whose own +// files were kept on disk when it was shared again is reused, never seeded +// over, so an earlier own copy is not lost. +export function categoriesToSeed(categories , targetDir , exists ) { + return categories.filter((category) => !CONFIG_CATEGORY_FILES[category].some((file) => exists(join(targetDir, file)))); +} + +export const CONFIG_CATEGORY_LABELS = Object.freeze({ + models: "models & profiles", + persona: "persona", + appearance: "appearance", + behavior: "behavior", +}); + +export function describeEnvScope(own ) { + if (own.length === 0) return "shares everything with the base home"; + if (own.length === CONFIG_CATEGORIES.length) return "fully own (shares nothing)"; + const label = (list ) => list.map((category) => CONFIG_CATEGORY_LABELS[category]).join(", "); + return `own ${label(CONFIG_CATEGORIES.filter((c) => own.includes(c)))} · shares ${label(CONFIG_CATEGORIES.filter((c) => !own.includes(c)))}`; +} diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index ad8d14d8f..7f411b524 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -2,6 +2,10 @@ import { basename, dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, } from "./child-package-injection.mjs"; import { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.mjs"; +import { ENV_NAME_ENV, envDir, envNameForDir, envsRoot, validateEnvName } from "./gentle-shell-envs.mjs"; +import { ENV_CONFIG_HOME_ENV, ENV_OWN_CONFIG_ENV } from "./gentle-config-scope.mjs"; + +export { ENV_NAME_ENV }; // The gentle-shell launcher: pure, side-effect-free functions over injected // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process, @@ -31,6 +35,8 @@ function isPiSubcommand(token ) { + +   @@ -43,11 +49,12 @@ function isPiSubcommand(token ) { // untouched as commandArgs — T2 owns interpreting `home link|isolated|`. // `self-uninstall` is not `uninstall`, which stays pi's own alias for `remove`. export function parseLauncherArgs(argv ) { - if (argv[0] === "home" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { + if (argv[0] === "home" || argv[0] === "envs" || argv[0] === "upgrade" || argv[0] === "self-uninstall" || argv[0] === "memory") { return { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, @@ -62,6 +69,7 @@ export function parseLauncherArgs(argv ) { let link = false; let isolated = false; let home ; + let env ; let packageRoot ; let help = false; let version = false; @@ -120,6 +128,22 @@ export function parseLauncherArgs(argv ) { i += 1; continue; } + if (arg === "--env" || arg.startsWith("--env=")) { + const inline = arg.startsWith("--env="); + const value = inline ? arg.slice("--env=".length) : argv[i + 1]; + if (!inline && value !== undefined) i += 1; + if (value === undefined || value.length === 0) { + error = "--env requires a non-empty env name"; + continue; + } + const invalid = validateEnvName(value); + if (invalid !== undefined) { + error = invalid; + continue; + } + env = value; + continue; + } if (arg.startsWith("--package-root=")) { const value = arg.slice("--package-root=".length); if (value.length === 0) { @@ -167,7 +191,13 @@ export function parseLauncherArgs(argv ) { } if (error === undefined) { - if (link && isolated) { + if (env !== undefined && link) { + error = "--env cannot be combined with --link"; + } else if (env !== undefined && isolated) { + error = "--env cannot be combined with --isolated"; + } else if (env !== undefined && home !== undefined) { + error = "--env cannot be combined with --home"; + } else if (link && isolated) { error = "--link cannot be combined with --isolated"; } else if (link && home !== undefined) { error = "--link cannot be combined with --home"; @@ -176,7 +206,7 @@ export function parseLauncherArgs(argv ) { } } - return { link, isolated, home, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; + return { link, isolated, home, env, packageRoot, help, version, command, commandArgs, passthrough, piSubcommand, error }; } // --- home resolution ------------------------------------------------------- @@ -190,6 +220,11 @@ export function parseLauncherArgs(argv ) { + + + + + // A discriminated union instead of a plain `home: string` field: `resolveHome` // switches on `mode` rather than re-parsing the raw on-disk string, and the // `path` case carries its `dir` explicitly so a "link"/"isolated" string can @@ -238,6 +273,20 @@ export function standaloneConfigHome(home ) { return basename(dir) === "agent" && basename(parent) === ".gentle-shell" ? join(parent, "gentle-ai") : undefined; } +// The launcher config folder a launch of `home` uses (#2201): the isolated +// home's own, and for a named env the base isolated home's, since "shared" +// means shared with the base home. Undefined keeps ~/.pi/gentle-ai (--link, +// a plain --home, or a base home without its own folder). +export function launchConfigHome(home , env , homedir ) { + if (home.mode === "isolated") return standaloneConfigHome(home); + return home.env === undefined ? undefined : baseConfigHome(env, homedir); +} + +// The base (isolated) home's own config folder, or undefined when it has none. +export function baseConfigHome(env , homedir ) { + return standaloneConfigHome({ mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }); +} + // The filesystem behind migrateStandaloneConfig; bin/gentle-shell.mjs wires the real one. @@ -300,12 +349,18 @@ export function resolveHome(input ) { if (args.link) return { mode: "link", dir: userPiHome(env, homedir), source: "flag" }; if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" }; - if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" }; + if (args.home !== undefined) { + // An explicit --home into the envs root is that env, labelled like --env. + const name = envNameForDir(envsRoot(env, homedir), args.home); + return name === undefined ? { mode: "path", dir: args.home, source: "flag" } : { mode: "path", dir: args.home, source: "flag", env: name }; + } + if (args.env !== undefined) return { mode: "path", dir: envDir(envsRoot(env, homedir), args.env), source: "flag", env: args.env }; if (config !== undefined) { if (config.mode === "link") return { mode: "link", dir: userPiHome(env, homedir), source: "config" }; if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" }; - return { mode: "path", dir: config.dir, source: "config" }; + const name = envNameForDir(envsRoot(env, homedir), config.dir); + return name === undefined ? { mode: "path", dir: config.dir, source: "config" } : { mode: "path", dir: config.dir, source: "config", env: name }; } return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" }; @@ -321,6 +376,7 @@ export function resolveHome(input ) { // gentle-shell's own default when no selector is given. export function homeSelectorFlags(home ) { if (home.mode === "link") return ["--link"]; + if (home.mode === "path" && home.env !== undefined) return ["--env", home.env]; if (home.mode === "path") return ["--home", home.dir]; return []; } @@ -1087,12 +1143,28 @@ export function buildPiInvocation(input ) { GENTLE_PI_AGENT_HOME: input.home.dir, [USER_PI_HOME_ENV]: userPiHome(input.baseEnv, input.homedir), }; - // #2201: only the standalone isolated home gets its own config folder; any - // other launch removes an inherited marker, so a nested `--link` launch from - // an isolated session reads ~/.pi/gentle-ai again. - const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); + // #2201: only the standalone isolated home, and the named envs that share + // it, get its own config folder; any other launch removes an inherited + // marker, so a nested `--link` launch from an isolated session reads + // ~/.pi/gentle-ai again. + const configHome = input.bundled === true ? undefined : launchConfigHome(input.home, input.baseEnv, input.homedir); if (configHome === undefined) delete env[GENTLE_SHELL_CONFIG_HOME_ENV]; else env[GENTLE_SHELL_CONFIG_HOME_ENV] = configHome; + + // Exactly this home's env name: an inherited value (a gentle-shell started + // from inside another env's session) must never label a different home. + if (input.home.env === undefined) delete env[ENV_NAME_ENV]; + else env[ENV_NAME_ENV] = input.home.env; + // Same rule for the env's config scope: only an env that owns categories + // sets it, so an outer session's scope never reaches another home. + const ownConfig = input.home.env === undefined ? [] : (input.home.ownConfig ?? []); + if (ownConfig.length === 0) { + delete env[ENV_CONFIG_HOME_ENV]; + delete env[ENV_OWN_CONFIG_ENV]; + } else { + env[ENV_CONFIG_HOME_ENV] = join(input.home.dir, "gentle-ai"); + env[ENV_OWN_CONFIG_ENV] = ownConfig.join(","); + } if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); @@ -1291,6 +1363,7 @@ export function helpText() { "Usage: gentle-shell [options] [-- pi-args...]", " gentle-shell home [link|isolated|]", " gentle-shell [home selectors] home import-pi [--dry-run]", + " gentle-shell envs [list | new [scope] | config [scope]]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", @@ -1305,6 +1378,7 @@ export function helpText() { " --isolated Use the dedicated ~/.gentle-shell/agent home (default), with its own", " settings in ~/.gentle-shell/gentle-ai (copied once from ~/.pi/gentle-ai).", " --home Use a custom agent home directory.", + " --env Use the named env ~/.gentle-shell/envs/ (must already exist).", " --package-root Force this directory as the gentle-pi package to load, taking over", " from any conflicting package the target settings.json already declares.", " --help, -h Show this help text.", @@ -1317,6 +1391,12 @@ export function helpText() { " level when it has none. Never overwrites a file or setting, never copies", " packages, extensions or caches, and never changes your pi home.", " --dry-run only shows the plan. Refuses --link (nothing to import).", + " envs Manage named envs: on a terminal, a full-screen manager to create,", + " rename, delete, launch, set the default, set up, install into and", + " check sign-ins of each env. 'envs list' prints them, marking (default);", + " 'envs new ' creates one; 'envs config ' chooses which Gentle", + " config it owns (--own/--share models,persona,appearance,behavior).", + " Envs are separate homes, not sandboxes.", " setup Provision the resolved home with the gentle-ai companion packages", " (runs the package-local gentle-ai 'install --agent pi --scope global').", " Accepts --dry-run, forwarded to gentle-ai. Accepts a home selector", @@ -1369,6 +1449,7 @@ export function helpText() { " inside a .gentle-shell folder, its settings live in the gentle-ai", " folder next to it; elsewhere they stay in ~/.pi/gentle-ai.", " GENTLE_PI_CONFIG_HOME Gentle Shell settings folder for every home, overriding the above.", + " GENTLE_SHELL_ENVS_DIR Directory holding named envs (default: ~/.gentle-shell/envs).", " PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.", "", "Every other argument is forwarded to pi unchanged.", diff --git a/runtime/gentle-shell-uninstall.mjs b/runtime/gentle-shell-uninstall.mjs index 4dd9f84f7..b19a7fb76 100644 --- a/runtime/gentle-shell-uninstall.mjs +++ b/runtime/gentle-shell-uninstall.mjs @@ -3,6 +3,9 @@ import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmdir import { createInterface } from "node:readline"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { isolatedDir, launcherConfigPath, parseRawLauncherConfig, provisionedEntry, userPiHome } from "./gentle-shell-launcher.mjs"; +import { envsRoot } from "./gentle-shell-envs.mjs"; + +const NAMED_ENVS_LABEL = "your named envs"; // `gentle-shell self-uninstall`: removes what Gentle Shell itself created, then // the gentle-pi package with the package manager that owns it. Everything is @@ -120,6 +123,23 @@ function comparable(path , platform ) { return platform === "win32" ? path.toLowerCase() : path; } +/** + * Where each symlink on the way to `path` lives: the root's own link and any + * symlinked folder above it, each as its resolved parent plus its name. + * Removing a folder that holds one of these breaks the configured path even + * when the real target is guarded elsewhere. + */ +function symlinkEntries(path ) { + const entries = []; + let current = resolve(path); + for (let parent = dirname(current); parent !== current; current = parent, parent = dirname(current)) { + let link = false; + try { link = lstatSync(current).isSymbolicLink(); } catch { /* missing: nothing to break */ } + if (link) entries.push(join(canonical(parent), basename(current))); + } + return entries; +} + /** The real path, resolving the nearest existing ancestor when `path` does not exist. */ function canonical(path ) { const absolute = resolve(path); @@ -140,7 +160,8 @@ function canonical(path ) { /** True when `child` is `parent` itself or lies inside it. */ function within(parent , child , platform ) { const path = relative(comparable(parent, platform), comparable(child, platform)); - return path === "" || (!path.startsWith("..") && !isAbsolute(path)); + // Only a whole `..` segment is parent traversal; `..envs` is a child name. + return path === "" || (!/^\.\.(?:[\\/]|$)/.test(path) && !isAbsolute(path)); } @@ -344,6 +365,17 @@ async function planSelfUninstall(input , configHome , const seen = new Set([comparable(isolatedReal, platform), comparable(canonical(piHome), platform)]); plan.notTouched.push({ path: piHome, label: "your Pi home" }); + // Named envs are user data: their root is listed once, never each env as a + // custom --home home, and the overlap check below protects all of them. + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); + // A symlinked root still holds the user's envs: protect its real target too. + const namedEnvsState = inspect(namedEnvs, "dir"); + const namedEnvsReal = namedEnvsState === "ok" || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok") ? canonical(namedEnvs) : undefined; + if (namedEnvsReal !== undefined) { + seen.add(comparable(namedEnvsReal, platform)); + plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); + } const customHomes = [typeof launcherConfig.home === "string" && !["link", "isolated", ""].includes(launcherConfig.home) ? launcherConfig.home : undefined]; const provisioned = launcherConfig.provisioned; if (typeof provisioned === "object" && provisioned !== null && !Array.isArray(provisioned)) customHomes.push(...Object.keys(provisioned)); @@ -351,6 +383,7 @@ async function planSelfUninstall(input , configHome , if (home === undefined || !isAbsolute(home)) continue; const key = comparable(canonical(home), platform); if (seen.has(key)) continue; + if (namedEnvsReal !== undefined && within(namedEnvsReal, canonical(home), platform)) continue; seen.add(key); plan.notTouched.push({ path: home, label: "custom --home home" }); } @@ -359,8 +392,12 @@ async function planSelfUninstall(input , configHome , // Nothing is both removed and not touched: a removal that is, holds or lies // inside a path listed as not touched refuses the whole run. if (plan.refusals.length === 0) { + const namedEnvsLinks = namedEnvsReal !== undefined ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { const real = canonical(removal.path); + if (namedEnvsLinks.some((entry) => within(real, entry, platform))) { + plan.refusals.push(`${removal.path} would be removed, but it overlaps ${namedEnvs} (${NAMED_ENVS_LABEL}), which is not touched; refusing to remove anything.`); + } for (const kept of plan.notTouched) { if (kept.path === undefined) continue; const target = canonical(kept.path); @@ -691,8 +728,19 @@ function planBundledSelfUninstall(input ) if (windows) consider(join(root, BUNDLED_WINDOWS_MARKER.name), "the bundled install's folder marker", "file", plan.own); consider(join(shellHome, "agent"), "your sign-ins, chats, settings and extensions", "dir", plan.shared); consider(join(shellHome, "config.json"), "the launcher's settings", "file", plan.shared); + // Resolved: a trailing slash would make lstat follow a symlinked root. + const namedEnvs = resolve(envsRoot(env, homedir)); + const namedEnvsState = inspect(namedEnvs, "dir"); + const hasNamedEnvs = namedEnvsState === "ok"; + // A symlinked root still holds the user's envs: guard and list its real + // target too. Only a real folder counts as a known prefix entry; a link + // there stays foreign and so keeps the prefix. + const guardsNamedEnvs = hasNamedEnvs || (namedEnvsState === "symlink" && inspect(canonical(namedEnvs), "dir") === "ok"); const known = new Set([...BUNDLED_OWN.map((item) => item.name), ...(windows ? [BUNDLED_WINDOWS_MARKER.name] : []), - ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : [])]); + ...(same(canonical(shellHome), real) ? ["agent", "config.json"] : []), + // Named envs are user data that may share the prefix folder: kept, and + // listed below instead of as an unknown entry. + ...(hasNamedEnvs && same(canonical(dirname(namedEnvs)), real) ? [basename(namedEnvs)] : [])]); for (const name of names) { if (known.has(name)) continue; foreign = true; @@ -700,13 +748,18 @@ function planBundledSelfUninstall(input ) } plan.removeRoot = !foreign; + if (guardsNamedEnvs) guards.push({ path: namedEnvs, label: NAMED_ENVS_LABEL, inside: true }); + const namedEnvsLinks = guardsNamedEnvs ? symlinkEntries(namedEnvs) : []; for (const removal of [...plan.own, ...plan.shared]) { - const reason = collision(canonical(removal.path), guards, platform); + const real = canonical(removal.path); + const reason = collision(real, guards, platform) + ?? (namedEnvsLinks.some((entry) => within(real, entry, platform)) ? `contains ${namedEnvs} (${NAMED_ENVS_LABEL})` : undefined); if (reason) plan.refusals.push(`${removal.path} would be removed, but it ${reason}; refusing to remove anything.`); } plan.notTouched.push({ path: piHome, label: "your Pi home" }, { path: join(homedir, ".pi", "gentle-ai"), label: "Gentle AI's configuration for Pi" }, { path: join(homedir, ".gentle-ai"), label: "Gentle AI's own state" }, { path: join(homedir, ".engram"), label: "Engram's memory" }); + if (guardsNamedEnvs) plan.notTouched.push({ path: namedEnvs, label: NAMED_ENVS_LABEL }); if (env.GENTLE_SHELL_HOME && !same(canonical(env.GENTLE_SHELL_HOME), canonical(join(shellHome, "agent")))) { plan.notTouched.push({ path: env.GENTLE_SHELL_HOME, label: "the Gentle Shell home GENTLE_SHELL_HOME names" }); } diff --git a/scripts/build-runtime-modules.mjs b/scripts/build-runtime-modules.mjs index 52d74d9f0..2462a04dd 100644 --- a/scripts/build-runtime-modules.mjs +++ b/scripts/build-runtime-modules.mjs @@ -15,6 +15,10 @@ const sources = [ "status-timing-diagnostics", "telemetry-trigger", "child-package-injection", + "gentle-config-scope", + "gentle-shell-envs", + "gentle-brand", + "gentle-shell-envs-tui", "gentle-shell-launcher", "gentle-shell-bundled", "gentle-shell-startup-update", diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index dd2e4cd1b..462c5d51a 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -86,6 +86,10 @@ export const requiredPaths = [ "extensions/skill-registry.ts", "lib/child-package-injection.ts", "lib/gentle-ai-binary.ts", + "lib/gentle-config-scope.ts", + "lib/gentle-shell-envs.ts", + "lib/gentle-brand.ts", + "lib/gentle-shell-envs-tui.ts", "lib/gentle-shell-launcher.ts", "lib/gentle-shell-bundled.ts", "lib/gentle-shell-startup-update.ts", @@ -104,6 +108,10 @@ export const requiredPaths = [ "lib/session-scratch.ts", "runtime/child-package-injection.mjs", "runtime/gentle-ai-binary.mjs", + "runtime/gentle-config-scope.mjs", + "runtime/gentle-shell-envs.mjs", + "runtime/gentle-brand.mjs", + "runtime/gentle-shell-envs-tui.mjs", "runtime/gentle-shell-launcher.mjs", "runtime/gentle-shell-bundled.mjs", "runtime/gentle-shell-startup-update.mjs", diff --git a/tests/background-subagents.test.ts b/tests/background-subagents.test.ts index 7b79f4e68..ef2930b03 100644 --- a/tests/background-subagents.test.ts +++ b/tests/background-subagents.test.ts @@ -399,6 +399,18 @@ test("the resolver attributes the project file, with its path", () => { assert.equal(resolution.envValue, "off"); }); +test("the resolver reads the behavior home from the env it was given, not the process env", () => { + const cwd = makeScratch("gp-bg-src-scope-"); + const ownHome = join(makeScratch("gp-bg-own-"), "gentle-ai"); + writePolicyFile(ownHome, "on"); + const resolution = resolveBackgroundSubagentsPolicy(cwd, { + env: { GENTLE_SHELL_ENV_CONFIG_HOME: ownHome, GENTLE_SHELL_ENV_OWN_CONFIG: "behavior" }, + }); + assert.equal(resolution.globalFile, join(ownHome, "background-subagents.json")); + assert.equal(resolution.policy, "on"); + assert.equal(resolution.source, "global_file"); +}); + test("the resolver attributes the global file when no project file exists", () => { const cwd = makeScratch("gp-bg-src-global-"); const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai"); diff --git a/tests/gentle-config-scope.test.ts b/tests/gentle-config-scope.test.ts new file mode 100644 index 000000000..1ba7827a8 --- /dev/null +++ b/tests/gentle-config-scope.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { gentlePiConfigHome } from "../lib/agent-home.ts"; +import { + CONFIG_CATEGORIES, + CONFIG_CATEGORY_FILES, + ENV_CONFIG_HOME_ENV, + ENV_OWN_CONFIG_ENV, + INSTALL_CONFIG_FILES, + ownConfigCategories, +} from "../lib/gentle-config-scope.ts"; + +// --- categories --------------------------------------------------------------- + +test("every Gentle config file belongs to exactly one category or is install-level", () => { + assert.deepEqual([...CONFIG_CATEGORIES], ["models", "persona", "appearance", "behavior"]); + const seen = new Map(); + for (const category of CONFIG_CATEGORIES) { + for (const file of CONFIG_CATEGORY_FILES[category]) { + assert.ok(!seen.has(file), `${file} is in ${seen.get(file)} and ${category}`); + seen.set(file, category); + } + } + for (const file of INSTALL_CONFIG_FILES) assert.ok(!seen.has(file), `${file} is install-level`); + for (const file of ["models.json", "profiles.json"]) assert.equal(seen.get(file), "models", "a profile is a snapshot of the routing"); + assert.equal(seen.get("persona.json"), "persona"); + assert.equal(seen.get("card-style.json"), "appearance"); + assert.equal(seen.get("permission-mode.json"), "behavior"); +}); + +test("ownConfigCategories trusts only an absolute env config home and known categories", () => { + const home = join("/", "e", "work", "gentle-ai"); + assert.deepEqual([...ownConfigCategories({ [ENV_CONFIG_HOME_ENV]: home, [ENV_OWN_CONFIG_ENV]: "persona,appearance,bogus" })], ["persona", "appearance"]); + assert.deepEqual([...ownConfigCategories({ [ENV_OWN_CONFIG_ENV]: "persona" })], [], "no env config home, nothing is own"); + assert.deepEqual([...ownConfigCategories({ [ENV_CONFIG_HOME_ENV]: "relative/dir", [ENV_OWN_CONFIG_ENV]: "persona" })], []); + assert.deepEqual([...ownConfigCategories({ [ENV_CONFIG_HOME_ENV]: home })], [], "no list, nothing is own"); +}); + +// --- resolution ----------------------------------------------------------------- + +test("an own category resolves to the env config home; shared ones keep the existing chain", () => { + const envHome = join("/", "e", "work", "gentle-ai"); + const env = { GENTLE_PI_CONFIG_HOME: "/shared/gentle-ai", [ENV_CONFIG_HOME_ENV]: envHome, [ENV_OWN_CONFIG_ENV]: "persona,behavior" }; + assert.equal(gentlePiConfigHome(env, "persona"), envHome); + assert.equal(gentlePiConfigHome(env, "behavior"), envHome); + assert.equal(gentlePiConfigHome(env, "models"), "/shared/gentle-ai"); + assert.equal(gentlePiConfigHome(env, "appearance"), "/shared/gentle-ai"); + assert.equal(gentlePiConfigHome(env), "/shared/gentle-ai", "install-level reads never move"); +}); + +test("without env scope variables every category resolves exactly as before", () => { + for (const category of CONFIG_CATEGORIES) { + assert.equal(gentlePiConfigHome({ GENTLE_PI_CONFIG_HOME: "/custom" }, category), "/custom"); + assert.equal(gentlePiConfigHome({}, category), join(homedir(), ".pi", "gentle-ai")); + } +}); + +// --- drift guard --------------------------------------------------------------- + +// Install-level reads that must ignore env scope: the dev binary registration, +// integrity and the update channel belong to the install, never to an env. +// Session scratch ledgers are runtime state, not configuration, so they stay +// in the shared config home whatever categories an env owns. +const INSTALL_LEVEL_CALLS = new Set(["extensions/session-scratch.ts:93"]); + +test("every config-home read names its category (drift guard)", () => { + const root = new URL("..", import.meta.url); + const offenders: string[] = []; + // Recursive: config-home reads also live in subfolders such as extensions/history. + for (const top of ["lib", "extensions"]) { + for (const entry of readdirSync(new URL(`${top}/`, root), { recursive: true }) as string[]) { + const rel = entry.split("\\").join("/"); + if (!rel.endsWith(".ts")) continue; + const slash = rel.lastIndexOf("/"); + const dir = slash < 0 ? top : `${top}/${rel.slice(0, slash)}`; + const name = slash < 0 ? rel : rel.slice(slash + 1); + const source = readFileSync(new URL(`${dir}/${name}`, root), "utf8"); + source.split("\n").forEach((line, index) => { + if (/export function gentlePiConfigHome/.test(line)) return; + if (/function gentleAiConfigHome\(category/.test(line)) return; + // A hand-written copy of the chain bypasses the category entirely. + if (/bundledGentlePiConfigHome\([^)]*\)\s*\?\?\s*[\w.]*GENTLE_PI_CONFIG_HOME/.test(line) && !/^(gentle-ai-binary|agent-home)\.ts$/.test(name)) { + offenders.push(`${dir}/${name}:${index + 1}: hand-written config chain`); + } + for (const match of line.matchAll(/(? { + const { GENTLE_SHELL_CONFIG_HOME_ENV } = await import("../lib/agent-home.ts"); + const envHome = join("/", "e", "work", "gentle-ai"); + const launcherHome = join("/", "s", ".gentle-shell", "gentle-ai"); + const scoped = { [ENV_CONFIG_HOME_ENV]: envHome, [ENV_OWN_CONFIG_ENV]: "appearance", [GENTLE_SHELL_CONFIG_HOME_ENV]: launcherHome }; + assert.equal(gentlePiConfigHome(scoped, "appearance"), envHome); + assert.equal(gentlePiConfigHome(scoped, "models"), launcherHome); + assert.equal(gentlePiConfigHome({ ...scoped, GENTLE_PI_CONFIG_HOME: "" }, "models"), launcherHome, "an empty override falls through"); + assert.equal(gentlePiConfigHome({ [ENV_CONFIG_HOME_ENV]: envHome, [ENV_OWN_CONFIG_ENV]: "appearance" }, "models"), join(homedir(), ".pi", "gentle-ai")); +}); diff --git a/tests/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 9ddcc5f79..a592070b9 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3513,3 +3513,328 @@ test("gentle-shell setup asks Gentle AI to skip npm:gentle-pi, so nothing is ins assert.deepEqual(JSON.parse(lines[0]), { args: ["install", "--agent", "pi", "--scope", "global"], excluded: "npm:gentle-pi" }); }); +// --- named envs (`envs`, `--env`; lib/gentle-shell-envs.ts) ------------------- + +test("envs new creates a managed, owned env that --env launches into", (t) => { + const f = fixture(t); + const created = run(f.env, ["envs", "new", "work"]); + assert.equal(created.status, 0, created.stderr); + const dir = join(f.home, ".gentle-shell", "envs", "work"); + assert.ok(existsSync(join(dir, ".gentle-shell-home")), "carries the ownership marker"); + const launched = run(f.env, ["--env", "work", "-p", "hi"]); + assert.equal(launched.status, 0, launched.stderr); + assert.equal(JSON.parse(launched.stdout.trim().split("\n").at(-1) ?? "{}").PI_CODING_AGENT_DIR, dir); +}); + +test("envs new removes its partial env when bootstrap fails, so a retry succeeds", (t) => { + const f = fixture(t); + const preload = join(f.root, "fail-marker.cjs"); + writeFileSync(preload, [ + "const fs = require('node:fs');", + "const { syncBuiltinESMExports } = require('node:module');", + "const original = fs.writeFileSync;", + "fs.writeFileSync = (path, ...args) => { if (String(path).endsWith('.gentle-shell-home')) throw new Error('marker write failed'); return original(path, ...args); };", + "syncBuiltinESMExports();", + ].join("\n")); + const failed = spawnSync(process.execPath, ["--require", preload, binPath, "envs", "new", "work"], { encoding: "utf8", env: f.env }); + assert.equal(failed.status, 1, failed.stderr); + assert.match(failed.stderr, /marker write failed/); + assert.equal(existsSync(join(f.home, ".gentle-shell", "envs", "work")), false, "no half-created env is left behind"); + const retried = run(f.env, ["envs", "new", "work"]); + assert.equal(retried.status, 0, retried.stderr); + assert.ok(existsSync(join(f.home, ".gentle-shell", "envs", "work", ".gentle-shell-home"))); +}); + +test("--env never creates an unknown env and rejects invalid names", (t) => { + const f = fixture(t); + const unknown = run(f.env, ["--env", "nope"]); + assert.equal(unknown.status, 2); + assert.match(unknown.stderr, /unknown env "nope"/); + assert.equal(existsSync(join(f.home, ".gentle-shell", "envs", "nope")), false); + assert.equal(run(f.env, ["envs", "new", "Bad"]).status, 2); + assert.equal(run(f.env, ["envs", "new", "dup"]).status, 0); + assert.equal(run(f.env, ["envs", "new", "dup"]).status, 1); +}); + +test("--env accepts only a real env directory, never a file or a symlink", (t) => { + const f = fixture(t); + const root = join(f.home, ".gentle-shell", "envs"); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "file"), "not an env\n"); + const target = join(f.root, "elsewhere"); + mkdirSync(target); + // A junction on Windows: it needs no symlink privilege and lstat still reports a link. + symlinkSync(target, join(root, "link"), process.platform === "win32" ? "junction" : "dir"); + for (const name of ["file", "link"]) { + const result = run(f.env, ["--env", name]); + assert.equal(result.status, 2, `${name}: ${result.stderr}`); + assert.match(result.stderr, new RegExp(`unknown env "${name}"`)); + } + assert.equal(readFileSync(join(root, "file"), "utf8"), "not an env\n"); + assert.deepEqual(readdirSync(target), []); +}); + +test("envs without a TTY lists envs, marks the default, and honors GENTLE_SHELL_ENVS_DIR", (t) => { + const f = fixture(t); + const envsDir = join(f.root, "custom-envs"); + const env = { ...f.env, GENTLE_SHELL_ENVS_DIR: envsDir }; + assert.equal(run(env, ["envs", "new", "a"]).status, 0); + assert.equal(run(env, ["envs", "new", "b"]).status, 0); + assert.equal(run(env, ["home", join(envsDir, "b")]).status, 0); + const listed = run(env, ["envs"]); + assert.equal(listed.status, 0, listed.stderr); + assert.match(listed.stdout, /^a +\//m); + assert.match(listed.stdout, /^b \(default\) +\//m); + assert.doesNotMatch(listed.stdout, /^\*/m, "no leading star: the default reads like /gentle:profiles markers"); + const version = run(env, ["--version"]); + assert.match(version.stdout, new RegExp(`home path ${join(envsDir, "b").replace(/[\\.]/g, "\\$&")}`)); +}); + +test("envs rejects unknown subcommands with usage", (t) => { + const f = fixture(t); + const result = run(f.env, ["envs", "bogus"]); + assert.equal(result.status, 2); + assert.match(result.stderr, /Usage: gentle-shell envs/); +}); + +test("envs lists the base home first and stars exactly what a plain launch opens", (t) => { + const f = fixture(t); + assert.equal(run(f.env, ["envs", "new", "work"]).status, 0); + const starred = () => run(f.env, ["envs", "list"]).stdout.split("\n").filter((line) => /^\S+ \(default\)/.test(line)).map((line) => line.split(/\s+/)[0]); + const opened = () => JSON.parse(run(f.env, ["-p", "hi"]).stdout.trim().split("\n").at(-1) ?? "{}").PI_CODING_AGENT_DIR; + const lines = run(f.env, ["envs", "list"]).stdout.split("\n"); + assert.match(lines[0], new RegExp(`^\\[base\\] \\(default\\) +${f.gentleShellHome.replace(/[\\.]/g, "\\$&")} `), "base first, default with no config"); + assert.equal(opened(), f.gentleShellHome); + + assert.equal(run(f.env, ["home", "link"]).status, 0); + assert.deepEqual(starred(), [], "link opens none of the listed homes"); + + assert.equal(run(f.env, ["home", join(f.home, ".gentle-shell", "envs", "work")]).status, 0); + assert.deepEqual(starred(), ["work"]); + assert.equal(opened(), join(f.home, ".gentle-shell", "envs", "work")); + + assert.equal(run(f.env, ["home", "isolated"]).status, 0); + assert.deepEqual(starred(), ["[base]"]); + assert.equal(opened(), f.gentleShellHome); +}); + +// The envs manager needs a TTY. Like the bundled startup-offer test, a +// preload makes stdin/stdout report a TTY with a no-op raw mode, and the +// keys arrive as stdin input; the manager handles them in order. +function runManager(env: NodeJS.ProcessEnv, keys: string) { + const preload = ` + Object.defineProperty(process.stdin, 'isTTY', {value:true}); + Object.defineProperty(process.stdout, 'isTTY', {value:true}); + process.stdin.setRawMode = (raw) => { process.stdin.isRaw = raw; }; + `; + return spawnSync(process.execPath, [binPath, "envs"], { + env: { ...env, NODE_OPTIONS: `${env.NODE_OPTIONS ?? ""} --import=data:text/javascript,${encodeURIComponent(preload)}`.trim() }, + encoding: "utf8", input: keys, timeout: 15000, + }); +} + +test("the envs manager renames, sets the default and deletes envs on disk", (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + for (const name of ["a", "b"]) assert.equal(run(f.env, ["envs", "new", name]).status, 0); + // Entries: base (default), a, b. Rename a -> alpha, make it the default, then delete b + // (a wrong confirmation first, which must not delete anything). + const keys = ["j", "r", "\x7f", "alpha", "\r", "s", "j", "d", "x", "\r", "\x7f", "b", "\r", "q"].join(""); + const result = runManager(f.env, keys); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readdirSync(envs).sort(), ["alpha"]); + const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8")); + assert.equal(config.home, join(envs, "alpha"), "the default follows the renamed env"); + // Keys arriving together redraw once at the end, so disk and config are the evidence. + assert.match(result.stdout, /\x1b\[\?1049l/, "the terminal is restored"); +}); + +test("the envs manager rolls a rename back when config.json cannot be written", { skip: (process.platform === "win32" || process.getuid?.() === 0) && "needs a non-root POSIX read-only dir" }, (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + assert.equal(run(f.env, ["envs", "new", "a"]).status, 0); + // A readable config in a read-only folder: the manager loads, every write fails. + const lockedDir = join(f.root, "locked"); + mkdirSync(lockedDir); + writeFileSync(join(lockedDir, "config.json"), "{}\n"); + chmodSync(lockedDir, 0o500); + let result; + // Unlocked before the fixture's own cleanup, which runs first. + try { result = runManager({ ...f.env, GENTLE_SHELL_CONFIG: join(lockedDir, "config.json") }, ["j", "r", "\x7f", "alpha", "\r", "q"].join("")); } + finally { chmodSync(lockedDir, 0o700); } + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readdirSync(envs), ["a"], "the env keeps its old name, matching config.json"); +}); + +test("the envs manager keeps an env it cannot drop from config.json", { skip: (process.platform === "win32" || process.getuid?.() === 0) && "needs a non-root POSIX read-only dir" }, (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + assert.equal(run(f.env, ["envs", "new", "a"]).status, 0); + // The env is the default: losing its folder while config.json still names + // it would make the next plain launch recreate it empty. + const lockedDir = join(f.root, "locked"); + mkdirSync(lockedDir); + writeFileSync(join(lockedDir, "config.json"), `${JSON.stringify({ home: join(envs, "a") })}\n`); + chmodSync(lockedDir, 0o500); + let result; + try { result = runManager({ ...f.env, GENTLE_SHELL_CONFIG: join(lockedDir, "config.json") }, ["j", "d", "a", "\r", "q"].join("")); } + finally { chmodSync(lockedDir, 0o700); } + assert.equal(result.status, 0, result.stderr); + assert.ok(existsSync(join(envs, "a", ".gentle-shell-home")), "the env stays on disk, matching config.json"); +}); + +test("the envs manager never lists or touches a symlinked env", (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + assert.equal(run(f.env, ["envs", "new", "real"]).status, 0); + const outside = join(f.root, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "keep.txt"), "keep"); + symlinkSync(outside, join(envs, "linked")); + // Entries: base, real. Two downs stay on real; delete it, then quit. + const result = runManager(f.env, ["j", "j", "d", "real", "\r", "q"].join("")); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stdout, /\blinked\b/); + assert.equal(readFileSync(join(outside, "keep.txt"), "utf8"), "keep"); + assert.deepEqual(readdirSync(envs), ["linked"]); +}); + +// --- per-env config scope (envs new/config, launch scope) ----------------------- + +test("envs new --own seeds own categories from the base config home and the launch scopes them", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "persona.json"), '{"persona":"base"}'); + writeFileSync(join(base, "models.json"), '{"routing":"base"}'); + const created = run(env, ["envs", "new", "work", "--own", "persona"]); + assert.equal(created.status, 0, created.stderr); + const dir = join(f.home, ".gentle-shell", "envs", "work"); + assert.equal(readFileSync(join(dir, "gentle-ai", "persona.json"), "utf8"), '{"persona":"base"}', "copied from the base"); + assert.equal(existsSync(join(dir, "gentle-ai", "models.json")), false, "shared categories are not copied"); + assert.deepEqual(JSON.parse(readFileSync(join(dir, ".gentle-shell-env.json"), "utf8")), { schema: 1, own: ["persona"] }); + const launched = JSON.parse(run(env, ["--env", "work", "-p", "hi"]).stdout.trim().split("\n").at(-1) ?? "{}"); + assert.equal(launched.PI_CODING_AGENT_DIR, dir); + const shown = run(env, ["envs", "config", "work"]); + assert.equal(shown.status, 0, shown.stderr); + assert.match(shown.stdout, /own persona · shares models & profiles, appearance, behavior/); +}); + +test("envs config changes scope with --from another env or --empty, and --share keeps own files", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "persona.json"), '{"persona":"base"}'); + assert.equal(run(env, ["envs", "new", "a", "--own", "persona"]).status, 0); + const envs = join(f.home, ".gentle-shell", "envs"); + writeFileSync(join(envs, "a", "gentle-ai", "persona.json"), '{"persona":"a"}'); + assert.equal(run(env, ["envs", "new", "b"]).status, 0); + assert.equal(run(env, ["envs", "config", "b", "--own", "persona", "--from", "a"]).status, 0); + assert.equal(readFileSync(join(envs, "b", "gentle-ai", "persona.json"), "utf8"), '{"persona":"a"}', "copied from env a's own persona"); + assert.equal(run(env, ["envs", "config", "a", "--share", "persona"]).status, 0); + assert.equal(JSON.parse(readFileSync(join(envs, "a", ".gentle-shell-env.json"), "utf8")).own.length, 0); + assert.equal(existsSync(join(envs, "a", "gentle-ai", "persona.json")), true, "sharing again keeps the own copy on disk"); + assert.equal(run(env, ["envs", "config", "b", "--own", "all", "--empty"]).status, 0); + assert.equal(existsSync(join(envs, "b", "gentle-ai", "persona.json")), true, "already own: --empty never wipes it"); + assert.equal(existsSync(join(envs, "b", "gentle-ai", "models.json")), false); + const bad = run(env, ["envs", "config", "b", "--own", "persona", "--from", "nope"]); + assert.equal(bad.status, 2); + assert.match(bad.stderr, /unknown env "nope"/); + assert.equal(run(env, ["envs", "config", "nope"]).status, 2); +}); + +test("a launched env carries its own config scope to Pi, and a sharing env clears an inherited one", (t) => { + const f = fixture(t); + const pi = join(f.root, "scope-pi.mjs"); + writeFileSync(pi, [ + "#!/usr/bin/env node", + "if (process.argv.includes('--version')) { console.log('1.2.0'); process.exit(0); }", + "console.log(JSON.stringify({ home: process.env.GENTLE_SHELL_ENV_CONFIG_HOME ?? null, own: process.env.GENTLE_SHELL_ENV_OWN_CONFIG ?? null }));", + "", + ].join("\n")); + chmodSync(pi, 0o755); + const env = { ...f.env, GENTLE_SHELL_PI: pi, GENTLE_PI_CONFIG_HOME: join(f.root, "base-config"), GENTLE_SHELL_ENV_CONFIG_HOME: "/outer/gentle-ai", GENTLE_SHELL_ENV_OWN_CONFIG: "models" }; + assert.equal(run(env, ["envs", "new", "own", "--own", "persona,behavior", "--empty"]).status, 0); + assert.equal(run(env, ["envs", "new", "plain"]).status, 0); + const last = (args: string[]) => JSON.parse(run(env, args).stdout.trim().split("\n").at(-1) ?? "{}"); + assert.deepEqual(last(["--env", "own", "-p", "hi"]), { home: join(f.home, ".gentle-shell", "envs", "own", "gentle-ai"), own: "persona,behavior" }); + assert.deepEqual(last(["--env", "plain", "-p", "hi"]), { home: null, own: null }); + assert.deepEqual(last(["-p", "hi"]), { home: null, own: null }, "the base home never inherits env scope"); +}); + +test("envs config refuses a symlinked env or gentle-ai folder and leaves the target untouched", (t) => { + const f = fixture(t); + const shared = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: shared }; + mkdirSync(shared, { recursive: true }); + writeFileSync(join(shared, "persona.json"), '{"persona":"shared"}'); + const envs = join(f.home, ".gentle-shell", "envs"); + assert.equal(run(env, ["envs", "new", "linked"]).status, 0); + symlinkSync(shared, join(envs, "linked", "gentle-ai")); + const refused = run(env, ["envs", "config", "linked", "--own", "persona", "--empty"]); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /refusing/); + assert.equal(readFileSync(join(shared, "persona.json"), "utf8"), '{"persona":"shared"}', "the shared config is never touched"); + assert.equal(existsSync(join(envs, "linked", ".gentle-shell-env.json")), false); + const outside = join(f.root, "outside-env"); + mkdirSync(outside); + symlinkSync(outside, join(envs, "aliased")); + const aliased = run(env, ["envs", "config", "aliased", "--own", "persona"]); + assert.notEqual(aliased.status, 0); + assert.match(aliased.stderr, /refusing/); + assert.deepEqual(readdirSync(outside), []); +}); + +test("re-owning a category reuses its kept own copy instead of seeding over it", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "persona.json"), '{"persona":"base"}'); + assert.equal(run(env, ["envs", "new", "a", "--own", "persona"]).status, 0); + const own = join(f.home, ".gentle-shell", "envs", "a", "gentle-ai", "persona.json"); + writeFileSync(own, '{"persona":"edited"}'); + assert.equal(run(env, ["envs", "config", "a", "--share", "persona"]).status, 0); + assert.equal(run(env, ["envs", "config", "a", "--own", "persona", "--empty"]).status, 0); + assert.equal(readFileSync(own, "utf8"), '{"persona":"edited"}', "the kept own copy is reused"); +}); + +test("envs config removes a partial seed when a copy fails, so a retry seeds the whole category", (t) => { + const f = fixture(t); + const base = join(f.root, "base-config"); + const env = { ...f.env, GENTLE_PI_CONFIG_HOME: base }; + mkdirSync(base, { recursive: true }); + writeFileSync(join(base, "models.json"), '{"models":"base"}'); + // profiles.json is a directory, so its copy throws after models.json was copied. + mkdirSync(join(base, "profiles.json")); + assert.equal(run(env, ["envs", "new", "a"]).status, 0); + const own = join(f.home, ".gentle-shell", "envs", "a", "gentle-ai"); + const failed = run(env, ["envs", "config", "a", "--own", "models"]); + assert.equal(failed.status, 1, failed.stderr); + assert.equal(existsSync(join(own, "models.json")), false, "no partial copy is left to read as a kept own copy"); + assert.equal(existsSync(join(f.home, ".gentle-shell", "envs", "a", ".gentle-shell-env.json")), false); + rmSync(join(base, "profiles.json"), { recursive: true }); + writeFileSync(join(base, "profiles.json"), '{"profiles":"base"}'); + assert.equal(run(env, ["envs", "config", "a", "--own", "models"]).status, 0); + assert.equal(readFileSync(join(own, "profiles.json"), "utf8"), '{"profiles":"base"}', "the retry seeds the whole category"); +}); + +test("envs new rejects scope flags without --own and leaves no half-created env", (t) => { + const f = fixture(t); + const envs = join(f.home, ".gentle-shell", "envs"); + for (const flags of [["--empty"], ["--share", "persona"], ["--from", "x"]]) { + const result = run(f.env, ["envs", "new", "lonely", ...flags]); + assert.equal(result.status, 2, flags.join(" ")); + assert.match(result.stderr, /--own/); + assert.equal(existsSync(join(envs, "lonely")), false, flags.join(" ")); + } + // A seed that fails (the source file is a directory, so the copy throws) rolls the new env back. + assert.equal(run(f.env, ["envs", "new", "src"]).status, 0); + mkdirSync(join(envs, "src", "gentle-ai", "persona.json"), { recursive: true }); + writeFileSync(join(envs, "src", ".gentle-shell-env.json"), '{"schema":1,"own":["persona"]}'); + const failed = run(f.env, ["envs", "new", "rolled", "--own", "persona", "--from", "src"]); + assert.notEqual(failed.status, 0); + assert.equal(existsSync(join(envs, "rolled")), false, "a failed seed leaves no env behind"); +}); diff --git a/tests/gentle-shell-bundled.test.ts b/tests/gentle-shell-bundled.test.ts index f9b686aac..31ec96219 100644 --- a/tests/gentle-shell-bundled.test.ts +++ b/tests/gentle-shell-bundled.test.ts @@ -255,7 +255,7 @@ const FAKE_PI = [ " }", "}", "const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'npm_config_registry', 'NODE_OPTIONS', 'PI_CODING_AGENT_DIR',", - "\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_SERVER_MODE', 'ENGRAM_SOCKET', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];", + "\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_SERVER_MODE', 'ENGRAM_SOCKET', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK', 'GENTLE_SHELL_ENV'];", "const record = join(process.env.GENTLE_SHELL_PREFIX ?? '', 'config', 'engram-provenance.json');", "const provenance = (() => { try { return JSON.parse(readFileSync(record, 'utf8')); } catch { return null; } })();", "console.log(JSON.stringify({ cli: process.argv[1], args, provenance, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));", @@ -1197,3 +1197,33 @@ test("gentle-shell self-uninstall in a bundled install never edits or deletes a assert.match(result.stdout, /does not describe this install's PATH entry/); assert.match(result.stdout, /\(none recorded; see below\)/); }); + +// Named envs (lib/gentle-shell-envs.ts) inside a bundled install: the envs root +// shares ~/.gentle-shell with the prefix, launches use the bundled Pi, and a +// self-uninstall never removes the user's envs. +test("named envs work in a bundled install and survive its self-uninstall", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const created = prefix.run(["envs", "new", "work"]); + assert.equal(created.status, 0, created.stderr); + const dir = join(prefix.root, "envs", "work"); + assert.ok(existsSync(join(dir, ".gentle-shell-home"))); + assert.match(prefix.run(["envs", "list"]).stdout, /^work +\//m); + + const launched = prefix.run(["--env", "work"], { FIXTURE_NO_PROBE: "0" }); + assert.equal(launched.status, 0, launched.stderr); + const seen = lastLaunch(launched); + assert.equal(seen.env.PI_CODING_AGENT_DIR, dir); + assert.equal(seen.env.GENTLE_SHELL_ENV, "work"); + assert.equal(seen.env.GENTLE_SHELL_PI, undefined, "the bundled Pi runs, never GENTLE_SHELL_PI"); + + const plain = lastLaunch(prefix.run([], { GENTLE_SHELL_ENV: "work" })); + assert.equal(plain.env.GENTLE_SHELL_ENV, undefined, "the default home is never labelled as an env"); + + const plan = prefix.run(["self-uninstall", "--dry-run"]).stdout; + assert.ok(plan.includes(`${join(prefix.root, "envs")} (your named envs)`), plan); + assert.doesNotMatch(plan, /envs: not written by the bundled install/); + const removed = prefix.run(["self-uninstall", "--yes"]); + assert.equal(removed.status, 0, removed.stderr); + assert.ok(existsSync(join(dir, ".gentle-shell-home")), "envs are user data and are kept"); + assert.deepEqual(readdirSync(prefix.root).sort(), ["agent", "envs"]); +}); diff --git a/tests/gentle-shell-envs-tui.test.ts b/tests/gentle-shell-envs-tui.test.ts new file mode 100644 index 000000000..13ae96bc3 --- /dev/null +++ b/tests/gentle-shell-envs-tui.test.ts @@ -0,0 +1,396 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { type EnvSummary } from "../lib/gentle-shell-envs.ts"; +import { initialEnvsState, parseKeys, reduceEnvs, renderEnvsScreen, type EnvsKey, type EnvsState } from "../lib/gentle-shell-envs-tui.ts"; + +function env(name: string, extra: Partial = {}): EnvSummary { + return { name, dir: `/e/${name}`, isDefault: false, packages: [], providers: [], provisioned: "not provisioned", sessionCount: 0, ...extra }; +} + +const char = (c: string): EnvsKey => ({ name: "char", char: c }); + +function press(state: EnvsState, ...keys: EnvsKey[]) { + let effect; + for (const key of keys) ({ state, effect } = reduceEnvs(state, key)); + return { state, effect }; +} + +function typed(text: string): EnvsKey[] { + return [...text].map(char); +} + +// --- key parsing ----------------------------------------------------------- + +test("parseKeys decodes arrows, enter, backspace, escape, ctrl-c and printable text", () => { + assert.deepEqual(parseKeys("\x1b[A\x1b[B\r\x7fab\x03"), [ + { name: "up" }, + { name: "down" }, + { name: "enter" }, + { name: "backspace" }, + char("a"), + char("b"), + { name: "ctrl-c" }, + ]); + assert.deepEqual(parseKeys("\x1b"), [{ name: "escape" }]); + assert.deepEqual(parseKeys("\x1bOA\x1bOB"), [{ name: "up" }, { name: "down" }]); + assert.deepEqual(parseKeys("\x1b[5~"), [], "unknown escape sequences are dropped"); + assert.deepEqual(parseKeys("\x1b[D\x1b[C "), [{ name: "left" }, { name: "right" }, char(" ")]); +}); + +// --- navigation and direct actions ------------------------------------------- + +test("selection moves with arrows and j/k and is clamped", () => { + const s0 = initialEnvsState([env("a"), env("b")]); + assert.equal(press(s0, { name: "down" }, char("j"), char("j")).state.selected, 1); + assert.equal(press(s0, { name: "down" }, char("k"), { name: "up" }).state.selected, 0); +}); + +test("initial selection starts on the default env", () => { + assert.equal(initialEnvsState([env("a"), env("b", { isDefault: true })]).selected, 1); +}); + +test("enter launches, s sets default, p sets up, a checks auth, q quits", () => { + const s0 = initialEnvsState([env("a"), env("b")]); + const s1 = press(s0, { name: "down" }).state; + assert.deepEqual(press(s1, { name: "enter" }).effect, { kind: "launch", name: "b" }); + assert.deepEqual(press(s1, char("s")).effect, { kind: "set-default", name: "b" }); + assert.deepEqual(press(s1, char("p")).effect, { kind: "setup", name: "b" }); + assert.deepEqual(press(s1, char("a")).effect, { kind: "auth-check", name: "b" }); + assert.deepEqual(press(s1, char("q")).effect, { kind: "quit" }); + assert.deepEqual(press(s1, { name: "escape" }).effect, { kind: "quit" }); + assert.deepEqual(press(s1, { name: "ctrl-c" }).effect, { kind: "quit" }); +}); + +test("env-scoped actions on an empty list do nothing but explain how to create one", () => { + const { state, effect } = press(initialEnvsState([]), { name: "enter" }); + assert.equal(effect, undefined); + assert.match(state.message ?? "", /n/); +}); + +// --- prompts --------------------------------------------------------------- + +test("n opens a name prompt that validates and rejects duplicates before creating", () => { + const s0 = initialEnvsState([env("work")]); + const bad = press(s0, char("n"), ...typed("Work"), { name: "enter" }); + assert.equal(bad.effect, undefined); + assert.match(bad.state.prompt?.error ?? "", /env name/); + const dup = press(s0, char("n"), ...typed("work"), { name: "enter" }); + assert.match(dup.state.prompt?.error ?? "", /already exists/); + const named = press(s0, char("n"), ...typed("perzonal"), { name: "backspace" }, { name: "backspace" }, ...typed("al"), { name: "enter" }); + assert.equal(named.effect, undefined, "a valid name opens the config form first"); + assert.equal(named.state.prompt, undefined); + assert.equal(named.state.scope?.name, "perzonal"); + const ok = press(named.state, { name: "enter" }); + assert.deepEqual(ok.effect, { kind: "create", name: "perzonal", own: [] }, "enter with defaults shares everything, as before"); + assert.equal(ok.state.scope, undefined); +}); + +test("keys typed into a prompt never trigger list actions", () => { + const { state, effect } = press(initialEnvsState([env("a")]), char("n"), char("q"), char("d"), char("s")); + assert.equal(effect, undefined); + assert.equal(state.prompt?.value, "qds"); +}); + +test("escape cancels a prompt without quitting", () => { + const { state, effect } = press(initialEnvsState([env("a")]), char("n"), char("x"), { name: "escape" }); + assert.equal(effect, undefined); + assert.equal(state.prompt, undefined); +}); + +test("r renames the selected env, prefilled, and a no-op rename just closes", () => { + const s0 = initialEnvsState([env("old"), env("taken")]); + const opened = press(s0, char("r")).state; + assert.equal(opened.prompt?.value, "old"); + assert.equal(press(opened, { name: "enter" }).effect, undefined); + assert.match(press(opened, ...Array(3).fill({ name: "backspace" }), ...typed("taken"), { name: "enter" }).state.prompt?.error ?? "", /already exists/); + assert.deepEqual(press(opened, { name: "backspace" }, char("x"), { name: "enter" }).effect, { kind: "rename", from: "old", to: "olx" }); +}); + +test("d deletes only after the env name is typed exactly", () => { + const s0 = initialEnvsState([env("work")]); + const wrong = press(s0, char("d"), ...typed("wor"), { name: "enter" }); + assert.equal(wrong.effect, undefined); + assert.match(wrong.state.prompt?.error ?? "", /type "work"/); + assert.deepEqual(press(s0, char("d"), ...typed("work"), { name: "enter" }).effect, { kind: "delete", name: "work" }); +}); + +test("i installs a non-empty package source into the selected env", () => { + const s0 = initialEnvsState([env("work")]); + assert.match(press(s0, char("i"), { name: "enter" }).state.prompt?.error ?? "", /source/); + assert.deepEqual(press(s0, char("i"), ...typed("npm:pi-btw"), { name: "enter" }).effect, { kind: "install", name: "work", source: "npm:pi-btw" }); +}); + +// --- rendering --------------------------------------------------------------- + +test("render fits the terminal exactly and shows the list, details and key hints", () => { + const state = initialEnvsState([ + env("work", { isDefault: true, packages: ["npm:gentle-engram"], providers: ["anthropic"], sessionCount: 2, lastSessionAt: "2026-05-02T10:00:00.000Z" }), + env("personal"), + ]); + const lines = renderEnvsScreen(state, 80, 20); + assert.equal(lines.length, 20); + for (const line of lines) assert.ok(stripAnsi(line).length <= 80, `too wide: ${line}`); + const text = lines.map(stripAnsi).join("\n"); + for (const expected of ["work", "personal", "default", "/e/work", "npm:gentle-engram", "anthropic", "2 sessions", "enter"]) { + assert.ok(text.includes(expected), `missing ${expected}`); + } +}); + +test("render shows the prompt with its error, and an empty-state hint", () => { + const prompting = press(initialEnvsState([env("a")]), char("n"), ...typed("Bad"), { name: "enter" }).state; + const text = renderEnvsScreen(prompting, 60, 12).map(stripAnsi).join("\n"); + assert.ok(text.includes("New env name: Bad")); + assert.match(text, /invalid env name/); + assert.match(renderEnvsScreen(initialEnvsState([]), 60, 12).map(stripAnsi).join("\n"), /No envs yet/); +}); + +test("render degrades without throwing on tiny terminals", () => { + const lines = renderEnvsScreen(initialEnvsState([env("a")]), 10, 3); + assert.equal(lines.length, 3); + for (const line of lines) assert.ok(stripAnsi(line).length <= 10); +}); + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ""); +} + +// --- Gentle visual identity ------------------------------------------------- + +const ROSE = "\x1b[38;2;255;118;195m"; +const sample = () => initialEnvsState([ + env("work", { isDefault: true, packages: ["npm:gentle-engram"], providers: ["anthropic"], sessionCount: 2 }), + env("personal"), +]); + +test("the gate is branded: Gentle Shell title with version and a call to choose an env", () => { + const text = renderEnvsScreen(sample(), 100, 24, { version: "4.2.0" }).map(stripAnsi).join("\n"); + assert.match(text, /✿ Gentle Shell/); + assert.match(text, /v4\.2\.0/); + assert.match(text, /Choose an environment/); +}); + +test("envs and details render as rounded Gentle cards with the rose title glyph", () => { + const lines = renderEnvsScreen(sample(), 100, 24).map(stripAnsi); + assert.ok(lines.some((line) => /╭─ ✿ Envs ─+╮/.test(line)), lines.join("\n")); + assert.ok(lines.some((line) => /╭─ ✿ work ─+╮/.test(line)), "detail card titled with the selected env"); + assert.ok(lines.some((line) => /^╰─+╯/.test(line))); +}); + +test("the selected env is marked with the Gentle chevron and the default like /gentle:profiles markers", () => { + const text = renderEnvsScreen(sample(), 100, 24).map(stripAnsi).join("\n"); + assert.match(text, /│ ❯ work \(default\)/); + assert.doesNotMatch(text, /★/, "the default reads like /gentle:profiles markers, not a star"); + assert.match(text, /│ {3}personal/); +}); + +test("color uses the Gentle rose and never reverse video; monochrome emits no truecolor", () => { + const colored = renderEnvsScreen(sample(), 100, 24, { color: true }).join("\n"); + assert.ok(colored.includes(ROSE), "rose accent"); + assert.ok(!colored.includes("\x1b[7m"), "no reverse video"); + const plain = renderEnvsScreen(sample(), 100, 24, { color: false }).join("\n"); + assert.ok(!plain.includes("\x1b[38;2;"), "no truecolor without color"); +}); + +test("tall terminals show the Gentle rose artwork; short ones keep only the title", () => { + const tall = renderEnvsScreen(sample(), 100, 40).map(stripAnsi).join("\n"); + assert.match(tall, /[⣀-⣿]{6}/, "braille rose shown"); + const short = renderEnvsScreen(sample(), 100, 20).map(stripAnsi).join("\n"); + assert.doesNotMatch(short, /[⣀-⣿]{6}/); + assert.match(short, /✿ Gentle Shell/); +}); + +test("the gate is a centered composition: cards fit their content on wide, tall terminals", () => { + const lines = renderEnvsScreen(sample(), 200, 60).map(stripAnsi); + const top = lines.findIndex((line) => line.includes("╭─ ✿ Envs")); + const bottom = lines.findIndex((line, index) => index > top && line.trimStart().startsWith("╰")); + assert.ok(bottom - top < 20, `cards fit content, got ${bottom - top} rows`); + assert.ok(lines[top].indexOf("╭") >= 40, "block centered horizontally"); + assert.ok(lines[top].trimEnd().length - lines[top].indexOf("╭") <= 112, "block width capped"); + assert.ok(lines.findIndex((line) => line.includes("✿ Gentle Shell")) > 5, "vertically centered, not glued to the top"); +}); + +test("key hints wrap instead of losing actions, and sit right under the cards", () => { + for (const width of [80, 60]) { + const lines = renderEnvsScreen(sample(), width, 24).map(stripAnsi); + const text = lines.join("\n"); + for (const hint of ["enter open", "n new", "d delete", "q quit"]) assert.ok(text.includes(hint), `${width}: ${hint}`); + const bottom = lines.findLastIndex((line) => line.trimStart().startsWith("╰")); + const hints = lines.findIndex((line) => line.includes("enter open")); + assert.ok(hints > bottom && hints - bottom <= 3, `${width}: hints right under the cards`); + } +}); + +test("every layout fills the terminal exactly within its width", () => { + for (const [w, h] of [[160, 50], [100, 40], [80, 24], [60, 16], [40, 12], [20, 6], [10, 3]] as const) { + for (const color of [true, false]) { + const lines = renderEnvsScreen(sample(), w, h, { color, version: "4.2.0" }); + assert.equal(lines.length, h, `${w}x${h}`); + for (const line of lines) assert.ok([...stripAnsi(line)].length <= w, `${w}x${h}: ${stripAnsi(line)}`); + } + } +}); + +// --- the base home (the install's own home, `--isolated`) ---------------------- + +const baseEntry = (extra: Partial = {}): EnvSummary => env("base", { dir: "/h/.gentle-shell/agent", base: true, ...extra }); + +test("base home actions carry base and never a managed env name", () => { + const s0 = initialEnvsState([baseEntry(), env("work")]); + assert.deepEqual(press(s0, { name: "enter" }).effect, { kind: "launch", name: "base", base: true }); + assert.deepEqual(press(s0, char("s")).effect, { kind: "set-default", name: "base", base: true }); + assert.deepEqual(press(s0, char("p")).effect, { kind: "setup", name: "base", base: true }); + assert.deepEqual(press(s0, char("a")).effect, { kind: "auth-check", name: "base", base: true }); + assert.deepEqual(press(s0, char("i"), ...typed("npm:pi-btw"), { name: "enter" }).effect, { kind: "install", name: "base", source: "npm:pi-btw", base: true }); + assert.deepEqual(press(s0, { name: "down" }, { name: "enter" }).effect, { kind: "launch", name: "work" }, "envs keep their exact effects"); +}); + +test("the base home can be neither renamed nor deleted", () => { + const s0 = initialEnvsState([baseEntry(), env("work")]); + for (const key of ["r", "d"]) { + const { state, effect } = press(s0, char(key)); + assert.equal(effect, undefined); + assert.equal(state.prompt, undefined); + assert.match(state.message ?? "", /base home/); + } +}); + +test("an env may be named base: the base home never takes part in name checks", () => { + const s0 = initialEnvsState([baseEntry(), env("work")]); + assert.deepEqual(press(s0, char("n"), ...typed("base"), { name: "enter" }, { name: "enter" }).effect, { kind: "create", name: "base", own: [] }); +}); + +test("the base home renders first with its own marker and card, starred when default", () => { + const s0 = initialEnvsState([baseEntry({ isDefault: true }), env("work")]); + const text = renderEnvsScreen(s0, 100, 24).map(stripAnsi).join("\n"); + assert.match(text, /│ ❯ Gentle Shell base \(default\)/); + assert.doesNotMatch(text, /◆/, "no marker that would need a legend"); + assert.match(text, /╭─ ✿ Base home ─+╮/); + assert.match(text, /The install's own home/); + assert.equal(s0.selected, 0, "initial selection follows the default"); +}); + +test("reselection never confuses the base home with an env of the same name", () => { + const envs = [baseEntry(), env("base"), env("work")]; + assert.equal(initialEnvsState(envs, undefined, "base").selected, 1); + assert.equal(initialEnvsState(envs, undefined, { base: true }).selected, 0); +}); + +test("a home that was never set up says it is set up on first open, never just 'none'", () => { + const fresh = initialEnvsState([baseEntry({ provisioned: "not provisioned", setUp: false })]); + const text = renderEnvsScreen(fresh, 100, 24).map(stripAnsi).join("\n"); + assert.match(text, /Setup +not set up yet/); + assert.match(text, /Packages +installed when you first open it/); + assert.doesNotMatch(text, /Packages +none/); + const ready = initialEnvsState([env("work", { setUp: true })]); + assert.match(renderEnvsScreen(ready, 100, 24).map(stripAnsi).join("\n"), /Packages +none/); +}); + +test("text from the user's own files never reaches the terminal as control sequences", () => { + const evil = env("work", { dir: "/e/work\x1b]0;x\x07", packages: ["npm:a\x1b[2J"], providers: ["anth\x1b[31mropic"], provisioned: "ok\r" }); + const lines = renderEnvsScreen(initialEnvsState([evil]), 100, 24, { color: false }); + // The renderer's own SGR styling is allowed; nothing else may remain. + for (const line of lines) assert.doesNotMatch(stripAnsi(line), /[\x00-\x1f\x7f]/, JSON.stringify(line)); + const text = lines.join("\n"); + assert.match(text, /npm:a/); + assert.match(text, /anthropic|anth.*ropic/); +}); + +// --- config scope form (VS Code-like profiles) ------------------------------------- + +const scoped = (name: string, own: EnvSummary["own"] = [], extra: Partial = {}) => env(name, { own, ...extra }); + +test("the new-env form toggles categories and picks where own copies come from", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona"]), scoped("lab")]); + let { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }); + assert.equal(state.scope?.mode, "create"); + state = press(state, { name: "down" }, char(" ")).state; // persona -> own + state = press(state, { name: "down" }, { name: "right" }).state; // appearance -> own + assert.deepEqual(state.scope?.own, ["persona", "appearance"]); + // The source row cycles: base -> each other env -> empty. + state = press(state, { name: "down" }, { name: "down" }, { name: "right" }).state; + assert.deepEqual(state.scope?.source, { kind: "env", name: "work" }); + state = press(state, { name: "right" }, { name: "right" }).state; + assert.deepEqual(state.scope?.source, { kind: "empty" }); + state = press(state, { name: "right" }).state; + assert.deepEqual(state.scope?.source, { kind: "base" }, "wraps around"); + state = press(state, { name: "left" }).state; + assert.deepEqual(state.scope?.source, { kind: "empty" }); + assert.deepEqual(press(state, { name: "enter" }).effect, { kind: "create", name: "client", own: ["persona", "appearance"], empty: true }); + const fromWork = press(state, { name: "right" }, { name: "right" }, { name: "enter" }).effect; + assert.deepEqual(fromWork, { kind: "create", name: "client", own: ["persona", "appearance"], from: "work" }); +}); + +test("a toggles every category own or shared, and esc cancels without creating", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")]); + const { state } = press(s0, char("n"), ...typed("x"), { name: "enter" }, char("a")); + assert.deepEqual(state.scope?.own, ["models", "persona", "appearance", "behavior"]); + assert.deepEqual(press(state, char("a")).state.scope?.own, []); + const cancelled = press(state, { name: "escape" }); + assert.equal(cancelled.effect, undefined); + assert.equal(cancelled.state.scope, undefined); +}); + +test("c edits an existing env's scope; seeding applies only to newly own categories", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona"]), scoped("lab")], undefined, "work"); + const { state } = press(s0, char("c")); + assert.equal(state.scope?.mode, "edit"); + assert.deepEqual(state.scope?.own, ["persona"]); + const edited = press(state, char(" "), { name: "down" }, char(" "), { name: "enter" }); + assert.deepEqual(edited.effect, { kind: "scope", name: "work", own: ["models"], share: ["persona"] }); + assert.deepEqual(press(state, { name: "enter" }).effect, undefined, "no change, nothing to apply"); + assert.equal(press(state, { name: "enter" }).state.scope, undefined); +}); + +test("c on the base home explains it is the shared configuration", () => { + const { state, effect } = press(initialEnvsState([baseEntry(), scoped("work")]), char("c")); + assert.equal(effect, undefined); + assert.equal(state.scope, undefined); + assert.match(state.message ?? "", /shared configuration/); +}); + +test("the form renders as a Gentle card with each category's choice and the source", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona"])]); + const { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }, { name: "down" }, char(" ")); + const text = renderEnvsScreen(state, 110, 30).map(stripAnsi).join("\n"); + assert.match(text, /╭─ ✿ New env · client ─+╮/); + assert.match(text, /Models & profiles +Shared with base/); + assert.match(text, /❯ Persona +Own copy/); + assert.match(text, /New own copies from +Gentle Shell base/); + assert.match(text, /space toggle/); + assert.match(text, /enter create/); +}); + +test("the detail card names each env's config scope", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work", ["persona", "appearance"])], undefined, "work"); + assert.match(renderEnvsScreen(s0, 120, 30).map(stripAnsi).join("\n"), /Config +own persona, appearance · shares models & profiles, behavior/); + const base = renderEnvsScreen(initialEnvsState([baseEntry(), scoped("work")], undefined, { base: true }), 120, 30).map(stripAnsi).join("\n"); + assert.match(base, /Config +the shared configuration envs can reuse/); +}); + +test("the source row reads as inactive until a category is an own copy", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")]); + const { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }); + const idle = renderEnvsScreen(state, 110, 30).map(stripAnsi).join("\n"); + assert.match(idle, /Mark categories as Own copy \(space\),/); + assert.match(idle, /then choose where their copies come from\./); + assert.match(idle, /New own copies from +— mark a category as Own copy first/); + assert.doesNotMatch(idle, /New own copies from +Gentle Shell base/); + const active = renderEnvsScreen(press(state, char(" ")).state, 110, 30).map(stripAnsi).join("\n"); + assert.match(active, /New own copies from +Gentle Shell base/); +}); + +test("enter on the source row with nothing own explains instead of silently doing nothing", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")]); + const { state } = press(s0, char("n"), ...typed("client"), { name: "enter" }, { name: "down" }, { name: "down" }, { name: "down" }, { name: "down" }, { name: "right" }); + assert.deepEqual(state.scope?.source, { kind: "base" }, "the source does not change while nothing is own"); + assert.match(state.message ?? "", /mark a category as Own copy first/); +}); + +test("applying an unchanged config form says nothing changed", () => { + const s0 = initialEnvsState([baseEntry(), scoped("work")], undefined, "work"); + const { state, effect } = press(s0, char("c"), { name: "enter" }); + assert.equal(effect, undefined); + assert.equal(state.scope, undefined); + assert.match(state.message ?? "", /No changes/); + assert.match(state.message ?? "", /Own copy/); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts new file mode 100644 index 000000000..ac1961e90 --- /dev/null +++ b/tests/gentle-shell-envs.test.ts @@ -0,0 +1,270 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import test from "node:test"; +import { + envDir, + envNameForDir, + envsRoot, + removeEnvFromConfig, + renameEnvInConfig, + setDefaultEnvInConfig, + summarizeEnv, + validateEnvName, +} from "../lib/gentle-shell-envs.ts"; +import { homeSelectorFlags, parseLauncherArgs, resolveHome } from "../lib/gentle-shell-launcher.ts"; + +// --- names and paths ----------------------------------------------------- + +test("validateEnvName accepts lowercase names with dots, dashes and underscores", () => { + for (const name of ["work", "client-a", "v2.1", "a_b", "0x"]) assert.equal(validateEnvName(name), undefined, name); +}); + +test("validateEnvName rejects empty, dot paths, separators, uppercase and overlong names", () => { + for (const name of ["", ".", "..", ".hidden", "-x", "a/b", "a\\b", "Work", "a b", "x".repeat(41)]) { + assert.match(validateEnvName(name) ?? "", /env name/, name); + } +}); + +test("envsRoot honors GENTLE_SHELL_ENVS_DIR, else ~/.gentle-shell/envs", () => { + assert.equal(envsRoot({}, "/h"), join("/h", ".gentle-shell", "envs")); + assert.equal(envsRoot({ GENTLE_SHELL_ENVS_DIR: "/custom" }, "/h"), "/custom"); + assert.equal(envsRoot({ GENTLE_SHELL_ENVS_DIR: "" }, "/h"), join("/h", ".gentle-shell", "envs")); +}); + +test("envNameForDir maps only a direct, validly named child of the root", () => { + const root = join("/h", "envs"); + assert.equal(envNameForDir(root, envDir(root, "work")), "work"); + assert.equal(envNameForDir(root, join(root, "work", "nested")), undefined); + assert.equal(envNameForDir(root, join("/elsewhere", "work")), undefined); + assert.equal(envNameForDir(root, join(root, "Bad Name")), undefined); +}); + +// --- config mutations ---------------------------------------------------- + +test("setDefaultEnvInConfig persists the env dir as home and keeps other keys", () => { + const next = setDefaultEnvInConfig({ home: "link", other: 1 }, "/e/work"); + assert.deepEqual(next, { home: "/e/work", other: 1 }); +}); + +test("renameEnvInConfig moves provisioned markers and the default home for every alias", () => { + const marker = { gentleAi: "1", gentlePi: "2", at: "t" }; + const config = { home: "/e/old", provisioned: { "/real/e/old": marker, "/e/keep": marker }, other: true }; + const next = renameEnvInConfig(config, ["/e/old", "/real/e/old"], "/e/new"); + assert.deepEqual(next, { home: "/e/new", provisioned: { "/e/keep": marker, "/e/new": marker }, other: true }); + assert.equal(config.home, "/e/old", "never mutates its input"); +}); + +test("renameEnvInConfig keys the moved marker by the new realpath when given", () => { + const marker = { gentleAi: "1", at: "t" }; + const next = renameEnvInConfig({ home: "/e/old", provisioned: { "/real/old": marker } }, ["/e/old", "/real/old"], "/e/new", "/real/new"); + assert.deepEqual(next, { home: "/e/new", provisioned: { "/real/new": marker } }); +}); + +test("renameEnvInConfig leaves an unrelated default untouched", () => { + assert.deepEqual(renameEnvInConfig({ home: "isolated" }, ["/e/old"], "/e/new"), { home: "isolated" }); +}); + +test("removeEnvFromConfig drops the env's markers and clears it as default", () => { + const marker = { gentleAi: "1", at: "t" }; + const next = removeEnvFromConfig({ home: "/e/x", provisioned: { "/e/x": marker, "/e/y": marker } }, ["/e/x"]); + assert.deepEqual(next, { provisioned: { "/e/y": marker } }); +}); + +// --- info --------------------------------------------------------------- + +test("summarizeEnv reports packages, provider ids only, provisioning and sessions", () => { + const info = summarizeEnv({ + name: "work", + dir: "/e/work", + isDefault: true, + settingsText: JSON.stringify({ packages: ["npm:gentle-engram", { source: "npm:pi-btw" }, 42] }), + authText: JSON.stringify({ anthropic: { type: "oauth", access: "SECRET" }, openai: { key: "SECRET" } }), + provisioned: { gentleAi: "4.0.0", gentlePi: "4.0.0", at: "2026-05-01T00:00:00.000Z" }, + sessionCount: 3, + lastSessionAt: "2026-05-02T00:00:00.000Z", + }); + assert.deepEqual(info.packages, ["npm:gentle-engram", "npm:pi-btw"]); + assert.deepEqual(info.providers, ["anthropic", "openai"]); + assert.equal(info.provisioned, "gentle-ai 4.0.0 · gentle-pi 4.0.0 · 2026-05-01"); + assert.equal(info.sessionCount, 3); + assert.ok(!JSON.stringify(info).includes("SECRET"), "never leaks credential values"); +}); + +test("summarizeEnv tolerates missing or malformed files", () => { + const info = summarizeEnv({ name: "x", dir: "/e/x", isDefault: false, settingsText: "{", authText: "[]", sessionCount: 0 }); + assert.deepEqual(info.packages, []); + assert.deepEqual(info.providers, []); + assert.equal(info.provisioned, "not provisioned"); + assert.equal(info.lastSessionAt, undefined); +}); + +// --- launcher integration ---------------------------------------------- + +test("parseLauncherArgs captures --env and --env=", () => { + assert.equal(parseLauncherArgs(["--env", "work"]).env, "work"); + assert.equal(parseLauncherArgs(["--env=work", "-p", "hi"]).env, "work"); + assert.deepEqual(parseLauncherArgs(["--env=work", "-p", "hi"]).passthrough, ["-p", "hi"]); +}); + +test("parseLauncherArgs rejects a missing or invalid --env and combinations with other selectors", () => { + assert.match(parseLauncherArgs(["--env"]).error ?? "", /--env requires/); + assert.match(parseLauncherArgs(["--env", "Bad"]).error ?? "", /env name/); + assert.match(parseLauncherArgs(["--env", "a", "--link"]).error ?? "", /--env cannot be combined with --link/); + assert.match(parseLauncherArgs(["--env", "a", "--isolated"]).error ?? "", /--env cannot be combined with --isolated/); + assert.match(parseLauncherArgs(["--env", "a", "--home", "/x"]).error ?? "", /--env cannot be combined with --home/); +}); + +test("parseLauncherArgs treats argv[0] envs as the envs command", () => { + const parsed = parseLauncherArgs(["envs", "list"]); + assert.equal(parsed.command, "envs"); + assert.deepEqual(parsed.commandArgs, ["list"]); + assert.equal(parseLauncherArgs(["-p", "envs"]).command, undefined); +}); + +test("resolveHome resolves --env to its managed dir and reproduces it as --env", () => { + const home = resolveHome({ args: parseLauncherArgs(["--env", "work"]), env: {}, homedir: "/h", config: undefined }); + assert.deepEqual(home, { mode: "path", dir: join("/h", ".gentle-shell", "envs", "work"), source: "flag", env: "work" }); + assert.deepEqual(homeSelectorFlags(home), ["--env", "work"]); +}); + +test("resolveHome names a persisted default that lives in the envs root", () => { + const dir = join("/h", ".gentle-shell", "envs", "work"); + const home = resolveHome({ args: parseLauncherArgs([]), env: {}, homedir: "/h", config: { mode: "path", dir } }); + assert.equal(home.env, "work"); + assert.equal(home.source, "config"); + const plain = resolveHome({ args: parseLauncherArgs([]), env: {}, homedir: "/h", config: { mode: "path", dir: "/other" } }); + assert.equal(plain.env, undefined); + assert.deepEqual(homeSelectorFlags(plain), ["--home", "/other"]); +}); + +test("buildPiInvocation names the env for the session and never leaks an inherited one", async () => { + const { buildPiInvocation, ENV_NAME_ENV } = await import("../lib/gentle-shell-launcher.ts"); + const base = { + runtime: { kind: "path" as const, command: "pi", args: [] }, + packageRoot: "/pkg", + declaration: undefined, + takeOver: false, + otherPackagePaths: [], + passthrough: [], + homedir: "/h", + cwd: "/w", + }; + const named = buildPiInvocation({ ...base, home: { mode: "path", dir: "/h/.gentle-shell/envs/work", source: "flag", env: "work" }, baseEnv: {} }); + assert.equal(ENV_NAME_ENV, "GENTLE_SHELL_ENV"); + assert.equal(named.env.GENTLE_SHELL_ENV, "work"); + const plain = buildPiInvocation({ ...base, home: { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" }, baseEnv: { GENTLE_SHELL_ENV: "outer" } }); + assert.ok(!("GENTLE_SHELL_ENV" in plain.env), "an outer session's env name never reaches a different home"); +}); + +test("activeEnvName accepts only a valid env name from the environment", async () => { + const { activeEnvName } = await import("../lib/gentle-shell-envs.ts"); + assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "work" }), "work"); + assert.equal(activeEnvName({}), undefined); + assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "" }), undefined); + assert.equal(activeEnvName({ GENTLE_SHELL_ENV: "\x1b[31mevil" }), undefined); +}); + +test("resolveHome names an explicit --home that is an env in the envs root", () => { + const dir = join("/h", ".gentle-shell", "envs", "work"); + const home = resolveHome({ args: parseLauncherArgs(["--home", dir]), env: {}, homedir: "/h", config: undefined }); + assert.equal(home.env, "work"); + assert.deepEqual(homeSelectorFlags(home), ["--env", "work"]); + const other = resolveHome({ args: parseLauncherArgs(["--home", "/elsewhere/work"]), env: {}, homedir: "/h", config: undefined }); + assert.equal(other.env, undefined); +}); + +test("summarizeEnv reports whether the home was ever set up", () => { + assert.equal(summarizeEnv({ name: "x", dir: "/e/x", isDefault: false, sessionCount: 0 }).setUp, false); + assert.equal(summarizeEnv({ name: "x", dir: "/e/x", isDefault: false, sessionCount: 0, provisioned: { gentleAi: "1", at: "2026-01-01" } }).setUp, true); +}); + +test("buildPiInvocation scopes own config categories to the env and never leaks inherited scope", async () => { + const { buildPiInvocation } = await import("../lib/gentle-shell-launcher.ts"); + const base = { + runtime: { kind: "path" as const, command: "pi", args: [] }, + packageRoot: "/pkg", + declaration: undefined, + takeOver: false, + otherPackagePaths: [], + passthrough: [], + homedir: "/h", + cwd: "/w", + }; + const dir = join("/h", ".gentle-shell", "envs", "work"); + const own = buildPiInvocation({ ...base, home: { mode: "path", dir, source: "flag", env: "work", ownConfig: ["persona", "appearance"] }, baseEnv: {} }); + assert.equal(own.env.GENTLE_SHELL_ENV_CONFIG_HOME, join(dir, "gentle-ai")); + assert.equal(own.env.GENTLE_SHELL_ENV_OWN_CONFIG, "persona,appearance"); + const inherited = { GENTLE_SHELL_ENV_CONFIG_HOME: "/outer/gentle-ai", GENTLE_SHELL_ENV_OWN_CONFIG: "models" }; + const shared = buildPiInvocation({ ...base, home: { mode: "path", dir, source: "flag", env: "work" }, baseEnv: inherited }); + assert.ok(!("GENTLE_SHELL_ENV_CONFIG_HOME" in shared.env) && !("GENTLE_SHELL_ENV_OWN_CONFIG" in shared.env), "a sharing env clears outer scope"); + const isolated = buildPiInvocation({ ...base, home: { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" }, baseEnv: inherited }); + assert.ok(!("GENTLE_SHELL_ENV_CONFIG_HOME" in isolated.env), "the base home never inherits env scope"); +}); + +// --- per-env config scope (VS Code-like profiles) -------------------------------- + +test("env scope files are tolerant: anything unreadable means sharing everything", async () => { + const { parseEnvScope, serializeEnvScope } = await import("../lib/gentle-shell-envs.ts"); + assert.deepEqual(parseEnvScope(undefined), []); + assert.deepEqual(parseEnvScope("{"), []); + assert.deepEqual(parseEnvScope(JSON.stringify({ schema: 2, own: ["persona"] })), [], "unknown schema"); + assert.deepEqual(parseEnvScope(JSON.stringify({ schema: 1, own: ["appearance", "bogus", "persona", "persona"] })), ["persona", "appearance"]); + assert.deepEqual(parseEnvScope(serializeEnvScope(["behavior", "models"])), ["models", "behavior"]); +}); + +test("parseScopeFlags reads --own/--share/--from/--empty with all and validation", async () => { + const { parseScopeFlags } = await import("../lib/gentle-shell-envs.ts"); + assert.deepEqual(parseScopeFlags([]), { own: [], share: [], from: undefined, empty: false }); + assert.deepEqual(parseScopeFlags(["--own", "persona,appearance", "--from", "work"]), { own: ["persona", "appearance"], share: [], from: "work", empty: false }); + assert.deepEqual(parseScopeFlags(["--own=all", "--empty"]).own, ["models", "persona", "appearance", "behavior"]); + assert.deepEqual(parseScopeFlags(["--share", "all"]).share, ["models", "persona", "appearance", "behavior"]); + assert.match(parseScopeFlags(["--own", "bogus"]).error ?? "", /unknown config category "bogus"/); + assert.match(parseScopeFlags(["--own"]).error ?? "", /--own needs/); + assert.match(parseScopeFlags(["--from", "work", "--empty"]).error ?? "", /--from and --empty/); + assert.match(parseScopeFlags(["--own", "persona", "--share", "persona"]).error ?? "", /both own and shared/); + assert.match(parseScopeFlags(["--bogus"]).error ?? "", /unknown option/); + assert.match(parseScopeFlags(["--from", "Bad"]).error ?? "", /env name/); +}); + +test("planScopeChange seeds only categories that become own, from a source or empty", async () => { + const { planScopeChange } = await import("../lib/gentle-shell-envs.ts"); + const plan = planScopeChange({ current: ["persona"], own: ["persona", "appearance", "models"], share: [], seed: { kind: "copy" } }); + assert.deepEqual(plan.next, ["models", "persona", "appearance"]); + assert.deepEqual(plan.seed, ["models", "appearance"], "persona was already own: its files are kept"); + const back = planScopeChange({ current: ["persona", "models"], own: [], share: ["models"], seed: { kind: "empty" } }); + assert.deepEqual(back.next, ["persona"]); + assert.deepEqual(back.seed, []); +}); + +test("seedOperations copies existing source files and clears the rest, never outside the category", async () => { + const { seedOperations } = await import("../lib/gentle-shell-envs.ts"); + const exists = (path: string) => ["/src/persona.json", "/src/card-style.json"].includes(path); + const ops = seedOperations({ categories: ["persona", "appearance"], mode: "copy", sourceDirFor: () => "/src", targetDir: "/t", exists }); + assert.deepEqual(ops.filter((op) => op.kind === "copy"), [ + { kind: "copy", from: join("/src", "persona.json"), to: join("/t", "persona.json") }, + { kind: "copy", from: join("/src", "card-style.json"), to: join("/t", "card-style.json") }, + ]); + assert.ok(ops.some((op) => op.kind === "remove" && op.path === join("/t", "banner.json")), "a source default resets the target"); + assert.ok(ops.every((op) => !(op.kind === "remove" && op.path.endsWith("models.json"))), "other categories untouched"); + const empty = seedOperations({ categories: ["persona"], mode: "empty", sourceDirFor: () => "/src", targetDir: "/t", exists }); + assert.deepEqual(empty, [{ kind: "remove", path: join("/t", "persona.json") }]); +}); + +test("describeEnvScope reads like a sentence for the gate", async () => { + const { describeEnvScope } = await import("../lib/gentle-shell-envs.ts"); + assert.equal(describeEnvScope([]), "shares everything with the base home"); + assert.equal(describeEnvScope(["models", "persona", "appearance", "behavior"]), "fully own (shares nothing)"); + assert.equal(describeEnvScope(["persona", "appearance"]), "own persona, appearance · shares models & profiles, behavior"); +}); + +test("summarizeEnv carries the env's own config categories to the gate", () => { + assert.deepEqual(summarizeEnv({ name: "w", dir: "/e/w", isDefault: false, sessionCount: 0, own: ["persona"] }).own, ["persona"]); + assert.deepEqual(summarizeEnv({ name: "w", dir: "/e/w", isDefault: false, sessionCount: 0 }).own, []); +}); + +test("categoriesToSeed skips categories that already have own files kept on disk", async () => { + const { categoriesToSeed } = await import("../lib/gentle-shell-envs.ts"); + const kept = (path: string) => path === join("/t", "persona.json"); + assert.deepEqual(categoriesToSeed(["persona", "appearance"], "/t", kept), ["appearance"]); + assert.deepEqual(categoriesToSeed(["persona"], "/t", () => false), ["persona"]); +}); diff --git a/tests/gentle-shell-launcher.test.ts b/tests/gentle-shell-launcher.test.ts index 66a3519fe..11a4c0133 100644 --- a/tests/gentle-shell-launcher.test.ts +++ b/tests/gentle-shell-launcher.test.ts @@ -58,6 +58,7 @@ test("parseLauncherArgs returns all-false defaults for an empty argv", () => { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, diff --git a/tests/gentle-shell-startup-update.test.ts b/tests/gentle-shell-startup-update.test.ts index f85bf6975..cbb11a774 100644 --- a/tests/gentle-shell-startup-update.test.ts +++ b/tests/gentle-shell-startup-update.test.ts @@ -8,13 +8,13 @@ const eligible = { }; test("ordinary bundled interactive launch and resume are eligible", () => { - for (const argv of [[], ["--resume"], ["-r"], ["--continue"], ["-c"]]) { + for (const argv of [[], ["--resume"], ["-r"], ["--continue"], ["-c"], ["--env", "work"]]) { assert.equal(shouldCheckStartupUpdate({ ...eligible, args: parseLauncherArgs(argv) }), true); } }); test("commands, overrides, non-TTY runs and child/offline opt-outs are excluded", () => { - for (const argv of [["upgrade"], ["home"], ["setup"], ["install", "npm:x"], ["--help"], ["--version"], ["--package-root", "/custom"]]) { + for (const argv of [["upgrade"], ["home"], ["envs"], ["setup"], ["install", "npm:x"], ["--help"], ["--version"], ["--package-root", "/custom"]]) { assert.equal(shouldCheckStartupUpdate({ ...eligible, args: parseLauncherArgs(argv) }), false, argv.join(" ")); } for (const overrides of [{ bundled: false }, { inputTTY: false }, { outputTTY: false }, { args: { ...eligible.args, error: "invalid arguments" } }]) { diff --git a/tests/gentle-shell-uninstall.test.ts b/tests/gentle-shell-uninstall.test.ts index 8edfd68b5..354c5e61b 100644 --- a/tests/gentle-shell-uninstall.test.ts +++ b/tests/gentle-shell-uninstall.test.ts @@ -408,6 +408,104 @@ test("custom --home homes and the user's Pi home are listed as not touched", asy } finally { w.cleanup(); } }); +test("named envs are listed once as not touched and kept, even as the default or provisioned home", async () => { + const w = world(); + try { + const envs = join(w.home, ".gentle-shell", "envs"); + const work = join(envs, "work"); + const personal = join(envs, "personal"); + for (const dir of [work, personal]) write(join(dir, ".gentle-shell-home")); + write(w.paths.launcherConfig, JSON.stringify({ home: work, provisioned: { [personal]: { gentleAi: "4.0.0", at: "now" } } })); + assert.equal(await w.uninstall(["--yes"]), 0); + const plan = w.out.join("\n"); + assert.ok(plan.includes(`${envs} (your named envs)`), plan); + assert.doesNotMatch(plan, /envs[\\/]work +\(custom --home home\)/); + for (const dir of [work, personal]) assert.equal(existsSync(join(dir, ".gentle-shell-home")), true, dir); + } finally { w.cleanup(); } +}); + +test("a symlinked named envs root is still listed as not touched and kept", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-envs"); + write(join(real, "work", ".gentle-shell-home")); + symlinkSync(real, join(w.home, ".gentle-shell", "envs")); + assert.equal(await w.uninstall(["--yes"]), 0); + assert.ok(w.out.join("\n").includes(`${join(w.home, ".gentle-shell", "envs")} (your named envs)`)); + assert.equal(existsSync(join(real, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + +test("a named envs root reached through a symlinked folder inside a removed home is refused", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-parent"); + write(join(real, "envs", "work", ".gentle-shell-home")); + const alias = join(w.isolated, "alias"); + symlinkSync(real, alias); + await refused(w, { GENTLE_SHELL_ENVS_DIR: join(alias, "envs") }, /your named envs/); + assert.equal(lstatSync(alias).isSymbolicLink(), true); + assert.equal(existsSync(join(real, "envs", "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + +test("a named envs root symlink written with a trailing slash is still guarded as a link", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-envs"); + write(join(real, "work", ".gentle-shell-home")); + const link = join(w.isolated, "envs"); + symlinkSync(real, link); + await refused(w, { GENTLE_SHELL_ENVS_DIR: `${link}/` }, /your named envs/); + assert.equal(lstatSync(link).isSymbolicLink(), true); + } finally { w.cleanup(); } +}); + +test("a named envs root named like '..envs' inside a removed home is still inside it", async () => { + const w = world(); + try { + const envs = join(w.isolated, "..envs"); + write(join(envs, "work", ".gentle-shell-home")); + await refused(w, { GENTLE_SHELL_ENVS_DIR: envs }, /your named envs/); + assert.equal(existsSync(join(envs, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + +test("a named envs root symlink inside a removed home is refused even when its target is outside", posixOnly, async () => { + const w = world(); + try { + const real = join(w.root, "real-envs"); + write(join(real, "work", ".gentle-shell-home")); + const link = join(w.isolated, "envs"); + symlinkSync(real, link); + await refused(w, { GENTLE_SHELL_ENVS_DIR: link }, /your named envs/); + assert.equal(lstatSync(link).isSymbolicLink(), true); + assert.equal(existsSync(join(real, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + +test("an isolated home inside the named envs is refused, never removed", async () => { + const w = world(); + try { + const work = join(w.home, ".gentle-shell", "envs", "work"); + write(join(work, ".gentle-shell-home")); + write(join(work, "auth.json"), "{}"); + await refused(w, { GENTLE_SHELL_HOME: work }, /your named envs/); + assert.equal(existsSync(join(work, "auth.json")), true); + } finally { w.cleanup(); } +}); + +test("a named envs root overridden into the isolated home as a '..'-prefixed folder is refused, never removed", async () => { + const w = world(); + try { + // `..envs` is an ordinary child name, not parent traversal. + const envs = join(w.paths.isolated, "..envs"); + write(join(envs, "work", ".gentle-shell-home")); + await refused(w, { GENTLE_SHELL_ENVS_DIR: envs }, /your named envs/); + assert.equal(existsSync(join(envs, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("tools the installer may have added are listed with how to remove them, never removed", async () => { for (const platform of ["darwin", "win32"]) { const w = world(); @@ -915,6 +1013,88 @@ test("bundled: a Pi home inside the prefix is protected, except the session's ow } finally { w.cleanup(); } }); +test("bundled: named envs in the prefix survive removing every other prefix entry", posixOnly, async () => { + const w = bundledWorld(); + try { + const env = join(w.prefix, "envs", "work", ".gentle-shell-home"); + write(env); + assert.equal(await w.uninstall(["--yes", "--include-shared"]), 0, w.err.join("\n")); + assert.equal(existsSync(env), true); + assert.deepEqual(readdirSync(w.prefix), ["envs"], "only the named envs keep the prefix"); + assert.doesNotMatch(section(w.out, "Kept:").join("\n"), /not written by the bundled install/); + assert.ok(section(w.out, "Not touched:").join("\n").includes(`${join(w.prefix, "envs")} (your named envs)`)); + } finally { w.cleanup(); } +}); + +test("bundled: a removal that overlaps the named envs, even a '..'-prefixed one, refuses the whole run", posixOnly, async () => { + for (const envs of [(w: BundledWorld) => join(w.prefix, "versions", "envs"), (w: BundledWorld) => join(w.shared.agent, "..envs")]) { + const w = bundledWorld(); + try { + const root = envs(w); + write(join(root, "work", ".gentle-shell-home")); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: root } }), 1, root); + assert.match(w.err.join("\n"), /your named envs/, root); + bundledUntouched(w); + assert.equal(existsSync(join(root, "work", ".gentle-shell-home")), true, root); + } finally { w.cleanup(); } + } +}); + +test("bundled: a symlinked named envs root whose target a removal holds refuses the whole run", posixOnly, async () => { + const w = bundledWorld(); + try { + const target = join(w.prefix, "versions", "envs"); + write(join(target, "work", ".gentle-shell-home")); + const link = join(w.base, "linked-envs"); + symlinkSync(target, link); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: link } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(existsSync(join(target, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + +test("bundled: a named envs root reached through a symlinked folder inside a removal refuses the whole run", posixOnly, async () => { + const w = bundledWorld(); + try { + const real = join(w.base, "real-parent"); + write(join(real, "envs", "work", ".gentle-shell-home")); + const alias = join(w.prefix, "versions", "alias"); + symlinkSync(real, alias); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: join(alias, "envs") } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(lstatSync(alias).isSymbolicLink(), true); + } finally { w.cleanup(); } +}); + +test("bundled: a named envs root named like '..envs' inside a removal refuses the whole run", posixOnly, async () => { + const w = bundledWorld(); + try { + const envs = join(w.prefix, "versions", "..envs"); + write(join(envs, "work", ".gentle-shell-home")); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: envs } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(existsSync(join(envs, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + +test("bundled: a named envs root symlink inside a removal refuses the whole run even when its target is outside", posixOnly, async () => { + const w = bundledWorld(); + try { + const target = join(w.base, "real-envs"); + write(join(target, "work", ".gentle-shell-home")); + const link = join(w.prefix, "versions", "envs"); + symlinkSync(target, link); + assert.equal(await w.uninstall(["--yes", "--include-shared"], { env: { GENTLE_SHELL_ENVS_DIR: link } }), 1); + assert.match(w.err.join("\n"), /your named envs/); + bundledUntouched(w); + assert.equal(lstatSync(link).isSymbolicLink(), true); + assert.equal(existsSync(join(target, "work", ".gentle-shell-home")), true); + } finally { w.cleanup(); } +}); + test("bundled: in a terminal one question confirms, the shared home is asked separately and kept by default; EOF cancels and Ctrl-C aborts", posixOnly, async () => { const cancelled = bundledWorld(); try { diff --git a/tests/history-env-scope.test.ts b/tests/history-env-scope.test.ts new file mode 100644 index 000000000..c41a2334c --- /dev/null +++ b/tests/history-env-scope.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { captureEnabled } from "../extensions/history/index.ts"; + +// An env that owns `behavior` keeps history-capture.json in its own config +// home: the capture gate must read that file, not the shared one. +test("history capture follows the env's own behavior scope", (t) => { + const root = mkdtempSync(join(tmpdir(), "history-env-scope-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = join(root, "shared"); + const own = join(root, "env", "gentle-ai"); + for (const [dir, policy] of [[shared, "off"], [own, "on"]] as const) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "history-capture.json"), JSON.stringify({ schema: "gentle-pi.history-capture/v1", policy })); + } + const base = { GENTLE_PI_CONFIG_HOME: shared }; + assert.equal(captureEnabled(base), false, "no scope: the shared preference"); + assert.equal(captureEnabled({ ...base, GENTLE_SHELL_ENV_CONFIG_HOME: own, GENTLE_SHELL_ENV_OWN_CONFIG: "behavior" }), true); + assert.equal(captureEnabled({ ...base, GENTLE_SHELL_ENV_CONFIG_HOME: own, GENTLE_SHELL_ENV_OWN_CONFIG: "appearance" }), false, "behavior shared"); +}); diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index f2a2e9b5a..7f6041a31 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -863,3 +863,77 @@ test("the narrow-layout bottom-only bar stays unchanged in the float style", (t) useCardStyle(t, CARD_STYLE.FLOAT); assert.deepEqual(renderShellBottomOnlyBar(data, theme, 100, "alt+u"), neon); }); + +// --- named env (GENTLE_SHELL_ENV, lib/gentle-shell-envs.ts) ------------------ + +test("buildShellHeaderModel carries the env only when a named env is active", () => { + assert.equal(buildShellHeaderModel(model({ env: "work" })).env, "work"); + assert.ok(!("env" in buildShellHeaderModel(model()))); +}); + +test("renderShellHeaderBar shows the env right after the brand", () => { + const { text } = renderShellHeaderBar(buildShellHeaderModel(model({ env: "work", profile: "team" })), plainTheme, 140); + assert.ok(text.startsWith("✿ Gentle Shell ⟡ env work ⟡ ~/work/gentle-pi main"), text); + const { text: plain } = renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 140); + assert.doesNotMatch(plain, /env /); +}); + +test("renderShellHeaderBar keeps the env after profile, effort and location are dropped", () => { + const header = buildShellHeaderModel(model({ env: "work", profile: "team" })); + for (const width of [100, 90, 70]) { + const { text } = renderShellHeaderBar(header, plainTheme, width); + assert.equal(visibleWidth(text), width); + assert.match(text, /^✿ Gentle Shell ⟡ env work/, `width ${width}: ${text}`); + } +}); + +test("renderShellHeaderBar keeps the env over the brand when only one of them fits", () => { + const header = buildShellHeaderModel(model({ env: "work", profile: "team" })); + for (const width of [12, 16, 24]) { + const { text } = renderShellHeaderBar(header, plainTheme, width); + assert.ok(visibleWidth(text) <= width, `width ${width}`); + assert.match(text, /^env work/, `width ${width}: ${text}`); + } + // Without an env the narrow header still shows the brand. + assert.match(renderShellHeaderBar(buildShellHeaderModel(model()), plainTheme, 16).text, /^\u273f Gentle Shell/); +}); + +test("the narrowest header keeps the env beside the running job count", () => { + const data = model({ env: "work", jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] }); + for (const width of [20, 24]) { + const text = renderShellHeaderBar(buildShellHeaderModel(data, true), plainTheme, width).text; + assert.ok(visibleWidth(text) <= width, `width ${width}: ${text}`); + assert.match(text, /^env work/, `width ${width}: ${text}`); + assert.match(text, /\u29d7 1 job$/, `width ${width}: ${text}`); + } +}); + +test("minimal density hides the brand but still names the env", () => { + const settings = { ...DEFAULT_VISUAL_SETTINGS, density: "minimal" as const }; + const { text } = renderShellHeaderBar(buildShellHeaderModel(model({ env: "work" })), plainTheme, 140, undefined, settings); + assert.doesNotMatch(text, /Gentle Shell/); + assert.match(text, /^env work/); +}); + +test("regular-mode bar names the env right after the brand and keeps it when narrowed", () => { + const [line] = renderShellBar(model({ env: "work" }), plainTheme, 160); + assert.ok(line.startsWith("✿ gentle shell ⟡ env work ⟡ ~/work/gentle-pi main"), line); + const [narrow] = renderShellBar(model({ env: "work" }), plainTheme, 30); + assert.match(narrow, /env work/); + // Narrower than brand plus env: the brand gives way, never the env. + for (const width of [12, 20]) assert.equal(renderShellBar(model({ env: "work" }), plainTheme, width)[0], "env work", `width ${width}`); + const [plain] = renderShellBar(model(), plainTheme, 160); + assert.doesNotMatch(plain, /env /); + const settings = { ...DEFAULT_VISUAL_SETTINGS, density: "minimal" as const }; + assert.match(renderShellBar(model({ env: "work" }), plainTheme, 160, settings)[0]!, /^env work/); +}); + +test("sidebar Status card shows the env as its own group above Project", () => { + const lines = renderShellSidebarBar(model({ env: "work" }), plainTheme, 60).map((line) => line.replace(/[│▎]/g, "").trim()); + const env = lines.indexOf("Env"); + const project = lines.indexOf("Project"); + assert.ok(env >= 0 && project > env, lines.join("\n")); + assert.ok(lines.slice(env + 1, project).includes("work"), lines.join("\n")); + assert.ok(!lines.slice(project).some((line) => /^Env\b/.test(line)), "no Env line left inside Project"); + assert.ok(!renderShellSidebarBar(model(), plainTheme, 60).some((line) => /\bEnv\b/.test(line))); +}); diff --git a/tests/standalone-config-home.test.ts b/tests/standalone-config-home.test.ts index 57a36e945..1d79e5704 100644 --- a/tests/standalone-config-home.test.ts +++ b/tests/standalone-config-home.test.ts @@ -322,3 +322,35 @@ test("standalone setup gives Gentle AI the config pair and keeps both persona fi assert.equal(readFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "utf8"), "{\"mode\":\"neutral\"}"); assert.equal(readFileSync(join(w.shellConfig, "persona.json"), "utf8"), "{\"mode\":\"neutral\"}", "the migrated copy is restored too"); }); + +// --- named envs share the base home's config (#2259) ----------------------------- + +test("a named env launch shares the base isolated home's config folder, and only that", () => { + const env: ResolvedHome = { mode: "path", dir: "/os-home/.gentle-shell/envs/work", source: "flag", env: "work" }; + assert.equal(launch(env).env[MARKER], "/os-home/.gentle-shell/gentle-ai", "the base home's own config, not ~/.pi/gentle-ai"); + assert.equal(MARKER in launch(env, { GENTLE_SHELL_HOME: "/custom/shell/agent" }).env, false, "a base without its own folder keeps the shared one"); + assert.equal(MARKER in launch({ mode: "path", dir: "/custom/home", source: "flag" }).env, false, "a plain --home is not an env"); + assert.equal(MARKER in launch(env, {}, true).env, false, "bundled resolves its own prefix config"); +}); + +test("named envs read, seed from and migrate the base home's config, not ~/.pi/gentle-ai", posixOnly, () => { + const w = launcherWorld(); + mkdirSync(w.shellConfig, { recursive: true }); + writeFileSync(join(w.shellConfig, "persona.json"), "{\"persona\":\"base\"}\n"); + writeFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "{\"persona\":\"pi\"}\n"); + assert.equal(w.run(["envs", "new", "work"]).status, 0); + const launched = w.run(["--env", "work", "list"]); + assert.equal(launched.status, 0, launched.stderr); + assert.deepEqual(JSON.parse(launched.stdout.trim().split("\n").at(-1)!), { marker: w.shellConfig, explicit: null }); + const created = w.run(["envs", "new", "own", "--own", "persona"]); + assert.equal(created.status, 0, created.stderr); + assert.equal(readFileSync(join(w.home, ".gentle-shell", "envs", "own", "gentle-ai", "persona.json"), "utf8"), "{\"persona\":\"base\"}\n", "seeded from the base home"); +}); + +test("an env launched before the base home ever was still gets the base config migrated", posixOnly, () => { + const w = launcherWorld(); + assert.equal(w.run(["envs", "new", "work"]).status, 0); + const launched = w.run(["--env", "work", "list"]); + assert.equal(launched.status, 0, launched.stderr); + assert.equal(readFileSync(join(w.shellConfig, "banner.json"), "utf8"), "{\"showTextLogo\":false}\n"); +});