Files
nntrivi2001 8a82895c65 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>
2026-08-10 16:05:37 +07:00

88 lines
3.2 KiB
JavaScript

#!/usr/bin/env node
/**
* @file plugin-open.js
* @description Builds the dashboard UI bundle for a plugin install and prints
* the dashboard URL. `scripts/plugin-bootstrap.js` calls `buildClient` eagerly
* on session start, so client-only routes (e.g. `/run`) work the moment
* `claude` is started, not just the API and MCP tools. `/ccam-open` re-runs
* this standalone — a no-op when the bundle already matches — as a fallback
* for `--force` rebuilds or a first build that failed during bootstrap.
*
* The client source is copied out of the (replaced-on-update) plugin cache into
* the runtime dir before building, so neither the cache nor the checkout is
* written to, and the output lands where the server already serves from
* (`DASHBOARD_CLIENT_DIST`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const boot = require("./plugin-bootstrap");
const { resolveDashboardPort } = require("../server/lib/server-info");
const PLUGIN_ROOT = path.resolve(__dirname, "..");
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
function run(cmd, args, cwd, stdio) {
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
return res.status === 0;
}
/**
* Build the bundle into `<runtime>/client-dist`. Idempotent: an existing bundle
* is left alone unless `force` is set.
*
* `logPath`, when given, redirects npm's output there instead of inheriting the
* caller's stdio — used when this runs inside the detached bootstrap worker,
* whose own stdout is discarded, so a build failure is still debuggable.
*
* @returns {"ready"|"built"|"failed"}
*/
function buildClient({ rt = boot.runtimeDir(), root = PLUGIN_ROOT, force = false, logPath } = {}) {
const dist = path.join(rt, "client-dist");
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
const logFd = logPath ? fs.openSync(logPath, "a") : null;
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
try {
const src = path.join(rt, "client-src");
fs.rmSync(src, { recursive: true, force: true });
fs.cpSync(path.join(root, "client"), src, {
recursive: true,
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
});
if (!run(NPM, ["install", "--no-audit", "--no-fund"], src, stdio)) return "failed";
if (!run(NPM, ["run", "build"], src, stdio)) return "failed";
fs.rmSync(dist, { recursive: true, force: true });
fs.cpSync(path.join(src, "dist"), dist, { recursive: true });
return "built";
} finally {
if (logFd !== null) fs.closeSync(logFd);
}
}
function dashboardUrl() {
try {
return `http://localhost:${resolveDashboardPort()}`;
} catch {
return "http://localhost:4820";
}
}
if (require.main === module) {
const result = buildClient({ force: process.argv.includes("--force") });
if (result === "failed") {
console.error("Client build failed — the API and MCP tools still work.");
process.exitCode = 1;
} else {
if (result === "built") console.log("Dashboard UI built.");
console.log(dashboardUrl());
}
}
module.exports = { buildClient, dashboardUrl };