8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
898 lines
32 KiB
JavaScript
898 lines
32 KiB
JavaScript
/**
|
|
* @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 cover: low-risk text-file
|
|
* artifacts (skills, agents, commands, output styles, memory, per-project
|
|
* auto-memory), the structured keybindings.json editor, settings.json layers
|
|
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
|
|
* plugin/marketplace management delegated to the `claude plugin …` CLI, and
|
|
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
|
|
* write is preceded by a timestamped backup; the CLI-delegated writes are
|
|
* not files this server touches directly, so there is nothing to back up —
|
|
* the CLI owns `~/.claude.json`. Every write carries `sameOriginGuard`: an
|
|
* MCP server or a plugin is code Claude Code later runs.
|
|
*
|
|
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
|
|
* account the local CLI runs as; those carry identity metadata only, never
|
|
* token material.
|
|
*
|
|
* Whose config (server mode): the router's own gate (`cc-target.ccGate`) is
|
|
* the single rule for who reaches what — an admin everything; a non-admin only
|
|
* the allowlisted reads and member-capable writes (never `/profiles*` or
|
|
* `/plugins/update-all`; an unlisted endpoint is an admin's). Every handler
|
|
* then acts on ONE target (`cc-target.resolveCcTarget`): the host's Claude
|
|
* home for an admin and in local mode, exactly as before; a member's own
|
|
* `~/.claude` under S3, with every write run AS them (write-as, sudo `claude`)
|
|
* and a project layer only in a workspace of their own (`?cwd=`); 403
|
|
* ENOACCOUNT for a non-admin with no account — never the host's. A member's
|
|
* change is broadcast to that member alone (`ownerId`), and their `/reload`
|
|
* types into their own panes only.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const path = require("path");
|
|
const cc = require("../lib/cc-discovery");
|
|
const ccMutate = require("../lib/cc-mutate");
|
|
const ccCli = require("../lib/cc-plugin-cli");
|
|
const ccMcpCli = require("../lib/cc-mcp-cli");
|
|
const ccProfiles = require("../lib/cc-profiles");
|
|
const ccUsage = require("../lib/cc-usage");
|
|
const projectMemory = require("../lib/project-memory");
|
|
const { sameOriginGuard } = require("../lib/same-origin");
|
|
const { broadcast } = require("../websocket");
|
|
const { reloadOpenSessions } = require("../lib/cc-reload");
|
|
const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all");
|
|
const { detectPluginInstall } = require("../lib/update-check");
|
|
const scope = require("../lib/scope");
|
|
const { resolveCcTarget, ccGate } = require("../lib/cc-target");
|
|
|
|
/** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach
|
|
* an argv in the detached batch worker, so they are filtered before they go. */
|
|
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
|
|
const router = Router();
|
|
// Server mode: who may reach which endpoint at all (an allowlist for a
|
|
// non-admin; see cc-target.js), then a non-admin's ?cwd= only names one of
|
|
// their own workspaces.
|
|
router.use(ccGate);
|
|
router.use(scope.ownCwdQuery);
|
|
|
|
/**
|
|
* `cc_config_changed` for a change to `tgt`. A member's own config is news
|
|
* for that member alone (`ownerId`: scope.exactAudience) — its file names are
|
|
* nobody else's business; the host's goes to everyone as before.
|
|
*/
|
|
function emitChanged(payload, tgt, req) {
|
|
try {
|
|
const owner = tgt && tgt.own && req && req.user ? { ownerId: req.user.id } : {};
|
|
broadcast("cc_config_changed", { source: "dashboard", ...payload, ...owner });
|
|
} catch {
|
|
/* websocket may not be initialised in unit tests */
|
|
}
|
|
}
|
|
|
|
/** The request's target, or null once a 403 ENOACCOUNT has been answered. */
|
|
function target(req, res) {
|
|
try {
|
|
return resolveCcTarget(req);
|
|
} catch (err) {
|
|
res.status(err.status || 403).json({
|
|
error: { code: err.code || "ENOACCOUNT", message: err.message },
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const askedCwd = (req) =>
|
|
typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
|
|
|
/**
|
|
* Reader options for `tgt`: the host's exactly as before (`?cwd=` or the
|
|
* server's own folder); a member's own home, with a project layer only when
|
|
* they named one of their workspaces (ownCwdQuery held it there) — never the
|
|
* dashboard's own folder.
|
|
*/
|
|
function readOpts(req, tgt) {
|
|
if (!tgt.own) return { cwd: cwdOf(req) };
|
|
const asked = askedCwd(req);
|
|
return {
|
|
home: tgt.home,
|
|
claudeJson: tgt.claudeJson,
|
|
cwd: asked || undefined,
|
|
noProject: !asked,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Writer options for `tgt`, or null once answered. The host's: the server's
|
|
* own folder for the project layers, never `?cwd=` (as before). A member's:
|
|
* their own home, and a project layer only in a workspace of their own whose
|
|
* folder is their own account's (`?cwd=`) — anything else 403 EFOLDER.
|
|
*/
|
|
function writeOpts(req, res, tgt) {
|
|
if (!tgt.own) return { cwd: process.cwd() };
|
|
const asked = askedCwd(req);
|
|
if (!asked) return { home: tgt.home, noProject: true };
|
|
let owner = null;
|
|
try {
|
|
owner = path.isAbsolute(asked) ? require("../lib/run-as").accountForPath(asked) : null;
|
|
} catch {
|
|
owner = null;
|
|
}
|
|
if (
|
|
!path.isAbsolute(asked) ||
|
|
owner?.name !== tgt.acct.name ||
|
|
!scope.insideOwnWorkspace(req, path.resolve(asked))
|
|
) {
|
|
res.status(403).json({
|
|
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
|
});
|
|
return null;
|
|
}
|
|
return { home: tgt.home, cwd: path.resolve(asked) };
|
|
}
|
|
|
|
/** The member a CLI call runs as (`acct`), or undefined for the host's own CLI. */
|
|
const cliAcct = (tgt) => (tgt.own ? tgt.acct : undefined);
|
|
|
|
// 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,
|
|
EBADDIR: 400,
|
|
EBADTOOL: 400,
|
|
// The project already has a memory file; creating would clobber it.
|
|
EEXISTS: 409,
|
|
ETOOLARGE: 413,
|
|
EOUTOFROOT: 400,
|
|
ENOTFOUND: 404,
|
|
EREDACTED: 400,
|
|
// The file on disk is unusable as-is; the caller must fix it by hand first.
|
|
EBADJSON: 409,
|
|
// S3: the folder's member has no usable account — nothing runs as the host instead.
|
|
EMEMBERGONE: 409,
|
|
};
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* The Claude home whose USER skills, commands and plugins a request's sessions
|
|
* load — so the run form's `/` list matches what its pane's Claude can call.
|
|
* Under S3 a pane runs as its FOLDER's account: `?cwd=` picks that account; a
|
|
* non-admin with no `?cwd=` reads their own. Undefined = the host's (local
|
|
* mode, or an admin in a host folder / the Config Explorer); null = none, for
|
|
* a non-admin with no account of their own — another user's or the host's
|
|
* skills are never listed as theirs.
|
|
*/
|
|
function userClaudeDir(req) {
|
|
if (!req.user) return undefined;
|
|
const runAs = require("../lib/run-as");
|
|
const cwd = typeof req.query.cwd === "string" && req.query.cwd;
|
|
let acct = null;
|
|
try {
|
|
if (cwd && path.isAbsolute(cwd)) acct = runAs.ownerForRead(path.resolve(cwd));
|
|
else if (req.user.role !== "admin") acct = runAs.accountForUser(req.user.username);
|
|
} catch {
|
|
acct = null;
|
|
}
|
|
if (acct) return path.join(acct.home, ".claude");
|
|
return req.user.role === "admin" ? undefined : null;
|
|
}
|
|
|
|
/**
|
|
* `{scope, cwd, userClaudeDir}` for the two lists a member may read (the run form's `/`
|
|
* autocomplete). Server mode keeps a member's project items to their own
|
|
* workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is
|
|
* no project at all (never the dashboard's own working directory). Returns null
|
|
* once it has answered; `none` = nothing to list.
|
|
*/
|
|
function listOpts(req, res) {
|
|
const opts = { scope: scopeOf(req), cwd: cwdOf(req), userClaudeDir: userClaudeDir(req) };
|
|
const asked = typeof req.query.cwd === "string" && req.query.cwd;
|
|
// No account of their own: only a named workspace's project items, if any.
|
|
if (opts.userClaudeDir === null) {
|
|
if (opts.scope === "user" || !asked) return { none: true };
|
|
opts.scope = "project";
|
|
}
|
|
if (scope.memberId(req) == null) return opts;
|
|
if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" };
|
|
if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) {
|
|
res.status(403).json({
|
|
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
|
});
|
|
return null;
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
/**
|
|
* GET /target — whose config this page shows: `own` (a member's, S3), `host`
|
|
* (an admin's, local mode; an analyst's read-only view), or `none` (a
|
|
* non-admin with no account: the page is not theirs to open).
|
|
*/
|
|
router.get("/target", (req, res) => {
|
|
let tgt;
|
|
try {
|
|
tgt = resolveCcTarget(req);
|
|
} catch {
|
|
return res.json({ kind: "none", readOnly: true, claudeHome: null });
|
|
}
|
|
res.json({ kind: tgt.own ? "own" : "host", readOnly: tgt.readOnly, claudeHome: tgt.home });
|
|
});
|
|
|
|
router.get("/overview", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json(cc.readOverview(readOpts(req, tgt)));
|
|
});
|
|
|
|
router.get("/skills", (req, res) => {
|
|
const opts = listOpts(req, res);
|
|
if (opts) res.json({ items: opts.none ? [] : cc.readSkills(opts) });
|
|
});
|
|
|
|
router.get("/agents", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json({ items: cc.readAgents({ scope: scopeOf(req), ...readOpts(req, tgt) }) });
|
|
});
|
|
|
|
router.get("/commands", (req, res) => {
|
|
const opts = listOpts(req, res);
|
|
if (opts) res.json({ items: opts.none ? [] : cc.readCommands(opts) });
|
|
});
|
|
|
|
router.get("/output-styles", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) {
|
|
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), ...readOpts(req, tgt) }) });
|
|
}
|
|
});
|
|
|
|
router.get("/plugins", (req, res) => {
|
|
const home = userClaudeDir(req);
|
|
if (home === null) return res.json({ manifestPath: null, manifestExists: false, plugins: [] });
|
|
res.json(cc.readPlugins(home));
|
|
});
|
|
|
|
router.get("/mcp", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json(cc.readMcpServers(readOpts(req, tgt)));
|
|
});
|
|
|
|
router.get("/hooks", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json({ items: cc.readHooks(readOpts(req, tgt)) });
|
|
});
|
|
|
|
router.get("/settings", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json({ items: cc.readSettings(readOpts(req, tgt)) });
|
|
});
|
|
|
|
router.get("/memory", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json({ items: cc.readMemory(readOpts(req, tgt)) });
|
|
});
|
|
|
|
router.get("/marketplaces", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json(cc.readMarketplaces(readOpts(req, tgt)));
|
|
});
|
|
|
|
router.get("/keybindings", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json(cc.readKeybindings(readOpts(req, tgt)));
|
|
});
|
|
|
|
// 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", sameOriginGuard, (req, res) => {
|
|
const { groups } = req.body || {};
|
|
if (!Array.isArray(groups)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "EBADREQ", message: "groups array is required" } });
|
|
}
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
const result = ccMutate.writeKeybindings({ groups, home: tgt.own ? tgt.home : undefined });
|
|
emitChanged({ action: "write", type: "keybindings", name: null, project: null }, tgt, req);
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
router.get("/statusline", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json(cc.readStatusline(readOpts(req, tgt)));
|
|
});
|
|
|
|
router.get("/hook-scripts", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (tgt) res.json(cc.readHookScripts(readOpts(req, tgt)));
|
|
});
|
|
|
|
// GET /api/cc-config/file?path=/abs/path — return body of a single file.
|
|
// Path must resolve under the target's Claude home, project .claude/, or be
|
|
// project CLAUDE.md. An analyst with an account of their own also keeps the
|
|
// host's files they read before (read-only).
|
|
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 tgt = target(req, res);
|
|
if (!tgt) return;
|
|
let result = cc.readFileSafe(p, readOpts(req, tgt));
|
|
if (result.error && tgt.own && req.user?.role === "analyst") {
|
|
const hostRead = cc.readFileSafe(p, { cwd: cwdOf(req) });
|
|
if (!hostRead.error) result = hostRead;
|
|
}
|
|
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", sameOriginGuard, (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" } });
|
|
}
|
|
const tgt = target(req, res);
|
|
const opts = tgt && writeOpts(req, res, tgt);
|
|
if (!opts) return;
|
|
try {
|
|
// The host's: process.cwd(), never ?cwd= — the same rule PATCH /settings
|
|
// already follows: a caller-chosen write target lets any request drop a
|
|
// file anywhere on disk. A member's: their own workspace only (writeOpts).
|
|
const result = ccMutate.writeArtifact({
|
|
scope,
|
|
type,
|
|
name,
|
|
content,
|
|
project,
|
|
...opts,
|
|
});
|
|
emitChanged(
|
|
{ action: "write", scope, type, name: name || null, project: project || null },
|
|
tgt,
|
|
req
|
|
);
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
router.delete("/file", sameOriginGuard, (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" } });
|
|
}
|
|
const tgt = target(req, res);
|
|
const opts = tgt && writeOpts(req, res, tgt);
|
|
if (!opts) return;
|
|
try {
|
|
// Pinned for the same reason as PUT above.
|
|
const result = ccMutate.deleteArtifact({ scope, type, name, project, ...opts });
|
|
emitChanged(
|
|
{ action: "delete", scope, type, name: name || null, project: project || null },
|
|
tgt,
|
|
req
|
|
);
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/cc-config/project-memory — create `CLAUDE.md` in a project the
|
|
* dashboard has never run in. Body: `{ dir, content }`.
|
|
*
|
|
* The one write here that honours a caller-supplied directory, which is the
|
|
* whole point: you cannot bootstrap a NEW project's memory from a writer
|
|
* pinned to the server's own cwd. It stays narrow — see project-memory.js —
|
|
* and sits behind the same loopback-origin guard the process-spawning routes
|
|
* use, so a random web page cannot plant instructions in a project directory.
|
|
* A member names only a folder of their own workspaces, in their own account
|
|
* (403 EFOLDER otherwise); under S3 the file is written as the folder's
|
|
* account (project-memory.js), an admin's create in a member's folder too.
|
|
*/
|
|
router.post("/project-memory", sameOriginGuard, (req, res) => {
|
|
const { dir, content } = req.body || {};
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
if (tgt.own) {
|
|
let owner = null;
|
|
try {
|
|
owner =
|
|
typeof dir === "string" && path.isAbsolute(dir)
|
|
? require("../lib/run-as").accountForPath(dir)
|
|
: null;
|
|
} catch {
|
|
owner = null;
|
|
}
|
|
if (owner?.name !== tgt.acct.name || !scope.insideOwnWorkspace(req, path.resolve(dir))) {
|
|
return res.status(403).json({
|
|
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
const result = projectMemory.createProjectMemory({ tool: "claude", dir, content });
|
|
emitChanged({ action: "write", scope: "project", type: "memory", name: null }, tgt, req);
|
|
res.json({ ...result, created: true });
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
|
|
//
|
|
// These shell out to `claude plugin …` with an argv array — never a shell
|
|
// string — because the CLI owns the manifest + cache tree that plugin state
|
|
// lives in. Identifiers travel in the body, not the URL, so a key like
|
|
// `agenthub@sg` needs no encoding (same reason `DELETE /file` takes a body).
|
|
|
|
const CLI_ERR_TO_STATUS = {
|
|
EBADARG: 400,
|
|
ECLINOTFOUND: 503,
|
|
// S3: `claude` is not published to the shared tools, so no member can run it.
|
|
ENOTSHARED: 503,
|
|
ETIMEOUT: 504,
|
|
ECLIFAILED: 502,
|
|
};
|
|
|
|
function cliError(res, err) {
|
|
const status = CLI_ERR_TO_STATUS[err.code] || 500;
|
|
return res
|
|
.status(status)
|
|
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
|
}
|
|
|
|
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
|
|
function cliResult(res, result, changed, extra, tgt, req) {
|
|
if (!result.ok) {
|
|
return res.status(422).json({
|
|
error: {
|
|
code: "ECLIEXIT",
|
|
message: ccCli.cliMessage(result) || `claude exited with code ${result.code}`,
|
|
},
|
|
code: result.code,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
});
|
|
}
|
|
emitChanged(changed, tgt, req);
|
|
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, ...extra });
|
|
}
|
|
|
|
router.get("/plugins/available", async (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
res.json(await ccCli.listAvailable({ acct: cliAcct(tgt) }));
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/plugins/install", sameOriginGuard, async (req, res) => {
|
|
const { spec, scope } = req.body || {};
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
const result = await ccCli.pluginInstall({ spec, scope, acct: cliAcct(tgt) });
|
|
return cliResult(
|
|
res,
|
|
result,
|
|
{ action: "install", type: "plugin", name: spec },
|
|
undefined,
|
|
tgt,
|
|
req
|
|
);
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
const PLUGIN_ACTIONS = {
|
|
enable: ccCli.pluginEnable,
|
|
disable: ccCli.pluginDisable,
|
|
update: ccCli.pluginUpdate,
|
|
uninstall: ccCli.pluginUninstall,
|
|
};
|
|
|
|
router.post("/plugins/action", sameOriginGuard, async (req, res) => {
|
|
const { key, action, reload } = req.body || {};
|
|
const run = PLUGIN_ACTIONS[action];
|
|
if (!run) {
|
|
return res.status(400).json({
|
|
error: {
|
|
code: "EBADACTION",
|
|
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
|
|
},
|
|
});
|
|
}
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
const result = await run({ spec: key, acct: cliAcct(tgt) });
|
|
// "Update one, reload that one" — the counterpart to the batch, which
|
|
// reloads once at the end. Only on success, and only when asked: an
|
|
// enable/disable already needs a reload but the caller decides, and a
|
|
// reload after a FAILED update would tell the session to activate nothing.
|
|
let reloaded;
|
|
if (reload && result.ok) {
|
|
try {
|
|
reloaded = await reloadOpenSessions({ account: cliAcct(tgt) });
|
|
} catch {
|
|
/* a reload that cannot be delivered must not fail the update itself */
|
|
}
|
|
}
|
|
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded }, tgt, req);
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Type `/reload-plugins` + `/reload-skills` into the live Claude Code lane
|
|
* panes. Not a config mutation — it changes nothing on disk, it just tells
|
|
* sessions to re-read what is already there — so it neither queues on the CLI
|
|
* mutation chain nor emits `cc_config_changed`. A member's reload (S3) types
|
|
* into the panes that run as them, and only those; an admin's into the
|
|
* host's, as before.
|
|
*/
|
|
router.post("/reload", sameOriginGuard, async (req, res) => {
|
|
const { kinds } = req.body || {};
|
|
if (kinds !== undefined && !Array.isArray(kinds)) {
|
|
return res.status(400).json({
|
|
error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" },
|
|
});
|
|
}
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
res.json({ ok: true, ...(await reloadOpenSessions({ kinds, account: cliAcct(tgt) })) });
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: { code: "ERELOAD", message: err.message || String(err) },
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── Update all ─────────────────────────────────────────────────────────
|
|
//
|
|
// Runs detached (see server/lib/cc-update-all.js): the batch ends with AgentHub
|
|
// itself when this server runs from the plugin cache, and that step stops the
|
|
// process. The response is therefore only an acknowledgement — progress is
|
|
// polled from GET below, which reads the worker's on-disk journal.
|
|
|
|
router.get("/plugins/update-all", (_req, res) => {
|
|
res.json({ batch: readUpdateAll() });
|
|
});
|
|
|
|
router.post("/plugins/update-all", sameOriginGuard, async (req, res) => {
|
|
if (isRunning()) {
|
|
return res.status(409).json({
|
|
error: { code: "EBUSY", message: "an update-all batch is already running" },
|
|
batch: readUpdateAll(),
|
|
});
|
|
}
|
|
|
|
let installed;
|
|
try {
|
|
installed = (await ccCli.listAvailable()).installed || [];
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
|
|
// Ids come from the CLI's own JSON, never from the request body, but they
|
|
// reach an argv in a detached process — so they are filtered here too.
|
|
const plugins = installed.map((p) => String(p.id || "")).filter((id) => PLUGIN_SPEC_RE.test(id));
|
|
|
|
// Which plugin, if any, is the one this dashboard is running from. Derived
|
|
// from our own path — never from the request — so no caller can nominate a
|
|
// different plugin as "self" and get a bootstrap run against it.
|
|
const selfRoot = path.resolve(__dirname, "..", "..");
|
|
const detected = detectPluginInstall(selfRoot);
|
|
const selfPlugin = detected
|
|
? { marketplace: detected.marketplace, plugin: detected.plugin }
|
|
: null;
|
|
|
|
const plan = {
|
|
plugins,
|
|
selfPlugin,
|
|
selfRoot: selfPlugin ? selfRoot : null,
|
|
reload: req.body?.reload !== false,
|
|
};
|
|
|
|
try {
|
|
const started = startUpdateAll(plan);
|
|
res.json({ ok: true, ...started, planned: plugins.length, self_plugin: selfPlugin });
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: { code: "EUPDATEALL", message: err.message || String(err) },
|
|
});
|
|
}
|
|
});
|
|
|
|
router.post("/marketplaces/add", sameOriginGuard, async (req, res) => {
|
|
const { source, scope } = req.body || {};
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
const result = await ccCli.marketplaceAdd({ source, scope, acct: cliAcct(tgt) });
|
|
return cliResult(
|
|
res,
|
|
result,
|
|
{ action: "add", type: "marketplace", name: source },
|
|
undefined,
|
|
tgt,
|
|
req
|
|
);
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
const MARKETPLACE_ACTIONS = {
|
|
remove: ccCli.marketplaceRemove,
|
|
update: ccCli.marketplaceUpdate,
|
|
};
|
|
|
|
router.post("/marketplaces/action", sameOriginGuard, async (req, res) => {
|
|
const { name, action } = req.body || {};
|
|
const run = MARKETPLACE_ACTIONS[action];
|
|
if (!run) {
|
|
return res.status(400).json({
|
|
error: {
|
|
code: "EBADACTION",
|
|
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
|
|
},
|
|
});
|
|
}
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
const result = await run({ name, acct: cliAcct(tgt) });
|
|
return cliResult(res, result, { action, type: "marketplace", name }, undefined, tgt, req);
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
// ── MCP servers (via the `claude` CLI) ─────────────────────────────────
|
|
//
|
|
// Same reasoning as the plugin/marketplace routes above: `~/.claude.json` is
|
|
// rewritten concurrently by the running CLI, so add/remove shell out to
|
|
// `claude mcp …` rather than editing the file. Reads are unaffected — GET
|
|
// /mcp above stays on `readMcpServers`; the client refetches it after either
|
|
// call below succeeds.
|
|
|
|
router.post("/mcp/add", sameOriginGuard, async (req, res) => {
|
|
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
const result = await ccMcpCli.mcpAdd({
|
|
name,
|
|
scope,
|
|
transport,
|
|
command,
|
|
args,
|
|
url,
|
|
headers,
|
|
env,
|
|
acct: cliAcct(tgt),
|
|
});
|
|
return cliResult(res, result, { action: "add", type: "mcp", name }, undefined, tgt, req);
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/mcp/remove", sameOriginGuard, async (req, res) => {
|
|
const { name, scope } = req.body || {};
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
try {
|
|
const result = await ccMcpCli.mcpRemove({ name, scope, acct: cliAcct(tgt) });
|
|
return cliResult(res, result, { action: "remove", type: "mcp", name }, undefined, tgt, req);
|
|
} catch (err) {
|
|
return cliError(res, err);
|
|
}
|
|
});
|
|
|
|
// PATCH /api/cc-config/settings — merge individual keys into one settings
|
|
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
|
|
// unset?: ["dotted"] }. Hooks are edited through this same route as
|
|
// `set: { "hooks.PreToolUse": [...] }` in settings.json's own nested shape.
|
|
//
|
|
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
|
|
// target chosen by the caller would let any request create a settings.json
|
|
// anywhere on disk. Reads keep the override; writes do not. A member's own
|
|
// config is the exception that proves it: their home, and a project layer
|
|
// only in one of their own workspaces (writeOpts).
|
|
router.patch("/settings", sameOriginGuard, (req, res) => {
|
|
const { scope, set, unset } = req.body || {};
|
|
const tgt = target(req, res);
|
|
const opts = tgt && writeOpts(req, res, tgt);
|
|
if (!opts) return;
|
|
try {
|
|
const result = ccMutate.patchSettings({ scope, set, unset, ...opts });
|
|
emitChanged({ action: "patch", type: "settings", name: scope, project: null }, tgt, req);
|
|
res.json(result);
|
|
} catch (err) {
|
|
return mutateError(res, err);
|
|
}
|
|
});
|
|
|
|
// ── Auth profiles ──────────────────────────────────────────────────────
|
|
//
|
|
// Switch which Anthropic account (= which quota) the local `claude` CLI runs
|
|
// as, keeping every other config surface in place. Responses carry identity
|
|
// metadata only — never token material. See cc-profiles.js.
|
|
|
|
const PROFILE_ERR_TO_STATUS = {
|
|
EBADNAME: 400,
|
|
EBADLABEL: 400,
|
|
EBADREQ: 400,
|
|
ENOTFOUND: 404,
|
|
EBADJSON: 409,
|
|
EACTIVE: 409,
|
|
EUNSAVED: 409,
|
|
ENOAUTH: 409,
|
|
EDUPLICATE: 409,
|
|
ELABELTAKEN: 409,
|
|
EMISMATCH: 409,
|
|
};
|
|
|
|
function profileError(res, e) {
|
|
const status = PROFILE_ERR_TO_STATUS[e.code] || 500;
|
|
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
|
}
|
|
|
|
// The live token's owner (asked of Anthropic, cached per token) decides who is
|
|
// logged in — `.claude.json` can name another account. Null offline: then the
|
|
// config is all there is, as before.
|
|
router.get("/profiles", async (_req, res) => {
|
|
try {
|
|
res.json(ccProfiles.listProfiles({ owner: await ccUsage.liveOwner() }));
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
router.post("/profiles", sameOriginGuard, async (req, res) => {
|
|
try {
|
|
const owner = await ccUsage.liveOwner();
|
|
const result = ccProfiles.saveProfile((req.body || {}).name, { owner });
|
|
emitChanged({ action: "save", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
// Rename is a label edit; the profile's `name` identifier never moves.
|
|
router.patch("/profiles", sameOriginGuard, (req, res) => {
|
|
try {
|
|
const body = req.body || {};
|
|
const result = ccProfiles.renameProfile(body.name, body.label);
|
|
emitChanged({ action: "rename", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
router.post("/profiles/activate", sameOriginGuard, async (req, res) => {
|
|
try {
|
|
const owner = await ccUsage.liveOwner();
|
|
const result = ccProfiles.activateProfile((req.body || {}).name, { owner });
|
|
emitChanged({ action: "activate", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /profiles/usage — how much of this profile's account quota is spent.
|
|
*
|
|
* A POST, and behind the same-origin guard, because it is not a read: a saved
|
|
* profile's access token has almost always expired, so answering means
|
|
* refreshing it, and the refresh rotates the stored credentials on disk (see
|
|
* `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks
|
|
* per profile, when the user asks.
|
|
*
|
|
* The response is two percentages and two reset timestamps. No token material
|
|
* crosses this boundary, in either direction: the name is all the caller sends.
|
|
*/
|
|
router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
|
|
const name = (req.body || {}).name;
|
|
try {
|
|
res.json(await ccUsage.profileUsage(name));
|
|
} catch (e) {
|
|
const status =
|
|
{
|
|
ENOTFOUND: 404,
|
|
EBADNAME: 400,
|
|
ENOREFRESH: 409,
|
|
ENOAUTH: 409,
|
|
ESTALE: 409,
|
|
ELIVEEXPIRED: 409,
|
|
EUNAUTHORIZED: 401,
|
|
}[e.code] || 502;
|
|
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
|
|
}
|
|
});
|
|
|
|
// Identifier travels in the body, not the URL — same reason as DELETE /file.
|
|
router.delete("/profiles", sameOriginGuard, (req, res) => {
|
|
try {
|
|
const result = ccProfiles.deleteProfile((req.body || {}).name);
|
|
emitChanged({ action: "delete", type: "profile", name: result.name, project: null });
|
|
res.json(result);
|
|
} catch (e) {
|
|
return profileError(res, e);
|
|
}
|
|
});
|
|
|
|
router.get("/backups", (req, res) => {
|
|
const tgt = target(req, res);
|
|
if (!tgt) return;
|
|
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, ...readOpts(req, tgt) }) });
|
|
});
|
|
|
|
module.exports = router;
|