code-ai-installer 4.0.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +5 -3
  2. package/dist/mcp/audit_ledger.d.ts +10 -0
  3. package/dist/mcp/audit_ledger.js +15 -0
  4. package/dist/mcp/cli.js +1 -0
  5. package/dist/mcp/tools/render_diff.d.ts +25 -0
  6. package/dist/mcp/tools/render_diff.js +138 -0
  7. package/dist/mcp/tools/sign_off.js +10 -5
  8. package/dist/mcp/tools/stubs.js +2 -0
  9. package/dist/mcp_setup.d.ts +79 -48
  10. package/dist/mcp_setup.js +215 -107
  11. package/dist/shared/tools.d.ts +38 -0
  12. package/dist/shared/tools.js +24 -0
  13. package/domains/analytics/agents/conductor.md +15 -1
  14. package/domains/analytics/locales/en/agents/conductor.md +15 -1
  15. package/domains/content/agents/conductor.md +15 -1
  16. package/domains/content/locales/en/agents/conductor.md +15 -1
  17. package/domains/development/.agents/skills/mcp-integration/SKILL.md +3 -1
  18. package/domains/development/.agents/workflows/audit.md +25 -0
  19. package/domains/development/.agents/workflows/pipeline-rules.md +1 -0
  20. package/domains/development/AGENTS.md +1 -0
  21. package/domains/development/agents/architect.md +1 -1
  22. package/domains/development/agents/auditor.md +4 -3
  23. package/domains/development/agents/conductor.md +4 -1
  24. package/domains/development/agents/devops.md +1 -1
  25. package/domains/development/agents/reviewer.md +2 -1
  26. package/domains/development/agents/senior_full_stack.md +1 -1
  27. package/domains/development/agents/tester.md +1 -1
  28. package/domains/development/locales/en/.agents/skills/mcp-integration/SKILL.md +3 -1
  29. package/domains/development/locales/en/.agents/workflows/audit.md +25 -0
  30. package/domains/development/locales/en/.agents/workflows/pipeline-rules.md +1 -0
  31. package/domains/development/locales/en/AGENTS.md +2 -0
  32. package/domains/development/locales/en/agents/architect.md +1 -1
  33. package/domains/development/locales/en/agents/auditor.md +4 -3
  34. package/domains/development/locales/en/agents/conductor.md +4 -1
  35. package/domains/development/locales/en/agents/devops.md +1 -1
  36. package/domains/development/locales/en/agents/reviewer.md +2 -1
  37. package/domains/development/locales/en/agents/senior_full_stack.md +1 -1
  38. package/domains/development/locales/en/agents/tester.md +1 -1
  39. package/domains/product/agents/conductor.md +15 -1
  40. package/domains/product/locales/en/agents/conductor.md +15 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -48,7 +48,7 @@ Every domain also carries a meta **Auditor** beside the pipeline. Each skill dec
48
48
 
49
49
  ## 🚪 The MCP Gate Pipeline
50
50
 
51
- When you install for **Claude**, the installer registers `code-ai-mcp` in your global (user-scope) Claude config via `claude mcp add --scope user` (idempotent — a server already present in user scope is left untouched). From then on, the agents drive the work *through the server*, not by free-form prompting:
51
+ When you install for **Claude**, the installer registers `code-ai-mcp` in your global (user-scope) Claude config it adds an entry to the `mcpServers` object of `~/.claude.json` (idempotent — a server already present is left untouched, so an existing global `mempalace` is never duplicated). From then on, the agents drive the work *through the server*, not by free-form prompting:
52
52
 
53
53
  1. **`current_gate` / `classify_gate`** — the conductor reads where the run is and classifies the gate outcome (`auto_resolve` / `fork` / `exception`).
54
54
  2. **`get_skill`** — the active role fetches the skills it owns at this gate (the fetch is logged as telemetry for the Auditor).
@@ -151,14 +151,16 @@ Depending on `--target`, `code-ai` restructures your project:
151
151
  1. **Orchestration entry** — `AGENTS.md` (plus tool aliases like `CLAUDE.md`, `CODEX.md`, `GEMINI.md`, `QWEN.md`, `KIMI.md`).
