Files
AgentHub/scripts/plugin-doctor.js
T
nntrivi2001 c7dbaf2d9f refactor: finish the AgentHub rename and drop the MCP server
Two leftovers from the rebrand, done together because they touch the same
docs and manifests.

**Data directory.** `agent-dashboard` could not simply be renamed in the
source: it names a real directory holding the live SQLite database. Startup
now moves it once, via `migrateLegacyDataDir()` in `server/lib/claude-home.js`
— a same-filesystem rename, so it is instant even at multiple gigabytes and
cannot leave two divergent copies. `getDataDir()` stays PURE and resolves to
the legacy path until the move happens; the first version of this put the
rename inside the getter, which meant `npm run test:server` relocated the
developer's real database as a side effect. The call sits behind
`require.main === module` and ahead of the router requires, because those pull
in db.js, which opens SQLite at module load. A failed rename is silent and the
old directory keeps being used: an unfashionable directory name beats a blank
database beside an unreachable one. Same treatment for the port-discovery file
(`.agent-dashboard.json` → `.agenthub.json`, old one dropped on write).

Not renamed on purpose: the Sagi localStorage keys (renaming silently resets
everyone's prefs) and the PWA manifest `id` (changing it orphans an installed
app).

**MCP server.** 4 tool calls in 46 days across 577,132 events, against 4,556
lines of TypeScript, a 2 MB committed build artifact, and a pre-commit guard
that had already blocked commits twice. Deleted: `mcp/`, the maintainer skills
and `mcp-reviewer` agent, the build check, `plugins/agenthub/.mcp.json`,
`docs/MCP.md`, 12 npm scripts, 5 Makefile targets, and every doc section
describing it.

Kept, because they are unrelated things sharing the name: the Config
Explorer's MCP tab and its API, per-lane Playwright MCP, the agenthub-config
plugin's MCP audit, and the historical `mcp__plugin_agenthub_*` events that
analytics must keep attributing.

Verified: server 1432/1432, client 459/459, tsc clean, prettier clean. Both
directions of the migration proven — a sandbox-HOME test run leaves the legacy
directory untouched, and a real boot moves it with its contents intact.
2026-09-08 09:48:40 +07:00

166 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
/**
* @file plugin-doctor.js
* @description Reports the health of a plugin-installed AgentHub: Node version,
* bootstrap state, runtime dependencies, server liveness, duplicate hook
* entries (the one failure that silently doubles every token and cost figure),
* the `agenthub` CLI launcher and its PATH, and whether the slash commands the pipeline templates name
* as node drivers are actually resolvable (the `superpowers` plugin, today).
* Read-only — it diagnoses, it never repairs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const boot = require("./plugin-bootstrap");
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
const PLUGIN_ROOT = path.resolve(__dirname, "..");
/** @returns {{level:"ok"|"warn"|"fail", label:string, detail:string}[]} */
function diagnose() {
const rt = boot.runtimeDir();
const out = [];
const add = (level, label, detail) => out.push({ level, label, detail });
add(
boot.nodeVersionOk() ? "ok" : "fail",
"Node",
boot.nodeVersionOk()
? `v${process.versions.node}`
: `v${process.versions.node} — AgentHub needs >= ${boot.MIN_NODE.join(".")} (node:sqlite)`
);
add("ok", "Plugin root", PLUGIN_ROOT);
add("ok", "Data dir", getDataDir());
const state = boot.readState(rt);
if (!state) {
add(
"warn",
"Bootstrap",
`no state recorded — start a new session, or check ${rt}/bootstrap.log`
);
} else {
const moved = state.pluginRoot !== PLUGIN_ROOT;
add(
moved ? "warn" : "ok",
"Bootstrap",
moved
? `recorded against a previous plugin version (${state.pluginRoot}) — run /agenthub-update`
: `last run ${state.updatedAt}`
);
}
const deps = fs.existsSync(path.join(rt, "node_modules"));
add(
deps ? "ok" : "fail",
"Runtime deps",
deps ? path.join(rt, "node_modules") : "missing — run /agenthub-update"
);
const live = boot.liveServers();
add(
live.length ? "ok" : "fail",
"Server",
live.length
? `listening on ${live.map((s) => `http://localhost:${s.port}`).join(", ")}`
: `not running — see ${path.join(rt, "server.log")}`
);
const dup = countLegacyHookEntries();
add(
dup ? "fail" : "ok",
"Hooks",
dup
? `${dup} entr${dup === 1 ? "y" : "ies"} in ${getSettingsPath()} duplicate the plugin's hooks — ` +
`every event is counted twice. Remove them (a new session does it automatically).`
: "provided by the plugin only"
);
add(...cliStatus());
add(...skillPluginStatus());
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
add(
fs.existsSync(path.join(dist, "index.html")) ? "ok" : "warn",
"Dashboard UI",
fs.existsSync(path.join(dist, "index.html")) ? dist : "not built yet — run /agenthub-open"
);
return out;
}
/**
* WARN (never FAIL) when a plugin the pipelines depend on cannot be resolved:
* the dashboard, hooks and CLI all work without it — only the pipeline
* skills that drive it stop partway, so this must not fail a doctor run.
*
* The requirement derivation and the missing/satisfied rules live in
* `server/lib/skill-plugins.js`, shared with the bootstrap that installs them.
*/
function skillPluginStatus() {
const skillPlugins = require("../server/lib/skill-plugins");
const required = skillPlugins.requiredSkillPlugins();
if (!required.size) return ["ok", "Skill plugins", "no pipeline names an external plugin"];
let missing;
try {
missing = skillPlugins.missingSkillPlugins();
} catch {
return ["warn", "Skill plugins", "could not read the installed-plugin manifest"];
}
if (!missing.length) {
return ["ok", "Skill plugins", `${[...required.keys()].sort().join(", ")} available`];
}
return [
"warn",
"Skill plugins",
`${missing.map((m) => `${m.name} (needed for ${m.skills.join(", ")})`).join("; ")} — ` +
`the pipelines name these commands; install with ` +
`claude plugin install ${missing.map((m) => m.autoInstall?.spec || m.name).join(" ")}`,
];
}
function countLegacyHookEntries() {
try {
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
if (!settings.hooks) return 0;
const isLegacy = boot.isCheckoutHookEntry();
return Object.values(settings.hooks)
.filter(Array.isArray)
.reduce((n, entries) => n + entries.filter(isLegacy).length, 0);
} catch {
return 0;
}
}
function cliStatus() {
const dir = path.join(os.homedir(), ".local", "bin");
const file = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
if (!fs.existsSync(file)) return ["warn", "agenthub CLI", `no launcher at ${file}`];
const onPath = (process.env.PATH || "")
.split(path.delimiter)
.some((p) => path.resolve(p) === path.resolve(dir));
return onPath
? ["ok", "agenthub CLI", file]
: [
"warn",
"agenthub CLI",
`${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`,
];
}
function report() {
const marks = { ok: "OK ", warn: "WARN", fail: "FAIL" };
const rows = diagnose();
for (const r of rows) console.log(`${marks[r.level]} ${r.label.padEnd(14)} ${r.detail}`);
return rows.some((r) => r.level === "fail") ? 1 : 0;
}
if (require.main === module) process.exitCode = report();
module.exports = { diagnose, report, countLegacyHookEntries };