#!/usr/bin/env node /** * @file plugin-doctor.js * @description Reports the health of a plugin-installed AgentHub: Node version, * npm, free disk on the data dir, bootstrap state, runtime dependencies (every * declared package, not just that node_modules exists), server liveness, * duplicate hook entries (the one failure that silently doubles every token and * cost figure), the `agenthub` CLI launcher and its PATH, whether the slash * commands the pipeline templates name as node drivers are actually resolvable * (the `superpowers` plugin, today), and the dashboard bundle — quoting the last * build error when it is missing. * Read-only — it diagnoses, it never repairs. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); 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}` ); } add(...runtimeDepsStatus(rt)); add(...npmStatus()); add(...diskStatus()); 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()); add(...dashboardUiStatus(rt)); return out; } /** * `node_modules` existing proves nothing: an interrupted install or a truncated * cache entry leaves a directory that npm reports as installed and that is * missing packages — the failure mode that took the client build down with * TS2318. Check every declared runtime dependency actually landed. * * Only `dependencies` count. `optionalDependencies` (better-sqlite3) are allowed * to be absent by design — the server falls back to `node:sqlite`. */ function runtimeDepsStatus(rt) { const nm = path.join(rt, "node_modules"); if (!fs.existsSync(nm)) return ["fail", "Runtime deps", "missing — run /agenthub-update"]; let declared; try { declared = Object.keys( JSON.parse(fs.readFileSync(path.join(rt, "package.json"), "utf8")).dependencies || {} ); } catch { return ["warn", "Runtime deps", `${nm} — no manifest beside it, cannot verify the tree`]; } const missing = declared.filter((d) => !fs.existsSync(path.join(nm, d, "package.json"))); if (missing.length) { return [ "fail", "Runtime deps", `${missing.join(", ")} declared but not installed — the tree is incomplete. ` + `Delete ${nm} and run /agenthub-update`, ]; } return ["ok", "Runtime deps", `${declared.length} packages in ${nm}`]; } /** No npm, no dashboard bundle and no runtime install — both go through it. */ function npmStatus() { const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], { encoding: "utf8", shell: process.platform === "win32", }); if (res.status !== 0) { return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"]; } return ["ok", "npm", `v${res.stdout.trim()}`]; } /** * A full disk is the quiet cause of half-written installs and a SQLite file that * refuses writes, and it reads as unrelated breakage everywhere else. */ function diskStatus() { const dir = getDataDir(); let free; try { const s = fs.statfsSync(dir); free = s.bavail * s.bsize; } catch { return ["ok", "Disk", "not measurable on this platform"]; } const mb = Math.round(free / 1024 / 1024); if (free < 200 * 1024 * 1024) { return ["fail", "Disk", `${mb} MB free on ${dir} — installs and the database will truncate`]; } if (free < 1024 * 1024 * 1024) return ["warn", "Disk", `${mb} MB free on ${dir}`]; return ["ok", "Disk", `${Math.round(mb / 1024)} GB free`]; } /** * When the bundle is missing the useful thing is not "run /agenthub-open" — it * is why the last build failed, which sits in `client-build.log` and which * nobody thinks to open. */ function dashboardUiStatus(rt) { const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist"); if (fs.existsSync(path.join(dist, "index.html"))) return ["ok", "Dashboard UI", dist]; const err = lastBuildError(path.join(rt, "client-build.log")); return [ "warn", "Dashboard UI", err ? `not built — the last build failed with: ${err} (full log: ${path.join(rt, "client-build.log")})` : "not built yet — run /agenthub-open", ]; } /** First compiler/npm error line of the log's last build, or null. */ function lastBuildError(logPath) { let lines; try { lines = fs.readFileSync(logPath, "utf8").split("\n"); } catch { return null; } const hit = lines.filter((l) => /\berror\b|npm ERR!/i.test(l)).pop(); return hit ? hit.trim().slice(0, 200) : null; } /** * 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 };