From 32839332f2ff5ab310bc8aa1c55eb25d9c9b41f1 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 8 Sep 2026 17:54:36 +0700 Subject: [PATCH] feat(codex): expose the Codex config API --- openapi.yaml | 1276 +++++++++++++++++++++++++ server/__tests__/codex-config.test.js | 493 ++++++++++ server/index.js | 2 + server/openapi-extra.js | 2 + server/openapi-extra/codex-config.js | 744 ++++++++++++++ server/routes/codex-config.js | 366 +++++++ 6 files changed, 2883 insertions(+) create mode 100644 server/__tests__/codex-config.test.js create mode 100644 server/openapi-extra/codex-config.js create mode 100644 server/routes/codex-config.js diff --git a/openapi.yaml b/openapi.yaml index ae53000c..df8b830a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -52,6 +52,8 @@ tags: description: OpenAPI/Swagger endpoints - name: CcConfig description: 'Claude Code configuration explorer: discover and (for low-risk artifacts) edit skills, agents, commands, output styles, MCP servers, hooks, settings, memory, plugins, marketplaces, keybindings, statusline' + - 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.' - name: Run description: Spawn and supervise Claude Code runs from the dashboard (headless or interactive conversation), stream output over WebSocket, and manage run history - name: Lanes @@ -3005,6 +3007,339 @@ components: example: - /Users/son/.claude/.credentials.json - /Users/son/.claude.json + 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" + model = "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 + Hello. + 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" + 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 RunHandle: type: object description: Live, in-memory view of a spawned `claude` subprocess. Returned by GET /api/run (in `items`), POST /api/run (201), and GET /api/run/{id}. Backed by the spawner's handle map, which reaps each handle 5 minutes after the process exits — after that the run is only visible via GET /api/run/history. @@ -8426,6 +8761,947 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /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: + - 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 + responses: + '200': + description: Overview of roots and counts. + content: + application/json: + schema: + $ref: '#/components/schemas/CodexConfigOverviewResponse' + /api/codex-config/skills: + get: + tags: + - CodexConfig + summary: List discovered skills + operationId: codexConfigGetSkills + parameters: + - 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 + - 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 + responses: + '200': + description: List discovered skills. + content: + application/json: + schema: + type: object + properties: + skills: + type: array + items: + $ref: '#/components/schemas/CodexConfigSkill' + example: + skills: + - scope: user + name: code-reviewer + frontmatter: {} + preview: ... + /api/codex-config/agents: + get: + tags: + - CodexConfig + summary: List discovered agent definitions + operationId: codexConfigGetAgents + parameters: + - 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 + - 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 + responses: + '200': + description: List discovered agent definitions. + content: + application/json: + schema: + type: object + properties: + agents: + type: array + items: + $ref: '#/components/schemas/CodexConfigAgent' + example: + agents: + - scope: user + name: reviewer + meta: + model: gpt-5 + /api/codex-config/rules: + get: + tags: + - CodexConfig + summary: List discovered rules files + operationId: codexConfigGetRules + parameters: + - 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 + - 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 + responses: + '200': + description: List discovered rules files. + content: + application/json: + schema: + type: object + properties: + rules: + type: array + items: + $ref: '#/components/schemas/CodexConfigRule' + example: + rules: + - scope: user + name: default + preview: allow + /api/codex-config/memory: + get: + tags: + - CodexConfig + summary: Read AGENTS.md at both scopes + operationId: codexConfigGetMemory + parameters: + - 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 + responses: + '200': + description: Read AGENTS.md at both scopes. + content: + application/json: + schema: + type: object + properties: + memory: + type: array + items: + $ref: '#/components/schemas/CodexConfigMemoryEntry' + example: + memory: + - scope: project + file: /repo/AGENTS.md + exists: true + /api/codex-config/config: + get: + tags: + - CodexConfig + summary: Read config.toml at both scopes + operationId: codexConfigGetConfig + parameters: + - 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 + responses: + '200': + description: Read config.toml at both scopes. + content: + application/json: + schema: + type: object + properties: + config: + type: array + items: + $ref: '#/components/schemas/CodexConfigConfigEntry' + example: + config: + - 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 + - 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 + responses: + '200': + description: File body with metadata. + content: + application/json: + schema: + $ref: '#/components/schemas/CodexConfigFileReadResponse' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: READ_DENIED + message: 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: + - 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 + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: EBADNAME + message: name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ + '413': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: ETOOLARGE + message: 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: + - 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 + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: ENOTFOUND + message: 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 + - 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 + 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': + 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 + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: ECLINOTFOUND + message: 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': + 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 + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: ECLINOTFOUND + message: 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': + 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 + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: ECLINOTFOUND + message: 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': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: EBADARG + message: url must be a valid http(s) URL + '422': + 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 + /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': + 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 + /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': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: EBADACTION + message: action must be one of add, remove + '422': + 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 + /api/codex-config/marketplaces/add: + post: + tags: + - CodexConfig + summary: Register a marketplace + description: Runs `codex plugin marketplace add `. + 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': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: EBADARG + message: marketplace url must be a valid http(s) URL + '422': + 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 + /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': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: EBADACTION + message: action must be one of remove + '422': + 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 + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + code: ECLINOTFOUND + message: the `codex` CLI was not found on PATH /api/run: get: tags: diff --git a/server/__tests__/codex-config.test.js b/server/__tests__/codex-config.test.js new file mode 100644 index 00000000..797507d7 --- /dev/null +++ b/server/__tests__/codex-config.test.js @@ -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ĩ + */ + +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; + } + }); +}); diff --git a/server/index.js b/server/index.js index 6066e2c9..77898157 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/server/openapi-extra.js b/server/openapi-extra.js index a0d31a58..f016701a 100644 --- a/server/openapi-extra.js +++ b/server/openapi-extra.js @@ -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, diff --git a/server/openapi-extra/codex-config.js b/server/openapi-extra/codex-config.js new file mode 100644 index 00000000..55618932 --- /dev/null +++ b/server/openapi-extra/codex-config.js @@ -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ĩ + */ + +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. 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 ` 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 `.", + 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 `.", + 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 }; diff --git a/server/routes/codex-config.js b/server/routes/codex-config.js new file mode 100644 index 00000000..bccf048e --- /dev/null +++ b/server/routes/codex-config.js @@ -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ĩ + */ + +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;