152
152
  2. **Agents** — roles copied into the target's layout (e.g. `.claude/agents/<role>.md`, `.github/copilot-instructions.md`).
153
153
  3. **Skills & workflows** — the execution skillsets, with per-tool metadata sidecars.
154
- 4. **MCP wiring (Claude only)** — registers `code-ai-mcp` (run as `npx -p code-ai-installer code-ai-mcp`) in your global (user-scope) Claude config via `claude mcp add --scope user`, plus an optional [MemPalace](https://www.npmjs.com/package/mempalace) memory server when present, and a project-local `.code-ai/config.json` that records the active domain and decision-store backend. If the `claude` CLI isn't on PATH, the installer prints the exact commands to run instead of editing your config by hand.
154
+ 4. **MCP wiring (Claude only)** — registers `code-ai-mcp` (run as `npx -p code-ai-installer code-ai-mcp`) in your global (user-scope) Claude config by adding it to the `mcpServers` object of `~/.claude.json` (backed up first, written atomically, idempotent), plus an optional [MemPalace](https://www.npmjs.com/package/mempalace) memory server when present, and a project-local `.code-ai/config.json` that records the active domain and decision-store backend. If `~/.claude.json` can't be read or written, the installer prints the exact JSON entries to add by hand instead of guessing.
155
155
 
156
156
  ---
157
157
 
158
158
  ## 🧬 Versions & migration
159
159
 
160
- `code-ai-installer` is on **v4.0.0**.
160
+ `code-ai-installer` is on **v4.3.0**.
161
161
 
162
+ - **v4.3.0** — `render_diff` MCP tool (unified diff → a standalone HTML review page); MCP gate-flow + stop-at-user-gate sections added to the content / analytics / product conductors; Auditor trigger — a `/audit` command plus a Release-Gate nudge that surfaces after every 3rd completed run (development pilot).
163
+ - **v4.1.0** — MCP servers now register in your **global (user-scope)** config via a direct, idempotent `~/.claude.json` merge (no dependency on the `claude` CLI); the conductor halts at each user gate — one at a time, no batching, no auto-pass on green.
162
164
  - **v4.0.0** — consolidated the previously separate `code-ai-mcp` and types packages into this single package with **two bins** (`code-ai` + `code-ai-mcp`). Installing the CLI now also delivers the MCP server; for Claude it is auto-registered in your global (user-scope) MCP config. Existing 3.x CLI behavior is unchanged.
163
165
  - **v3.0.0 (breaking)** — removed the legacy flat root layout (`AGENTS.md` + `agents/` + `.agents/` at package root); the CLI now reads exclusively from `domains/<id>/`. **Migrating from v2.x:** add `--domain <development|content|analytics|product>` to every CLI call. If you used a custom `--project-dir`, restructure it to `domains/<id>/` instead of a flat layout.
164
166
 
@@ -10,3 +10,13 @@ export declare function readLedger(): Promise<RunScorecard[]>;
10
10
  * break a sign-off (telemetry is never load-bearing for the gate).
11
11
  */
12
12
  export declare function recordRunScorecard(state: TaskState): Promise<void>;
13
+ /** Number of COMPLETED (RG-signed) runs in the ledger. */
14
+ export declare function countCompletedRuns(): Promise<number>;
15
+ /**
16
+ * A1 cadence (Variant A1): surface an Auditor nudge on every 3rd completed run
17
+ * (3, 6, 9, …). Stateless — no last-audit marker — so it re-reminds until the
18
+ * user runs /audit. Surfacing only; returns undefined when no nudge is due.
19
+ */
20
+ export declare function auditNudgeFor(completedRuns: number): {
21
+ runs_total: number;
22
+ } | undefined;
@@ -80,3 +80,18 @@ export async function recordRunScorecard(state) {
80
80
  const extras = await readSideCounts(state.task_id);
81
81
  await appendScorecard(buildScorecard(state, extras));
82
82
  }
83
+ /** Number of COMPLETED (RG-signed) runs in the ledger. */
84
+ export async function countCompletedRuns() {
85
+ const cards = await readLedger();
86
+ return cards.filter((c) => c.completed).length;
87
+ }
88
+ /**
89
+ * A1 cadence (Variant A1): surface an Auditor nudge on every 3rd completed run
90
+ * (3, 6, 9, …). Stateless — no last-audit marker — so it re-reminds until the
91
+ * user runs /audit. Surfacing only; returns undefined when no nudge is due.
92
+ */
93
+ export function auditNudgeFor(completedRuns) {
94
+ return completedRuns >= 3 && completedRuns % 3 === 0
95
+ ? { runs_total: completedRuns }
96
+ : undefined;
97
+ }
package/dist/mcp/cli.js CHANGED
@@ -56,6 +56,7 @@ const TOOL_DESCRIPTIONS = {
56
56
  dependency_supply_chain: "[stub] Check supply chain (npm audit, Socket integration, license check).",
57
57
  docker_compose: "[stub] Run docker compose up/down for a target.",
58
58
  e2e_playwright: "[stub] Run Playwright end-to-end suite.",
59
+ render_diff: "Render a unified diff (e.g. `git diff`) into a colored, per-file, line-numbered HTML review page written to the OS temp dir. Returns the path + a file:// URL the user opens in a browser. Use at the REV gate to present code changes for review. Informational only — the file lives in temp (cleared on reboot), never in the project.",
59
60
  };
60
61
  function buildServer() {
61
62
  const dispatch = new CodeAiMcpServer();
@@ -0,0 +1,25 @@
1
+ import { type RenderDiffInput, type RenderDiffOutput } from "../../shared/index.js";
2
+ type RowKind = "hunk" | "add" | "del" | "ctx";
3
+ interface DiffRow {
4
+ kind: RowKind;
5
+ text: string;
6
+ o: number | "";
7
+ n: number | "";
8
+ }
9
+ export interface DiffFile {
10
+ name: string;
11
+ added: number;
12
+ removed: number;
13
+ rows: DiffRow[];
14
+ }
15
+ /** Parse a unified diff into per-file rows with old/new line numbers. Pure. */
16
+ export declare function parseUnifiedDiff(raw: string): DiffFile[];
17
+ /** Render parsed diff files into a self-contained HTML page. Pure. */
18
+ export declare function renderDiffHtml(diff: string, title: string): {
19
+ html: string;
20
+ files: number;
21
+ added: number;
22
+ removed: number;
23
+ };
24
+ export declare function renderDiff(input: RenderDiffInput): Promise<RenderDiffOutput>;
25
+ export {};
@@ -0,0 +1,138 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { createHash } from "node:crypto";
6
+ /**
7
+ * render_diff — turn a unified diff into a colored, per-file, line-numbered HTML
8
+ * review page and write it to the OS temp dir.
9
+ *
10
+ * Why a tool (not a loose script): code reviews in the pipeline are presented as
11
+ * an HTML diff the user opens via a file:// link. Shipping it as an MCP tool keeps
12
+ * it version-controlled, testable, and reachable wherever the server runs.
13
+ *
14
+ * The output is INFORMATIONAL only — it lives in the system temp dir (cleared on
15
+ * reboot), never in the project, so it pollutes nothing and needs no cleanup.
16
+ * Self-contained: no deps, no network.
17
+ */
18
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
19
+ /** Parse a unified diff into per-file rows with old/new line numbers. Pure. */
20
+ export function parseUnifiedDiff(raw) {
21
+ const blocks = raw.split(/^diff --git /m).filter(Boolean);
22
+ const files = [];
23
+ for (const b of blocks) {
24
+ const lines = ("diff --git " + b).split("\n");
25
+ const header = lines[0];
26
+ const m = header.match(/a\/(.+?) b\/(.+)$/);
27
+ const name = m ? m[2] : header;
28
+ let added = 0;
29
+ let removed = 0;
30
+ const rows = [];
31
+ let oldLn = 0;
32
+ let newLn = 0;
33
+ for (const ln of lines) {
34
+ if (ln.startsWith("diff --git") ||
35
+ ln.startsWith("index ") ||
36
+ ln.startsWith("--- ") ||
37
+ ln.startsWith("+++ "))
38
+ continue;
39
+ const hunk = ln.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
40
+ if (hunk) {
41
+ oldLn = Number(hunk[1]);
42
+ newLn = Number(hunk[2]);
43
+ rows.push({ kind: "hunk", text: ln, o: "", n: "" });
44
+ continue;
45
+ }
46
+ if (ln.startsWith("+")) {
47
+ added++;
48
+ rows.push({ kind: "add", text: ln.slice(1), o: "", n: newLn++ });
49
+ }
50
+ else if (ln.startsWith("-")) {
51
+ removed++;
52
+ rows.push({ kind: "del", text: ln.slice(1), o: oldLn++, n: "" });
53
+ }
54
+ else if (ln.startsWith("\\")) {
55
+ // "" — skip
56
+ }
57
+ else {
58
+ rows.push({ kind: "ctx", text: ln.slice(1), o: oldLn++, n: newLn++ });
59
+ }
60
+ }
61
+ files.push({ name, added, removed, rows });
62
+ }
63
+ return files;
64
+ }
65
+ /** Render parsed diff files into a self-contained HTML page. Pure. */
66
+ export function renderDiffHtml(diff, title) {
67
+ const files = parseUnifiedDiff(diff);
68
+ const fileSections = files
69
+ .map((f, i) => {
70
+ const rowsHtml = f.rows
71
+ .map((r) => {
72
+ if (r.kind === "hunk")
73
+ return `<tr class="hunk"><td class="ln"></td><td class="ln"></td><td class="code">${esc(r.text)}</td></tr>`;
74
+ const sign = r.kind === "add" ? "+" : r.kind === "del" ? "-" : " ";
75
+ return `<tr class="${r.kind}"><td class="ln">${r.o}</td><td class="ln">${r.n}</td><td class="code"><span class="sign">${sign}</span>${esc(r.text)}</td></tr>`;
76
+ })
77
+ .join("\n");
78
+ return `<section id="f${i}">
79
+ <h2>${esc(f.name)} <span class="stat"><span class="plus">+${f.added}</span> <span class="minus">-${f.removed}</span></span></h2>
80
+ <table>${rowsHtml}</table>
81
+ </section>`;
82
+ })
83
+ .join("\n");
84
+ const added = files.reduce((s, f) => s + f.added, 0);
85
+ const removed = files.reduce((s, f) => s + f.removed, 0);
86
+ const nav = files
87
+ .map((f, i) => `<a href="#f${i}">${esc(f.name.split("/").pop() ?? f.name)}</a>`)
88
+ .join("");
89
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
90
+ <title>${esc(title)}</title>
91
+ <style>
92
+ :root { color-scheme: light dark; }
93
+ body { font: 13px/1.5 ui-monospace, "Cascadia Code", Consolas, monospace; margin: 0; background:#0d1117; color:#c9d1d9; }
94
+ header { padding: 16px 24px; border-bottom: 1px solid #30363d; position: sticky; top:0; background:#0d1117; }
95
+ header h1 { font: 600 16px system-ui, sans-serif; margin: 0 0 4px; }
96
+ header .summary { font: 13px system-ui, sans-serif; color:#8b949e; }
97
+ .plus { color:#3fb950; } .minus { color:#f85149; }
98
+ section { margin: 20px 24px; border: 1px solid #30363d; border-radius: 8px; overflow:hidden; }
99
+ section h2 { font: 600 13px ui-monospace, monospace; margin:0; padding:10px 14px; background:#161b22; border-bottom:1px solid #30363d; }
100
+ .stat { float:right; font-weight:400; }
101
+ table { border-collapse: collapse; width:100%; }
102
+ td { padding: 0 6px; white-space: pre-wrap; word-break: break-word; vertical-align: top; }
103
+ td.ln { width:1%; text-align:right; color:#6e7681; user-select:none; border-right:1px solid #21262d; padding:0 8px; }
104
+ td.code { width:100%; }
105
+ .sign { display:inline-block; width:1ch; color:#6e7681; }
106
+ tr.add { background: rgba(63,185,80,.15); } tr.add .sign { color:#3fb950; }
107
+ tr.del { background: rgba(248,81,73,.15); } tr.del .sign { color:#f85149; }
108
+ tr.hunk td { background:#161b22; color:#8b949e; padding:4px 14px; }
109
+ nav { padding: 0 24px; font: 13px system-ui, sans-serif; }
110
+ nav a { color:#58a6ff; text-decoration:none; display:inline-block; margin:2px 12px 2px 0; }
111
+ </style></head><body>
112
+ <header>
113
+ <h1>${esc(title)}</h1>
114
+ <div class="summary">${files.length} files · <span class="plus">+${added}</span> <span class="minus">-${removed}</span></div>
115
+ </header>
116
+ <nav>${nav}</nav>
117
+ ${fileSections}
118
+ </body></html>`;
119
+ return { html, files: files.length, added, removed };
120
+ }
121
+ /** Slugify a label for use in a temp filename. */
122
+ function slug(s) {
123
+ return (s
124
+ .toLowerCase()
125
+ .replace(/[^a-z0-9]+/g, "-")
126
+ .replace(/^-+|-+$/g, "")
127
+ .slice(0, 40) || "review");
128
+ }
129
+ export async function renderDiff(input) {
130
+ const title = input.title ?? "Diff review";
131
+ const { html, files, added, removed } = renderDiffHtml(input.diff, title);
132
+ // Content-hashed name → same diff reuses one file; lands in the OS temp dir
133
+ // (cleared on reboot), never in the project.
134
+ const hash = createHash("sha1").update(input.diff).digest("hex").slice(0, 8);
135
+ const path = join(tmpdir(), `code-ai-diff-${slug(input.task_id ?? title)}-${hash}.html`);
136
+ await writeFile(path, html, "utf8");
137
+ return { path, file_url: pathToFileURL(path).href, files, added, removed };
138
+ }
@@ -1,6 +1,6 @@
1
1
  import { getGateConfig, loadPipeline } from "../pipeline.js";
2
2
  import { readTaskState, writeTaskState } from "../task_state.js";
3
- import { recordRunScorecard } from "../audit_ledger.js";
3
+ import { recordRunScorecard, countCompletedRuns, auditNudgeFor } from "../audit_ledger.js";
4
4
  import { resolveActiveDomain } from "../config.js";
5
5
  /**
6
6
  * Records a sign-off event for the task's current gate. Validates that:
@@ -27,7 +27,9 @@ export async function signOff(input) {
27
27
  switch (gateCfg.sign_off_policy) {
28
28
  case "user":
29
29
  if (input.signer !== "user") {
30
- throw new Error(`sign_off: gate ${input.gate} requires signer 'user' (policy=user), got '${input.signer}'`);
30
+ throw new Error(`sign_off: gate ${input.gate} requires signer 'user' (policy=user), got '${input.signer}'. ` +
31
+ `This is a USER gate — HALT: present the ${input.gate} artifact and request the user's sign_off(signer='user') for THIS gate. ` +
32
+ `Do not auto-pass it, do not batch it with other gates, and do not begin any downstream-gate work until it is signed.`);
31
33
  }
32
34
  break;
33
35
  case "mcp_auto_pass":
@@ -56,15 +58,18 @@ export async function signOff(input) {
56
58
  });
57
59
  await writeTaskState(state);
58
60
  // Auditor telemetry: when the terminal gate is signed, the run is complete —
59
- // append a scorecard to the local ledger. Best-effort: a ledger failure must
60
- // NEVER break a sign-off (telemetry is not load-bearing for the gate).
61
+ // append a scorecard to the local ledger, then compute the /audit nudge.
62
+ // Best-effort: a ledger failure (or nudge failure) must NEVER break a
63
+ // sign-off (telemetry is not load-bearing for the gate).
64
+ let audit_nudge;
61
65
  if (input.gate === "RG") {
62
66
  try {
63
67
  await recordRunScorecard(state);
68
+ audit_nudge = auditNudgeFor(await countCompletedRuns());
64
69
  }
65
70
  catch {
66
71
  /* swallow — see contract in audit_ledger.recordRunScorecard */
67
72
  }
68
73
  }
69
- return { signed: true, signer: input.signer, timestamp };
74
+ return { signed: true, signer: input.signer, timestamp, ...(audit_nudge ? { audit_nudge } : {}) };
70
75
  }
@@ -31,6 +31,7 @@ import { reviewProposal } from "./review_proposal.js";
31
31
  import { e2ePlaywright } from "./e2e_playwright.js";
32
32
  import { dockerCompose } from "./docker_compose.js";
33
33
  import { dependencySupplyChain } from "./dependency_supply_chain.js";
34
+ import { renderDiff } from "./render_diff.js";
34
35
  /** Thrown by every stub until the real implementation lands. */
35
36
  export class NotImplementedError extends Error {
36
37
  constructor(tool) {
@@ -83,4 +84,5 @@ export const DEFAULT_HANDLERS = {
83
84
  dependency_supply_chain: dependencySupplyChain,
84
85
  docker_compose: dockerCompose,
85
86
  e2e_playwright: e2ePlaywright,
87
+ render_diff: renderDiff,
86
88
  };
@@ -5,23 +5,33 @@ import type { DomainId } from "./shared/index.js";
5
5
  * Responsibilities:
6
6
  * 1. Detect / install MemPalace as an opt-in mirror for decision storage.
7
7
  * 2. Register `code-ai-mcp` (always) and `mempalace` (when accepted) in
8
- * Claude Code's USER (global) scope via `claude mcp add --scope user`,
9
- * so the servers are available across all the user's projects.
8
+ * Claude Code's USER (global) config so the servers are available across
9
+ * all the user's projects.
10
10
  * 3. Write `.code-ai/config.json` so `code-ai-mcp` knows which backend +
11
11
  * domain to use. This stays PROJECT-local — the global server reads it
12
12
  * from the project cwd at runtime.
13
13
  *
14
+ * Why direct edit of `~/.claude.json` (not `claude mcp add --scope user`):
15
+ * earlier versions registered via the CLI, but `claude mcp add` in current
16
+ * Claude Code rejects the `-s/--scope` flag whenever a stdio passthrough
17
+ * (`-- <command> ...`) is present — a commander parsing quirk — so a stdio
18
+ * server like `code-ai-mcp` can never be added to user scope through the CLI.
19
+ * The CLI is also version-unstable here. User-scope servers live as plain
20
+ * entries in the top-level `mcpServers` object of `~/.claude.json` (exactly
21
+ * where `mempalace`, `figma`, etc. already sit), so we merge there directly.
22
+ *
14
23
  * Why user scope (not a project `.mcp.json`): MCP servers are tools the user
15
- * wants everywhere, not per-project copies. Writing a project `.mcp.json` both
24
+ * wants everywhere, not per-project copies. A project `.mcp.json` both
16
25
  * scattered `code-ai-mcp` per-project and duplicated an already-global
17
- * `mempalace` into the project. Registration is idempotent — a server already
18
- * present in user scope is left untouched.
26
+ * `mempalace`. Registration is idempotent — a server already present in the
27
+ * user config is left untouched (so a pre-existing global `mempalace` is never
28
+ * duplicated or rewritten).
19
29
  *
20
30
  * Non-Claude targets skip this whole flow — MCP is Claude-specific.
21
31
  *
22
- * Graceful degradation: every step is best-effort. If the `claude` CLI is not
23
- * on PATH we never touch the user's config by hand we print the exact
24
- * `claude mcp add` commands to run manually instead.
32
+ * Graceful degradation: every step is best-effort. If `~/.claude.json` cannot
33
+ * be read or written we never guess we print the exact JSON entries to add by
34
+ * hand. Writes are atomic (temp file + rename) and back up the original first.
25
35
  */
26
36
  export type PythonRuntimeTool = "uv" | "pipx" | "pip";
27
37
  export interface PythonRuntime {
@@ -35,17 +45,6 @@ export interface McpServerEntry {
35
45
  args: string[];
36
46
  env?: Record<string, string>;
37
47
  }
38
- /**
39
- * Minimal `claude` CLI surface this module needs. Injectable so tests can
40
- * assert the constructed argv without spawning the real CLI.
41
- */
42
- export interface ClaudeCli {
43
- /** Run `claude <args>`; resolve { ok, output } (combined stdout+stderr). */
44
- run(args: string[]): Promise<{
45
- ok: boolean;
46
- output: string;
47
- }>;
48
- }
49
48
  export interface McpSetupOptions {
50
49
  destinationDir: string;
51
50
  /** User's answer to the MemPalace prompt (true = wants it). */
@@ -64,15 +63,15 @@ export interface McpSetupReport {
64
63
  mempalaceInstallAttempted: boolean;
65
64
  mempalaceInstallSucceeded: boolean;
66
65
  pythonRuntime: PythonRuntime | null;
67
- /** Was the `claude` CLI found on PATH? When false, registration falls back to manual instructions. */
68
- claudeCliAvailable: boolean;
66
+ /** Absolute path of the Claude user config we register servers into. */
67
+ userConfigPath: string;
69
68
  /** How servers were registered. */
70
69
  registration: "user-scope" | "manual-fallback";
71
- /** Server names freshly added to user scope this run. */
70
+ /** Server names freshly added to the user config this run. */
72
71
  serversRegistered: string[];
73
- /** Server names already present in user scope (left untouched). */
72
+ /** Server names already present in the user config (left untouched). */
74
73
  serversAlreadyPresent: string[];
75
- /** Server names whose registration failed. */
74
+ /** Server names whose registration failed (config unwritable). */
76
75
  serversFailed: string[];
77
76
  configPath: string;
78
77
  notices: string[];
@@ -85,8 +84,6 @@ export interface McpSetupReport {
85
84
  * instead of registering a config that can't launch.
86
85
  */
87
86
  export declare function detectMemPalace(): Promise<boolean>;
88
- /** True when the `claude` CLI is on PATH (probed via `claude --version`). */
89
- export declare function detectClaudeCli(cli?: ClaudeCli): Promise<boolean>;
90
87
  /**
91
88
  * Find the first available Python install runtime, in preference order:
92
89
  * uv → pipx → pip. Returns null if none available.
@@ -102,20 +99,54 @@ export declare function installMemPalace(runtime: PythonRuntime): Promise<{
102
99
  output: string;
103
100
  }>;
104
101
  /**
105
- * Build the argv for `claude mcp add --scope user <name> -- <command> [args]`.
106
- * Pure exported for testing. Env vars become `-e KEY=VALUE` flags placed
107
- * before the server name (as `claude mcp add` expects options first).
102
+ * The Claude user config holds user-scope MCP servers in a top-level
103
+ * `mcpServers` object. We only ever read it, merge our entries, and write it
104
+ * back preserving every other key.
105
+ */
106
+ export interface UserConfigIO {
107
+ /** Absolute path of the config file. */
108
+ readonly configPath: string;
109
+ /** Read + parse the config. `ok:false` when missing or not a JSON object. */
110
+ read(): Promise<{
111
+ ok: boolean;
112
+ data?: Record<string, unknown>;
113
+ error?: string;
114
+ }>;
115
+ /** Back up the original then atomically write `data`. */
116
+ writeWithBackup(data: Record<string, unknown>): Promise<{
117
+ ok: boolean;
118
+ backupPath?: string;
119
+ error?: string;
120
+ }>;
121
+ }
122
+ /**
123
+ * Resolve the Claude user config path. Honours `CLAUDE_CONFIG_DIR` when set,
124
+ * else `~/.claude.json`.
125
+ */
126
+ export declare function userConfigPath(): string;
127
+ /** Real filesystem-backed UserConfigIO. Factory so tests can target a temp file. */
128
+ export declare function createUserConfigIO(configPath: string): UserConfigIO;
129
+ /**
130
+ * Idempotently merge `servers` into the config's top-level `mcpServers`. Pure —
131
+ * returns a new config object plus which names were freshly added vs already
132
+ * present. An existing key is NEVER overwritten (so a pre-existing global
133
+ * `mempalace` is preserved untouched). Exported for unit testing.
108
134
  */
109
- export declare function buildClaudeAddArgs(name: string, entry: McpServerEntry): string[];
110
- /** Build the argv for `claude mcp remove --scope user <name>`. Pure — exported for testing. */
111
- export declare function buildClaudeRemoveArgs(name: string): string[];
135
+ export declare function mergeUserScopeServers(config: Record<string, unknown>, servers: Record<string, McpServerEntry>): {
136
+ config: Record<string, unknown>;
137
+ registered: string[];
138
+ alreadyPresent: string[];
139
+ };
112
140
  /**
113
- * Is `name` already registered in Claude's USER scope? Uses `claude mcp get`,
114
- * whose output prints `Scope: User config` for user-scope servers. A server in
115
- * a different scope (local/project) returns false here — we still want it in
116
- * user scope.
141
+ * Idempotently remove `names` from the config's `mcpServers`. Pure returns a
142
+ * new config object plus which names were removed vs were not present. Exported
143
+ * for unit testing.
117
144
  */
118
- export declare function isRegisteredInUserScope(cli: ClaudeCli, name: string): Promise<boolean>;
145
+ export declare function removeUserScopeServers(config: Record<string, unknown>, names: string[]): {
146
+ config: Record<string, unknown>;
147
+ removed: string[];
148
+ notPresent: string[];
149
+ };
119
150
  /**
120
151
  * Write `<destinationDir>/.code-ai/config.json` with the chosen backend.
121
152
  * Idempotent — re-running overwrites with the same content if backend unchanged.
@@ -133,17 +164,17 @@ export declare function writeCodeAiConfig(destinationDir: string, config: {
133
164
  * Order:
134
165
  * 1. If user wants MemPalace: detect → install if absent → fall back if install fails.
135
166
  * 2. Build server entries (code-ai-mcp always; mempalace only when usable).
136
- * 3. Register them in Claude USER scope via `claude mcp add --scope user`
137
- * (idempotent; skip if already present). If `claude` CLI is absent, emit
138
- * manual commands instead of touching the config by hand.
167
+ * 3. Merge them into the Claude user config's `mcpServers` (idempotent; skip
168
+ * already-present). If the config can't be read/written, print the exact
169
+ * JSON to add by hand instead of guessing.
139
170
  * 4. Write project-local `.code-ai/config.json` with the backend choice.
140
171
  *
141
172
  * Reports all decisions in `McpSetupReport.notices` so callers can surface
142
- * them to the user verbatim. `cli` is injectable for testing.
173
+ * them to the user verbatim. `io` is injectable for testing.
143
174
  */
144
- export declare function setupMcp(opts: McpSetupOptions, cli?: ClaudeCli): Promise<McpSetupReport>;
175
+ export declare function setupMcp(opts: McpSetupOptions, io?: UserConfigIO): Promise<McpSetupReport>;
145
176
  export interface McpTeardownReport {
146
- claudeCliAvailable: boolean;
177
+ userConfigPath: string;
147
178
  removal: "user-scope" | "manual-fallback";
148
179
  serversRemoved: string[];
149
180
  serversNotPresent: string[];
@@ -151,10 +182,10 @@ export interface McpTeardownReport {
151
182
  notices: string[];
152
183
  }
153
184
  /**
154
- * Remove the installer-owned MCP server(s) from Claude's USER scope via
155
- * `claude mcp remove --scope user`. Idempotent — a server that isn't registered
156
- * is reported as "nothing to remove". If the `claude` CLI is absent, prints the
157
- * manual commands instead of touching the config. `cli` is injectable for tests.
185
+ * Remove the installer-owned MCP server(s) from the Claude user config's
186
+ * `mcpServers`. Idempotent — a server that isn't present is reported as
187
+ * "nothing to remove". If the config can't be read/written, prints guidance
188
+ * instead of guessing. `mempalace` is never touched. `io` is injectable.
158
189
  *
159
190
  * NOTE: the registration is global (shared across all projects), so this removes
160
191
  * code-ai-mcp for every project. That is the chosen behaviour (symmetric with
@@ -162,4 +193,4 @@ export interface McpTeardownReport {
162
193
  */
163
194
  export declare function teardownMcp(opts: {
164
195
  dryRun: boolean;
165
- }, cli?: ClaudeCli): Promise<McpTeardownReport>;
196
+ }, io?: UserConfigIO): Promise<McpTeardownReport>;