Files
AgentHub/scripts/plugin-bootstrap.js
T
nntrivi2001 6cb7f4f843 feat(plugin): install the pipelines' skill plugins on SessionStart
The ship-feature pipelines drive `/superpowers:*` commands as their per-node
drivers, so a fresh install had a dependency it was only told about — the
`/ccam-doctor` WARN row and a line in the README.

`server/lib/skill-plugins.js` now carries the whole thing, shared by the
doctor (reports) and the bootstrap (installs) so the two cannot disagree
about what is missing. The bootstrap installs at the very end of its DETACHED
worker — the install clones from the network and nothing in the 10 s
SessionStart hook may wait on it — retrying once behind a `marketplace add`,
logging a failure with the manual command, and never throwing: the dashboard,
hooks, CLI and MCP all work without these plugins. The outcome is recorded as
`state.skillPluginsSettled`, which `fastPathOk()` now also requires, so the
check runs exactly once per install and `/ccam-update` is the retry.
`CCAM_AUTO_INSTALL_PLUGINS=0|false|off` opts out.

Reporting is derived, installing is allowlisted. Requirements come from the
loaded templates, which is right for a report; installing from that same
source would not be, because a template is a file anyone can drop into
`DASHBOARD_PIPELINES_DIR` — it would turn "edit a JSON file" into "run this
vendor's install command on the next session". Auto-install consults
`AUTO_INSTALLABLE`, a constant in code (`superpowers` today), and reports
every other requirement as skipped.

Tests inject a `run` seam so no case reaches a real `claude plugin install`,
and cover the refusal explicitly.
2026-09-04 14:03:12 +07:00

637 lines
22 KiB
JavaScript

