docs(plans): record the Codex config panel and Claude settings plans
This commit is contained in:
@@ -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 `<redacted>` 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ĩ <vinnt@smartgift.vn>`.
|
||||||
|
- **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<string, unknown>, 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: "<redacted>" } }] });
|
||||||
|
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 `"<redacted>"` 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 <name> [--scope user|project] [--transport http --url <url> | -- <command> <args…>]`
|
||||||
|
- `mcpRemove({name, scope})` → `claude mcp remove <name> [--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.
|
||||||
@@ -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ĩ <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**
|
||||||
|
|
||||||
|
```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` — `<cwd>/.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 <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**
|
||||||
|
|
||||||
|
```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 `<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**
|
||||||
|
|
||||||
|
```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 <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**
|
||||||
|
|
||||||
|
```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 `<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**
|
||||||
|
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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**
|
||||||
|
|
||||||
|
```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(<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/`.
|
||||||
|
|
||||||
|
```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.
|
||||||
Reference in New Issue
Block a user