feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file One-off codemod: shifts every raw `blue-*`/`amber-*` Tailwind utility
|
||||
* one shade darker (100→200 ... 700→800), mirroring the 1-step darkening
|
||||
* already applied to the accent token (`#2563eb` blue-600 → `#1d4ed8`
|
||||
* blue-700). `blue` here is the "current stage" / info color (e.g.
|
||||
* PipelineMap's `current` state, unrelated to the `accent` CSS-variable
|
||||
* token); `amber` is the warning/pending/auto-detected color used across
|
||||
* badges, banners, and the pipeline's dashed "auto: <stage>" chip. `950` is
|
||||
* left alone — already the darkest step available.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SHIFT = {
|
||||
50: 100,
|
||||
100: 200,
|
||||
200: 300,
|
||||
300: 400,
|
||||
400: 500,
|
||||
500: 600,
|
||||
600: 700,
|
||||
700: 800,
|
||||
800: 900,
|
||||
};
|
||||
const COLORS = ["blue", "amber"];
|
||||
const PROPS = ["text", "bg", "border", "ring", "from", "to", "via", "fill"];
|
||||
|
||||
// Descending by `from` — JS enumerates integer-like object keys ascending
|
||||
// regardless of source order, and shifting low-to-high would let an already
|
||||
// -shifted "200" (from 100) get caught and shifted AGAIN by the 200 rule.
|
||||
// Highest-first guarantees each original shade is only ever matched once.
|
||||
const SHIFTS_DESC = Object.entries(SHIFT)
|
||||
.map(([from, to]) => [Number(from), to])
|
||||
.sort((a, b) => b[0] - a[0]);
|
||||
|
||||
function rewrite(text) {
|
||||
let out = text;
|
||||
for (const prop of PROPS) {
|
||||
for (const color of COLORS) {
|
||||
for (const [from, to] of SHIFTS_DESC) {
|
||||
const re = new RegExp(`\\b${prop}-${color}-${from}(\\/[0-9]+)?\\b`, "g");
|
||||
out = out.replace(re, (_m, opacity) => `${prop}-${color}-${to}${opacity || ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
if (entry === "node_modules") continue;
|
||||
walk(full, files);
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const root = join(scriptDir, "..", "client", "src");
|
||||
const files = walk(root);
|
||||
let changed = 0;
|
||||
for (const file of files) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = rewrite(before);
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
console.log(`rewrote ${changed} file(s)`);
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file One-off codemod: rewrites raw Tailwind gray-scale utility classes
|
||||
* (neutral, gray, slate, zinc, stone shades) across client/src to the
|
||||
* semantic surface/border/fg tokens introduced for dark/light mode.
|
||||
* Status colors (emerald/red/amber) and the categorical hue palette
|
||||
* (violet/indigo/cyan/teal/sky/rose/pink/orange/yellow) are deliberately
|
||||
* out of scope — see docs/superpowers/plans/2026-07-31-color-redesign-dark-light-mode.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MAP = {
|
||||
text: {
|
||||
"gray-50": "fg-primary",
|
||||
"gray-100": "fg-primary",
|
||||
"gray-200": "fg-primary",
|
||||
"neutral-50": "fg-primary",
|
||||
"neutral-100": "fg-primary",
|
||||
"gray-300": "fg-secondary",
|
||||
"gray-400": "fg-secondary",
|
||||
"neutral-200": "fg-secondary",
|
||||
"neutral-300": "fg-secondary",
|
||||
"neutral-400": "fg-secondary",
|
||||
"slate-300": "fg-secondary",
|
||||
"gray-500": "fg-muted",
|
||||
"gray-600": "fg-muted",
|
||||
"gray-700": "fg-muted",
|
||||
"neutral-500": "fg-muted",
|
||||
"neutral-600": "fg-muted",
|
||||
},
|
||||
placeholder: {
|
||||
"gray-500": "fg-muted",
|
||||
"gray-600": "fg-muted",
|
||||
},
|
||||
fill: {
|
||||
"gray-100": "fg-primary",
|
||||
"gray-300": "fg-secondary",
|
||||
"gray-600": "fg-muted",
|
||||
},
|
||||
bg: {
|
||||
"neutral-900": "surface-0",
|
||||
"gray-900": "surface-0",
|
||||
"neutral-800": "surface-2",
|
||||
"gray-800": "surface-2",
|
||||
"neutral-700": "surface-3",
|
||||
"gray-700": "surface-3",
|
||||
"neutral-500": "surface-4",
|
||||
"gray-500": "surface-4",
|
||||
"gray-400": "surface-4",
|
||||
"gray-600": "surface-4",
|
||||
"slate-500": "surface-4",
|
||||
},
|
||||
border: {
|
||||
"neutral-800": "border",
|
||||
"gray-800": "border",
|
||||
"neutral-700": "border-light",
|
||||
"gray-700": "border-light",
|
||||
"neutral-500": "border-light",
|
||||
"gray-500": "border-light",
|
||||
"gray-600": "border-light",
|
||||
},
|
||||
ring: {
|
||||
"slate-500": "border-light",
|
||||
"slate-400": "border-light",
|
||||
},
|
||||
};
|
||||
|
||||
function rewrite(text) {
|
||||
let out = text;
|
||||
for (const [prop, shadeMap] of Object.entries(MAP)) {
|
||||
for (const [rawShade, token] of Object.entries(shadeMap)) {
|
||||
// Matches `bg-gray-500`, `bg-gray-500/10`, `bg-gray-500/50` etc. — the
|
||||
// opacity suffix is preserved verbatim since the token's CSS var
|
||||
// already supports `<alpha-value>`.
|
||||
const re = new RegExp(`\\b${prop}-${rawShade}(\\/[0-9]+)?\\b`, "g");
|
||||
out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
if (entry === "node_modules") continue;
|
||||
walk(full, files);
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const root = join(scriptDir, "..", "client", "src");
|
||||
const files = walk(root);
|
||||
let changed = 0;
|
||||
for (const file of files) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = rewrite(before);
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
console.log(`rewrote ${changed} file(s)`);
|
||||
@@ -247,7 +247,7 @@ function main() {
|
||||
record({ ok: true, step: "done", plugin_root: newRoot });
|
||||
}
|
||||
|
||||
module.exports = { failureText };
|
||||
module.exports = { failureText, resolveClaude };
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file One-off codemod: rewrites raw `emerald-*`/`red-*`/`amber-*` Tailwind
|
||||
* utilities across client/src to the shared `status-success`/`status-danger`/
|
||||
* `status-warning` CSS-variable tokens (see `src/index.css`), so every
|
||||
* badge/button/component that means "success"/"danger"/"warning" uses the
|
||||
* SAME shade per theme instead of each usage picking its own. The
|
||||
* categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose,
|
||||
* pink, orange, yellow, and `blue` — which doubles as both "info" and a
|
||||
* categorical role color in places like message bubbles) is deliberately
|
||||
* left alone: those distinguish between different *kinds* of thing, not a
|
||||
* status, and collapsing them would erase that distinction.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const COLOR_TO_STATUS = {
|
||||
emerald: "status-success",
|
||||
red: "status-danger",
|
||||
amber: "status-warning",
|
||||
};
|
||||
const PROPS = ["text", "bg", "border", "ring", "from", "to", "via", "fill", "placeholder"];
|
||||
|
||||
function rewrite(text) {
|
||||
let out = text;
|
||||
for (const prop of PROPS) {
|
||||
for (const [color, token] of Object.entries(COLOR_TO_STATUS)) {
|
||||
// Shade number is dropped entirely — the token carries its own
|
||||
// per-theme value, so `emerald-400`, `emerald-500`, `emerald-600` all
|
||||
// collapse onto the one `status-success` (that collapse IS the fix:
|
||||
// no more per-usage shade picking). Opacity suffix (`/10`, `/60`) is
|
||||
// preserved verbatim.
|
||||
const re = new RegExp(`\\b${prop}-${color}-[0-9]+(\\/\\[[0-9.]+\\]|\\/[0-9]+)?`, "g");
|
||||
out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
if (entry === "node_modules") continue;
|
||||
walk(full, files);
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const scriptDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const root = join(scriptDir, "..", "client", "src");
|
||||
const files = walk(root);
|
||||
let changed = 0;
|
||||
for (const file of files) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = rewrite(before);
|
||||
if (after !== before) {
|
||||
writeFileSync(file, after);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
console.log(`rewrote ${changed} file(s)`);
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/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-<uid>.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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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@<any>` 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);
|
||||
}
|
||||
Reference in New Issue
Block a user