Files
AgentHub/docs/superpowers/plans/2026-09-08-codex-config-panel.md
T

28 KiB

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ĩ <vinnt@smartgift.vn> (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

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
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 — <cwd>/.codex
    • getProjectRoot(cwd: string): string — cwd (where AGENTS.md lives)
  • Step 1: Write the failing test

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 <cwd>/.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
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 <home>/skills/*/SKILL.md and <cwd>/.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 <home>/AGENTS.md and <cwd>/AGENTS.md
    • readConfig(opts): ConfigFile[] — {scope, file, exists, size, mtime, content, truncated} for <home>/config.toml and <cwd>/.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

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
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 <name> -- <command> <args…>
    • mcpRemove(name) → codex mcp remove <name>
    • 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

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

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 <root>/codex-config-backups/<type>/<name>.<ts>.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
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

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

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
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<string> — 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

it("loads the existing body through the injected fetcher, not a hardcoded API", async () => {
  const fetchContent = vi.fn().mockResolvedValue("old body");
  render(<EditorModal
    state={{ mode: "edit", type: "rules", scope: "user", name: "default", filePath: "/r/default.rules" }}
    fetchContent={fetchContent}
    onClose={() => {}}
    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(<EditorModal state={{ mode: "create", type: "agents", defaultScope: "user" }}
    fetchContent={async () => ""} 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
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

it("offers create on the authored surfaces and withholds it on the CLI-owned ones", async () => {
  stubCodexApi({ skills: [], mcp: { servers: [] } });
  render(<CodexConfig />);
  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(<CodexConfig />);
  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/.

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.