diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 2ffcc0892..99325a01b 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, @@ -105,6 +106,17 @@ 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 { + 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 +418,7 @@ function emptyArgs() { link: false, isolated: false, home: undefined, + env: undefined, packageRoot: undefined, help: false, version: false, @@ -689,6 +702,360 @@ async function handleImportPiCommand(args) { process.exitCode = result.failed ? 1 : 0; } +// --- named envs (`gentle-shell envs`, lib/gentle-shell-envs*.ts) ------------- + +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), {}))]; +} + +// 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)])]; + rmSync(dir, { recursive: true, force: true }); + const configPath = resolveConfigPath(); + writeRawConfig(configPath, removeEnvFromConfig(readRawConfig(configPath), aliases)); +} + +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 ]\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"; + +// 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 invalid = validateEnvName(rest[0]); + if (invalid !== undefined) fail(`gentle-shell: ${invalid}`, 2); + try { + process.stdout.write(`Created env ${rest[0]} at ${await createEnv(root, rest[0])}\n`); + } catch (error) { + fail(`gentle-shell: ${error.message}`, 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") { + 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") { + await createEnv(root, effect.name); + return reload(`Created ${effect.name}. Press enter to launch it (first launch installs the companion packages).`, 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 @@ -1896,6 +2263,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 +2309,13 @@ 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); + } // Version and help runs (Shell's own or forwarded to Pi) never probe Engram, // ensure the companion, print memory notices or touch provenance. 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..afd9c4a6f 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 ] 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,23 @@ 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, `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. `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), 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. They share the repository you work in, `~/.pi/gentle-ai/persona.json`, 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. + ### 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-shell.ts b/extensions/gentle-shell.ts index 5996188e9..e500d966c 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; @@ -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() { @@ -1923,13 +1928,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, }); diff --git a/extensions/startup-banner.ts b/extensions/startup-banner.ts index ea1aa5ffb..1e6696c68 100644 --- a/extensions/startup-banner.ts +++ b/extensions/startup-banner.ts @@ -7,6 +7,7 @@ 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"; export type BannerColor = "pink" | "cyan" | "yellow" | "green"; @@ -42,26 +43,6 @@ 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`; } @@ -665,7 +646,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/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-shell-envs-tui.ts b/lib/gentle-shell-envs-tui.ts new file mode 100644 index 000000000..3ec1b2585 --- /dev/null +++ b/lib/gentle-shell-envs-tui.ts @@ -0,0 +1,376 @@ +import { BRAND_GLYPH, BRAND_NAME, GENTLE_COLORS, ROSE_ART, type Rgb, truecolor } from "./gentle-brand.ts"; +import { 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: "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 } + | { kind: "create"; name: string } + | { 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; +} + +export interface EnvsState { + envs: EnvSummary[]; + selected: number; + prompt?: EnvsPrompt; + // 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" }); + 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`); + return done(prompt.kind === "create" ? { kind: "create", name: value } : { 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 }); +} + +export function reduceEnvs(state: EnvsState, key: EnvsKey): EnvsStep { + if (state.prompt !== undefined) return reducePrompt(state, state.prompt, 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 && "sparid".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 === "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("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 })), + ]; +} + +const HINTS: Array<[string, string]> = [ + ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["q", "quit"], +]; + +// Wrapped, never truncated: every action stays discoverable at any width. +function hintLines(width: number, paint: Paint): 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 { + 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 details = 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 detail = card(selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name, 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..fb5210096 --- /dev/null +++ b/lib/gentle-shell-envs.ts @@ -0,0 +1,162 @@ +import { basename, dirname, join, resolve as resolvePath } from "node:path"; + +// 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; +} + +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; +} + +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 } : {}), + }; +} diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index 37a47273e..821e718ef 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1,12 +1,15 @@ 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"; + +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 +26,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 +47,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 +67,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 +126,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 +189,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 +204,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 +216,8 @@ 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; } // A discriminated union instead of a plain `home: string` field: `resolveHome` @@ -299,12 +330,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 +357,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 []; } @@ -1092,6 +1130,11 @@ export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation { const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); 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; if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); @@ -1290,6 +1333,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 ]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", @@ -1304,6 +1348,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 +1361,10 @@ 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 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 +1417,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/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/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-shell-envs-tui.mjs b/runtime/gentle-shell-envs-tui.mjs new file mode 100644 index 000000000..5de1eb388 --- /dev/null +++ b/runtime/gentle-shell-envs-tui.mjs @@ -0,0 +1,377 @@ +// 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 { 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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +// `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" }); + 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`); + return done(prompt.kind === "create" ? { kind: "create", name: value } : { 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 }); +} + +export function reduceEnvs(state , key ) { + if (state.prompt !== undefined) return reducePrompt(state, state.prompt, 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 && "sparid".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 === "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("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 })), + ]; +} + +const HINTS = [ + ["enter", "open"], ["n", "new"], ["r", "rename"], ["d", "delete"], ["s", "default"], + ["p", "setup"], ["i", "install"], ["a", "sign-ins"], ["q", "quit"], +]; + +// Wrapped, never truncated: every action stays discoverable at any width. +function hintLines(width , paint ) { + 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 { + 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 details = 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 detail = card(selectedEnv === undefined ? "Welcome" : selectedEnv.base ? "Base home" : selectedEnv.name, 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..9920540e0 --- /dev/null +++ b/runtime/gentle-shell-envs.mjs @@ -0,0 +1,163 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { basename, dirname, join, resolve as resolvePath } from "node:path"; + +// 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 } : {}), + }; +} diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index ad8d14d8f..541585a94 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -2,6 +2,9 @@ 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"; + +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 +34,8 @@ function isPiSubcommand(token ) { + +   @@ -43,11 +48,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 +68,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 +127,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 +190,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 +205,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 +219,8 @@ 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 @@ -300,12 +331,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 +358,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 []; } @@ -1093,6 +1131,11 @@ export function buildPiInvocation(input ) { const configHome = input.bundled === true ? undefined : standaloneConfigHome(input.home); 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; if (childInjection === undefined) delete env[CHILD_PACKAGE_INJECTION_ENV]; else env[CHILD_PACKAGE_INJECTION_ENV] = encodeChildPackageInjection(childInjection); @@ -1291,6 +1334,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 ]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", " gentle-shell upgrade --rollback", @@ -1305,6 +1349,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 +1362,10 @@ 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 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 +1418,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..f32ed78b4 100644 --- a/scripts/build-runtime-modules.mjs +++ b/scripts/build-runtime-modules.mjs @@ -15,6 +15,9 @@ const sources = [ "status-timing-diagnostics", "telemetry-trigger", "child-package-injection", + "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..6e70b2600 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -86,6 +86,9 @@ export const requiredPaths = [ "extensions/skill-registry.ts", "lib/child-package-injection.ts", "lib/gentle-ai-binary.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 +107,9 @@ export const requiredPaths = [ "lib/session-scratch.ts", "runtime/child-package-injection.mjs", "runtime/gentle-ai-binary.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/gentle-shell-bin.test.ts b/tests/gentle-shell-bin.test.ts index 9ddcc5f79..940be6010 100644 --- a/tests/gentle-shell-bin.test.ts +++ b/tests/gentle-shell-bin.test.ts @@ -3513,3 +3513,171 @@ 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 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"]); +}); 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..51d358918 --- /dev/null +++ b/tests/gentle-shell-envs-tui.test.ts @@ -0,0 +1,291 @@ +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"); +}); + +// --- 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 ok = press(s0, char("n"), ...typed("perzonal"), { name: "backspace" }, { name: "backspace" }, ...typed("al"), { name: "enter" }); + assert.deepEqual(ok.effect, { kind: "create", name: "perzonal".slice(0, -2) + "al" }); + assert.equal(ok.state.prompt, 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" }).effect, { kind: "create", name: "base" }); +}); + +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/); +}); diff --git a/tests/gentle-shell-envs.test.ts b/tests/gentle-shell-envs.test.ts new file mode 100644 index 000000000..b2a24ef16 --- /dev/null +++ b/tests/gentle-shell-envs.test.ts @@ -0,0 +1,179 @@ +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); +}); 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/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))); +});