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>(seeskills/file-headers/). - User-visible strings live in i18n JSON, never as literals in components. Both
enandvimust be updated in the same commit. - No new npm dependencies. TOML is never parsed in-process;
codex … --jsonor 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.jsgives today. CODEX_HOMEoverrides~/.codexeverywhere, exactly asCLAUDE_HOMEoverrides~/.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 localcreateBackup,atomicWriteFile,timestamp; import them instead) - Test:
server/__tests__/config-write.test.js
Interfaces:
-
Consumes:
isUnderfromserver/lib/cc-discovery.js -
Produces:
atomicWriteFile(filePath: string, content: string): voidcreateBackup(opts: {source: string, backupRoot: string, type: string, name: string}): string | null— returns the backup path, ornullwhensourcedoes not exist. Copies a directory recursively whensourceis one.assertUnder(root: string, target: string): void— throwsObject.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 localcodexHome, 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>/.codexgetProjectRoot(cwd: string): string—cwd(whereAGENTS.mdlives)
-
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_BYTESfromserver/lib/cc-discovery.js -
Produces (every reader takes
{scope, cwd}wherescopeis"user" | "project" | undefinedmeaning both):readSkills(opts): Skill[]—{scope, name, path, file, size, mtime, truncated, frontmatter, preview}from<home>/skills/*/SKILL.mdand<cwd>/.codex/skills/*/SKILL.mdreadAgents(opts): Agent[]—{scope, name, file, size, mtime, meta, preview}from*/agents/*.toml;metais{name, description, model, sandbox_mode}best-effortreadRules(opts): Rule[]—{scope, name, file, size, mtime, preview}from*/rules/*.rulesreadMemory(opts): Memory[]—{scope, file, exists, size, mtime, preview}for<home>/AGENTS.mdand<cwd>/AGENTS.mdreadConfig(opts): ConfigFile[]—{scope, file, exists, size, mtime, content, truncated}for<home>/config.tomland<cwd>/.codex/config.tomlreadOverview(opts)—{counts: {skills, agents, rules, memory, config}, roots: {codexHome, projectCodexDir, projectRoot}}readTomlMeta(text): object— the best-effort top-level scalar reader used byreadAgents
-
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};datais the parsed JSON when the command emits it):mcpList()→codex mcp list --jsonmcpAdd({name, command, args, env})→codex mcp add <name> -- <command> <args…>mcpRemove(name)→codex mcp remove <name>pluginList()→codex plugin list --jsonpluginAdd(spec)/pluginRemove(name)marketplaceList()→codex plugin marketplace list --jsonmarketplaceAdd(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 failingNAME_REthrows 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 chokepointwriteArtifact({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: sameERR_TO_STATUSmapping, sameemitChangedbroadcast (event namecodex_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.codexConfigwithoverview(),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 typesCodexScope = "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
EditorModalgaining 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
apiat 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(addPanel: CodexConfigto the existingcodexentry),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()— tabsoverview | skills | agents | rules | memory | config | mcp | plugins, with create/edit/delete onskills | agents | rules | memory | config, and CLI-driven actions onmcp | 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.