feat(codex): expose the Codex config API
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* @file codex-config.js
|
||||
* @description HTTP surface for inspecting and (carefully) mutating Codex
|
||||
* CLI configuration: skills, agents, rules, memory (AGENTS.md),
|
||||
* config.toml, MCP servers, plugins, and marketplaces. Powers the Codex
|
||||
* Config panel — the Codex counterpart of cc-config.js.
|
||||
*
|
||||
* Read paths (skills/agents/rules/memory/config) come from codex-discovery.js
|
||||
* (pure file reads). MCP servers, plugins, and marketplaces have no on-disk
|
||||
* reader — codex-discovery.js deliberately leaves them to the `codex` CLI —
|
||||
* so those three GET routes, plus every mutation, shell out through
|
||||
* codex-cli.js. A non-zero CLI exit is reported as 422 with stdout/stderr
|
||||
* preserved, never a 500.
|
||||
*
|
||||
* Write paths (skills/agents/rules/memory/config) go through codex-mutate.js:
|
||||
* every write/delete is preceded by a timestamped backup, and every mutation
|
||||
* error code is mapped explicitly below so an unmapped one can never fall
|
||||
* through to a bare 500.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Router } = require("express");
|
||||
const codexDiscovery = require("../lib/codex-discovery");
|
||||
const codexMutate = require("../lib/codex-mutate");
|
||||
const codexCli = require("../lib/codex-cli");
|
||||
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("../lib/codex-home");
|
||||
const { isUnder, MAX_FILE_BYTES } = require("../lib/cc-discovery");
|
||||
const { broadcast } = require("../websocket");
|
||||
|
||||
const router = Router();
|
||||
|
||||
function emitChanged(payload) {
|
||||
try {
|
||||
broadcast("codex_config_changed", { source: "dashboard", ...payload });
|
||||
} catch {
|
||||
/* websocket may not be initialised in unit tests */
|
||||
}
|
||||
}
|
||||
|
||||
function scopeOf(req) {
|
||||
const s = String(req.query.scope || "all");
|
||||
return s === "user" || s === "project" ? s : "all";
|
||||
}
|
||||
|
||||
function cwdOf(req) {
|
||||
// Mirrors cc-config's cwdOf: the server's own cwd is the natural
|
||||
// "project", but ?cwd= lets the caller inspect another working dir.
|
||||
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
||||
return c || process.cwd();
|
||||
}
|
||||
|
||||
// ── Mutation errors (codex-mutate.js) → HTTP status ────────────────────
|
||||
// Anything unmapped is a 500 — this map must cover every code codex-mutate
|
||||
// can throw.
|
||||
const ERR_TO_STATUS = {
|
||||
EBADTYPE: 400,
|
||||
EBADSCOPE: 400,
|
||||
EBADNAME: 400,
|
||||
EBADCONTENT: 400,
|
||||
EOUTOFROOT: 400,
|
||||
ETOOLARGE: 413,
|
||||
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 } });
|
||||
}
|
||||
|
||||
// ── Discovery reads ─────────────────────────────────────────────────────
|
||||
|
||||
router.get("/overview", (req, res) => {
|
||||
res.json(codexDiscovery.readOverview({ cwd: cwdOf(req) }));
|
||||
});
|
||||
|
||||
router.get("/skills", (req, res) => {
|
||||
res.json({ skills: codexDiscovery.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/agents", (req, res) => {
|
||||
res.json({ agents: codexDiscovery.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/rules", (req, res) => {
|
||||
res.json({ rules: codexDiscovery.readRules({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/memory", (req, res) => {
|
||||
res.json({ memory: codexDiscovery.readMemory({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/config", (req, res) => {
|
||||
res.json({ config: codexDiscovery.readConfig({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
// GET /api/codex-config/file?path=/abs/path — return body of a single file.
|
||||
// Path must resolve under CODEX_HOME, the project .codex dir, or (only for a
|
||||
// file literally named AGENTS.md) the project root. No dedicated reader for
|
||||
// this exists in codex-discovery.js (it only reads whole surfaces), so this
|
||||
// mirrors cc-discovery's readFileSafe against the Codex roots instead.
|
||||
function readCodexFileSafe(absPath, opts = {}) {
|
||||
const codexHome = getCodexHome();
|
||||
const projectCodexDir = getProjectCodexDir(opts.cwd);
|
||||
const projectRoot = getProjectRoot(opts.cwd);
|
||||
const resolved = path.resolve(absPath);
|
||||
const inside = [codexHome, projectCodexDir, projectRoot].some((root) => isUnder(root, resolved));
|
||||
if (!inside) return { error: "path is outside allowed roots" };
|
||||
if (
|
||||
isUnder(projectRoot, resolved) &&
|
||||
!isUnder(projectCodexDir, resolved) &&
|
||||
path.basename(resolved) !== "AGENTS.md"
|
||||
) {
|
||||
return { error: "only AGENTS.md is readable from project root" };
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
return { error: "file not readable" };
|
||||
}
|
||||
if (!stat.isFile()) return { error: "file not readable" };
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(resolved, "utf8");
|
||||
} catch {
|
||||
return { error: "file not readable" };
|
||||
}
|
||||
const truncated = stat.size > MAX_FILE_BYTES;
|
||||
return {
|
||||
ok: true,
|
||||
file: resolved,
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
truncated,
|
||||
text: truncated ? text.slice(0, MAX_FILE_BYTES) : text,
|
||||
};
|
||||
}
|
||||
|
||||
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 = readCodexFileSafe(p, { cwd: cwdOf(req) });
|
||||
if (result.error)
|
||||
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Mutation endpoints (codex-mutate.js) ────────────────────────────────
|
||||
//
|
||||
// PUT /api/codex-config/file — create or overwrite. Body: { scope, type, name?, content }
|
||||
// DELETE /api/codex-config/file — delete. Body: { scope, type, name? }
|
||||
// GET /api/codex-config/backups[?scope=&type=] — list backups
|
||||
|
||||
router.put("/file", (req, res) => {
|
||||
const { scope, type, name, content } = 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 = codexMutate.writeArtifact({ scope, type, name, content, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "write", scope, type, name: name || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/file", (req, res) => {
|
||||
const { scope, type, name } = 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 = codexMutate.deleteArtifact({ scope, type, name, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "delete", scope, type, name: name || 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;
|
||||
try {
|
||||
res.json({ items: codexMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── MCP servers / plugins / marketplaces (via the `codex` CLI) ─────────
|
||||
//
|
||||
// Same reasoning as cc-config.js's plugin/marketplace routes: these shell
|
||||
// out to `codex …` with an argv array — never a shell string — because the
|
||||
// CLI owns config.toml's schema. Identifiers travel in the body, not the
|
||||
// URL, for the same reason DELETE /file does.
|
||||
|
||||
const CLI_ERR_TO_STATUS = {
|
||||
EBADARG: 400,
|
||||
ECLINOTFOUND: 503,
|
||||
ETIMEOUT: 504,
|
||||
};
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human message from a failed run. codex-cli.js does not export
|
||||
* one (unlike cc-plugin-cli's cliMessage), so this route-local helper does
|
||||
* the same "last non-empty line, prefer a marked one" heuristic.
|
||||
*/
|
||||
function cliMessage(result) {
|
||||
const text = `${result.stderr || ""}\n${result.stdout || ""}`;
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const marked = lines.find((l) => l.includes("✘") || l.toLowerCase().includes("error"));
|
||||
const picked = marked || lines[lines.length - 1] || "";
|
||||
return picked.replace(/^✘\s*/, "").slice(0, 1000);
|
||||
}
|
||||
|
||||
/** A non-zero CLI exit is a 422, not a 500 — the caller can inspect stderr. */
|
||||
function cliFailure(res, result) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: "ECLIEXIT",
|
||||
message: cliMessage(result) || `codex exited with code ${result.code}`,
|
||||
},
|
||||
code: result.code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read-through result for a GET route backed by the CLI. */
|
||||
function cliRead(res, result, key) {
|
||||
if (!result.ok) return cliFailure(res, result);
|
||||
res.json({ [key]: result.data !== undefined ? result.data : result.stdout });
|
||||
}
|
||||
|
||||
/** Result for a mutating route: broadcast on success, 422 on CLI failure. */
|
||||
function cliResult(res, result, changed) {
|
||||
if (!result.ok) return cliFailure(res, result);
|
||||
emitChanged(changed);
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
||||
}
|
||||
|
||||
router.get("/mcp", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.mcpList(), "mcp");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/plugins", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.pluginList(), "plugins");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/marketplaces", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.marketplaceList(), "marketplaces");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/add", async (req, res) => {
|
||||
try {
|
||||
const result = await codexCli.mcpAdd(req.body || {});
|
||||
return cliResult(res, result, { action: "add", type: "mcp", name: (req.body || {}).name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/remove", async (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.mcpRemove(name);
|
||||
return cliResult(res, result, { action: "remove", type: "mcp", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const PLUGIN_ACTIONS = {
|
||||
add: codexCli.pluginAdd,
|
||||
remove: codexCli.pluginRemove,
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
const { spec, action } = 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(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run(spec);
|
||||
return cliResult(res, result, { action, type: "plugin", name: spec });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
const { url } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.marketplaceAdd(url);
|
||||
return cliResult(res, result, { action: "add", type: "marketplace", name: url });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const MARKETPLACE_ACTIONS = {
|
||||
remove: codexCli.marketplaceRemove,
|
||||
};
|
||||
|
||||
router.post("/marketplaces/action", 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(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run(name);
|
||||
return cliResult(res, result, { action, type: "marketplace", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user