#!/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ĩ */ 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 };