153 lines
8.4 KiB
Markdown
153 lines
8.4 KiB
Markdown
# 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.
|