diff --git a/docs/superpowers/plans/2026-09-08-claude-settings-editable.md b/docs/superpowers/plans/2026-09-08-claude-settings-editable.md new file mode 100644 index 00000000..488926cb --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-claude-settings-editable.md @@ -0,0 +1,152 @@ +# Editable settings.json + MCP for the Claude Code Panel — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the Claude Code panel's two read-only surfaces — `settings.json` and MCP servers — into surfaces you can change from `/settings?tool=claude-code`. + +**Architecture:** The backend already does the dangerous half: `PATCH /api/cc-config/settings` writes through `patchSettings`, which validates every dotted key path, refuses prototype-polluting segments, rejects a payload still carrying the read path's `` marker, backs the file up and writes atomically. So settings editing is a UI task against an existing, tested API. MCP is the opposite: `cc-mutate.js` excludes it on purpose, because the live `claude` CLI writes `~/.claude.json` concurrently and a dashboard write would race it. MCP therefore gets the same treatment plugins already get — mutation through the vendor CLI (`claude mcp add|remove`), never by editing the file. + +**Tech Stack:** Node 22 + Express + `node:test`, React 18 + Vitest + react-i18next, `claude` CLI. + +**Spec:** none written — scope fixed by the user in-session: "Mở thêm settings.json + MCP". This plan is the spec of record. + +## Global Constraints + +- **Every file created or modified gets the project header** with the exact author line `@author Nguyễn Ngọc Trí Vĩ `. +- **User-visible strings live in i18n JSON**, `en` and `vi` together. +- **Never write `~/.claude.json` directly.** MCP changes go through the `claude` CLI, argv array, no shell. +- **A value the read path redacted must never be written back.** `patchSettings`' `assertNoRedactionMarker` already enforces this; the UI must additionally render redacted fields as non-editable so the user is never invited to try. +- **Depends on:** `2026-09-08-codex-config-panel.md` Task 8 (the extracted `components/config/` modals). Run that task first, or inline the modal locally and lift it later. +- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`. + +--- + +### Task 1: Editable settings.json + +**Files:** +- Modify: `client/src/pages/CcConfig.tsx` (the `SettingsPanel` / `SettingsBlock` components), `client/src/lib/api.ts` (add `api.ccConfig.patchSettings`), `client/src/i18n/locales/{en,vi}/ccConfig.json` +- Test: `client/src/pages/__tests__/CcConfigSettings.test.tsx` + +**Interfaces:** +- Consumes: existing `PATCH /api/cc-config/settings` — body `{scope: "user" | "project" | "project-local", set: Record, unset?: string[]}`, response `{ok, file, backup}` +- Produces: `api.ccConfig.patchSettings(args: {scope, set, unset?}): Promise<{ok: boolean, file: string, backup: string | null}>` + +- [ ] **Step 1: Write the failing test** + +```tsx +it("sends only the edited key, as a dotted path", async () => { + const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.bak" }); + renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings }); + + await userEvent.click(screen.getByRole("button", { name: /edit model/i })); + await userEvent.clear(screen.getByRole("textbox")); + await userEvent.type(screen.getByRole("textbox"), "sonnet"); + await userEvent.click(screen.getByRole("button", { name: /save/i })); + + expect(patchSettings).toHaveBeenCalledWith({ scope: "user", set: { model: "sonnet" } }); +}); + +it("will not let a redacted value be edited", () => { + renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { apiKey: "" } }] }); + expect(screen.queryByRole("button", { name: /edit apiKey/i })).not.toBeInTheDocument(); +}); + +it("names the backup it wrote, so the change is reversible in one glance", async () => { + const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.json.2026.bak" }); + renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings }); + await saveAnEdit(); + expect(await screen.findByText(/s\.json\.2026\.bak/)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx` +Expected: FAIL — no edit affordance exists + +- [ ] **Step 3: Implement** + +`SettingsBlock` renders each leaf with an inline edit control; the dotted path is built from the node's ancestry as it recurses. A value equal to `""` renders as a static chip with a tooltip saying the dashboard never received it. + +- [ ] **Step 4: Run test + typecheck** + +Run: `cd client && npx tsc -b && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add client/src/pages/CcConfig.tsx client/src/lib/api.ts client/src/i18n client/src/pages/__tests__/CcConfigSettings.test.tsx +git commit -m "feat(cc-config): edit settings.json values from the dashboard" +``` + +--- + +### Task 2: MCP servers through the claude CLI + +**Files:** +- Create: `server/lib/cc-mcp-cli.js`, `server/__tests__/cc-mcp-cli.test.js` +- Modify: `server/routes/cc-config.js` (add `POST /mcp/add`, `POST /mcp/remove`), `server/openapi-extra/cc-config.js`, `client/src/lib/api.ts`, `client/src/pages/CcConfig.tsx` (`McpPanel`), `client/src/i18n/locales/{en,vi}/ccConfig.json` +- Test: `server/__tests__/cc-mcp-cli.test.js`, extend `server/__tests__/cc-config.test.js` + +**Interfaces:** +- Produces: + - `mcpAdd({name, scope, transport, command, args, url, headers, env})` → `claude mcp add [--scope user|project] [--transport http --url | -- ]` + - `mcpRemove({name, scope})` → `claude mcp remove [--scope …]` + - both async, returning `{ok, code, stdout, stderr}`, argv array, no shell — same contract as `cc-plugin-cli.js` + - Routes return **422** on a non-zero CLI exit, **400** on a name failing `NAME_RE`. + +- [ ] **Step 1: Write the failing test** + +```js +it("builds an http server as a transport flag, not a shell string", async () => { + const spawned = []; + const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } }); + await cli.mcpAdd({ name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" }); + assert.deepEqual(spawned[0][1], + ["mcp", "add", "vercel", "--scope", "user", "--transport", "http", "--url", "https://mcp.vercel.com"]); +}); + +it("puts stdio command args after the -- separator", async () => { + const spawned = []; + const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } }); + await cli.mcpAdd({ name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] }); + assert.deepEqual(spawned[0][1].slice(-5), ["--", "node", "s.js", "--port", "3000"]); +}); + +it("refuses a server name that reads as a flag", async () => { + await assert.rejects(() => require("../lib/cc-mcp-cli").mcpRemove({ name: "--all" }), /name/i); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/cc-mcp-cli.test.js` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the wrapper, then the two routes, then the UI** + +`McpPanel` gains an "Add server" form (name, scope, stdio-vs-http toggle) and a remove action per row. Each row keeps its existing read-only detail view — the source of truth stays `readMcpServers`, refetched after every CLI call. + +- [ ] **Step 4: Run both suites** + +Run: `npm run test:server && cd client && npx tsc -b && npm test` +Expected: PASS + +- [ ] **Step 5: Regenerate OpenAPI, update docs, commit** + +```bash +npm run openapi:yaml +git add server/lib/cc-mcp-cli.js server/routes/cc-config.js server/openapi-extra/cc-config.js server/__tests__ client/src openapi.yaml docs/API.md +git commit -m "feat(cc-config): add and remove MCP servers through the claude CLI" +``` + +--- + +## Self-Review + +**Spec coverage:** "Mở thêm settings.json + MCP" → Task 1 (settings.json, using the API that already exists) and Task 2 (MCP, via CLI because direct file writes would race the CLI). Hooks stay read-only — they live inside settings.json, so Task 1 already covers editing them by key path, and a bespoke hook editor was not asked for. + +**Placeholder scan:** none. + +**Type consistency:** `scope` for settings is Claude's three-value union `"user" | "project" | "project-local"` (matching `readSettings`), while MCP's CLI scope flag accepts only `user | project` — the UI must not offer `project-local` on the MCP form. diff --git a/docs/superpowers/plans/2026-09-08-codex-config-panel.md b/docs/superpowers/plans/2026-09-08-codex-config-panel.md new file mode 100644 index 00000000..d5cdbda8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-codex-config-panel.md @@ -0,0 +1,612 @@ +# Codex Config Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `/settings?tool=codex` the same create/read/update/delete power the Claude Code tab already has, against Codex's own config surfaces. + +**Architecture:** Mirror the existing `cc-discovery` / `cc-mutate` / `cc-plugin-cli` / `routes/cc-config.js` trio with a Codex-side quartet, sharing the safe-write primitives instead of copying them. Codex's structured surfaces (MCP servers, plugins, marketplaces) are read and mutated through the `codex` CLI, which emits JSON — so no TOML parser enters the tree. File-shaped surfaces (skills, agents, rules, AGENTS.md, config.toml) go through the same containment + backup + atomic-rename path Claude's artifacts use. The client extracts CcConfig's already-generic modals and list components into `components/config/` and adds a Codex orchestrator beside them. + +**Tech Stack:** Node 22 + Express + `node:test` (backend), React 18 + Vite + Vitest + react-i18next (frontend), `codex` CLI 0.149.0. + +**Spec:** none written — the scope was fixed by the user in-session: "parity đầy đủ với tab Claude Code". This plan is the spec of record. + +## Global Constraints + +- **Every file created or modified gets the project header** — `@file`, `@description`, and the exact author line `@author Nguyễn Ngọc Trí Vĩ ` (see `skills/file-headers/`). +- **User-visible strings live in i18n JSON**, never as literals in components. Both `en` and `vi` must be updated in the same commit. +- **No new npm dependencies.** TOML is never parsed in-process; `codex … --json` or a raw-text editor covers every case. +- **Path containment is non-negotiable.** Every write resolves through one chokepoint and is re-checked with `isUnder(root, target)` after regex validation. +- **Backup before every mutation of an existing file**, atomic rename on write — the same guarantees `cc-mutate.js` gives today. +- **`CODEX_HOME` overrides `~/.codex`** everywhere, exactly as `CLAUDE_HOME` overrides `~/.claude`. Tests inject a temp home through it; nothing in the suite may touch the real one. +- Backend tests: `npm run test:server`. Client tests: `cd client && npm test`. Both must be green before a commit. + +--- + +### Task 1: Shared safe-write primitives + +`cc-mutate.js` owns the three guarantees a config writer needs — containment, backup, atomic rename. Codex needs the identical guarantees; copying them would mean two divergent copies of security code. Extract first, then reuse. + +**Files:** +- Create: `server/lib/config-write.js` +- Modify: `server/lib/cc-mutate.js` (delete the local `createBackup`, `atomicWriteFile`, `timestamp`; import them instead) +- Test: `server/__tests__/config-write.test.js` + +**Interfaces:** +- Consumes: `isUnder` from `server/lib/cc-discovery.js` +- Produces: + - `atomicWriteFile(filePath: string, content: string): void` + - `createBackup(opts: {source: string, backupRoot: string, type: string, name: string}): string | null` — returns the backup path, or `null` when `source` does not exist. Copies a directory recursively when `source` is one. + - `assertUnder(root: string, target: string): void` — throws `Object.assign(new Error(...), {code: "EOUTOFROOT"})` + - `timestamp(): string` — filesystem-safe ISO stamp + +- [ ] **Step 1: Write the failing test** + +```js +const { atomicWriteFile, createBackup, assertUnder } = require("../lib/config-write"); + +it("leaves no temp file behind after a successful write", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-")); + atomicWriteFile(path.join(dir, "a.md"), "body"); + assert.equal(fs.readFileSync(path.join(dir, "a.md"), "utf8"), "body"); + assert.deepEqual(fs.readdirSync(dir), ["a.md"]); +}); + +it("backs a directory up as a tree, not a single file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-")); + fs.mkdirSync(path.join(dir, "src", "sk"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src", "sk", "SKILL.md"), "x"); + const out = createBackup({ + source: path.join(dir, "src", "sk"), + backupRoot: path.join(dir, "bak"), + type: "skills", + name: "sk", + }); + assert.ok(fs.existsSync(path.join(out, "SKILL.md"))); +}); + +it("rejects a target outside the root", () => { + assert.throws(() => assertUnder("/a/b", "/a/c/x"), (e) => e.code === "EOUTOFROOT"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/config-write.test.js` +Expected: FAIL — `Cannot find module '../lib/config-write'` + +- [ ] **Step 3: Move the implementations** + +Cut `timestamp`, `createBackup`, `atomicWriteFile` out of `server/lib/cc-mutate.js` verbatim into `server/lib/config-write.js`; generalize `createBackup`'s hardcoded backup root into the `backupRoot` argument; add `assertUnder` wrapping the existing `isUnder` check. Re-export nothing from `cc-mutate` — it imports what it needs. + +- [ ] **Step 4: Run both suites to verify nothing regressed** + +Run: `node --test server/__tests__/config-write.test.js server/__tests__/cc-mutate-settings.test.js server/__tests__/cc-config.test.js` +Expected: PASS — the cc-mutate suites are the proof the extraction was behavior-preserving. + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/config-write.js server/lib/cc-mutate.js server/__tests__/config-write.test.js +git commit -m "refactor(config): extract the safe-write primitives cc-mutate guards with" +``` + +--- + +### Task 2: Codex home resolution + +`codexHome()` already exists inside `server/lib/codex-ingest.js:101` but is not exported, so every new module would re-derive it. Give it a home of its own, matching `claude-home.js`'s role. + +**Files:** +- Create: `server/lib/codex-home.js` +- Modify: `server/lib/codex-ingest.js` (drop the local `codexHome`, import it) +- Test: `server/__tests__/codex-home.test.js` + +**Interfaces:** +- Produces: + - `getCodexHome(): string` — `process.env.CODEX_HOME || path.join(os.homedir(), ".codex")` + - `getProjectCodexDir(cwd: string): string` — `/.codex` + - `getProjectRoot(cwd: string): string` — `cwd` (where `AGENTS.md` lives) + +- [ ] **Step 1: Write the failing test** + +```js +it("honours CODEX_HOME over the home directory", () => { + process.env.CODEX_HOME = "/tmp/injected-codex"; + delete require.cache[require.resolve("../lib/codex-home")]; + assert.equal(require("../lib/codex-home").getCodexHome(), "/tmp/injected-codex"); +}); + +it("puts the project surfaces under /.codex", () => { + const { getProjectCodexDir } = require("../lib/codex-home"); + assert.equal(getProjectCodexDir("/repo"), path.join("/repo", ".codex")); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/codex-home.test.js` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the module and re-point codex-ingest** + +`getCodexHome` must read `process.env` at call time (not at module load) so tests can inject. + +- [ ] **Step 4: Run the codex suites** + +Run: `node --test server/__tests__/codex-home.test.js server/__tests__/codex-ingest.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/codex-home.js server/lib/codex-ingest.js server/__tests__/codex-home.test.js +git commit -m "refactor(codex): give the CODEX_HOME lookup its own module" +``` + +--- + +### Task 3: Codex discovery (read side) + +**Files:** +- Create: `server/lib/codex-discovery.js` +- Test: `server/__tests__/codex-discovery.test.js` + +**Interfaces:** +- Consumes: `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2); `parseFrontmatter`, `isUnder`, `MAX_FILE_BYTES` from `server/lib/cc-discovery.js` +- Produces (every reader takes `{scope, cwd}` where `scope` is `"user" | "project" | undefined` meaning both): + - `readSkills(opts): Skill[]` — `{scope, name, path, file, size, mtime, truncated, frontmatter, preview}` from `/skills/*/SKILL.md` and `/.codex/skills/*/SKILL.md` + - `readAgents(opts): Agent[]` — `{scope, name, file, size, mtime, meta, preview}` from `*/agents/*.toml`; `meta` is `{name, description, model, sandbox_mode}` best-effort + - `readRules(opts): Rule[]` — `{scope, name, file, size, mtime, preview}` from `*/rules/*.rules` + - `readMemory(opts): Memory[]` — `{scope, file, exists, size, mtime, preview}` for `/AGENTS.md` and `/AGENTS.md` + - `readConfig(opts): ConfigFile[]` — `{scope, file, exists, size, mtime, content, truncated}` for `/config.toml` and `/.codex/config.toml` + - `readOverview(opts)` — `{counts: {skills, agents, rules, memory, config}, roots: {codexHome, projectCodexDir, projectRoot}}` + - `readTomlMeta(text): object` — the best-effort top-level scalar reader used by `readAgents` +- Note: MCP servers and plugins are NOT here — they come from the CLI in Task 4. + +- [ ] **Step 1: Write the failing test** + +```js +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-disc-")); +process.env.CODEX_HOME = TMP; +const cx = require("../lib/codex-discovery"); + +it("reads a user skill and a project skill into one scoped list", () => { + write(path.join(TMP, "skills", "release-guard", "SKILL.md"), "---\nname: release-guard\n---\nbody"); + write(path.join(REPO, ".codex", "skills", "onboarding", "SKILL.md"), "---\nname: onboarding\n---\nb"); + + const rows = cx.readSkills({ cwd: REPO }); + assert.deepEqual( + rows.map((r) => [r.scope, r.name]).sort(), + [["project", "onboarding"], ["user", "release-guard"]] + ); + assert.equal(rows.find((r) => r.name === "release-guard").frontmatter.name, "release-guard"); +}); + +it("pulls an agent's identity out of its TOML without a TOML parser", () => { + write(path.join(REPO, ".codex", "agents", "reviewer.toml"), + 'name = "reviewer"\ndescription = "Read-only review agent."\nmodel = "gpt-5.4"\n' + + 'developer_instructions = """\nmulti\nline\n"""\n'); + + const [agent] = cx.readAgents({ cwd: REPO, scope: "project" }); + assert.equal(agent.meta.name, "reviewer"); + assert.equal(agent.meta.description, "Read-only review agent."); + assert.equal(agent.meta.model, "gpt-5.4"); +}); + +it("reports a missing AGENTS.md as a row that does not exist, not a gap", () => { + const rows = cx.readMemory({ cwd: REPO }); + const user = rows.find((r) => r.scope === "user"); + assert.equal(user.exists, false, "the UI offers to create it, so the row must be present"); +}); + +it("never walks outside the codex roots", () => { + write(path.join(TMP, "skills", "esc", "SKILL.md"), "x"); + for (const row of cx.readSkills({ cwd: REPO })) { + assert.ok(row.file.startsWith(TMP) || row.file.startsWith(REPO)); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/codex-discovery.test.js` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the readers** + +`readTomlMeta` handles exactly what the UI shows: top-level `key = "value"` / `key = 123` / `key = true` lines before the first `[section]`, skipping `"""` blocks. Anything richer is the raw-text editor's job. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/codex-discovery.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/codex-discovery.js server/__tests__/codex-discovery.test.js +git commit -m "feat(codex): read the Codex config surfaces" +``` + +--- + +### Task 4: Codex CLI wrapper + +**Files:** +- Create: `server/lib/codex-cli.js` +- Test: `server/__tests__/codex-cli.test.js` + +**Interfaces:** +- Produces (all async, all returning `{ok: boolean, code: number, stdout: string, stderr: string, data?: object}`; `data` is the parsed JSON when the command emits it): + - `mcpList()` → `codex mcp list --json` + - `mcpAdd({name, command, args, env})` → `codex mcp add -- ` + - `mcpRemove(name)` → `codex mcp remove ` + - `pluginList()` → `codex plugin list --json` + - `pluginAdd(spec)` / `pluginRemove(name)` + - `marketplaceList()` → `codex plugin marketplace list --json` + - `marketplaceAdd(url)` / `marketplaceRemove(name)` + - `NAME_RE` — the identifier guard shared with the routes +- Every call builds an **argv array** and spawns without a shell, exactly like `cc-plugin-cli.js`. A name failing `NAME_RE` throws before spawn. + +- [ ] **Step 1: Write the failing test** + +```js +it("passes the server name as one argv entry, never through a shell", async () => { + const spawned = []; + const cli = proxy({ spawn: (cmd, args) => { spawned.push([cmd, args]); return ok('{"servers":[]}'); } }); + await cli.mcpAdd({ name: "docs", command: "node", args: ["server.js"] }); + assert.deepEqual(spawned[0][0], "codex"); + assert.deepEqual(spawned[0][1], ["mcp", "add", "docs", "--", "node", "server.js"]); +}); + +it("refuses a name that could be read as a flag or a path", async () => { + const cli = require("../lib/codex-cli"); + await assert.rejects(() => cli.mcpRemove("--force"), /name/i); + await assert.rejects(() => cli.mcpRemove("../../etc"), /name/i); +}); + +it("surfaces a non-zero exit as ok:false with stderr kept", async () => { + const cli = proxy({ spawn: () => fail(2, "no such marketplace") }); + const res = await cli.marketplaceRemove("nope"); + assert.equal(res.ok, false); + assert.match(res.stderr, /no such marketplace/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/codex-cli.test.js` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement, copying cc-plugin-cli.js's spawn/injection shape** + +Read `server/lib/cc-plugin-cli.js` first and follow its structure — same injectable spawn, same result object, same argv discipline. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/codex-cli.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/codex-cli.js server/__tests__/codex-cli.test.js +git commit -m "feat(codex): wrap the codex mcp/plugin CLI in argv-safe calls" +``` + +--- + +### Task 5: Codex mutation (write side) + +**Files:** +- Create: `server/lib/codex-mutate.js` +- Test: `server/__tests__/codex-mutate.test.js` + +**Interfaces:** +- Consumes: `atomicWriteFile`, `createBackup`, `assertUnder`, `timestamp` (Task 1); `getCodexHome`, `getProjectCodexDir`, `getProjectRoot` (Task 2) +- Produces: + - `TYPES` — `{skills: {kind: "dir", entry: "SKILL.md"}, agents: {kind: "file", ext: ".toml"}, rules: {kind: "file", ext: ".rules"}, memory: {kind: "fixed", file: "AGENTS.md"}, config: {kind: "fixed", file: "config.toml"}}` + - `resolveTarget(scope, type, name, opts): {kind, target, filePath, containmentRoot}` — the single chokepoint + - `writeArtifact({scope, type, name, content, cwd}): {file, created, backup}` + - `deleteArtifact({scope, type, name, cwd}): {file, backup}` + - `listBackups({scope, type}): {type, name, file, mtime, size}[]` + - `NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/` +- Error codes match cc-mutate's so the route error map is shared in shape: `EBADTYPE`, `EBADSCOPE`, `EBADNAME`, `EBADCONTENT`, `ETOOLARGE`, `ENOTFOUND`, `EOUTOFROOT`. + +- [ ] **Step 1: Write the failing test** + +```js +it("creates a project skill as a directory with SKILL.md inside", () => { + const { file, created } = cm.writeArtifact({ + scope: "project", type: "skills", name: "release-guard", content: "---\nname: x\n---\n", cwd: REPO, + }); + assert.equal(created, true); + assert.equal(file, path.join(REPO, ".codex", "skills", "release-guard", "SKILL.md")); +}); + +it("backs the old body up before overwriting it", () => { + cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "old", cwd: REPO }); + const { backup } = cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "new", cwd: REPO }); + assert.equal(fs.readFileSync(backup, "utf8"), "old"); + assert.equal(fs.readFileSync(path.join(TMP, "rules", "default.rules"), "utf8"), "new"); +}); + +it("refuses a name that climbs out of the root", () => { + assert.throws( + () => cm.writeArtifact({ scope: "user", type: "agents", name: "../../evil", content: "x", cwd: REPO }), + (e) => e.code === "EBADNAME" + ); +}); + +it("writes AGENTS.md at the repo root, not inside .codex", () => { + const { file } = cm.writeArtifact({ scope: "project", type: "memory", content: "# rules", cwd: REPO }); + assert.equal(file, path.join(REPO, "AGENTS.md")); +}); + +it("keeps a deleted skill's backup out of the skills tree", () => { + cm.writeArtifact({ scope: "user", type: "skills", name: "gone", content: "x", cwd: REPO }); + const { backup } = cm.deleteArtifact({ scope: "user", type: "skills", name: "gone", cwd: REPO }); + assert.ok(!backup.includes(path.join(TMP, "skills", "gone")), "a backup must never be discoverable as a skill"); + assert.equal(fs.existsSync(path.join(TMP, "skills", "gone")), false); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/codex-mutate.test.js` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement against `server/lib/cc-mutate.js`'s structure** + +Backups land in `/codex-config-backups//..bak`, mirroring Claude's dotted-away location so Codex never rediscovers a backup as a skill. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test server/__tests__/codex-mutate.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/lib/codex-mutate.js server/__tests__/codex-mutate.test.js +git commit -m "feat(codex): write Codex artifacts with backup and atomic rename" +``` + +--- + +### Task 6: Codex config routes + +**Files:** +- Create: `server/routes/codex-config.js`, `server/openapi-extra/codex-config.js` +- Modify: `server/index.js` (mount `/api/codex-config`), `server/openapi-extra.js` (merge the fragment) +- Test: `server/__tests__/codex-config.test.js` + +**Interfaces:** +- Consumes: Tasks 3, 4, 5 +- Produces: `GET /overview|/skills|/agents|/rules|/memory|/config|/mcp|/plugins|/marketplaces`, `GET /file?path=`, `PUT /file`, `DELETE /file`, `GET /backups`, `POST /mcp/add`, `POST /mcp/remove`, `POST /plugins/action`, `POST /marketplaces/add`, `POST /marketplaces/action` +- Mirrors `server/routes/cc-config.js`: same `ERR_TO_STATUS` mapping, same `emitChanged` broadcast (event name `codex_config_changed`), CLI non-zero exit → **422**. + +- [ ] **Step 1: Write the failing test** + +```js +it("lists skills for both scopes", async () => { + const res = await request(app).get("/api/codex-config/skills"); + assert.equal(res.status, 200); + assert.ok(Array.isArray(res.body.skills)); +}); + +it("rejects a write whose name escapes the root with 400, not 500", async () => { + const res = await request(app) + .put("/api/codex-config/file") + .send({ scope: "user", type: "agents", name: "../evil", content: "x" }); + assert.equal(res.status, 400); +}); + +it("reports a failed CLI call as 422 and keeps the stderr", async () => { + const res = await request(app).post("/api/codex-config/marketplaces/action") + .send({ name: "nope", action: "remove" }); + assert.equal(res.status, 422); +}); + +it("broadcasts codex_config_changed after a successful write", async () => { + const seen = []; + onBroadcast((msg) => seen.push(msg.type)); + await request(app).put("/api/codex-config/file") + .send({ scope: "user", type: "rules", name: "default", content: "allow" }); + assert.ok(seen.includes("codex_config_changed")); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test server/__tests__/codex-config.test.js` +Expected: FAIL — 404 on every route + +- [ ] **Step 3: Implement the router following `server/routes/cc-config.js`** + +- [ ] **Step 4: Run the full backend suite** + +Run: `npm run test:server` +Expected: PASS, no regressions + +- [ ] **Step 5: Regenerate the OpenAPI YAML and commit** + +```bash +npm run openapi:yaml +git add server/routes/codex-config.js server/openapi-extra/codex-config.js server/openapi-extra.js server/index.js server/__tests__/codex-config.test.js openapi.yaml +git commit -m "feat(codex): expose the Codex config API" +``` + +--- + +### Task 7: Client API namespace + +**Files:** +- Modify: `client/src/lib/api.ts` +- Test: `client/src/lib/__tests__/codexConfigApi.test.ts` + +**Interfaces:** +- Produces: `api.codexConfig` with `overview()`, `skills(scope?)`, `agents(scope?)`, `rules(scope?)`, `memory()`, `config()`, `mcp()`, `plugins()`, `marketplaces()`, `file(path)`, `write(args)`, `remove(args)`, `backups()`, `mcpAdd(args)`, `mcpRemove(name)`, `pluginAction(args)`, `marketplaceAdd(url)`, `marketplaceAction(args)`; plus exported types `CodexScope = "user" | "project"`, `CodexArtifactType = "skills" | "agents" | "rules" | "memory" | "config"`, `CodexSkill`, `CodexAgent`, `CodexRule`, `CodexMemoryFile`, `CodexConfigFile`, `CodexMcpServer`, `CodexPlugin`, `CodexMarketplace`, `CodexOverview`. + +- [ ] **Step 1: Write the failing test** + +```ts +it("sends a write as a single PUT with the artifact envelope", async () => { + const calls: RequestInit[] = []; + stubFetch(calls, { ok: true, file: "/x", created: true }); + await api.codexConfig.write({ scope: "user", type: "rules", name: "default", content: "allow" }); + expect(calls[0].method).toBe("PUT"); + expect(JSON.parse(calls[0].body as string)).toEqual({ + scope: "user", type: "rules", name: "default", content: "allow", + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts` +Expected: FAIL — `api.codexConfig` is undefined + +- [ ] **Step 3: Add the namespace beside `api.ccConfig`, following its exact shape** + +- [ ] **Step 4: Run test + typecheck** + +Run: `cd client && npx vitest run src/lib/__tests__/codexConfigApi.test.ts && npx tsc -b` +Expected: PASS, no type errors + +- [ ] **Step 5: Commit** + +```bash +git add client/src/lib/api.ts client/src/lib/__tests__/codexConfigApi.test.ts +git commit -m "feat(codex): add the codexConfig API namespace" +``` + +--- + +### Task 8: Extract the tool-agnostic config UI + +`CcConfig.tsx` is 4902 lines and its modals are already generic over `type: string` / `scope: string`. Lift them before a second tool copies them. + +**Files:** +- Create: `client/src/components/config/FileViewer.tsx`, `EditorModal.tsx`, `ConfirmDeleteModal.tsx`, `ToastNotice.tsx`, `MdItemList.tsx`, `ScopeBadge.tsx`, `configPrimitives.tsx` (`Empty`, `SkeletonRows`, `CopyButton`, `formatBytes`) +- Modify: `client/src/pages/CcConfig.tsx` (delete the moved definitions, import them) +- Test: `client/src/components/config/__tests__/EditorModal.test.tsx` + +**Interfaces:** +- Produces: the components above, with `EditorModal` gaining one new prop so it stops reaching for Claude's API directly: + - `fetchContent: (filePath: string) => Promise` — supplied by whichever orchestrator opens it + - existing props keep their names: `state`, `onClose`, `onSave` +- The moved components must not import `api` at all. + +- [ ] **Step 1: Write the failing test** + +```tsx +it("loads the existing body through the injected fetcher, not a hardcoded API", async () => { + const fetchContent = vi.fn().mockResolvedValue("old body"); + render( {}} + onSave={async () => {}} + />); + expect(fetchContent).toHaveBeenCalledWith("/r/default.rules"); + expect(await screen.findByDisplayValue("old body")).toBeInTheDocument(); +}); + +it("refuses to save a create with no name", async () => { + const onSave = vi.fn(); + render( ""} onClose={() => {}} onSave={onSave} />); + await userEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).not.toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && npx vitest run src/components/config/__tests__/EditorModal.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Move the components; pass `fetchContent={(p) => api.ccConfig.file(p)}` from CcConfig** + +- [ ] **Step 4: Verify the Claude tab is unchanged** + +Run: `cd client && npm test` +Expected: PASS — `screens.snapshot.test.tsx`'s Claude Config snapshot must NOT change. If it does, the move was not behavior-preserving; fix rather than update the snapshot. + +- [ ] **Step 5: Commit** + +```bash +git add client/src/components/config client/src/pages/CcConfig.tsx +git commit -m "refactor(config): lift the tool-agnostic config modals out of CcConfig" +``` + +--- + +### Task 9: The Codex panel + +**Files:** +- Create: `client/src/pages/CodexConfig.tsx`, `client/src/i18n/locales/en/codexConfig.json`, `client/src/i18n/locales/vi/codexConfig.json`, `client/src/pages/__tests__/CodexConfig.test.tsx` +- Modify: `client/src/pages/SettingsHub.tsx:33` (add `Panel: CodexConfig` to the existing `codex` entry), `client/src/i18n/index.ts` (register the namespace) +- Test: `client/src/pages/__tests__/CodexConfig.test.tsx` + +**Interfaces:** +- Consumes: `api.codexConfig` (Task 7), the extracted components (Task 8) +- Produces: `export function CodexConfig()` — tabs `overview | skills | agents | rules | memory | config | mcp | plugins`, with create/edit/delete on `skills | agents | rules | memory | config`, and CLI-driven actions on `mcp | plugins`. + +- [ ] **Step 1: Write the failing test** + +```tsx +it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => { + stubCodexApi({ skills: [], mcp: { servers: [] } }); + render(); + await userEvent.click(screen.getByRole("tab", { name: /skills/i })); + expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("tab", { name: /mcp/i })); + expect(screen.queryByRole("button", { name: /new/i })).not.toBeInTheDocument(); +}); + +it("writes an edited rule back through the codex API, not the Claude one", async () => { + const write = vi.fn().mockResolvedValue({ ok: true }); + stubCodexApi({ rules: [{ scope: "user", name: "default", file: "/r/default.rules", size: 3 }], write }); + render(); + await userEvent.click(screen.getByRole("tab", { name: /rules/i })); + await userEvent.click(screen.getByRole("button", { name: /edit/i })); + await userEvent.click(screen.getByRole("button", { name: /save/i })); + expect(write).toHaveBeenCalledWith(expect.objectContaining({ type: "rules", name: "default" })); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd client && npx vitest run src/pages/__tests__/CodexConfig.test.tsx` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the panel and both locale files** + +Every string goes through `useTranslation("codexConfig")`. The `vi` file is a real translation, not an English copy. + +- [ ] **Step 4: Run the whole client suite + typecheck** + +Run: `cd client && npx tsc -b && npm test` +Expected: PASS. The `screens.snapshot.test.tsx` Settings snapshot changes here (the Codex tab loses its "soon" badge) — that one is expected; review the diff before updating. + +- [ ] **Step 5: Update the docs and commit** + +Update `docs/API.md` (new `/api/codex-config` surface) and `ARCHITECTURE.md` (the Settings hub now hosts two tool panels), per `skills/update-project-docs/`. + +```bash +git add client/src/pages/CodexConfig.tsx client/src/pages/SettingsHub.tsx client/src/i18n client/src/pages/__tests__ docs/API.md ARCHITECTURE.md +git commit -m "feat(codex): create and customize Codex config from /settings" +``` + +--- + +## Self-Review + +**Spec coverage:** skills, agents, rules, AGENTS.md, config.toml → Tasks 3/5/9. MCP + plugins + marketplaces → Tasks 4/6/9. "Create and customize in settings" → Task 9's create/edit/delete wiring. The Claude-side settings.json/MCP editing the user also asked for is deliberately **not** here — it is a separate subsystem with its own plan (`2026-09-08-claude-settings-editable.md`). + +**Placeholder scan:** none — every step names its file, its command, and its expected output. + +**Type consistency:** `CodexArtifactType` (Task 7) matches `TYPES` (Task 5) matches the tab keys (Task 9): `skills | agents | rules | memory | config`. `scope` is `"user" | "project"` everywhere; Codex has no `project-local` or `auto-memory` analogue, which is why its scope union is narrower than Claude's.