feat(codex): expose the Codex config API

This commit is contained in:
2026-09-08 17:54:36 +07:00
parent 58623f37c0
commit 32839332f2
6 changed files with 2883 additions and 0 deletions
+1276
View File
File diff suppressed because it is too large Load Diff
+493
View File
@@ -0,0 +1,493 @@
/**
* @file codex-config.test.js
* @description Tests for /api/codex-config — the Codex CLI configuration
* explorer. Builds a fake CODEX_HOME and project .codex/ in tmpdir, points
* the server at it, and exercises every read/write surface plus
* path-containment guards, backups, and the CLI-backed MCP/plugin/
* marketplace routes (via a stubbed `codex` binary on PATH).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const http = require("node:http");
const { WebSocket } = require("ws");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-test-"));
const FAKE_HOME = path.join(TMP, "home", ".codex");
const FAKE_PROJECT = path.join(TMP, "project");
const FAKE_PROJECT_CODEX = path.join(FAKE_PROJECT, ".codex");
fs.mkdirSync(path.join(FAKE_HOME, "skills", "demo-skill"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "agents"), { recursive: true });
fs.mkdirSync(path.join(FAKE_HOME, "rules"), { recursive: true });
fs.mkdirSync(path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill"), { recursive: true });
fs.writeFileSync(
path.join(FAKE_HOME, "skills", "demo-skill", "SKILL.md"),
`---\nname: demo-skill\ndescription: A demo skill for tests\n---\n\nBody text here.`
);
fs.writeFileSync(
path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill", "SKILL.md"),
`---\nname: proj-skill\n---\n\nProject skill.`
);
fs.writeFileSync(
path.join(FAKE_HOME, "agents", "demo-agent.toml"),
`name = "demo-agent"\nmodel = "gpt-5"\n`
);
fs.writeFileSync(path.join(FAKE_HOME, "rules", "default.rules"), "allow");
fs.writeFileSync(path.join(FAKE_HOME, "config.toml"), 'model = "gpt-5"\n');
fs.writeFileSync(path.join(FAKE_PROJECT, "AGENTS.md"), "# Project memory\nHello.");
process.env.CODEX_HOME = FAKE_HOME;
const TEST_DB = path.join(TMP, "dashboard-test.db");
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function fetchJson(p, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const headers = { ...(opts.headers || {}) };
let bodyBuf;
if (opts.body !== undefined) {
bodyBuf = Buffer.from(JSON.stringify(opts.body));
headers["Content-Type"] = "application/json";
headers["Content-Length"] = bodyBuf.length;
}
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: opts.method || "GET",
headers,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
let json;
try {
json = JSON.parse(body);
} catch {
json = body;
}
resolve({ status: res.statusCode, body: json });
});
}
);
req.on("error", reject);
if (bodyBuf) req.write(bodyBuf);
req.end();
});
}
function stubCodex(scriptBody) {
const bin = path.join(TMP, `bin-${process.hrtime.bigint()}`);
fs.mkdirSync(bin, { recursive: true });
const file = path.join(bin, "codex");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
const ECHO_ARGV = 'for a in "$@"; do echo "$a"; done';
describe("/api/codex-config", () => {
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
it("overview reports counts and roots", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.equal(body.roots.codexHome, FAKE_HOME);
assert.equal(body.roots.projectCodexDir, FAKE_PROJECT_CODEX);
assert.equal(body.roots.projectRoot, FAKE_PROJECT);
assert.equal(body.counts.skills.user, 1);
assert.equal(body.counts.skills.project, 1);
assert.equal(body.counts.agents.user, 1);
assert.equal(body.counts.rules.user, 1);
});
it("lists skills for both scopes", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/skills?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.skills));
const demo = body.skills.find((s) => s.name === "demo-skill");
assert.equal(demo.scope, "user");
assert.equal(demo.frontmatter.name, "demo-skill");
const proj = body.skills.find((s) => s.name === "proj-skill");
assert.equal(proj.scope, "project");
});
it("scope=user filters out project items", async () => {
const { body } = await fetchJson(
`/api/codex-config/skills?scope=user&cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.ok(body.skills.every((s) => s.scope === "user"));
});
it("agents parses top-level TOML scalars", async () => {
const { body } = await fetchJson(
`/api/codex-config/agents?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const a = body.agents.find((x) => x.name === "demo-agent");
assert.equal(a.meta.model, "gpt-5");
});
it("rules returns preview text", async () => {
const { body } = await fetchJson(
`/api/codex-config/rules?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const r = body.rules.find((x) => x.name === "default");
assert.match(r.preview, /allow/);
});
it("memory returns project AGENTS.md", async () => {
const { body } = await fetchJson(
`/api/codex-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const proj = body.memory.find((x) => x.scope === "project");
assert.equal(proj.exists, true);
assert.match(proj.preview, /Project memory/);
});
it("config returns user config.toml content", async () => {
const { body } = await fetchJson(
`/api/codex-config/config?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
const user = body.config.find((x) => x.scope === "user");
assert.equal(user.exists, true);
assert.match(user.content, /gpt-5/);
});
it("file endpoint reads inside CODEX_HOME", async () => {
const target = path.join(FAKE_HOME, "rules", "default.rules");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
);
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.equal(body.text, "allow");
});
it("file endpoint blocks paths outside allowed roots", async () => {
const outside = path.join(TMP, "evil.rules");
fs.writeFileSync(outside, "secret");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(outside)}`
);
assert.equal(status, 400);
assert.equal(body.error.code, "READ_DENIED");
});
it("file endpoint requires a path", async () => {
const { status, body } = await fetchJson("/api/codex-config/file");
assert.equal(status, 400);
assert.equal(body.error.code, "BAD_PATH");
});
it("PUT /file creates a new rule (no backup, file did not exist)", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "rules", name: "fresh", content: "deny" },
}
);
assert.equal(status, 200);
assert.equal(body.created, true);
assert.equal(body.backup, null);
assert.equal(fs.readFileSync(body.file, "utf8"), "deny");
});
it("PUT /file overwrites an existing rule and creates a backup, and broadcasts codex_config_changed", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
const messages = [];
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "rules", name: "default", content: "deny-all" },
}
);
assert.equal(status, 200);
assert.equal(body.created, false);
assert.ok(body.backup, "backup metadata returned");
assert.match(fs.readFileSync(body.file, "utf8"), /deny-all/);
await new Promise((resolve) => setTimeout(resolve, 100));
ws.close();
assert.ok(
messages.some((m) => m.type === "codex_config_changed"),
"PUT /file should broadcast codex_config_changed"
);
});
it("PUT /file rejects a write whose name escapes the root with 400, not 500", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "agents", name: "../evil", content: "x" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
it("PUT /file rejects unknown type", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "plugins", name: "x", content: "y" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADTYPE");
});
it("PUT /file rejects a name on a fixed-filename type (memory)", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "project", type: "memory", name: "nope", content: "x" },
}
);
assert.equal(status, 400);
assert.equal(body.error.code, "EBADNAME");
});
it("PUT /file rejects oversize content", async () => {
const huge = "x".repeat(256 * 1024 + 1);
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "PUT",
body: { scope: "user", type: "rules", name: "huge", content: huge },
}
);
assert.equal(status, 413);
assert.equal(body.error.code, "ETOOLARGE");
});
it("DELETE /file backs up and removes a rule", async () => {
fs.writeFileSync(path.join(FAKE_HOME, "rules", "to-delete.rules"), "bye");
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "rules", name: "to-delete" },
}
);
assert.equal(status, 200);
assert.ok(body.backup);
assert.equal(fs.existsSync(path.join(FAKE_HOME, "rules", "to-delete.rules")), false);
});
it("DELETE /file 404s on a missing item", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
{
method: "DELETE",
body: { scope: "user", type: "rules", name: "never-existed" },
}
);
assert.equal(status, 404);
assert.equal(body.error.code, "ENOTFOUND");
});
it("backups endpoint lists everything we just created", async () => {
const { status, body } = await fetchJson(
`/api/codex-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
);
assert.equal(status, 200);
assert.ok(Array.isArray(body.items));
assert.ok(body.items.length >= 2, `expected >=2 backups, got ${body.items.length}`);
});
// ── CLI-backed routes (fake `codex` on PATH) ────────────────────────
it("GET /mcp returns the CLI's JSON output", async () => {
const restore = stubCodex(`cat <<'JSON'\n{"servers":[{"name":"docs"}]}\nJSON`);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp");
assert.equal(status, 200);
assert.deepEqual(body.mcp, { servers: [{ name: "docs" }] });
} finally {
restore();
}
});
it("POST /mcp/add runs the CLI with the given argv", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
method: "POST",
body: { name: "docs", url: "https://example.com/mcp" },
});
assert.equal(status, 200);
assert.equal(body.ok, true);
assert.deepEqual(body.stdout.trim().split("\n"), [
"mcp",
"add",
"docs",
"--url",
"https://example.com/mcp",
]);
} finally {
restore();
}
});
it("POST /mcp/remove runs the CLI", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/mcp/remove", {
method: "POST",
body: { name: "docs" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), ["mcp", "remove", "docs"]);
} finally {
restore();
}
});
it("POST /plugins/action validates the action name", async () => {
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
method: "POST",
body: { spec: "demo", action: "purge" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADACTION");
});
it("POST /plugins/action adds through the CLI", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
method: "POST",
body: { spec: "demo@market", action: "add" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), ["plugin", "add", "demo@market"]);
} finally {
restore();
}
});
it("POST /marketplaces/add runs the CLI with the pasted url", async () => {
const restore = stubCodex(ECHO_ARGV);
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
method: "POST",
body: { url: "https://example.com/mp.git" },
});
assert.equal(status, 200);
assert.deepEqual(body.stdout.trim().split("\n"), [
"plugin",
"marketplace",
"add",
"https://example.com/mp.git",
]);
} finally {
restore();
}
});
it("POST /marketplaces/add rejects a non-http(s) url", async () => {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
method: "POST",
body: { url: "not a url" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
});
it("reports a failed CLI call as 422 and keeps the stderr", async () => {
const restore = stubCodex('echo "✘ marketplace \\"nope\\" is not registered" >&2\nexit 1');
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
method: "POST",
body: { name: "nope", action: "remove" },
});
assert.equal(status, 422);
assert.equal(body.error.code, "ECLIEXIT");
assert.match(body.error.message, /not registered/);
} finally {
restore();
}
});
it("POST /marketplaces/action validates the action name", async () => {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
method: "POST",
body: { name: "demo-mp", action: "update" },
});
assert.equal(status, 400);
assert.equal(body.error.code, "EBADACTION");
});
it("reports a missing `codex` binary as 503 rather than a crash", async () => {
const empty = path.join(TMP, `empty-${process.hrtime.bigint()}`);
fs.mkdirSync(empty, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = empty;
try {
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
method: "POST",
body: { name: "demo-mp", action: "remove" },
});
assert.equal(status, 503);
assert.equal(body.error.code, "ECLINOTFOUND");
} finally {
process.env.PATH = originalPath;
}
});
});
+2
View File
@@ -72,6 +72,7 @@ const pushRouter = require("./routes/push");
const importRouter = require("./routes/import");
const updatesRouter = require("./routes/updates");
const ccConfigRouter = require("./routes/cc-config");
const codexConfigRouter = require("./routes/codex-config");
const runRouter = require("./routes/run");
const alertsRouter = require("./routes/alerts");
const webhooksRouter = require("./routes/webhooks");
@@ -113,6 +114,7 @@ function createApp() {
app.use("/api/import", importRouter);
app.use("/api/updates", updatesRouter);
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/codex-config", codexConfigRouter);
app.use("/api/run", runRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/locks", locksRouter);
+2
View File
@@ -11,6 +11,7 @@
// New endpoint groups (previously-undocumented gaps in the base spec).
const ccConfig = require("./openapi-extra/cc-config");
const codexConfig = require("./openapi-extra/codex-config");
const run = require("./openapi-extra/run");
const lanes = require("./openapi-extra/lanes");
const push = require("./openapi-extra/push");
@@ -44,6 +45,7 @@ function combine(...fragments) {
module.exports = combine(
ccConfig,
codexConfig,
run,
lanes,
push,
+744
View File
@@ -0,0 +1,744 @@
/**
* @file OpenAPI 3.0 fragment for the Codex Config router
* (`server/routes/codex-config.js`, mounted at `/api/codex-config`).
* Documents the read surfaces (skills, agents, rules, memory, config, plus
* MCP servers/plugins/marketplaces read live from the `codex` CLI), the
* low-risk mutation surface (PUT/DELETE /file), the backups listing, and the
* CLI-backed MCP/plugin/marketplace mutation endpoints.
*
* Mirrors `server/openapi-extra/cc-config.js`'s structure and level of
* detail is intentionally lighter — one shared response shape per surface
* kind rather than a bespoke schema per field — since this panel has far
* fewer fields per record. All schema names are prefixed with `CodexConfig`.
* The shared `{ error: { code, message } }` envelope reuses the base
* `ErrorResponse` schema (defined in server/openapi.js).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "CodexConfig",
description:
"Codex CLI configuration explorer: discover and (for low-risk artifacts) edit skills, agents, rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and marketplaces.",
},
];
// ── Shared sub-schemas ─────────────────────────────────────────────────
const schemas = {
CodexConfigScope: {
type: "string",
enum: ["user", "project", "all"],
description:
"Discovery scope. `user` = $CODEX_HOME (or ~/.codex); `project` = <cwd>/.codex; `all` = both, merged. Defaults to `all` when omitted or unrecognized.",
example: "all",
},
CodexConfigSkill: {
type: "object",
description:
"A discovered skill: a directory containing a SKILL.md under <root>/skills/<name>/.",
required: [
"scope",
"name",
"path",
"file",
"size",
"mtime",
"truncated",
"frontmatter",
"preview",
],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "code-reviewer" },
path: { type: "string", example: "/home/user/.codex/skills/code-reviewer" },
file: { type: "string", example: "/home/user/.codex/skills/code-reviewer/SKILL.md" },
size: { type: "integer", example: 2048 },
mtime: { type: "number", example: 1718900000000 },
truncated: { type: "boolean", example: false },
frontmatter: {
type: "object",
additionalProperties: { type: "string" },
example: { name: "code-reviewer", description: "Reviews diffs" },
},
preview: { type: "string", example: "Use this skill to review pull requests..." },
},
},
CodexConfigAgent: {
type: "object",
description: "A discovered TOML agent definition (<root>/agents/<name>.toml).",
required: ["scope", "name", "file", "size", "mtime", "meta", "preview"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "reviewer" },
file: { type: "string", example: "/home/user/.codex/agents/reviewer.toml" },
size: { type: "integer", example: 512 },
mtime: { type: "number", example: 1718900000000 },
meta: {
type: "object",
additionalProperties: true,
description:
"Best-effort top-level scalar fields (name, description, model, sandbox_mode).",
example: { name: "reviewer", model: "gpt-5" },
},
preview: { type: "string", example: 'name = "reviewer"\nmodel = "gpt-5"' },
},
},
CodexConfigRule: {
type: "object",
description: "A discovered rules file (<root>/rules/<name>.rules).",
required: ["scope", "name", "file", "size", "mtime", "preview"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "default" },
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
size: { type: "integer", example: 128 },
mtime: { type: "number", example: 1718900000000 },
preview: { type: "string", example: "allow" },
},
},
CodexConfigMemoryEntry: {
type: "object",
description: "AGENTS.md at either the Codex home or the project root.",
required: ["scope", "file", "exists"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "project" },
file: { type: "string", example: "/repo/AGENTS.md" },
exists: { type: "boolean", example: true },
size: { type: "integer", example: 256 },
mtime: { type: "number", example: 1718900000000 },
preview: { type: "string", example: "# Project memory\nHello." },
},
},
CodexConfigConfigEntry: {
type: "object",
description: "config.toml at either the Codex home or the project .codex dir.",
required: ["scope", "file", "exists"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
file: { type: "string", example: "/home/user/.codex/config.toml" },
exists: { type: "boolean", example: true },
size: { type: "integer", example: 640 },
mtime: { type: "number", example: 1718900000000 },
content: { type: "string", example: 'model = "gpt-5"\n' },
truncated: { type: "boolean", example: false },
},
},
CodexConfigOverviewResponse: {
type: "object",
required: ["roots", "counts"],
properties: {
roots: {
type: "object",
properties: {
codexHome: { type: "string", example: "/home/user/.codex" },
projectCodexDir: { type: "string", example: "/repo/.codex" },
projectRoot: { type: "string", example: "/repo" },
},
},
counts: {
type: "object",
additionalProperties: {
type: "object",
properties: { user: { type: "integer" }, project: { type: "integer" } },
},
example: {
skills: { user: 3, project: 1 },
agents: { user: 2, project: 0 },
rules: { user: 1, project: 0 },
memory: { user: 1, project: 1 },
config: { user: 1, project: 0 },
},
},
},
},
CodexConfigFileReadResponse: {
type: "object",
required: ["ok", "file", "size", "mtime", "truncated", "text"],
properties: {
ok: { type: "boolean", example: true },
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
size: { type: "integer", example: 128 },
mtime: { type: "number", example: 1718900000000 },
truncated: { type: "boolean", example: false },
text: { type: "string", example: "allow" },
},
},
CodexConfigFileWriteResponse: {
type: "object",
required: ["file", "created", "backup"],
properties: {
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
created: { type: "boolean", example: true },
backup: {
type: "object",
nullable: true,
additionalProperties: true,
description: "Backup metadata when an existing file was overwritten; null on first create.",
},
},
},
CodexConfigFileDeleteResponse: {
type: "object",
required: ["file", "backup"],
properties: {
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
backup: { type: "object", additionalProperties: true },
},
},
CodexConfigBackupsResponse: {
type: "object",
required: ["items"],
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string", example: "rules" },
name: { type: "string", example: "default.rules.2026-06-25T12-00-00.000Z.bak" },
file: { type: "string" },
mtime: { type: "number" },
size: { type: "integer", nullable: true },
},
},
},
},
},
CodexConfigCliActionResponse: {
type: "object",
description: "Result of a successful mutating `codex` CLI call.",
required: ["ok", "stdout", "stderr"],
properties: {
ok: { type: "boolean", example: true },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
};
// ── Reusable parameters ──────────────────────────────────────────────────
const scopeParam = {
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: ["user", "project", "all"], default: "all" },
description: "Discovery scope: `user`, `project`, or `all` (default).",
example: "all",
};
const cwdParam = {
name: "cwd",
in: "query",
required: false,
schema: { type: "string" },
description:
"Override the working directory used to resolve the project root and project .codex dir. Defaults to the dashboard server's own cwd.",
example: "/repo",
};
const errorResponse = (code, message) => ({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code, message } },
},
},
});
const cliExitResponse = {
description: "`ECLIEXIT` — the `codex` CLI exited non-zero. stdout/stderr are preserved.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "object",
properties: { code: { type: "string" }, message: { type: "string" } },
},
code: { type: "integer", nullable: true },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
example: {
error: { code: "ECLIEXIT", message: 'marketplace "nope" is not registered' },
code: 1,
stdout: "",
stderr: '✘ marketplace "nope" is not registered\n',
},
},
},
};
function readSurface(name, summary, schemaRef, exampleItems) {
return {
get: {
tags: ["CodexConfig"],
summary,
operationId: `codexConfigGet${name[0].toUpperCase()}${name.slice(1)}`,
parameters: name === "memory" || name === "config" ? [cwdParam] : [scopeParam, cwdParam],
responses: {
200: {
description: `${summary}.`,
content: {
"application/json": {
schema: {
type: "object",
properties: { [name]: { type: "array", items: { $ref: schemaRef } } },
},
example: { [name]: exampleItems },
},
},
},
},
},
};
}
// ── Paths ─────────────────────────────────────────────────────────────
const paths = {
"/api/codex-config/overview": {
get: {
tags: ["CodexConfig"],
summary: "Codex config explorer overview",
description:
"Read-only. Returns the three resolved filesystem roots plus per-scope counts across skills, agents, rules, memory, and config.",
operationId: "codexConfigGetOverview",
parameters: [cwdParam],
responses: {
200: {
description: "Overview of roots and counts.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigOverviewResponse" },
},
},
},
},
},
},
"/api/codex-config/skills": readSurface(
"skills",
"List discovered skills",
"#/components/schemas/CodexConfigSkill",
[{ scope: "user", name: "code-reviewer", frontmatter: {}, preview: "..." }]
),
"/api/codex-config/agents": readSurface(
"agents",
"List discovered agent definitions",
"#/components/schemas/CodexConfigAgent",
[{ scope: "user", name: "reviewer", meta: { model: "gpt-5" } }]
),
"/api/codex-config/rules": readSurface(
"rules",
"List discovered rules files",
"#/components/schemas/CodexConfigRule",
[{ scope: "user", name: "default", preview: "allow" }]
),
"/api/codex-config/memory": readSurface(
"memory",
"Read AGENTS.md at both scopes",
"#/components/schemas/CodexConfigMemoryEntry",
[{ scope: "project", file: "/repo/AGENTS.md", exists: true }]
),
"/api/codex-config/config": readSurface(
"config",
"Read config.toml at both scopes",
"#/components/schemas/CodexConfigConfigEntry",
[{ scope: "user", file: "/home/user/.codex/config.toml", exists: true }]
),
"/api/codex-config/file": {
get: {
tags: ["CodexConfig"],
summary: "Read a single file body",
description:
"Read-only. Returns the body of one file. Strict path containment: the resolved absolute path must live under CODEX_HOME, the project .codex dir, or (only for a file literally named AGENTS.md) the project root.",
operationId: "codexConfigGetFile",
parameters: [
{
name: "path",
in: "query",
required: true,
schema: { type: "string" },
example: "/home/user/.codex/rules/default.rules",
},
cwdParam,
],
responses: {
200: {
description: "File body with metadata.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileReadResponse" },
},
},
},
400: errorResponse("READ_DENIED", "path is outside allowed roots"),
},
},
put: {
tags: ["CodexConfig"],
summary: "Create or overwrite a text artifact",
description:
"Writes skills/agents/rules/memory/config. `name` is required for skills/agents/rules and forbidden for memory/config (fixed filenames). Backs up any existing file first; broadcasts `codex_config_changed` on success.",
operationId: "codexConfigPutFile",
parameters: [cwdParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["scope", "type", "content"],
properties: {
scope: { type: "string", enum: ["user", "project"] },
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
name: { type: "string", nullable: true, example: "default" },
content: { type: "string" },
},
},
example: { scope: "user", type: "rules", name: "default", content: "allow" },
},
},
},
responses: {
200: {
description: "File written.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileWriteResponse" },
},
},
},
400: errorResponse("EBADNAME", "name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"),
413: errorResponse("ETOOLARGE", "content exceeds 262144 bytes"),
},
},
delete: {
tags: ["CodexConfig"],
summary: "Delete a text artifact",
description:
"Deletes skills/agents/rules/memory/config. Backup is mandatory and runs first. Broadcasts `codex_config_changed` on success.",
operationId: "codexConfigDeleteFile",
parameters: [cwdParam],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["scope", "type"],
properties: {
scope: { type: "string", enum: ["user", "project"] },
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
name: { type: "string", nullable: true },
},
},
example: { scope: "user", type: "agents", name: "reviewer" },
},
},
},
responses: {
200: {
description: "File deleted.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileDeleteResponse" },
},
},
},
404: errorResponse("ENOTFOUND", "agents/reviewer does not exist"),
},
},
},
"/api/codex-config/backups": {
get: {
tags: ["CodexConfig"],
summary: "List backups",
description:
"Read-only. Lists timestamped backups, optionally filtered by scope and/or type.",
operationId: "codexConfigGetBackups",
parameters: [
{
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: ["user", "project"] },
},
{
name: "type",
in: "query",
required: false,
schema: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
},
cwdParam,
],
responses: {
200: {
description: "Backups list.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigBackupsResponse" },
},
},
},
},
},
},
"/api/codex-config/mcp": {
get: {
tags: ["CodexConfig"],
summary: "List configured MCP servers",
description:
"Reads live from `codex mcp list --json` — there is no on-disk reader for this surface.",
operationId: "codexConfigGetMcp",
responses: {
200: {
description: "MCP servers as reported by the CLI.",
content: { "application/json": { schema: { type: "object", properties: { mcp: {} } } } },
},
422: cliExitResponse,
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/plugins": {
get: {
tags: ["CodexConfig"],
summary: "List installed plugins",
description: "Reads live from `codex plugin list --json`.",
operationId: "codexConfigGetPlugins",
responses: {
200: {
description: "Plugins as reported by the CLI.",
content: {
"application/json": { schema: { type: "object", properties: { plugins: {} } } },
},
},
422: cliExitResponse,
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/marketplaces": {
get: {
tags: ["CodexConfig"],
summary: "List registered marketplaces",
description: "Reads live from `codex plugin marketplace list --json`.",
operationId: "codexConfigGetMarketplaces",
responses: {
200: {
description: "Marketplaces as reported by the CLI.",
content: {
"application/json": { schema: { type: "object", properties: { marketplaces: {} } } },
},
},
422: cliExitResponse,
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/mcp/add": {
post: {
tags: ["CodexConfig"],
summary: "Add an MCP server",
description:
"Runs `codex mcp add`. Accepts either a streamable-HTTP server (`url`, optional `bearerTokenEnvVar`) or a stdio server (`command`, `args`, optional `env`) — never both.",
operationId: "codexConfigPostMcpAdd",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
url: { type: "string" },
bearerTokenEnvVar: { type: "string" },
command: { type: "string" },
args: { type: "array", items: { type: "string" } },
env: { type: "object", additionalProperties: { type: "string" } },
},
},
example: { name: "github", url: "https://mcp.example.com/github" },
},
},
},
responses: {
200: {
description: "MCP server added.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADARG", "url must be a valid http(s) URL"),
422: cliExitResponse,
},
},
},
"/api/codex-config/mcp/remove": {
post: {
tags: ["CodexConfig"],
summary: "Remove an MCP server",
operationId: "codexConfigPostMcpRemove",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: { name: { type: "string" } },
},
example: { name: "github" },
},
},
},
responses: {
200: {
description: "MCP server removed.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
422: cliExitResponse,
},
},
},
"/api/codex-config/plugins/action": {
post: {
tags: ["CodexConfig"],
summary: "Add or remove a plugin",
description: "Runs `codex plugin add <spec>` or `codex plugin remove <spec>`.",
operationId: "codexConfigPostPluginsAction",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["spec", "action"],
properties: {
spec: { type: "string" },
action: { type: "string", enum: ["add", "remove"] },
},
},
example: { spec: "demo@market", action: "add" },
},
},
},
responses: {
200: {
description: "Plugin action applied.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADACTION", "action must be one of add, remove"),
422: cliExitResponse,
},
},
},
"/api/codex-config/marketplaces/add": {
post: {
tags: ["CodexConfig"],
summary: "Register a marketplace",
description: "Runs `codex plugin marketplace add <url>`.",
operationId: "codexConfigPostMarketplacesAdd",
requestBody: {
required: true,
content: {
"application/json": {
schema: { type: "object", required: ["url"], properties: { url: { type: "string" } } },
example: { url: "https://example.com/marketplace.json" },
},
},
},
responses: {
200: {
description: "Marketplace added.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADARG", "marketplace url must be a valid http(s) URL"),
422: cliExitResponse,
},
},
},
"/api/codex-config/marketplaces/action": {
post: {
tags: ["CodexConfig"],
summary: "Remove a marketplace",
description: "Runs `codex plugin marketplace remove <name>`.",
operationId: "codexConfigPostMarketplacesAction",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name", "action"],
properties: {
name: { type: "string" },
action: { type: "string", enum: ["remove"] },
},
},
example: { name: "demo-mp", action: "remove" },
},
},
},
responses: {
200: {
description: "Marketplace action applied.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADACTION", "action must be one of remove"),
422: cliExitResponse,
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
};
module.exports = { tags, schemas, paths };
+366
View File
@@ -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;