#!/usr/bin/env node /** * @file `agenthub uninstall` and Settings → System's **Uninstall AgentHub**: * removes everything AgentHub put on this machine, in an order that keeps a * running piece from putting anything back — terminals, then the service unit * (else `Restart=always` revives the server), servers, tray icon, lane stacks, * the hooks and statusline it wrote into Claude Code / Codex / Antigravity * config (only its own entries), the plugin itself, then its files and data * dir LAST (pure fs, so this script's own cache dir may already be gone). * * Never touched: the AI tools' own conversations (`~/.claude/projects`, * `~/.codex/sessions`, …), lane worktrees and branches, anything written into * a user's repo, config backups, a marketplace other than `agenthub`. * * Every module is required up front: the plugin cache and runtime this file * runs from are deleted mid-run. Each step's outcome goes to * `$TMPDIR/agenthub-uninstall-.log`, outside everything removed. Run as a * script (`--yes`) it is the detached worker `POST /api/settings/uninstall` * spawns; `bin/agenthub.js` calls `plan()`/`run()` in-process. * @author Nguyễn Ngọc Trí Vĩ */ require("../server/lib/windows-hide").install(); // before child_process is read const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); const { getClaudeHome, getDataDir, getSettingsPath } = require("../server/lib/claude-home"); const { getCodexHome } = require("../server/lib/codex-home"); const { settingsFilePath, loadEnvFiles } = require("../server/lib/settings-env"); const { getServerInfoPath } = require("../server/lib/server-info"); const { isOurEntry } = require("./install-hooks"); const statusline = require("../statusline/install"); const codexHooks = require("../server/lib/codex-hooks"); const agyHooks = require("../server/lib/antigravity-hooks"); const { resolveClaude } = require("./plugin-update-helper"); const REPO_ROOT = path.resolve(__dirname, ".."); const MARKETPLACE = "agenthub"; // .claude-plugin/marketplace.json — never a shared one const uid = () => (process.getuid ? process.getuid() : "user"); const logPath = () => path.join(os.tmpdir(), `agenthub-uninstall-${uid()}.log`); const lanesRoot = () => process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes"); function defaultIo() { const bootstrap = require("./plugin-bootstrap"); const tray = require("../server/lib/tray"); return { svc: require("./service-manager"), tmux: require("../server/lib/tmux"), livePids: bootstrap.livePids, stopDashboard: bootstrap.stopDashboard, liveTray: tray.liveTray, stopTray: tray.stopTray, claude: resolveClaude, run: (cmd, args, opts) => spawnSync(cmd, args, { encoding: "utf8", timeout: 120_000, ...opts }), kill: (pid, sig) => process.kill(pid, sig), sleep: (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms), }; } const exists = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } }; const rm = (p) => fs.rmSync(p, { recursive: true, force: true }); const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } }; const alive = (io, pid) => { try { io.kill(pid, 0); return true; } catch { return false; } }; /** `dir` is ancestor-or-equal of `p`. */ function covers(dir, p) { const r = path.relative(path.resolve(dir), path.resolve(p)); return r === "" || (!r.startsWith("..") && !path.isAbsolute(r)); } /** * A data dir is deleted whole only when it cannot be the user's own folder: * never `/`, the home, CLAUDE_HOME or this checkout (or any of their parents — * `DASHBOARD_DATA_DIR` is free text), and it must look like ours. */ function isSafeDataDir(dir) { if ([os.homedir(), getClaudeHome(), REPO_ROOT].some((p) => covers(dir, p))) return false; return ( ["agenthub", "agent-dashboard"].includes(path.basename(path.resolve(dir))) || exists(path.join(dir, "dashboard.db")) ); } /** Claude Code hook entries of ours: a checkout's hook-handler.js, a plugin's bootstrap. */ const isOurHook = (e) => isOurEntry(e) || JSON.stringify(e).includes("plugin-bootstrap.js"); function ourHookCount() { const hooks = readJson(getSettingsPath())?.hooks || {}; return Object.values(hooks) .filter(Array.isArray) .reduce((n, list) => n + list.filter(isOurHook).length, 0); } function removeOurHooks() { const file = getSettingsPath(); const settings = readJson(file); for (const [type, list] of Object.entries(settings.hooks || {})) { if (!Array.isArray(list)) continue; const kept = list.filter((e) => !isOurHook(e)); if (kept.length) settings.hooks[type] = kept; else delete settings.hooks[type]; } if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks; const backup = `${file}.agenthub-uninstall.bak`; fs.copyFileSync(file, backup); fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", "utf8"); return `backup: ${backup}`; } /** `agenthub@` plus every plugin of AgentHub's own marketplace. */ function ourPlugins() { const data = readJson(path.join(getClaudeHome(), "plugins", "installed_plugins.json")); const out = []; for (const [id, entries] of Object.entries(data?.plugins || {})) { if (!id.startsWith("agenthub@") && !id.endsWith(`@${MARKETPLACE}`)) continue; for (const e of entries || []) out.push({ id, scope: e.scope || "user", cwd: e.projectPath }); } return out; } function marketplaceKnown() { const known = readJson(path.join(getClaudeHome(), "plugins", "known_marketplaces.json")); return !!known && MARKETPLACE in known; } function cacheDirs() { const base = path.join(getClaudeHome(), "plugins", "cache"); let mkts = []; try { mkts = fs.readdirSync(base); } catch { return []; } return mkts.map((m) => path.join(base, m, "agenthub")).filter(exists); } function launchers() { const dir = path.join(os.homedir(), ".local", "bin"); return ["agenthub", "agenthub.cmd"] .map((n) => path.join(dir, n)) .filter((p) => { try { return fs.readFileSync(p, "utf8").includes("agenthub-plugin-launcher"); } catch { return false; } }); } function stackPids(io) { const state = path.join(lanesRoot(), ".state"); const out = []; let lanes = []; try { lanes = fs.readdirSync(state); } catch { return out; } for (const lane of lanes) { const run = path.join(state, lane, "run"); let files = []; try { files = fs.readdirSync(run).filter((f) => f.endsWith(".pid")); } catch { continue; } for (const f of files) { const pid = Number(fs.readFileSync(path.join(run, f), "utf8").trim()); if (Number.isInteger(pid) && pid > 1 && alive(io, pid)) out.push(pid); } } return out; } function codexProxyLayers() { const home = getCodexHome(); let names = []; try { names = fs.readdirSync(home).filter((n) => n.endsWith(".config.toml")); } catch { return []; } return names .map((n) => path.join(home, n)) .filter((p) => { try { return fs.readFileSync(p, "utf8").startsWith("# Written by AgentHub"); } catch { return false; } }); } function hookFiles(mod) { const s = mod.status(); const any = Object.values(s.events || {}).some((e) => e.installed); return [any && s.path, exists(s.script) && s.script].filter(Boolean); } /** Files and directories that are AgentHub's alone, deleted whole. */ function ownFiles() { const ch = getClaudeHome(); const lanes = lanesRoot(); let backups = []; try { const dir = path.dirname(getSettingsPath()); backups = fs .readdirSync(dir) .filter((n) => /^settings\.json\.agenthub-(bak\d*|backup-.+)$/.test(n)) .map((n) => path.join(dir, n)); } catch { /* no CLAUDE_HOME */ } return [ ...launchers(), settingsFilePath(), getServerInfoPath(), path.join(ch, ".agent-dashboard.json"), ...["proxies", "routers", "git-credentials", "accounts", "profiles"].map((n) => path.join(ch, `agenthub-${n}`) ), ...backups, path.join(lanes, ".state"), path.join(lanes, ".locks"), path.join(os.homedir(), "Library", "Logs", "agenthub.log"), path.join(os.tmpdir(), `agenthub-statusline-${uid()}.json`), path.join(os.tmpdir(), `agenthub-drops-${uid()}`), ].filter(exists); } function dataDirs() { const ch = getClaudeHome(); const dirs = new Set([getDataDir(), path.join(ch, "agenthub"), path.join(ch, "agent-dashboard")]); const out = [...dirs].filter(exists); const db = process.env.DASHBOARD_DB_PATH; if (db && /\.db$/.test(db) && !out.some((d) => covers(d, db))) { out.push(...[db, `${db}-wal`, `${db}-shm`].filter(exists)); } return out; } /** * The steps, in order. `find` lists what is there (empty = nothing to do, the * step is skipped); `remove` takes it away and may return a note for the log. */ function steps(io) { const self = process.env.AGENTHUB_RUN_ID || null; // the pane this runs in, if any return [ { key: "panes", label: "Close every workspace terminal", find: () => io.tmux.listSessions("agenthub-lane-").filter((n) => n !== self), remove: (names) => names.forEach((n) => io.tmux.killSession(n)), }, { key: "service", label: `Remove the ${io.svc.label}`, find: () => io.svc.systemctlAvailable() && io.svc.unitIsOurs("agenthub") ? [io.svc.unitPath("agenthub")] : [], remove: () => io.svc.uninstallUserService("agenthub"), }, { key: "servers", label: "Stop the dashboard server", find: () => io.livePids().map((p) => `pid ${p}`), remove: () => { io.stopDashboard(); for (let i = 0; i < 50 && io.livePids().length; i++) io.sleep(200); if (io.livePids().length) throw new Error(`still running: ${io.livePids().join(", ")}`); }, }, { key: "tray", label: "Close the status-area icon", find: () => (io.liveTray() ? [`pid ${io.liveTray().pid}`] : []), remove: () => io.stopTray(), }, { key: "stacks", label: "Stop workspace stacks", find: () => stackPids(io).map((p) => `pid ${p}`), remove: () => stackPids(io).forEach((p) => io.kill(p, "SIGTERM")), }, { key: "claude-hooks", label: "Remove AgentHub's hooks from Claude Code settings", find: () => (ourHookCount() ? [`${getSettingsPath()} (${ourHookCount()} entries)`] : []), remove: removeOurHooks, }, { key: "statusline", label: "Remove AgentHub's statusline", find: () => statusline.isOurs(readJson(getSettingsPath())?.statusLine) ? [getSettingsPath()] : [], remove: () => statusline.uninstall(getSettingsPath()).status, }, { key: "codex-hooks", label: "Remove AgentHub's Codex hooks", find: () => hookFiles(codexHooks), remove: () => void codexHooks.remove(), }, { key: "antigravity-hooks", label: "Remove AgentHub's Antigravity hook", find: () => hookFiles(agyHooks), remove: () => void agyHooks.remove(), }, { key: "codex-proxies", label: "Remove AI Proxy profiles from Codex", find: codexProxyLayers, remove: (files) => files.forEach(rm), }, { key: "plugin", label: "Uninstall the AgentHub plugin(s) from Claude Code", find: () => [ ...ourPlugins().map((p) => `${p.id} (${p.scope})`), ...(marketplaceKnown() ? [`marketplace ${MARKETPLACE}`] : []), ], remove: () => { const claude = io.claude(); if (!claude) throw new Error("`claude` CLI not found — run `claude plugin uninstall` yourself"); const failed = []; const call = (args, cwd) => { const r = io.run(claude, args, cwd && exists(cwd) ? { cwd } : {}); if (r.status !== 0) failed.push(`${args.join(" ")}: ${(r.stderr || r.stdout || "").trim()}`); }; for (const p of ourPlugins()) call(["plugin", "uninstall", p.id, "-s", p.scope, "-y"], p.cwd); if (marketplaceKnown()) call(["plugin", "marketplace", "remove", MARKETPLACE]); if (failed.length) throw new Error(failed.join("; ")); }, }, { key: "files", label: "Delete AgentHub's files, settings and stored logins", find: ownFiles, remove: (paths) => { paths.forEach(rm); for (const dir of [path.dirname(settingsFilePath()), lanesRoot()]) { try { fs.rmdirSync(dir); // only when nothing else is left in it } catch { /* not empty, or gone */ } } }, }, { key: "cache", label: "Delete the plugin's cached copies", // Claude Code still recording an install means its uninstall failed: // deleting the files then would leave it pointing at nothing. find: () => (ourPlugins().some((p) => p.id.startsWith("agenthub@")) ? [] : cacheDirs()), remove: (dirs) => dirs.forEach(rm), }, { key: "data", label: "Delete AgentHub's database and data directory", find: dataDirs, remove: (paths) => { const refused = paths.filter((p) => fs.statSync(p).isDirectory() && !isSafeDataDir(p)); paths.filter((p) => !refused.includes(p)).forEach(rm); if (refused.length) throw new Error(`not deleted (not clearly AgentHub's): ${refused.join(", ")}`); }, }, { key: "own-pane", label: "Close the terminal this runs in", find: () => (self && io.tmux.listSessions(self).includes(self) ? [self] : []), remove: () => io.tmux.killSession(self), }, ]; } /** What would be removed: `[{key, label, items}]`, only steps with something to do. */ function plan(io = defaultIo()) { return steps(io) .map((s) => { try { return { key: s.key, label: s.label, items: s.find() }; } catch (err) { return { key: s.key, label: s.label, items: [], error: err.message }; } }) .filter((s) => s.items.length || s.error); } /** * Run every step. A failing step is logged and the rest still run — half an * uninstall that stops at the first error is worse than one that reports it. * @returns {{ok: boolean, log: string, steps: object[]}} */ function run({ io = defaultIo(), onLine = () => {} } = {}) { const file = logPath(); const log = (line) => { onLine(line); try { fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`); } catch { /* best effort */ } }; log(`AgentHub uninstall started (from ${REPO_ROOT})`); const results = []; for (const s of steps(io)) { let items; try { items = s.find(); if (!items.length) continue; const note = s.remove(items); results.push({ key: s.key, status: "done" }); log(`✔ ${s.label}${typeof note === "string" ? ` — ${note}` : ""}`); for (const i of items) log(` ${i}`); } catch (err) { results.push({ key: s.key, status: "failed", error: err.message }); log(`✖ ${s.label} — ${err.message}`); } } const ok = results.every((r) => r.status === "done"); log(ok ? "AgentHub uninstalled." : "AgentHub uninstalled with errors (see above)."); return { ok, log: file, steps: results }; } module.exports = { plan, run, logPath, isSafeDataDir, isOurHook, steps }; if (require.main === module) { if (!process.argv.includes("--yes")) { console.error("usage: node scripts/uninstall.js --yes"); process.exit(2); } loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] }); process.exit(run({ onLine: (l) => console.log(l) }).ok ? 0 : 1); }