/** * @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ĩ */ 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` = /.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 /skills//.", 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 (/agents/.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 (/rules/.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, no filesystem side effect. 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, and must not escape that root through a symlink. Error messages never reveal what does or does not exist outside the 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 escapes the allowed root"), 404: errorResponse("NOT_FOUND", "file not found"), }, }, 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). `project` scope always resolves against the dashboard server's own cwd — there is no `?cwd=` override on writes, unlike the read routes, so a caller cannot point a write at an arbitrary directory. Backs up any existing file first; broadcasts `codex_config_changed` on success.", operationId: "codexConfigPutFile", 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. `project` scope always resolves against the dashboard server's own cwd — no `?cwd=` override, same as PUT. Backup is mandatory and runs first. Broadcasts `codex_config_changed` on success.", operationId: "codexConfigDeleteFile", 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, 502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"), 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` and unwraps its `{installed, available}` " + "object down to the `installed` array — the route's `plugins` field is always a list.", operationId: "codexConfigGetPlugins", responses: { 200: { description: "Installed plugins as reported by the CLI.", content: { "application/json": { schema: { type: "object", properties: { plugins: { type: "array", items: {} } } }, }, }, }, 422: cliExitResponse, 502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"), 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` and unwraps its " + "`{marketplaces}` object down to a bare array.", operationId: "codexConfigGetMarketplaces", responses: { 200: { description: "Marketplaces as reported by the CLI.", content: { "application/json": { schema: { type: "object", properties: { marketplaces: { type: "array", items: {} } }, }, }, }, }, 422: cliExitResponse, 502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"), 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 ` or `codex plugin remove `.", 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 `. `source` is an owner/repo shorthand, " + "an http(s) git URL, or an absolute local path — the same set `claude plugin " + "marketplace add` accepts; ssh remotes are not.", operationId: "codexConfigPostMarketplacesAdd", requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string" } } }, example: { url: "openai/codex" }, }, }, }, responses: { 200: { description: "Marketplace added.", content: { "application/json": { schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" }, }, }, }, 400: errorResponse( "EBADARG", "source must be owner/repo, an http(s) URL, or an absolute path" ), 422: cliExitResponse, }, }, }, "/api/codex-config/marketplaces/action": { post: { tags: ["CodexConfig"], summary: "Remove a marketplace", description: "Runs `codex plugin marketplace remove `.", 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 };