feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @file cc-config.js
|
||||
* @description HTTP surface for inspecting and (carefully) mutating Claude
|
||||
* Code configuration: skills, subagents, slash commands, output styles,
|
||||
* plugins, marketplaces, MCP servers, hooks, settings, memory, keybindings,
|
||||
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
|
||||
* page.
|
||||
*
|
||||
* Read paths cover every surface. Write paths exist only for low-risk
|
||||
* text-file artifacts (skills, agents, commands, output styles, memory, and
|
||||
* per-project auto-memory files) plus the structured keybindings.json editor,
|
||||
* and always create a timestamped backup before mutating. Plugins, MCP servers,
|
||||
* and the live settings.json files stay read-only because they are written
|
||||
* concurrently by the running Claude Code CLI.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const cc = require("../lib/cc-discovery");
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
const { broadcast } = require("../websocket");
|
||||
|
||||
const router = Router();
|
||||
|
||||
function emitChanged(payload) {
|
||||
try {
|
||||
broadcast("cc_config_changed", { source: "dashboard", ...payload });
|
||||
} catch {
|
||||
/* websocket may not be initialised in unit tests */
|
||||
}
|
||||
}
|
||||
|
||||
// Map mutate-error codes → HTTP status. Anything unmapped is a 500.
|
||||
const ERR_TO_STATUS = {
|
||||
EBADTYPE: 400,
|
||||
EBADSCOPE: 400,
|
||||
EBADNAME: 400,
|
||||
EBADPROJECT: 400,
|
||||
EBADCONTENT: 400,
|
||||
ETOOLARGE: 413,
|
||||
EOUTOFROOT: 400,
|
||||
ENOTFOUND: 404,
|
||||
};
|
||||
|
||||
function mutateError(res, err) {
|
||||
const status = ERR_TO_STATUS[err.code] || 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
|
||||
function scopeOf(req) {
|
||||
const s = String(req.query.scope || "all");
|
||||
return s === "user" || s === "project" ? s : "all";
|
||||
}
|
||||
|
||||
function cwdOf(req) {
|
||||
// The dashboard server's own cwd is the natural "project" — but allow
|
||||
// override via ?cwd= so the user can inspect another working dir without
|
||||
// restarting the server.
|
||||
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
||||
return c || process.cwd();
|
||||
}
|
||||
|
||||
router.get("/overview", (req, res) => {
|
||||
res.json(cc.readOverview({ cwd: cwdOf(req) }));
|
||||
});
|
||||
|
||||
router.get("/skills", (req, res) => {
|
||||
res.json({ items: cc.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/agents", (req, res) => {
|
||||
res.json({ items: cc.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/commands", (req, res) => {
|
||||
res.json({ items: cc.readCommands({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/output-styles", (req, res) => {
|
||||
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/plugins", (_req, res) => {
|
||||
res.json(cc.readPlugins());
|
||||
});
|
||||
|
||||
router.get("/mcp", (req, res) => {
|
||||
res.json(cc.readMcpServers({ cwd: cwdOf(req) }));
|
||||
});
|
||||
|
||||
router.get("/hooks", (req, res) => {
|
||||
res.json({ items: cc.readHooks({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/settings", (req, res) => {
|
||||
res.json({ items: cc.readSettings({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/memory", (req, res) => {
|
||||
res.json({ items: cc.readMemory({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/marketplaces", (_req, res) => {
|
||||
res.json(cc.readMarketplaces());
|
||||
});
|
||||
|
||||
router.get("/keybindings", (_req, res) => {
|
||||
res.json(cc.readKeybindings());
|
||||
});
|
||||
|
||||
// PUT /api/cc-config/keybindings — structured overwrite of the user's
|
||||
// ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key,
|
||||
// action }] }] }. Backs the file up first and preserves top-level metadata.
|
||||
router.put("/keybindings", (req, res) => {
|
||||
const { groups } = req.body || {};
|
||||
if (!Array.isArray(groups)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "groups array is required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.writeKeybindings({ groups });
|
||||
emitChanged({ action: "write", type: "keybindings", name: null, project: null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/statusline", (_req, res) => {
|
||||
res.json(cc.readStatusline());
|
||||
});
|
||||
|
||||
router.get("/hook-scripts", (_req, res) => {
|
||||
res.json(cc.readHookScripts());
|
||||
});
|
||||
|
||||
// GET /api/cc-config/file?path=/abs/path — return body of a single file.
|
||||
// Path must resolve under CLAUDE_HOME, project .claude/, or be project CLAUDE.md.
|
||||
router.get("/file", (req, res) => {
|
||||
const p = req.query.path;
|
||||
if (typeof p !== "string" || !p) {
|
||||
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
|
||||
}
|
||||
const result = cc.readFileSafe(p, { cwd: cwdOf(req) });
|
||||
if (result.error)
|
||||
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Phase-2 mutation endpoints ─────────────────────────────────────────
|
||||
//
|
||||
// PUT /api/cc-config/file — create or overwrite. Body: { scope, type, name?, content }
|
||||
// DELETE /api/cc-config/file — delete. Body: { scope, type, name? }
|
||||
// GET /api/cc-config/backups[?scope=&type=] — list backups
|
||||
//
|
||||
// Plugins, MCP, hooks-in-settings, and settings.json files are intentionally
|
||||
// not mutable here. See cc-mutate.js for the rationale.
|
||||
|
||||
router.put("/file", (req, res) => {
|
||||
const { scope, type, name, content, project } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.writeArtifact({ scope, type, name, content, project, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/file", (req, res) => {
|
||||
const { scope, type, name, project } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "delete", scope, type, name: name || null, project: project || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/backups", (req, res) => {
|
||||
const scope =
|
||||
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
||||
const type = typeof req.query.type === "string" ? req.query.type : undefined;
|
||||
res.json({ items: ccMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user