feat(plugins): make CCAM installable straight from a Claude Code plugin
Adds a root `ccam` plugin (`.claude-plugin/plugin.json`, `"source": "./"`) so
`/plugin marketplace add` + `/plugin install ccam@...` is enough on a machine
with nothing but Claude Code: no clone, no npm run setup, no manual npm start.
- scripts/plugin-bootstrap.js: SessionStart hook. Fast-path exit, Node >=22.5
gate (node:sqlite), mkdir lock with stale reclaim, deps installed into
~/.claude/agent-dashboard/runtime/ (never the plugin cache), legacy
checkout-hook cleanup (backed up), ~/.local/bin/ccam launcher, eager UI
build so client routes like /run work immediately, detached server spawn.
- scripts/plugin-open.js, scripts/plugin-doctor.js: /ccam-open, /ccam-doctor.
- server/index.js: DASHBOARD_CLIENT_DIST override (plugin cache is read-only).
- mcp/build/ is committed (plugin MCP servers start before any bootstrap could
build them) and kept honest by scripts/check-mcp-build.js (content hash,
not mtime), enforced by pre-commit when mcp/src changes.
- plugins/ccam-dashboard/.mcp.json moved under plugins/ccam/ with a working
${CLAUDE_PLUGIN_ROOT} path (the old relative path never resolved from a
marketplace-cached subdir).
- Docs: README, INSTALL, SETUP, ARCHITECTURE, CLAUDE.md, docs/PLUGINS.md,
docs/MCP.md, docs/CLI.md, docs/HOOKS.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file plugin-doctor.js
|
||||
* @description Reports the health of a plugin-installed CCAM: Node version,
|
||||
* bootstrap state, runtime dependencies, server liveness, duplicate hook
|
||||
* entries (the one failure that silently doubles every token and cost figure),
|
||||
* the `ccam` CLI launcher and its PATH, and whether the committed MCP build
|
||||
* still matches `mcp/src`. 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 { isOurEntry } = require("./install-hooks");
|
||||
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
|
||||
const { resolveAllDashboardPorts } = require("../server/lib/server-info");
|
||||
const { mcpBuildStatus } = require("./check-mcp-build");
|
||||
|
||||
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} — CCAM 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 /ccam-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 /ccam-update"
|
||||
);
|
||||
|
||||
const ports = (() => {
|
||||
try {
|
||||
return resolveAllDashboardPorts();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
add(
|
||||
ports.length ? "ok" : "fail",
|
||||
"Server",
|
||||
ports.length
|
||||
? `listening on ${ports.map((p) => `http://localhost:${p}`).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());
|
||||
|
||||
const mcp = mcpBuildStatus(PLUGIN_ROOT);
|
||||
add(
|
||||
mcp.ok ? "ok" : "fail",
|
||||
"MCP build",
|
||||
mcp.ok ? "matches mcp/src" : `${mcp.reason} — run npm run mcp:build`
|
||||
);
|
||||
|
||||
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 /ccam-open"
|
||||
);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function countLegacyHookEntries() {
|
||||
try {
|
||||
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
|
||||
if (!settings.hooks) return 0;
|
||||
return Object.values(settings.hooks)
|
||||
.filter(Array.isArray)
|
||||
.reduce((n, entries) => n + entries.filter(isOurEntry).length, 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function cliStatus() {
|
||||
const dir = path.join(os.homedir(), ".local", "bin");
|
||||
const file = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam");
|
||||
if (!fs.existsSync(file)) return ["warn", "ccam CLI", `no launcher at ${file}`];
|
||||
const onPath = (process.env.PATH || "")
|
||||
.split(path.delimiter)
|
||||
.some((p) => path.resolve(p) === path.resolve(dir));
|
||||
return onPath
|
||||
? ["ok", "ccam CLI", file]
|
||||
: [
|
||||
"warn",
|
||||
"ccam 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 };
|
||||
Reference in New Issue
Block a user