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,87 @@
|
||||
#!/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 };
|
||||
Reference in New Issue
Block a user