diff --git a/README.md b/README.md index 9118304b..364a7059 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 5f00c1d7..8421d792 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 00000000..c40ef5f7 --- /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 00000000..c41563f6 --- /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 2828fb1f..56060a26 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 59762447..6e0c43f6 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 00000000..c8b02278 --- /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 c7898250..295b6bba 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 4a943b80..0220fef1 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 aebbdd2a..f7ee70cd 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 f91da7ce..1619235f 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 46a00fc9..670df68b 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 15e7fa35..b4243c74 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 00000000..23ede4c1 --- /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 00000000..2810e7a8 --- /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 00000000..09cdcdfc --- /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 00000000..764e1268 --- /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 00000000..c30a5e74 --- /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); +}