#!/usr/bin/env node
/**
* @file plugin-bootstrap.js
* @description Makes CCAM usable straight from a Claude Code plugin install.
* Runs from the plugin's `SessionStart` hook and must NEVER block a session:
* the foreground pass only checks a fast path, then hands the real work to a
* detached worker copy of itself.
*
* The worker installs runtime dependencies, removes hook entries left behind by
* an older `npm run install-hooks` (they would double-count every event), puts
* the `ccam` CLI on PATH, and starts the dashboard server — as a systemd
* `--user` service when available (see `systemd-service.js`), so it survives
* reboots without Claude Code being opened first; otherwise a detached spawn
* scoped to this session, same as before.
*
* Everything writable lives under `~/.claude/agent-dashboard/runtime/` — NOT in
* the plugin cache, which Claude Code garbage-collects and replaces wholesale
* on every plugin update.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
const { spawn, spawnSync } = require("child_process");
const { getDataDir, getClaudeHome, getSettingsPath } = require("../server/lib/claude-home");
const { getServerInfoPath } = require("../server/lib/server-info");
const { isOurEntry } = require("./install-hooks");
const PLUGIN_ROOT = path.resolve(__dirname, "..");
// node:sqlite — the only SQLite driver a `--omit=dev --ignore-scripts` install
// leaves us with, since better-sqlite3 is not a runtime dependency and its
// native addon would need a build step.
const MIN_NODE = [22, 5, 0];
const LOCK_STALE_MS = 10 * 60 * 1000;
function runtimeDir() {
return path.join(getDataDir(), "runtime");
}
function pluginVersion() {
try {
return require(path.join(PLUGIN_ROOT, "package.json")).version;
} catch {
return "unknown";
}
}
/* ------------------------------------------------------------------ state */
function statePath(rt = runtimeDir()) {
return path.join(rt, "state.json");
}
function readState(rt = runtimeDir()) {
try {
return JSON.parse(fs.readFileSync(statePath(rt), "utf8"));
} catch {
return null;
}
}
function writeState(state, rt = runtimeDir()) {
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(statePath(rt), JSON.stringify(state, null, 2) + "\n", "utf8");
}
/** A dashboard server that is actually listening (the discovery file is PID-checked). */
function serverIsLive() {
// NOT resolveAllDashboardPorts(): it falls back to [DEFAULT_PORT] when the
// discovery file has no live entry (a reasonable guess for the CLI/hook
// handler), which would make bootstrap believe a server is already running
// when none is. livePids() only counts entries with a PID that is actually
// alive.
return livePids().length > 0;
}
/** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
function depsHash(root = PLUGIN_ROOT) {
const h = crypto.createHash("sha256");
for (const f of ["package.json", "package-lock.json"]) {
try {
h.update(fs.readFileSync(path.join(root, f)));
} catch {
h.update(f); // absent counts as its own state
}
}
return h.digest("hex").slice(0, 16);
}
/**
* Nothing to do when the recorded state matches this plugin build AND a server
* is already up. Deliberately cheap: this runs on every single SessionStart.
*/
function fastPathOk(rt = runtimeDir()) {
const state = readState(rt);
if (!state) return false;
if (state.pluginVersion !== pluginVersion()) return false;
// A plugin update lands in a NEW cache directory; the server still running
// from the old one has to be replaced, so this is not a fast path.
if (state.pluginRoot !== PLUGIN_ROOT) return false;
if (state.depsHash !== depsHash()) return false;
if (!fs.existsSync(path.join(rt, "node_modules"))) return false;
// A machine can be fully set up and still be missing the plugins the
// pipelines drive. Reading one recorded flag keeps this hot path cheap — the
// actual check (manifest + every user SKILL.md) runs once, in the worker.
if (!state.skillPluginsSettled) return false;
return serverIsLive();
}
/* ------------------------------------------------------- node version gate */
function nodeVersionOk(version = process.versions.node) {
const parts = String(version)
.split(".")
.map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < MIN_NODE.length; i++) {
if (parts[i] > MIN_NODE[i]) return true;
if (parts[i] < MIN_NODE[i]) return false;
}
return true;
}
/* -------------------------------------------------------------------- lock */
function lockPath(rt = runtimeDir()) {
return path.join(rt, ".bootstrap.lock");
}
function isPidAlive(pid) {
if (!pid) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return err.code === "EPERM";
}
}
function lockIsStale(dir, now = Date.now()) {
let stat;
try {
stat = fs.statSync(dir);
} catch {
return false; // gone already
}
if (now - stat.mtimeMs > LOCK_STALE_MS) return true;
let pid = 0;
try {
pid = parseInt(fs.readFileSync(path.join(dir, "pid"), "utf8").trim(), 10);
} catch {
return true; // lock dir without a readable pid is debris
}
return !isPidAlive(pid);
}
/**
* Atomic `mkdir` lock so two sessions starting at once cannot race the install
* or spawn two servers (the second would die on EADDRINUSE). A lock whose owner
* is dead, or older than LOCK_STALE_MS, is reclaimed.
*
* @returns {boolean} true when this process owns the lock
*/
function acquireLock(rt = runtimeDir()) {
const dir = lockPath(rt);
fs.mkdirSync(rt, { recursive: true });
for (let attempt = 0; attempt < 2; attempt++) {
try {
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, "pid"), String(process.pid), "utf8");
return true;
} catch (err) {
if (err.code !== "EEXIST") return false;
if (!lockIsStale(dir)) return false;
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
return false;
}
}
}
return false;
}
function releaseLock(rt = runtimeDir()) {
try {
fs.rmSync(lockPath(rt), { recursive: true, force: true });
} catch {
/* best effort */
}
}
/* --------------------------------------------------------- legacy cleanup */
/**
* `claude plugin install` materializes the plugin's inline hooks into
* `~/.claude/settings.json` itself, with `${CLAUDE_PLUGIN_ROOT}` already
* resolved to the cache path — confirmed by installing this plugin for real
* and inspecting the file. Those entries also contain `hook-handler.js`, so
* `isOurEntry()` alone (checkout vs. plugin, both match the same substring)
* cannot tell a legitimate plugin-installed hook from a leftover
* `npm run install-hooks` entry. Only entries whose command does NOT resolve
* under the plugin cache are the legacy, checkout-installed kind.
*
* @returns {(entry: object) => boolean}
*/
function isCheckoutHookEntry(claudeHome = getClaudeHome()) {
const cacheRoot = path.join(claudeHome, "plugins", "cache");
return (entry) => {
if (!isOurEntry(entry)) return false;
const commands = [
entry.command,
...(Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command) : []),
];
return !commands.some((c) => typeof c === "string" && c.includes(cacheRoot));
};
}
/**
* Remove hook entries a previous `npm run install-hooks` wrote into
* ~/.claude/settings.json. The plugin installs its own hooks, and events carry
* no id — two handlers mean every token and cost figure is counted twice.
* Never touches the plugin's own cache-resolved entries (see
* `isCheckoutHookEntry`) — only genuine leftover checkout paths. The original
* file is copied aside before any write.
*
* @returns {number} how many entries were removed
*/
function stripLegacyHooks(settingsPath = getSettingsPath()) {
let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
} catch {
return 0; // no settings file, or not ours to touch
}
if (!settings.hooks || typeof settings.hooks !== "object") return 0;
const isLegacy = isCheckoutHookEntry();
let removed = 0;
for (const [type, entries] of Object.entries(settings.hooks)) {
if (!Array.isArray(entries)) continue;
const kept = entries.filter((e) => !isLegacy(e));
removed += entries.length - kept.length;
if (kept.length) settings.hooks[type] = kept;
else delete settings.hooks[type];
}
if (!removed) return 0;
fs.copyFileSync(settingsPath, `${settingsPath}.ccam-bak`);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
return removed;
}
/* ------------------------------------------------------------ CLI on PATH */
function cliDir() {
return path.join(os.homedir(), ".local", "bin");
}
/**
* Write a launcher for `ccam` into ~/.local/bin. A launcher rather than a
* symlink into the plugin cache: that directory is replaced on every plugin
* update, which would leave a dangling link until the next session.
*
* Never overwrites a `ccam` this bootstrap did not write — a developer's
* `npm link`ed checkout CLI must keep winning.
*
* @returns {"written"|"exists"|"foreign"|"failed"}
*/
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
const marker = "# ccam-plugin-launcher";
const target = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam");
const body =
process.platform === "win32"
? `@rem ccam-plugin-launcher\r\n@node "${path.join(root, "bin", "ccam.js")}" %*\r\n`
: `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "ccam.js")}" "$@"\n`;
try {
if (fs.existsSync(target)) {
const current = fs.readFileSync(target, "utf8");
if (!current.includes("ccam-plugin-launcher")) return "foreign";
if (current === body) return "exists";
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(target, body, "utf8");
fs.chmodSync(target, 0o755);
return "written";
} catch {
return "failed";
}
}
/* ------------------------------------------------------------- dependencies */
/**
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
* the root postinstall from pulling the whole Vite client toolchain; the
* manifests are copied out of the (read-only) plugin cache so npm has a project
* to install for.
*/
function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
fs.mkdirSync(rt, { recursive: true });
for (const f of ["package.json", "package-lock.json"]) {
const src = path.join(root, f);
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const res = spawnSync(
npm,
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
);
return res.status === 0;
}
/* ------------------------------------------------------------------ server */
/**
* Discovery-file entries whose PID is actually alive right now. Reads the file
* directly rather than server-info's `resolveAllDashboardPorts()`, which falls
* back to `[DEFAULT_PORT]` when nothing is live — a reasonable guess for the
* CLI/hook handler, but wrong here: it would make the bootstrap believe a
* server is already running when none is.
*
* @returns {{port:number, pid:number}[]}
*/
function liveServers() {
try {
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
return servers.filter((s) => s && isPidAlive(s.pid));
} catch {
return [];
}
}
function livePids() {
return liveServers().map((s) => s.pid);
}
/**
* Stop running dashboard servers. Needed after a plugin update: the old server
* runs from a cache directory Claude Code has already replaced, so it must be
* retired before the new one can take the port.
*
* @returns {number} how many processes were signalled
*/
function stopDashboard() {
let stopped = 0;
for (const pid of livePids()) {
try {
process.kill(pid, "SIGTERM");
stopped++;
} catch {
/* already gone, or not ours to signal */
}
}
return stopped;
}
/**
* Block until no dashboard PID is alive, so the replacement server does not
* race the old one for the port. Bounded — the worker is detached, but it must
* not hang forever if a process refuses to die.
*/
function waitForPortsFree(timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
const sleeper = new Int32Array(new SharedArrayBuffer(4));
while (livePids().length && Date.now() < deadline) {
Atomics.wait(sleeper, 0, 0, 200);
}
return livePids().length === 0;
}
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
cwd: root,
detached: true,
stdio: ["ignore", logFile, logFile],
env: {
...process.env,
NODE_PATH: path.join(rt, "node_modules"),
// The plugin cache is read-only; /ccam-open builds the bundle here.
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
},
});
child.unref();
return child.pid;
}
/* --------------------------------------------------------------- worker run */
function log(rt, line) {
const stamped = `[${new Date().toISOString()}] ${line}\n`;
try {
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
} catch {
/* best effort */
}
}
/**
* Install the plugins CCAM's pipelines drive but this machine cannot resolve —
* today `superpowers`, which `/ccam:ship-feature(-lane)` calls step by step. A
* fresh `ccam` install otherwise runs those skills partway and fails at the
* first call with no warning beforehand.
*
* Off by setting `CCAM_AUTO_INSTALL_PLUGINS` to `0`, `false` or `off`; that
* leaves `/ccam-doctor`'s WARN row as the only notice, which is the right
* behaviour for anyone who wants to choose their own plugins.
*
* One-shot: the outcome is recorded in the runtime state and the fast path
* takes it into account, so a machine with no network does not re-attempt a
* clone on every session. `/ccam-update` (`--force`) retries.
*
* Best-effort throughout. Nothing here may throw or block: the dashboard,
* hooks, CLI and MCP all work without these plugins.
*
* @returns {"off"|"nothing-missing"|"installed"|"failed"|"error"} or the previously
* recorded outcome when this run had nothing to settle.
*/
function autoInstallSkillPlugins(rt, { force = false } = {}) {
const flag = process.env.CCAM_AUTO_INSTALL_PLUGINS;
if (flag === "0" || flag === "false" || flag === "off") return "off";
// Return the PREVIOUS outcome, not a marker: writeState records whatever this
// returns, and overwriting "installed" with "already-settled" would lose the
// only record of what actually happened.
const settled = readState(rt)?.skillPluginsSettled;
if (!force && settled) return settled;
try {
const { autoInstallSkillPlugins: install } = require("../server/lib/skill-plugins");
const summary = install((line) => log(rt, line));
if (!summary.attempted.length) return "nothing-missing";
return summary.failed.length ? "failed" : "installed";
} catch (err) {
log(rt, `skill-plugin auto-install skipped: ${err.message}`);
return "error";
}
}
/**
* The real work. Runs detached from the SessionStart hook, so its only channel
* is the log file; `/ccam-update` runs it in the foreground with `force`, which
* reinstalls dependencies and restarts the server unconditionally.
*
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"}
*/
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
fs.mkdirSync(rt, { recursive: true });
if (!nodeVersionOk()) {
log(
rt,
`Node ${process.versions.node} is too old — CCAM needs Node >= ${MIN_NODE.join(".")} ` +
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
);
return "node-too-old";
}
if (!acquireLock(rt)) {
log(rt, "another bootstrap holds the lock — nothing to do");
return "locked";
}
try {
const wantDeps = depsHash();
const state = readState(rt) || {};
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
log(rt, "installing runtime dependencies (first run takes a few minutes)");
if (!installDeps(rt)) {
log(rt, "npm install failed — see the output above; run /ccam-doctor after fixing it");
return "install-failed";
}
}
const removed = stripLegacyHooks();
if (removed) {
log(
rt,
`removed ${removed} hook entr${removed === 1 ? "y" : "ies"} left by npm run install-hooks ` +
`(backup: ${getSettingsPath()}.ccam-bak) — the plugin installs its own`
);
}
log(rt, `ccam CLI launcher: ${linkCli()}`);
// Built eagerly (not lazily behind /ccam-open) so the dashboard, including
// client-only routes like /run, works the instant `claude` is started —
// same trigger as the dependency install above: missing, or this plugin
// version has not built one yet.
const { buildClient } = require("./plugin-open");
const uiResult = buildClient({
rt,
root: PLUGIN_ROOT,
force: force || state.depsHash !== wantDeps,
logPath: path.join(rt, "client-build.log"),
});
if (uiResult === "failed") {
log(rt, "dashboard UI build failed — API and MCP tools still work; run /ccam-open to retry");
} else if (uiResult === "built") {
log(rt, "built the dashboard UI bundle");
}
// After a plugin update the running server executes code from a cache
// directory Claude Code has already discarded — retire it first.
const movedRoot = force || (state.pluginRoot && state.pluginRoot !== PLUGIN_ROOT);
if (movedRoot && stopDashboard()) {
log(rt, "stopped the server started from the previous plugin version");
waitForPortsFree();
}
if (movedRoot || !serverIsLive()) {
const svc = require("./systemd-service");
const result = svc.systemctlAvailable()
? svc.installUserService({
name: "ccam",
description: "CCAM dashboard server (Claude Code plugin)",
workingDir: PLUGIN_ROOT,
execStart: `${process.execPath} ${path.join(PLUGIN_ROOT, "server", "index.js")}`,
env: {
NODE_ENV: "production",
NODE_PATH: path.join(rt, "node_modules"),
DASHBOARD_CLIENT_DIST: path.join(rt, "client-dist"),
// systemd --user units get systemd's own minimal PATH, not the
// shell's — forward it so the server can find `claude`/`codex`.
PATH: process.env.PATH || "",
},
})
: "unavailable";
if (result === "written" || result === "unchanged") {
if (result === "written") svc.enableLinger();
log(
rt,
result === "written"
? "installed a systemd --user service — the dashboard now survives reboots without Claude Code"
: "systemd --user service already up to date"
);
} else {
// No systemd (macOS/Windows/WSL without it), or the unit exists but
// wasn't ours to touch — same detached-spawn fallback as before,
// scoped to this session only.
const pid = startDashboard(rt);
log(
rt,
`started the dashboard server (pid ${pid})${result === "foreign" ? " — a foreign systemd unit named 'ccam' already exists" : ""}`
);
}
}
// Last, and deliberately so: it clones from the network, and nothing above
// may wait on it. Runs here rather than in the foreground pass because this
// whole function is already detached from the 10 s SessionStart hook.
const skillPlugins = autoInstallSkillPlugins(rt, { force });
writeState(
{
pluginVersion: pluginVersion(),
depsHash: wantDeps,
pluginRoot: PLUGIN_ROOT,
claudeHome: getClaudeHome(),
// Settles the fast path: whatever the outcome, the next SessionStart
// must not spawn a worker just to retry a network clone. Cleared by
// `/ccam-update` (force), which is the retry.
skillPluginsSettled: skillPlugins,
updatedAt: new Date().toISOString(),
},
rt
);
return "ok";
} finally {
releaseLock(rt);
}
}
/**
* Foreground pass, invoked by the SessionStart hook. Returns within
* milliseconds in the steady state, and otherwise hands off to a detached
* worker so the session never waits on an install.
*/
function main() {
if (fastPathOk()) return;
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
});
child.unref();
}
if (require.main === module) {
const force = process.argv.includes("--force");
if (force || process.argv.includes("--worker")) {
// `--force` (from /ccam-update) runs in the foreground so the user sees the
// npm output and the result.
const result = bootstrap(runtimeDir(), { force });
if (force) {
console.log(result === "ok" ? "CCAM runtime refreshed." : `bootstrap: ${result}`);
if (result !== "ok") process.exitCode = 1;
}
} else {
try {
main();
} catch {
// A bootstrap failure must never break a Claude Code session.
}
}
}
module.exports = {
MIN_NODE,
LOCK_STALE_MS,
runtimeDir,
statePath,
readState,
writeState,
depsHash,
fastPathOk,
nodeVersionOk,
lockPath,
lockIsStale,
acquireLock,
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
linkCli,
installDeps,
serverIsLive,
liveServers,
livePids,
stopDashboard,
startDashboard,
autoInstallSkillPlugins,
bootstrap,
};