From 56661201377cf71999cb3223dfbb9c78ee373995 Mon Sep 17 00:00:00 2001 From: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:57:58 +0300 Subject: [PATCH] feat(cli): add `uninstall` command, in the CLI and the TUI menu (#135) There was no documented way to remove Atomic Agent. The README covered installing in one line and said nothing about the reverse, and the FAQ's answer ("delete the binary, delete the state directory") missed most of what the installers actually write. The installers lay down three separate things: the binary plus six asset trees beside it, a PATH block in a shell rc file, and the state directory. Only the first is obvious to a user; the other two are left behind by any manual cleanup. `atomic-agent uninstall` removes them by scope, mirroring the flag design Hermes and OpenClaw settled on: --app binary, `atag` alias, and the installed asset trees --path the `# added by atomic-agent installer` block --state config, sessions, memory, secrets, downloaded models --all every scope, plus --dry-run and --yes The state directory is NOT in the default set. Removing the program should not destroy the work done with it, and a reinstall then resumes where the operator left off. Erasing it takes an explicit --state/--all, and the confirmation says plainly that API keys and transcripts go with it. Two guards worth naming: - The install directory is never deleted when other programs live there. The POSIX default is ~/.local/bin, which on a real machine also holds unrelated binaries, so the plan removes files it installed *by name*. A directory named after the product (the Windows default) is ours and is removed outright. - The --app scope is skipped when not running from an installed binary. Under `node`/`tsx` in a dev checkout, execPath is the Node binary, and "the files beside it" would be someone's Node install. Planning is pure and separate from execution, so --dry-run and the real run share one plan and cannot drift apart. In the TUI the same flow is `/uninstall`, or Setup in the Ctrl+P menu. The node carries a slash name, so it is clickable, keyboard-selectable and searchable in the palette through the existing registry projection rather than a second dispatch path. It deliberately claims no chord: a destructive flow should not be one keystroke behind the leader. The confirm dialog renders the concrete plan instead of a bare "are you sure?", toggles the state scope on `s`, and locks while the removal is in flight so a repeated `y` cannot fire it twice. Tests: the planner (scope selection, the shared-directory guard, PATH block stripping, partial-failure reporting), the CLI parser, the reducer, and a full-app render exercising the real keystrokes. Closes #135 Co-Authored-By: Claude Opus 5 --- README.md | 31 +++ src/cli/index.ts | 9 +- src/cli/uninstall-command.test.ts | 48 ++++ src/cli/uninstall-command.ts | 213 +++++++++++++++ src/tui/app-key-bindings.ts | 56 ++++ src/tui/commands/slash-command-handler.ts | 11 + src/tui/components/uninstall-confirm.tsx | 92 +++++++ src/tui/menu/menu-registry.test.ts | 6 + src/tui/menu/menu-registry.ts | 14 + src/tui/reduce-ui-actions.ts | 64 +++++ src/tui/submit-handler.ts | 5 + src/tui/tui-action.ts | 23 ++ src/tui/tui-app.tsx | 141 +++++++++- src/tui/tui-state.ts | 26 ++ src/tui/uninstall-confirm-app.test.tsx | 120 ++++++++ src/tui/uninstall-confirm-reducer.test.ts | 109 ++++++++ src/uninstall/index.ts | 26 ++ src/uninstall/run-uninstall.ts | 98 +++++++ src/uninstall/uninstall-plan.test.ts | 246 +++++++++++++++++ src/uninstall/uninstall-plan.ts | 316 ++++++++++++++++++++++ 20 files changed, 1652 insertions(+), 2 deletions(-) create mode 100644 src/cli/uninstall-command.test.ts create mode 100644 src/cli/uninstall-command.ts create mode 100644 src/tui/components/uninstall-confirm.tsx create mode 100644 src/tui/uninstall-confirm-app.test.tsx create mode 100644 src/tui/uninstall-confirm-reducer.test.ts create mode 100644 src/uninstall/index.ts create mode 100644 src/uninstall/run-uninstall.ts create mode 100644 src/uninstall/uninstall-plan.test.ts create mode 100644 src/uninstall/uninstall-plan.ts diff --git a/README.md b/README.md index 9118304..364a705 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,37 @@ atag > [!TIP] > Coming from Hermes or OpenClaw? Run `/import` in the TUI for a one-shot migration: sessions, cron jobs, and optionally your provider keys. +### Uninstall + +```bash +atomic-agent uninstall +``` + +This removes the binary, the `atag` alias, and the asset folders the installer wrote beside them, then drops the `PATH` line it appended to your shell config. Your state directory (`~/.atomic-agent` by default) is **kept**, so reinstalling later picks up your sessions, memory and config where you left off. + +To preview without changing anything: + +```bash +atomic-agent uninstall --dry-run --all +``` + +To erase everything, including sessions, memory and stored API keys: + +```bash +atomic-agent uninstall --all +``` + +Scopes are `--app`, `--path` and `--state` (`--all` selects all three); add `--yes` to skip the confirmation in scripts. The same flow is available inside the TUI as `/uninstall`, or under **Setup** in the `Ctrl+P` menu. + +> [!NOTE] +> The install directory itself is never deleted when other programs live there. On the default macOS and Linux install (`~/.local/bin`) only the files listed above are removed; your other tools are left alone. + +To do it by hand instead, there are three things to remove: + +1. From the install directory (`~/.local/bin` by default, `%LOCALAPPDATA%\atomic-agent` on Windows): the `atomic-agent` binary, the `atag` alias, and the `grammars/`, `starter-skills/`, `assets/`, `vendor/`, `prebuilds/` and `node_modules/` folders. +2. The `# added by atomic-agent installer` block from your shell rc file. On Windows the installer edits the user `PATH` in the registry instead, so remove that entry from Settings > Environment Variables. +3. The state directory, if you want your data gone too. + ### Troubleshooting If something isn't working: diff --git a/src/cli/index.ts b/src/cli/index.ts index 5f00c1d..8421d79 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,7 @@ import { traceCommand } from "./trace-command.js"; import { taskCommand } from "./task-command.js"; import { modelsCommand } from "./models-command.js"; import { importCommand } from "./import-command.js"; +import { uninstallCommand } from "./uninstall-command.js"; import { tuiCommand } from "../tui/index.js"; import { getAppVersion } from "../version.js"; @@ -105,6 +106,12 @@ const COMMANDS: CommandDescriptor[] = [ summary: "Import conversation history + cron jobs from another agent (hermes)", run: importCommand, }, + { + name: "uninstall", + summary: + "Remove Atomic Agent from this machine (--app|--path|--state|--all, --dry-run)", + run: uninstallCommand, + }, ]; function printHelp(): void { @@ -117,7 +124,7 @@ function printHelp(): void { "", "Commands:", ...COMMANDS.filter((c) => !c.hidden).map( - (c) => ` ${c.name.padEnd(8)} ${c.summary}`, + (c) => ` ${c.name.padEnd(9)} ${c.summary}`, ), "", "User config (edit via `atomic-agent config`):", diff --git a/src/cli/uninstall-command.test.ts b/src/cli/uninstall-command.test.ts new file mode 100644 index 0000000..c40ef5f --- /dev/null +++ b/src/cli/uninstall-command.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { parseUninstallArgs } from "./uninstall-command.js"; + +describe("parseUninstallArgs", () => { + it("defaults to app + path — the state directory is never implied", () => { + const parsed = parseUninstallArgs([]); + expect([...parsed.scopes].sort()).toEqual(["app", "path"]); + expect(parsed.scopes).not.toContain("state"); + }); + + it("--all selects every scope", () => { + const parsed = parseUninstallArgs(["--all"]); + expect([...parsed.scopes].sort()).toEqual(["app", "path", "state"]); + }); + + it("named scopes replace the default set", () => { + const parsed = parseUninstallArgs(["--state"]); + expect(parsed.scopes).toEqual(["state"]); + }); + + it("accepts repeated scopes without duplicating them", () => { + const parsed = parseUninstallArgs(["--app", "--app", "--path"]); + expect([...parsed.scopes].sort()).toEqual(["app", "path"]); + }); + + it("parses --dry-run and both spellings of --yes", () => { + expect(parseUninstallArgs(["--dry-run"]).dryRun).toBe(true); + expect(parseUninstallArgs(["--yes"]).yes).toBe(true); + expect(parseUninstallArgs(["-y"]).yes).toBe(true); + }); + + it("reports an unknown option instead of guessing at it", () => { + const parsed = parseUninstallArgs(["--everything"]); + expect(parsed.error).toContain("--everything"); + }); + + it("recognises the help flags", () => { + expect(parseUninstallArgs(["--help"]).help).toBe(true); + expect(parseUninstallArgs(["-h"]).help).toBe(true); + }); + + it("combines scopes with flags", () => { + const parsed = parseUninstallArgs(["--all", "--dry-run", "-y"]); + expect([...parsed.scopes].sort()).toEqual(["app", "path", "state"]); + expect(parsed.dryRun).toBe(true); + expect(parsed.yes).toBe(true); + }); +}); diff --git a/src/cli/uninstall-command.ts b/src/cli/uninstall-command.ts new file mode 100644 index 0000000..c41563f --- /dev/null +++ b/src/cli/uninstall-command.ts @@ -0,0 +1,213 @@ +import { existsSync, readFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { getConfig } from "../config/index.js"; +import { canSelfUpdate } from "../update/index.js"; +import { + buildUninstallPlan, + DEFAULT_UNINSTALL_SCOPES, + formatUninstallOutcome, + formatUninstallPlan, + installDirFromExecPath, + isEmptyPlan, + runUninstall, + UNINSTALL_SCOPES, + type UninstallScope, +} from "../uninstall/index.js"; + +const HELP = + [ + "atomic-agent uninstall — remove Atomic Agent from this machine", + "", + "Removes the binary and the asset trees installed beside it, and drops", + "the PATH line the installer appended to your shell config. Your state", + "directory (config, sessions, memory, secrets, downloaded models) is", + "KEPT unless you ask for it: reinstalling then picks up where you left", + "off. Pass --state or --all to erase it.", + "", + "Usage:", + " atomic-agent uninstall [scopes] [--dry-run] [--yes]", + "", + "Scopes (default: --app --path):", + " --app The binary plus grammars/, vendor/, node_modules/, and", + " the other trees the installer wrote next to it", + " --path The `# added by atomic-agent installer` PATH block in", + " .zshrc / .bashrc / .bash_profile / .profile / fish", + " --state The state directory — config, sessions, memory, API", + " keys in plaintext, and any downloaded model weights.", + " NOT reversible", + " --all Every scope above", + "", + "Options:", + " --dry-run Print exactly what would be removed and exit. Changes", + " nothing", + " --yes, -y Skip the confirmation prompt (for scripts)", + "", + "Examples:", + " atomic-agent uninstall --dry-run --all", + " atomic-agent uninstall", + " atomic-agent uninstall --all --yes", + "", + "Note: the install directory itself is never deleted when it holds", + "other programs (~/.local/bin is shared) — only the files listed are.", + ].join("\n") + "\n"; + +interface ParsedArgs { + readonly scopes: readonly UninstallScope[]; + readonly dryRun: boolean; + readonly yes: boolean; + readonly help: boolean; + readonly error?: string; +} + +export function parseUninstallArgs(args: readonly string[]): ParsedArgs { + const scopes = new Set(); + let dryRun = false; + let yes = false; + let help = false; + + for (const arg of args) { + switch (arg) { + case "-h": + case "--help": + help = true; + break; + case "--dry-run": + dryRun = true; + break; + case "--yes": + case "-y": + yes = true; + break; + case "--all": + for (const scope of UNINSTALL_SCOPES) scopes.add(scope); + break; + case "--app": + scopes.add("app"); + break; + case "--path": + scopes.add("path"); + break; + case "--state": + scopes.add("state"); + break; + default: + return { + scopes: [], + dryRun, + yes, + help, + error: `unknown option: ${arg}`, + }; + } + } + + return { + scopes: scopes.size > 0 ? [...scopes] : DEFAULT_UNINSTALL_SCOPES, + dryRun, + yes, + help, + }; +} + +async function confirm(question: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const answer = ( + await new Promise((resolve) => rl.question(question, resolve)) + ) + .trim() + .toLowerCase(); + return answer === "y" || answer === "yes"; + } finally { + rl.close(); + } +} + +export async function uninstallCommand(args: string[]): Promise { + const parsed = parseUninstallArgs(args); + if (parsed.help) { + process.stdout.write(HELP); + return 0; + } + if (parsed.error) { + process.stderr.write(`${parsed.error}\n\n${HELP}`); + return 2; + } + + const config = getConfig(); + const stateDir = config.paths.stateDir; + const installDir = installDirFromExecPath(process.execPath); + + // Running under `node` / `tsx` in a dev checkout: execPath is the Node + // binary, so "the files beside it" are Node's, not ours. Refuse the app + // scope rather than offering to delete someone's Node install. + const installed = canSelfUpdate(); + const scopes = installed + ? parsed.scopes + : parsed.scopes.filter((s) => s !== "app"); + + if (!installed && parsed.scopes.includes("app")) { + process.stderr.write( + "note: not running from an installed binary (this looks like a dev\n" + + " checkout), so the --app scope is skipped. Remove the checkout\n" + + " by hand.\n\n", + ); + } + + const plan = buildUninstallPlan({ + scopes, + installDir, + stateDir, + exists: existsSync, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + }); + + process.stdout.write(`${formatUninstallPlan(plan)}\n`); + + if (parsed.dryRun) { + process.stdout.write("\nDry run — nothing was changed.\n"); + return 0; + } + + if (isEmptyPlan(plan)) return 0; + + if (!parsed.yes) { + const erasesData = plan.scopes.includes("state"); + const question = erasesData + ? "\nThis permanently deletes your sessions, memory and API keys. Continue? [y/N] " + : "\nProceed? [y/N] "; + const ok = await confirm(question); + if (!ok) { + process.stdout.write("Aborted — nothing was changed.\n"); + return 1; + } + } + + const outcome = runUninstall(plan); + process.stdout.write(`\n${formatUninstallOutcome(outcome)}\n`); + + if (outcome.failures.length > 0) { + process.stderr.write( + "\nsome items could not be removed (see 'failed' lines above); " + + "remove them by hand or re-run with sufficient permissions\n", + ); + return 1; + } + + if (outcome.edited.length > 0) { + process.stdout.write( + "\nPATH was edited — open a new terminal for it to take effect.\n", + ); + } + process.stdout.write("\nAtomic Agent has been removed. Thanks for trying it.\n"); + return 0; +} diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 2828fb1..56060a2 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -42,6 +42,10 @@ export interface AppKeyCallbacks { grant?: ApprovalGrantScope, ): void; onAbort(): void; + /** Uninstall overlay: re-preview the plan with the state scope flipped. */ + onUninstallPreviewRequested?(includeState: boolean): void; + /** Uninstall overlay: run the confirmed plan. */ + onUninstallConfirmed?(includeState: boolean): void; /** Persist the Enter-while-busy mode after a Ctrl+T flip. */ onWhileBusyModePersistRequested?(mode: WhileBusySubmitMode): void; /** Open a fresh OS terminal window running atomic-agent (Ctrl+N, `/window`). */ @@ -185,6 +189,12 @@ export function handleAppKey( if (state.pendingApproval) { return handleApprovalKey(input, key, state.pendingApproval, ctx); } + // The uninstall confirm claims y / s / n while it is open. Placed above + // every other binding so a chord cannot fire underneath a destructive + // dialog the operator is looking at. + if (state.uninstallConfirm) { + return handleUninstallConfirmKey(input, key, ctx); + } // A settled successful self-update parks the UI on a "press any key to // restart" prompt. The first keystroke (whatever it is) re-execs the new // binary; `quit_requested` then unmounts Ink so the restart handoff runs. @@ -432,6 +442,7 @@ function shouldTreatArrowAsChatScroll( if (state.chatFocus !== "editor") return false; if (state.sessionPickerOpen) return false; if (state.themePickerOpen) return false; + if (state.uninstallConfirm) return false; if (state.inputValue.length > 0) return false; if (state.inputHistoryCursor !== null) return false; return true; @@ -600,6 +611,51 @@ export function decideApproval( } } +/** + * Keys for the `/uninstall` confirmation overlay: `y` runs the shown + * plan, `s` toggles the state directory in or out of it, `n` cancels. + * Esc is handled by `onEscape` in `tui-app.tsx` alongside the other + * overlays. + * + * Everything is ignored while a removal is in flight, so a repeated `y` + * cannot fire the removal twice, and once it has finished the dialog + * only accepts dismissal — there is nothing left to confirm. + */ +function handleUninstallConfirmKey( + input: string, + key: Key, + ctx: AppKeyContext, +): boolean { + const { state, dispatch, callbacks } = ctx; + const confirm = state.uninstallConfirm; + if (!confirm) return false; + // A ctrl/meta-modified key was never aimed at this prompt. + if (key.ctrl || key.meta) return false; + if (confirm.submitting) return true; + if (confirm.done !== null) { + // Any of the dismissal keys closes the report. + if (key.return || input.toLowerCase() === "n" || input.toLowerCase() === "y") { + dispatch({ type: "uninstall_confirm_closed" }); + return true; + } + return true; + } + const lower = input.toLowerCase(); + if (lower === "y" || key.return) { + callbacks.onUninstallConfirmed?.(confirm.includeState); + return true; + } + if (lower === "s") { + callbacks.onUninstallPreviewRequested?.(!confirm.includeState); + return true; + } + if (lower === "n") { + dispatch({ type: "uninstall_confirm_closed" }); + return true; + } + return true; +} + function handleApprovalKey( input: string, key: Key, diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 5976244..6e0c43f 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -37,6 +37,12 @@ export interface SlashDispatchResult { readonly triggerSkillCatalogDump: boolean; /** When true the caller should write the TUI debug zip (`/dump`). */ readonly triggerDebugBundleDump: boolean; + /** + * When true the caller should open the uninstall confirmation overlay + * (`/uninstall`). The handler stays pure: it never removes anything + * itself, it only asks for the dialog. + */ + readonly triggerUninstall: boolean; /** When true the caller should forward the raw buffer as a normal message. */ readonly forwardAsMessage: boolean; /** When set, caller should probe this URL, persist on success, then refresh UI. */ @@ -143,6 +149,7 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, + triggerUninstall: false, forwardAsMessage: true, persistLlamaUrl: undefined, }; @@ -161,11 +168,14 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, + triggerUninstall: false, forwardAsMessage: false, persistLlamaUrl: undefined, }; } switch (resolved.name) { + case "uninstall": + return pureActions([], { triggerUninstall: true }); case "dump": return pureActions([], { triggerDebugBundleDump: true, @@ -367,6 +377,7 @@ function pureActions( triggerMemoryDump: false, triggerSkillCatalogDump: false, triggerDebugBundleDump: false, + triggerUninstall: false, forwardAsMessage: false, persistLlamaUrl: undefined, taskCancelId: undefined, diff --git a/src/tui/components/uninstall-confirm.tsx b/src/tui/components/uninstall-confirm.tsx new file mode 100644 index 0000000..c8b0227 --- /dev/null +++ b/src/tui/components/uninstall-confirm.tsx @@ -0,0 +1,92 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { theme } from "../theme/theme.js"; +import type { UninstallConfirmState } from "../tui-state.js"; + +export interface UninstallConfirmProps { + confirm: UninstallConfirmState; +} + +/** + * Confirmation overlay for `/uninstall`. Unlike the other y/n modals this + * one renders the *actual plan* — every path that is about to be removed + * — because "are you sure?" is not a fair question when the answer + * depends on which of three scopes are in play. + * + * `s` toggles the state directory in and out of the plan. It defaults to + * out: removing the program should not destroy the operator's sessions, + * memory and API keys unless they say so. The wording turns red once it + * is on, since that is the irreversible half. + */ +export function UninstallConfirm(props: UninstallConfirmProps): ReactElement { + const { confirm } = props; + + if (confirm.done !== null) { + return ( + + + uninstall complete + + {confirm.done + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line, index) => ( + + {line} + + ))} + + quit atomic-agent to finish · Esc / Enter = dismiss + + + ); + } + + const borderColor = confirm.error + ? theme.colors.error + : confirm.includeState + ? theme.colors.error + : theme.colors.warn; + + return ( + + + uninstall Atomic Agent? + + {confirm.preview + .split("\n") + .map((line, index) => ( + + {line} + + ))} + {confirm.includeState ? ( + + ! state included — sessions, memory and API keys are erased for good + + ) : ( + + state directory is kept — reinstalling restores your sessions + + )} + {confirm.error ? ( + ! {confirm.error} + ) : null} + + {confirm.submitting + ? "removing…" + : `y = uninstall · s = ${confirm.includeState ? "keep" : "also erase"} state · n / Esc = cancel`} + + + ); +} diff --git a/src/tui/menu/menu-registry.test.ts b/src/tui/menu/menu-registry.test.ts index c789825..295b6bb 100644 --- a/src/tui/menu/menu-registry.test.ts +++ b/src/tui/menu/menu-registry.test.ts @@ -206,6 +206,12 @@ const V0_2_2_SLASH_COMMANDS = [ description: "mouse support on/off/status (off restores the terminal's drag-to-select)", }, + // Added after v0.3.2: uninstall (#135). + { + name: "uninstall", + description: + "remove Atomic Agent from this machine (state directory kept unless you opt in)", + }, ]; describe("menu registry", () => { diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index 4a943b8..0220fef 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -536,6 +536,20 @@ export const MENU: readonly MenuNode[] = [ rank: 21, }, }, + { + kind: "action", + id: "setup.uninstall", + label: "Uninstall Atomic Agent…", + group: "setup", + // Deliberately no chord: a single keystroke after the leader should + // not open a destructive flow. Reachable by name, click, or menu. + slash: { + name: "uninstall", + description: + "remove Atomic Agent from this machine (state directory kept unless you opt in)", + rank: 36, + }, + }, { kind: "action", id: "setup.task", diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index aebbdd2..f7ee70c 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -36,6 +36,70 @@ export function reduceUiAction( } case "theme_picker_closed": return { ...state, themePickerOpen: false, themePickerOriginal: "" }; + case "uninstall_confirm_opened": + return { + ...state, + uninstallConfirm: { + preview: action.preview, + includeState: action.includeState, + submitting: false, + error: null, + done: null, + }, + }; + case "uninstall_confirm_state_toggled": { + // Ignored once the removal is in flight: the plan being executed + // must not diverge from the plan that was confirmed. + if (!state.uninstallConfirm || state.uninstallConfirm.submitting) { + return state; + } + if (state.uninstallConfirm.done !== null) return state; + return { + ...state, + uninstallConfirm: { + ...state.uninstallConfirm, + preview: action.preview, + includeState: action.includeState, + error: null, + }, + }; + } + case "uninstall_confirm_submitting": { + if (!state.uninstallConfirm) return state; + return { + ...state, + uninstallConfirm: { + ...state.uninstallConfirm, + submitting: true, + error: null, + }, + }; + } + case "uninstall_confirm_done": { + if (!state.uninstallConfirm) return state; + return { + ...state, + uninstallConfirm: { + ...state.uninstallConfirm, + submitting: false, + error: null, + done: action.result, + }, + }; + } + case "uninstall_confirm_failed": { + if (!state.uninstallConfirm) return state; + return { + ...state, + uninstallConfirm: { + ...state.uninstallConfirm, + submitting: false, + error: action.message, + }, + }; + } + case "uninstall_confirm_closed": + return { ...state, uninstallConfirm: null }; case "theme_picker_cursor_moved": { if (!state.themePickerOpen) return state; const max = THEME_NAMES.length - 1; diff --git a/src/tui/submit-handler.ts b/src/tui/submit-handler.ts index f91da7c..1619235 100644 --- a/src/tui/submit-handler.ts +++ b/src/tui/submit-handler.ts @@ -144,6 +144,11 @@ export function runSlashCommand( if (result.triggerDebugBundleDump) { callbacks.onDebugBundleExportRequested?.(state); } + // `/uninstall` only ever opens the confirmation overlay. The removal + // itself is behind the operator's explicit `y` in that dialog. + if (result.triggerUninstall) { + callbacks.onUninstallPreviewRequested?.(false); + } // Swap the active palette before dispatching `theme_set` so the forced // re-render reads the new colours through the theme proxy, then persist the // choice to the user config (`/theme ` direct path). diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index 46a00fc..670df68 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -152,6 +152,29 @@ export type TuiAction = | { type: "theme_picker_cursor_moved"; delta: 1 | -1 } /** Put the theme picker highlight on an absolute row (mouse click). */ | { type: "theme_picker_cursor_set"; row: number } + /** + * Open the `/uninstall` confirmation overlay with a rendered preview of + * the plan. Nothing is removed until the operator confirms. + */ + | { + type: "uninstall_confirm_opened"; + preview: string; + includeState: boolean; + } + /** Toggle whether the state directory is included, re-previewing. */ + | { + type: "uninstall_confirm_state_toggled"; + preview: string; + includeState: boolean; + } + /** Removal started — locks the dialog against a second confirm. */ + | { type: "uninstall_confirm_submitting" } + /** Removal finished; `result` is the outcome text to display. */ + | { type: "uninstall_confirm_done"; result: string } + /** Removal failed; the dialog stays open showing `message`. */ + | { type: "uninstall_confirm_failed"; message: string } + /** Close the uninstall overlay (cancel, or dismiss after completion). */ + | { type: "uninstall_confirm_closed" } /** * Hard-switch the TUI transcript to an already-loaded session. The * orchestrator performs the SessionStore load + swap, then dispatches diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index 15e7fa3..b4243c7 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -2,6 +2,7 @@ import { Box, Text, useApp, useInput, type DOMElement, type Key } from "ink"; import { useCallback, useEffect, + useMemo, useReducer, useRef, useState, @@ -28,6 +29,18 @@ import { PromptShell } from "./components/prompt-shell.js"; import { QueuedMessages } from "./components/queued-messages.js"; import { SessionPicker } from "./components/session-picker.js"; import { ThemePicker } from "./components/theme-picker.js"; +import { UninstallConfirm } from "./components/uninstall-confirm.js"; +import { existsSync, readFileSync } from "node:fs"; +import { canSelfUpdate } from "../update/index.js"; +import { + buildUninstallPlan, + formatUninstallOutcome, + formatUninstallPlan, + installDirFromExecPath, + runUninstall, + type UninstallScope, +} from "../uninstall/index.js"; +import { getConfig } from "../config/index.js"; import { isThemeName, setActiveTheme, @@ -317,6 +330,13 @@ export interface TuiAppCallbacks { * event bus. */ onDebugBundleExportRequested?(state: TuiState): void; + /** + * Fired by `/uninstall`: asks the orchestrator to build the removal + * plan and open the confirmation overlay. Nothing is removed here. + */ + onUninstallPreviewRequested?(includeState: boolean): void; + /** Confirmed in the overlay: perform the removal for the shown plan. */ + onUninstallConfirmed?(includeState: boolean): void; /** Telegram tab: refresh state mirror (token presence, owner, etc.). */ onTelegramRefreshRequested?(): void; /** @@ -436,7 +456,7 @@ const PROMPT_PLACEHOLDERS: readonly string[] = [ export function TuiApp({ session, bus, - callbacks, + callbacks: baseCallbacks, maxVisibleRows = DEFAULT_MAX_VISIBLE_ROWS, initialLayout, mouse, @@ -445,6 +465,112 @@ export function TuiApp({ createInitialTuiState(init.session, DEFAULT_RING_BUFFER_SIZE, init.initialLayout), ); const app = useApp(); + /** + * State directory for the uninstall planner, resolved lazily and + * cached. Deliberately NOT read at mount: `getConfig()` touches the + * filesystem (and creates a default config when none exists), and + * every TuiApp render — including the ones in tests that mount with a + * stub runtime — would pay for a dialog that is almost never opened. + */ + const uninstallStateDirRef = useRef(null); + const resolveUninstallStateDir = useCallback((): string => { + if (uninstallStateDirRef.current === null) { + uninstallStateDirRef.current = getConfig().paths.stateDir; + } + return uninstallStateDirRef.current; + }, []); + + /** + * Build the removal plan and open (or re-preview) the confirm overlay. + * The plan is built here rather than in the orchestrator because the + * dialog is pure UI state and the planner is pure — no reason to route + * a filesystem read through the event bus to come back as a render. + */ + const openUninstallPreview = useCallback( + (includeState: boolean) => { + const scopes: UninstallScope[] = ["app", "path"]; + if (includeState) scopes.push("state"); + const installed = canSelfUpdate(); + const plan = buildUninstallPlan({ + scopes: installed ? scopes : scopes.filter((s) => s !== "app"), + installDir: installDirFromExecPath(process.execPath), + stateDir: resolveUninstallStateDir(), + exists: existsSync, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + }); + const preview = installed + ? formatUninstallPlan(plan) + : `${formatUninstallPlan(plan)}\n note: running from a dev checkout — the installed binary scope is skipped.`; + dispatch( + state.uninstallConfirm + ? { type: "uninstall_confirm_state_toggled", preview, includeState } + : { type: "uninstall_confirm_opened", preview, includeState }, + ); + }, + [state.uninstallConfirm, resolveUninstallStateDir], + ); + + const confirmUninstall = useCallback( + (includeState: boolean) => { + const scopes: UninstallScope[] = ["app", "path"]; + if (includeState) scopes.push("state"); + const installed = canSelfUpdate(); + dispatch({ type: "uninstall_confirm_submitting" }); + try { + const plan = buildUninstallPlan({ + scopes: installed ? scopes : scopes.filter((s) => s !== "app"), + installDir: installDirFromExecPath(process.execPath), + stateDir: resolveUninstallStateDir(), + exists: existsSync, + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + }); + const outcome = runUninstall(plan); + if (outcome.failures.length > 0) { + dispatch({ + type: "uninstall_confirm_failed", + message: outcome.failures + .map((f) => `${f.path}: ${f.reason}`) + .join("; "), + }); + return; + } + dispatch({ + type: "uninstall_confirm_done", + result: formatUninstallOutcome(outcome), + }); + } catch (err) { + dispatch({ + type: "uninstall_confirm_failed", + message: err instanceof Error ? err.message : String(err), + }); + } + }, + [resolveUninstallStateDir], + ); + + // The uninstall handlers live here (not in the orchestrator) because + // they are pure UI state transitions over a pure planner. + const callbacks = useMemo( + () => ({ + ...baseCallbacks, + onUninstallPreviewRequested: openUninstallPreview, + onUninstallConfirmed: confirmUninstall, + }), + [baseCallbacks, openUninstallPreview, confirmUninstall], + ); + const [ctrlCArmed, setCtrlCArmed] = useState(false); const [menuLeaderArmed, setMenuLeaderArmed] = useState(false); const ctrlCTimer = useRef(null); @@ -688,6 +814,7 @@ export function TuiApp({ state.updateStatus === "done" || state.sessionPickerOpen || state.themePickerOpen || + Boolean(state.uninstallConfirm) || state.slashPaletteOpen || isPanelModalOpen(state); useEffect(() => { @@ -772,6 +899,13 @@ export function TuiApp({ ); const onEscape = useCallback(() => { + if (state.uninstallConfirm) { + // Never abandon the dialog while the removal is in flight — the + // operator would be left guessing what did and did not get deleted. + if (state.uninstallConfirm.submitting) return; + dispatch({ type: "uninstall_confirm_closed" }); + return; + } if (state.themePickerOpen) { // Cancel: revert the live-preview swap to the theme active on open. if (isThemeName(state.themePickerOriginal)) { @@ -1041,6 +1175,11 @@ export function TuiApp({ /> ) : null} + {state.uninstallConfirm ? ( + + + + ) : null} {state.slashPaletteOpen ? ( [0]["session"]; + +function strip(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\[[0-9;]*[A-Za-z]/g, ""); +} + +const tick = () => new Promise((r) => setTimeout(r, 25)); + +function makeApp() { + const bus = makeTuiEventBus(); + const callbacks: TuiAppCallbacks = { + onApprovalDecision: () => {}, + onAbort: () => {}, + onQuit: vi.fn(), + onMessageSubmitted: () => {}, + }; + return { ...render() }; +} + +describe("uninstall is reachable like every other command", () => { + it("appears in the slash palette", () => { + expect(SLASH_COMMANDS.map((c) => c.name)).toContain("uninstall"); + }); + + it("is fuzzy-searchable by a partial query", () => { + expect(filterSlashCommands("uninst").map((c) => c.name)).toContain( + "uninstall", + ); + }); + + it("sits in the Setup group of the operator menu", () => { + expect(menuRoots("setup").map((n) => n.id)).toContain("setup.uninstall"); + }); + + it("carries no chord — a destructive flow must not be one keystroke away", () => { + const node = MENU.find((n) => n.id === "setup.uninstall"); + expect(node).toBeDefined(); + expect(node?.chord).toBeUndefined(); + }); + + it("is an action node that runs through the slash handler", () => { + const node = MENU.find((n) => n.id === "setup.uninstall"); + expect(node?.kind).toBe("action"); + // Nodes without `slash` are inert when activated — this one must have it, + // which is also what makes the menu row clickable and keyboard-selectable. + expect(node?.slash?.name).toBe("uninstall"); + }); +}); + +describe("uninstall confirmation overlay", () => { + it("opens on /uninstall and states that state is kept by default", async () => { + const { lastFrame, stdin, unmount } = makeApp(); + await tick(); + stdin.write("/uninstall"); + await tick(); + stdin.write("\r"); + await tick(); + + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("uninstall Atomic Agent?"); + expect(frame).toContain("state directory is kept"); + unmount(); + }); + + it("n cancels and leaves the agent running", async () => { + const { lastFrame, stdin, unmount } = makeApp(); + await tick(); + stdin.write("/uninstall"); + await tick(); + stdin.write("\r"); + await tick(); + expect(strip(lastFrame() ?? "")).toContain("uninstall Atomic Agent?"); + + stdin.write("n"); + await tick(); + expect(strip(lastFrame() ?? "")).not.toContain("uninstall Atomic Agent?"); + unmount(); + }); + + it("s toggles the destructive scope on, with an explicit warning", async () => { + const { lastFrame, stdin, unmount } = makeApp(); + await tick(); + stdin.write("/uninstall"); + await tick(); + stdin.write("\r"); + await tick(); + + stdin.write("s"); + await tick(); + const frame = strip(lastFrame() ?? ""); + expect(frame).toContain("state included"); + expect(frame).toContain("erased for good"); + unmount(); + }); + + it("Esc closes the dialog", async () => { + const { lastFrame, stdin, unmount } = makeApp(); + await tick(); + stdin.write("/uninstall"); + await tick(); + stdin.write("\r"); + await tick(); + + stdin.write(""); + await tick(); + expect(strip(lastFrame() ?? "")).not.toContain("uninstall Atomic Agent?"); + unmount(); + }); +}); diff --git a/src/tui/uninstall-confirm-reducer.test.ts b/src/tui/uninstall-confirm-reducer.test.ts new file mode 100644 index 0000000..23ede4c --- /dev/null +++ b/src/tui/uninstall-confirm-reducer.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { reduceTuiState } from "./agent-event-reducer.js"; +import { createInitialTuiState, type TuiState } from "./tui-state.js"; +import { dispatchSlashCommand } from "./commands/slash-command-handler.js"; + +const SESSION = { + sessionId: "s-1", + workingDir: "/tmp", + model: "test-model", +} as unknown as Parameters[0]; + +function initial(): TuiState { + return createInitialTuiState(SESSION, 200); +} + +function opened(includeState = false): TuiState { + return reduceTuiState(initial(), { + type: "uninstall_confirm_opened", + preview: "would remove: /x", + includeState, + }); +} + +describe("/uninstall dispatch", () => { + it("asks for the confirmation overlay and removes nothing itself", () => { + const result = dispatchSlashCommand("/uninstall"); + expect(result.triggerUninstall).toBe(true); + expect(result.actions).toHaveLength(0); + expect(result.forwardAsMessage).toBe(false); + }); + + it("leaves the flag off for every other command", () => { + expect(dispatchSlashCommand("/help").triggerUninstall).toBe(false); + expect(dispatchSlashCommand("/quit").triggerUninstall).toBe(false); + }); +}); + +describe("uninstall confirm reducer", () => { + it("starts closed", () => { + expect(initial().uninstallConfirm).toBeNull(); + }); + + it("opens with the state scope off by default", () => { + const state = opened(); + expect(state.uninstallConfirm?.includeState).toBe(false); + expect(state.uninstallConfirm?.submitting).toBe(false); + expect(state.uninstallConfirm?.done).toBeNull(); + }); + + it("toggles the state scope and re-previews", () => { + const state = reduceTuiState(opened(), { + type: "uninstall_confirm_state_toggled", + preview: "would remove: /x and state", + includeState: true, + }); + expect(state.uninstallConfirm?.includeState).toBe(true); + expect(state.uninstallConfirm?.preview).toContain("state"); + }); + + it("refuses to change the plan once the removal is in flight", () => { + const submitting = reduceTuiState(opened(), { + type: "uninstall_confirm_submitting", + }); + const after = reduceTuiState(submitting, { + type: "uninstall_confirm_state_toggled", + preview: "different plan", + includeState: true, + }); + expect(after.uninstallConfirm?.includeState).toBe(false); + expect(after.uninstallConfirm?.preview).toBe("would remove: /x"); + }); + + it("records the outcome and clears the submitting flag", () => { + const done = reduceTuiState( + reduceTuiState(opened(), { type: "uninstall_confirm_submitting" }), + { type: "uninstall_confirm_done", result: "removed /x" }, + ); + expect(done.uninstallConfirm?.done).toBe("removed /x"); + expect(done.uninstallConfirm?.submitting).toBe(false); + }); + + it("keeps the dialog open on failure so the error is readable", () => { + const failed = reduceTuiState( + reduceTuiState(opened(), { type: "uninstall_confirm_submitting" }), + { type: "uninstall_confirm_failed", message: "EACCES" }, + ); + expect(failed.uninstallConfirm).not.toBeNull(); + expect(failed.uninstallConfirm?.error).toBe("EACCES"); + expect(failed.uninstallConfirm?.submitting).toBe(false); + }); + + it("closes on cancel", () => { + const closed = reduceTuiState(opened(), { + type: "uninstall_confirm_closed", + }); + expect(closed.uninstallConfirm).toBeNull(); + }); + + it("ignores stray progress actions when no dialog is open", () => { + const state = initial(); + for (const action of [ + { type: "uninstall_confirm_submitting" }, + { type: "uninstall_confirm_done", result: "x" }, + { type: "uninstall_confirm_failed", message: "x" }, + ] as const) { + expect(reduceTuiState(state, action).uninstallConfirm).toBeNull(); + } + }); +}); diff --git a/src/uninstall/index.ts b/src/uninstall/index.ts new file mode 100644 index 0000000..2810e7a --- /dev/null +++ b/src/uninstall/index.ts @@ -0,0 +1,26 @@ +export { + buildUninstallPlan, + candidateShellRcFiles, + DEFAULT_UNINSTALL_SCOPES, + formatUninstallPlan, + installDirFromExecPath, + INSTALLED_ASSET_DIRS, + INSTALLED_BINARY_NAMES, + isEmptyPlan, + isSharedInstallDir, + PATH_MARKER, + stripPathBlock, + UNINSTALL_SCOPES, + type BuildUninstallPlanParams, + type UninstallPathEdit, + type UninstallPlan, + type UninstallScope, + type UninstallTarget, +} from "./uninstall-plan.js"; +export { + defaultUninstallDeps, + formatUninstallOutcome, + runUninstall, + type RunUninstallDeps, + type UninstallOutcome, +} from "./run-uninstall.js"; diff --git a/src/uninstall/run-uninstall.ts b/src/uninstall/run-uninstall.ts new file mode 100644 index 0000000..09cdcdf --- /dev/null +++ b/src/uninstall/run-uninstall.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + formatUninstallPlan, + isEmptyPlan, + stripPathBlock, + type UninstallPlan, +} from "./uninstall-plan.js"; + +export interface UninstallOutcome { + readonly removed: readonly string[]; + readonly edited: readonly string[]; + readonly failures: readonly { path: string; reason: string }[]; +} + +export interface RunUninstallDeps { + readonly rm: (path: string) => void; + readonly readFile: (path: string) => string | null; + readonly writeFile: (path: string, contents: string) => void; +} + +export const defaultUninstallDeps: RunUninstallDeps = { + rm: (path) => rmSync(path, { recursive: true, force: true }), + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + writeFile: (path, contents) => writeFileSync(path, contents, "utf8"), +}; + +/** + * Execute a plan. Every removal is attempted even when an earlier one + * fails: a half-uninstalled tree is worse than a reported error, and the + * operator can act on a complete failure list. The binary is removed + * last so that a failure partway through still leaves a runnable command + * to retry with. + */ +export function runUninstall( + plan: UninstallPlan, + deps: RunUninstallDeps = defaultUninstallDeps, +): UninstallOutcome { + const removed: string[] = []; + const edited: string[] = []; + const failures: { path: string; reason: string }[] = []; + + for (const edit of plan.pathEdits) { + try { + const contents = deps.readFile(edit.file); + if (contents === null) continue; + const next = stripPathBlock(contents, edit.marker); + if (next !== contents) { + deps.writeFile(edit.file, next); + edited.push(edit.file); + } + } catch (error) { + failures.push({ + path: edit.file, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + const ordered = [...plan.targets].sort((a, b) => { + // Directories and state first; the binary itself last. + const rank = (label: string) => (label === "binary" ? 1 : 0); + return rank(a.label) - rank(b.label); + }); + + for (const target of ordered) { + try { + deps.rm(target.path); + removed.push(target.path); + } catch (error) { + failures.push({ + path: target.path, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return { removed, edited, failures }; +} + +/** Render the result of an executed plan. */ +export function formatUninstallOutcome(outcome: UninstallOutcome): string { + const lines: string[] = []; + for (const path of outcome.removed) lines.push(`removed ${path}`); + for (const file of outcome.edited) lines.push(`updated ${file}`); + for (const failure of outcome.failures) { + lines.push(`failed ${failure.path}: ${failure.reason}`); + } + if (lines.length === 0) lines.push("nothing to remove"); + return lines.join("\n"); +} + +export { formatUninstallPlan, isEmptyPlan, existsSync }; diff --git a/src/uninstall/uninstall-plan.test.ts b/src/uninstall/uninstall-plan.test.ts new file mode 100644 index 0000000..764e126 --- /dev/null +++ b/src/uninstall/uninstall-plan.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { + buildUninstallPlan, + formatUninstallPlan, + isEmptyPlan, + isSharedInstallDir, + PATH_MARKER, + stripPathBlock, + type BuildUninstallPlanParams, +} from "./uninstall-plan.js"; +import { runUninstall, type RunUninstallDeps } from "./run-uninstall.js"; + +const HOME = "/home/op"; +const SHARED_DIR = join(HOME, ".local", "bin"); +const OWN_DIR = join(HOME, "AppData", "Local", "atomic-agent"); +const STATE_DIR = join(HOME, ".atomic-agent"); + +/** Every path the installer could have written, plus a foreign binary. */ +const FULL_DISK = new Set([ + join(SHARED_DIR, "atomic-agent"), + join(SHARED_DIR, "atag"), + join(SHARED_DIR, "grammars"), + join(SHARED_DIR, "starter-skills"), + join(SHARED_DIR, "assets"), + join(SHARED_DIR, "vendor"), + join(SHARED_DIR, "prebuilds"), + join(SHARED_DIR, "node_modules"), + // Not ours. Must survive every plan below. + join(SHARED_DIR, "ripgrep"), + join(SHARED_DIR, "some-other-tool"), + STATE_DIR, +]); + +function makeParams( + overrides: Partial = {}, +): BuildUninstallPlanParams { + return { + scopes: ["app", "path"], + installDir: SHARED_DIR, + stateDir: STATE_DIR, + home: HOME, + platform: "linux", + exists: (path) => FULL_DISK.has(path), + readFile: () => null, + ...overrides, + }; +} + +describe("isSharedInstallDir", () => { + it("treats ~/.local/bin as shared — other programs live there", () => { + expect(isSharedInstallDir(SHARED_DIR, HOME)).toBe(true); + }); + + it("treats /usr/local/bin and ~/bin as shared", () => { + expect(isSharedInstallDir("/usr/local/bin", HOME)).toBe(true); + expect(isSharedInstallDir(join(HOME, "bin"), HOME)).toBe(true); + }); + + it("treats a directory named after the product as ours", () => { + expect(isSharedInstallDir(OWN_DIR, HOME)).toBe(false); + }); + + it("ignores a trailing separator", () => { + expect(isSharedInstallDir(`${OWN_DIR}/`, HOME)).toBe(false); + }); +}); + +describe("buildUninstallPlan", () => { + it("never removes the shared install directory itself", () => { + const plan = buildUninstallPlan(makeParams()); + const paths = plan.targets.map((t) => t.path); + expect(paths).not.toContain(SHARED_DIR); + expect(plan.preservedInstallDir).toBe(SHARED_DIR); + }); + + it("leaves foreign binaries in a shared directory untouched", () => { + const plan = buildUninstallPlan(makeParams()); + const paths = plan.targets.map((t) => t.path); + expect(paths).not.toContain(join(SHARED_DIR, "ripgrep")); + expect(paths).not.toContain(join(SHARED_DIR, "some-other-tool")); + }); + + it("removes the binary, the alias, and every installed asset tree", () => { + const plan = buildUninstallPlan(makeParams()); + const paths = plan.targets.map((t) => t.path); + for (const name of [ + "atomic-agent", + "atag", + "grammars", + "starter-skills", + "assets", + "vendor", + "prebuilds", + "node_modules", + ]) { + expect(paths).toContain(join(SHARED_DIR, name)); + } + }); + + it("keeps the state directory out of the default plan", () => { + const plan = buildUninstallPlan(makeParams()); + expect(plan.targets.map((t) => t.path)).not.toContain(STATE_DIR); + expect(plan.notes.join(" ")).toContain("State kept at"); + }); + + it("includes the state directory only when that scope is asked for", () => { + const plan = buildUninstallPlan( + makeParams({ scopes: ["app", "path", "state"] }), + ); + expect(plan.targets.map((t) => t.path)).toContain(STATE_DIR); + expect(plan.notes.join(" ")).toContain("not reversible"); + }); + + it("skips paths that are not on disk", () => { + const plan = buildUninstallPlan( + makeParams({ exists: (p) => p === join(SHARED_DIR, "atomic-agent") }), + ); + expect(plan.targets).toHaveLength(1); + }); + + it("finds the installer's PATH block in a shell rc file", () => { + const rc = join(HOME, ".zshrc"); + const plan = buildUninstallPlan( + makeParams({ + scopes: ["path"], + readFile: (p) => + p === rc ? `export A=1\n\n${PATH_MARKER}\nexport PATH="x:$PATH"\n` : null, + }), + ); + expect(plan.pathEdits.map((e) => e.file)).toEqual([rc]); + }); + + it("ignores rc files that the installer never touched", () => { + const plan = buildUninstallPlan( + makeParams({ scopes: ["path"], readFile: () => "export PATH=/x:$PATH\n" }), + ); + expect(plan.pathEdits).toHaveLength(0); + }); + + it("explains the registry PATH edit on Windows", () => { + const plan = buildUninstallPlan( + makeParams({ scopes: ["path"], platform: "win32" }), + ); + expect(plan.notes.join(" ")).toContain("registry"); + }); + + it("reports an empty plan when nothing is installed", () => { + const plan = buildUninstallPlan(makeParams({ exists: () => false })); + expect(isEmptyPlan(plan)).toBe(true); + expect(formatUninstallPlan(plan)).toContain("Nothing to remove"); + }); +}); + +describe("stripPathBlock", () => { + it("removes the marker and the export line under it", () => { + const before = `export EDITOR=vim\n\n${PATH_MARKER}\nexport PATH="$HOME/.local/bin:$PATH"\n`; + expect(stripPathBlock(before, PATH_MARKER)).toBe("export EDITOR=vim\n"); + }); + + it("leaves an untouched file exactly as it was", () => { + const before = 'export PATH="/opt/bin:$PATH"\n'; + expect(stripPathBlock(before, PATH_MARKER)).toBe(before); + }); + + it("is idempotent — running it twice changes nothing further", () => { + const before = `a=1\n\n${PATH_MARKER}\nexport PATH="x:$PATH"\n`; + const once = stripPathBlock(before, PATH_MARKER); + expect(stripPathBlock(once, PATH_MARKER)).toBe(once); + }); + + it("preserves lines that follow the block", () => { + const before = `a=1\n\n${PATH_MARKER}\nexport PATH="x:$PATH"\nb=2\n`; + expect(stripPathBlock(before, PATH_MARKER)).toBe("a=1\nb=2\n"); + }); +}); + +describe("runUninstall", () => { + function makeDeps( + failOn: readonly string[] = [], + ): { deps: RunUninstallDeps; removed: string[]; written: [string, string][] } { + const removed: string[] = []; + const written: [string, string][] = []; + return { + removed, + written, + deps: { + rm: (path) => { + if (failOn.includes(path)) throw new Error("EACCES"); + removed.push(path); + }, + readFile: () => `\n${PATH_MARKER}\nexport PATH="x:$PATH"\n`, + writeFile: (path, contents) => written.push([path, contents]), + }, + }; + } + + it("removes every planned target and reports them", () => { + const plan = buildUninstallPlan(makeParams()); + const { deps, removed } = makeDeps(); + const outcome = runUninstall(plan, deps); + expect(outcome.failures).toHaveLength(0); + expect(removed).toHaveLength(plan.targets.length); + }); + + it("removes the binary last so a partial failure leaves a way to retry", () => { + const plan = buildUninstallPlan(makeParams()); + const { deps, removed } = makeDeps(); + runUninstall(plan, deps); + expect(removed[removed.length - 1]).toContain("at"); + const binaryIndex = removed.indexOf(join(SHARED_DIR, "atomic-agent")); + const assetIndex = removed.indexOf(join(SHARED_DIR, "grammars")); + expect(binaryIndex).toBeGreaterThan(assetIndex); + }); + + it("keeps going after a failure and reports what could not be removed", () => { + const plan = buildUninstallPlan(makeParams()); + const blocked = join(SHARED_DIR, "vendor"); + const { deps, removed } = makeDeps([blocked]); + const outcome = runUninstall(plan, deps); + expect(outcome.failures.map((f) => f.path)).toEqual([blocked]); + expect(removed.length).toBe(plan.targets.length - 1); + }); + + it("strips the PATH block from the rc file", () => { + const plan = buildUninstallPlan( + makeParams({ + scopes: ["path"], + readFile: () => `\n${PATH_MARKER}\nexport PATH="x:$PATH"\n`, + }), + ); + const { deps, written } = makeDeps(); + const outcome = runUninstall(plan, deps); + expect(outcome.edited.length).toBeGreaterThan(0); + expect(written[0]?.[1]).not.toContain(PATH_MARKER); + }); + + it("does nothing at all for an empty plan", () => { + const plan = buildUninstallPlan(makeParams({ exists: () => false })); + const { deps, removed, written } = makeDeps(); + const outcome = runUninstall(plan, deps); + expect(removed).toHaveLength(0); + expect(written).toHaveLength(0); + expect(outcome.removed).toHaveLength(0); + }); +}); diff --git a/src/uninstall/uninstall-plan.ts b/src/uninstall/uninstall-plan.ts new file mode 100644 index 0000000..c30a5e7 --- /dev/null +++ b/src/uninstall/uninstall-plan.ts @@ -0,0 +1,316 @@ +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +/** + * What an uninstall may remove. The split mirrors what the installers + * actually write (see `scripts/install.sh` / `scripts/install.ps1`), + * not an invented taxonomy: + * + * - `app` the binary plus the six asset trees dropped beside it + * - `path` the `export PATH=...` block appended to a shell rc file + * - `state` the state directory: config, sessions, memory, secrets, + * and any downloaded model weights + * + * `app` and `path` together undo the install. `state` is what erases the + * user's data, so it is never implied — it must be asked for by name or + * via `--all`. This is the same default Hermes and OpenClaw settle on: + * removing the program does not destroy the work done with it. + */ +export type UninstallScope = "app" | "path" | "state"; + +export const UNINSTALL_SCOPES: readonly UninstallScope[] = [ + "app", + "path", + "state", +]; + +/** Scopes selected when the operator names none. */ +export const DEFAULT_UNINSTALL_SCOPES: readonly UninstallScope[] = [ + "app", + "path", +]; + +/** + * Asset trees the installer lays down as siblings of the binary. Kept as + * an explicit list rather than "delete the install dir" on purpose — see + * {@link isSharedInstallDir}. + */ +export const INSTALLED_ASSET_DIRS: readonly string[] = [ + "grammars", + "starter-skills", + "assets", + "vendor", + "prebuilds", + "node_modules", +]; + +/** Binary names the installer may have written, POSIX and Windows. */ +export const INSTALLED_BINARY_NAMES: readonly string[] = [ + "atomic-agent", + "atomic-agent.exe", + // Short alias installed alongside the binary since v0.3.2 (#195). + "atag", + "atag.exe", +]; + +/** The comment the installer writes above the PATH line it appends. */ +export const PATH_MARKER = "# added by atomic-agent installer"; + +/** + * Shell rc files the installer may have edited, by shell. Mirrors the + * `case "$_shell_name"` block in `install.sh`. + */ +export function candidateShellRcFiles(home: string): readonly string[] { + return [ + join(home, ".zshrc"), + join(home, ".bashrc"), + join(home, ".bash_profile"), + join(home, ".profile"), + join(home, ".config", "fish", "config.fish"), + ]; +} + +/** + * Directories that hold more than just this program. `install.sh` + * defaults to `~/.local/bin`, which on a real machine also holds + * unrelated binaries — deleting it wholesale would take out the + * operator's other tools. So the plan removes the files it installed *by + * name* and never the directory itself. `install.ps1` is the opposite + * case: it defaults to `%LOCALAPPDATA%\atomic-agent`, a directory that + * exists solely for this program, so there removing the tree is correct. + * + * When in doubt this returns `true`: leaving a stray empty directory is a + * harmless outcome, and deleting someone's `~/bin` is not. + */ +export function isSharedInstallDir( + dir: string, + home: string = homedir(), +): boolean { + const normalized = resolve(dir).replace(/[\\/]+$/, ""); + const leaf = basename(normalized).toLowerCase(); + // A directory named after the product is ours to remove. + if (leaf === "atomic-agent") return false; + // Anything else — ~/.local/bin, /usr/local/bin, ~/bin — is shared. + void home; + return true; +} + +/** One filesystem removal the plan intends to perform. */ +export interface UninstallTarget { + readonly scope: UninstallScope; + readonly path: string; + readonly kind: "file" | "directory"; + /** Shown in the preview so the operator knows what each line is. */ + readonly label: string; +} + +/** An edit to a shell rc file: drop the marker line and the line after it. */ +export interface UninstallPathEdit { + readonly scope: "path"; + readonly file: string; + readonly marker: string; +} + +export interface UninstallPlan { + readonly scopes: readonly UninstallScope[]; + readonly targets: readonly UninstallTarget[]; + readonly pathEdits: readonly UninstallPathEdit[]; + /** + * Install directory that was inspected but deliberately left in place + * because other programs live there. Surfaced so the preview can say + * so rather than leaving the operator wondering. + */ + readonly preservedInstallDir?: string; + /** Human-readable notes rendered under the preview. */ + readonly notes: readonly string[]; +} + +export interface BuildUninstallPlanParams { + readonly scopes: readonly UninstallScope[]; + /** Directory holding the installed binary (usually `dirname(execPath)`). */ + readonly installDir: string; + /** Resolved state directory (`config.paths.stateDir`). */ + readonly stateDir: string; + readonly home?: string; + readonly platform?: NodeJS.Platform; + /** Injected for tests; defaults to a real `existsSync`. */ + readonly exists: (path: string) => boolean; + /** Reads a shell rc file, or returns null when unreadable. */ + readonly readFile: (path: string) => string | null; +} + +/** + * Build the removal plan without touching the disk. Everything the + * command does — preview, confirmation text, and the removal itself — + * reads from this one structure, so `--dry-run` cannot drift from the + * real run: they are the same plan, executed or not. + */ +export function buildUninstallPlan( + params: BuildUninstallPlanParams, +): UninstallPlan { + const { + scopes, + installDir, + stateDir, + home = homedir(), + platform = process.platform, + exists, + readFile, + } = params; + + const selected = new Set(scopes); + const targets: UninstallTarget[] = []; + const pathEdits: UninstallPathEdit[] = []; + const notes: string[] = []; + let preservedInstallDir: string | undefined; + + if (selected.has("app")) { + const shared = isSharedInstallDir(installDir, home); + + for (const name of INSTALLED_BINARY_NAMES) { + const candidate = join(installDir, name); + if (exists(candidate)) { + targets.push({ + scope: "app", + path: candidate, + kind: "file", + label: "binary", + }); + } + } + + for (const name of INSTALLED_ASSET_DIRS) { + const candidate = join(installDir, name); + if (exists(candidate)) { + targets.push({ + scope: "app", + path: candidate, + kind: "directory", + label: "bundled assets", + }); + } + } + + if (shared) { + preservedInstallDir = installDir; + notes.push( + `${installDir} is left in place — other programs live there. ` + + "Only the files listed above are removed.", + ); + } + } + + if (selected.has("path")) { + for (const rc of candidateShellRcFiles(home)) { + const contents = readFile(rc); + if (contents !== null && contents.includes(PATH_MARKER)) { + pathEdits.push({ scope: "path", file: rc, marker: PATH_MARKER }); + } + } + if (platform === "win32") { + notes.push( + "On Windows the installer edits the user PATH in the registry. " + + "Remove the entry from Settings > Environment Variables, or run: " + + "[Environment]::SetEnvironmentVariable('Path', " + + "(([Environment]::GetEnvironmentVariable('Path','User')" + + ").Split(';') | Where-Object { $_ -ne '" + + installDir + + "' }) -join ';', 'User')", + ); + } + } + + if (selected.has("state")) { + if (exists(stateDir)) { + targets.push({ + scope: "state", + path: stateDir, + kind: "directory", + label: "config, sessions, memory, secrets, downloaded models", + }); + } + notes.push( + "The state directory holds your API keys in plaintext and every " + + "session transcript. Removing it is not reversible.", + ); + } else { + notes.push( + `State kept at ${stateDir} — reinstalling restores your sessions, ` + + "memory and config. Pass --state (or --all) to erase it.", + ); + } + + return { + scopes: [...selected].sort( + (a, b) => UNINSTALL_SCOPES.indexOf(a) - UNINSTALL_SCOPES.indexOf(b), + ), + targets, + pathEdits, + preservedInstallDir, + notes, + }; +} + +/** True when the plan would not touch anything. */ +export function isEmptyPlan(plan: UninstallPlan): boolean { + return plan.targets.length === 0 && plan.pathEdits.length === 0; +} + +/** + * Strip the installer's PATH block from an rc file: the marker comment + * and the single line that follows it, plus the blank line the installer + * wrote before the marker. Returns the file unchanged when the marker is + * absent, so running it twice is safe. + */ +export function stripPathBlock(contents: string, marker: string): string { + const lines = contents.split("\n"); + const out: string[] = []; + for (let i = 0; i < lines.length; i += 1) { + if (lines[i]?.trim() === marker) { + // Drop the marker and the export line under it. + i += 1; + // Also drop the blank separator the installer appended before it. + while (out.length > 0 && out[out.length - 1]?.trim() === "") { + out.pop(); + } + continue; + } + out.push(lines[i] ?? ""); + } + const result = out.join("\n"); + // Keep exactly one trailing newline when the original had one. + if (contents.endsWith("\n") && !result.endsWith("\n")) return `${result}\n`; + return result; +} + +/** Render the plan as the preview shown by `--dry-run` and the confirm. */ +export function formatUninstallPlan(plan: UninstallPlan): string { + const lines: string[] = []; + + if (isEmptyPlan(plan)) { + lines.push("Nothing to remove — no installed files matched."); + for (const note of plan.notes) lines.push(` note: ${note}`); + return lines.join("\n"); + } + + lines.push(`Scopes: ${plan.scopes.join(", ")}`); + lines.push(""); + lines.push("Would remove:"); + for (const target of plan.targets) { + const suffix = target.kind === "directory" ? "/" : ""; + lines.push(` ${target.path}${suffix} (${target.label})`); + } + for (const edit of plan.pathEdits) { + lines.push(` ${edit.file} (PATH line added by the installer)`); + } + if (plan.notes.length > 0) { + lines.push(""); + for (const note of plan.notes) lines.push(` note: ${note}`); + } + return lines.join("\n"); +} + +/** Resolve the install directory from the running binary. */ +export function installDirFromExecPath(execPath: string): string { + return dirname(execPath); +}