Files
AgentHub/docs/superpowers/plans/2026-09-08-claude-settings-editable.md
T

8.4 KiB

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

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

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