# 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.