Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand All @@ -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`):",
Expand Down
48 changes: 48 additions & 0 deletions src/cli/uninstall-command.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
213 changes: 213 additions & 0 deletions src/cli/uninstall-command.ts
Original file line number Diff line number Diff line change
@@ -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<UninstallScope>();
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<boolean> {
const rl = createInterface({
input: process.stdin,
output: process.stderr,
});
try {
const answer = (
await new Promise<string>((resolve) => rl.question(question, resolve))
)
.trim()
.toLowerCase();
return answer === "y" || answer === "yes";
} finally {
rl.close();
}
}

export async function uninstallCommand(args: string[]): Promise<number> {
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;
}
Loading