code-ai-installer 4.0.1 → 4.1.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.
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,7 +151,7 @@ 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
 
@@ -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>;
package/dist/mcp_setup.js CHANGED
@@ -1,9 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdir, writeFile } from "node:fs/promises";
2
+ import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
- const defaultClaudeCli = {
5
- run: (args) => spawnCapture("claude", args),
6
- };
7
5
  /**
8
6
  * Try `mempalace-mcp --help` — the dedicated MCP-server bin, which is exactly
9
7
  * what we register. Resolves true on exit code 0, false otherwise (including
@@ -14,11 +12,6 @@ const defaultClaudeCli = {
14
12
  export async function detectMemPalace() {
15
13
  return spawnExitZero("mempalace-mcp", ["--help"]);
16
14
  }
17
- /** True when the `claude` CLI is on PATH (probed via `claude --version`). */
18
- export async function detectClaudeCli(cli = defaultClaudeCli) {
19
- const res = await cli.run(["--version"]);
20
- return res.ok;
21
- }
22
15
  /**
23
16
  * Find the first available Python install runtime, in preference order:
24
17
  * uv → pipx → pip. Returns null if none available.
@@ -56,55 +49,118 @@ function installCommandArgs(runtime) {
56
49
  }
57
50
  }
58
51
  /**
59
- * Build the argv for `claude mcp add --scope user <name> -- <command> [args]`.
60
- * Pure — exported for testing. Env vars become `-e KEY=VALUE` flags placed
61
- * before the server name (as `claude mcp add` expects options first).
52
+ * Resolve the Claude user config path. Honours `CLAUDE_CONFIG_DIR` when set,
53
+ * else `~/.claude.json`.
62
54
  */
63
- export function buildClaudeAddArgs(name, entry) {
64
- const envFlags = entry.env
65
- ? Object.entries(entry.env).flatMap(([k, v]) => ["-e", `${k}=${v}`])
66
- : [];
67
- return ["mcp", "add", ...envFlags, "--scope", "user", name, "--", entry.command, ...entry.args];
55
+ export function userConfigPath() {
56
+ const dir = process.env.CLAUDE_CONFIG_DIR?.trim();
57
+ return dir ? join(dir, ".claude.json") : join(homedir(), ".claude.json");
58
+ }
59
+ /** Real filesystem-backed UserConfigIO. Factory so tests can target a temp file. */
60
+ export function createUserConfigIO(configPath) {
61
+ return {
62
+ configPath,
63
+ async read() {
64
+ let raw;
65
+ try {
66
+ raw = await readFile(configPath, "utf8");
67
+ }
68
+ catch (err) {
69
+ const e = err;
70
+ return { ok: false, error: e.code === "ENOENT" ? "file not found" : e.message };
71
+ }
72
+ try {
73
+ const data = JSON.parse(raw);
74
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
75
+ return { ok: false, error: "config is not a JSON object" };
76
+ }
77
+ return { ok: true, data: data };
78
+ }
79
+ catch (err) {
80
+ return { ok: false, error: `JSON parse error: ${err.message}` };
81
+ }
82
+ },
83
+ async writeWithBackup(data) {
84
+ const backupPath = `${configPath}.bak`;
85
+ try {
86
+ try {
87
+ await copyFile(configPath, backupPath);
88
+ }
89
+ catch (err) {
90
+ // Only tolerate "original did not exist"; surface anything else.
91
+ if (err.code !== "ENOENT")
92
+ throw err;
93
+ }
94
+ const tmpPath = `${configPath}.code-ai.tmp`;
95
+ await writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf8");
96
+ await rename(tmpPath, configPath);
97
+ return { ok: true, backupPath };
98
+ }
99
+ catch (err) {
100
+ return { ok: false, error: err.message };
101
+ }
102
+ },
103
+ };
68
104
  }
69
- /** Build the argv for `claude mcp remove --scope user <name>`. Pure — exported for testing. */
70
- export function buildClaudeRemoveArgs(name) {
71
- return ["mcp", "remove", "--scope", "user", name];
105
+ const defaultUserConfigIO = createUserConfigIO(userConfigPath());
106
+ /** Shape a server entry the way Claude stores user-scope stdio servers. */
107
+ function toUserScopeEntry(entry) {
108
+ return {
109
+ type: "stdio",
110
+ command: entry.command,
111
+ args: [...entry.args],
112
+ env: entry.env ?? {},
113
+ };
72
114
  }
73
- /** Human-readable manual command, for the no-CLI fallback notice. */
74
- function manualAddCommand(name, entry) {
75
- return `claude ${buildClaudeAddArgs(name, entry).join(" ")}`;
115
+ function readMcpServers(config) {
116
+ const existing = config.mcpServers;
117
+ return existing && typeof existing === "object" && !Array.isArray(existing)
118
+ ? { ...existing }
119
+ : {};
76
120
  }
77
121
  /**
78
- * Is `name` already registered in Claude's USER scope? Uses `claude mcp get`,
79
- * whose output prints `Scope: User config` for user-scope servers. A server in
80
- * a different scope (local/project) returns false here we still want it in
81
- * user scope.
122
+ * Idempotently merge `servers` into the config's top-level `mcpServers`. Pure
123
+ * returns a new config object plus which names were freshly added vs already
124
+ * present. An existing key is NEVER overwritten (so a pre-existing global
125
+ * `mempalace` is preserved untouched). Exported for unit testing.
82
126
  */
83
- export async function isRegisteredInUserScope(cli, name) {
84
- const res = await cli.run(["mcp", "get", name]);
85
- return res.ok && /Scope:\s*User config/i.test(res.output);
127
+ export function mergeUserScopeServers(config, servers) {
128
+ const next = { ...config };
129
+ const mcpServers = readMcpServers(config);
130
+ const registered = [];
131
+ const alreadyPresent = [];
132
+ for (const [name, entry] of Object.entries(servers)) {
133
+ if (Object.prototype.hasOwnProperty.call(mcpServers, name)) {
134
+ alreadyPresent.push(name);
135
+ continue;
136
+ }
137
+ mcpServers[name] = toUserScopeEntry(entry);
138
+ registered.push(name);
139
+ }
140
+ next.mcpServers = mcpServers;
141
+ return { config: next, registered, alreadyPresent };
86
142
  }
87
143
  /**
88
- * Register one server in user scope, idempotently. Skips if already present in
89
- * user scope. Never throws returns the outcome + a notice for the report.
144
+ * Idempotently remove `names` from the config's `mcpServers`. Pure returns a
145
+ * new config object plus which names were removed vs were not present. Exported
146
+ * for unit testing.
90
147
  */
91
- async function addServerToUserScope(cli, name, entry) {
92
- if (await isRegisteredInUserScope(cli, name)) {
93
- return {
94
- outcome: "already-present",
95
- notice: `MCP server '${name}' already registered in user scope — left untouched.`,
96
- };
97
- }
98
- const res = await cli.run(buildClaudeAddArgs(name, entry));
99
- if (res.ok) {
100
- return { outcome: "registered", notice: `Registered MCP server '${name}' in user scope (global).` };
148
+ export function removeUserScopeServers(config, names) {
149
+ const next = { ...config };
150
+ const mcpServers = readMcpServers(config);
151
+ const removed = [];
152
+ const notPresent = [];
153
+ for (const name of names) {
154
+ if (Object.prototype.hasOwnProperty.call(mcpServers, name)) {
155
+ delete mcpServers[name];
156
+ removed.push(name);
157
+ }
158
+ else {
159
+ notPresent.push(name);
160
+ }
101
161
  }
102
- return {
103
- outcome: "failed",
104
- notice: `Failed to register '${name}' in user scope. Run it manually:\n` +
105
- ` ${manualAddCommand(name, entry)}\n` +
106
- ` Output:\n${res.output}`,
107
- };
162
+ next.mcpServers = mcpServers;
163
+ return { config: next, removed, notPresent };
108
164
  }
109
165
  /**
110
166
  * Write `<destinationDir>/.code-ai/config.json` with the chosen backend.
@@ -142,21 +198,25 @@ function buildServerEntries(mempalaceUsed) {
142
198
  }
143
199
  return servers;
144
200
  }
201
+ /** Format a single "name: entry-json" line for the manual-fallback notice. */
202
+ function manualEntryLine(name, entry) {
203
+ return ` "${name}": ${JSON.stringify(toUserScopeEntry(entry))}`;
204
+ }
145
205
  /**
146
206
  * End-to-end orchestrator. Called from the install flow when `target=claude`.
147
207
  *
148
208
  * Order:
149
209
  * 1. If user wants MemPalace: detect → install if absent → fall back if install fails.
150
210
  * 2. Build server entries (code-ai-mcp always; mempalace only when usable).
151
- * 3. Register them in Claude USER scope via `claude mcp add --scope user`
152
- * (idempotent; skip if already present). If `claude` CLI is absent, emit
153
- * manual commands instead of touching the config by hand.
211
+ * 3. Merge them into the Claude user config's `mcpServers` (idempotent; skip
212
+ * already-present). If the config can't be read/written, print the exact
213
+ * JSON to add by hand instead of guessing.
154
214
  * 4. Write project-local `.code-ai/config.json` with the backend choice.
155
215
  *
156
216
  * Reports all decisions in `McpSetupReport.notices` so callers can surface
157
- * them to the user verbatim. `cli` is injectable for testing.
217
+ * them to the user verbatim. `io` is injectable for testing.
158
218
  */
159
- export async function setupMcp(opts, cli = defaultClaudeCli) {
219
+ export async function setupMcp(opts, io = defaultUserConfigIO) {
160
220
  const notices = [];
161
221
  let mempalaceUsed = false;
162
222
  let mempalaceInstallAttempted = false;
@@ -191,33 +251,49 @@ export async function setupMcp(opts, cli = defaultClaudeCli) {
191
251
  const serversRegistered = [];
192
252
  const serversAlreadyPresent = [];
193
253
  const serversFailed = [];
194
- const claudeCliAvailable = !opts.dryRun && (await detectClaudeCli(cli));
195
254
  let registration;
196
255
  if (opts.dryRun) {
197
256
  registration = "user-scope";
198
257
  for (const [name, entry] of Object.entries(servers)) {
199
- notices.push(`Would register MCP server '${name}' in user scope: ${manualAddCommand(name, entry)}`);
200
- }
201
- }
202
- else if (claudeCliAvailable) {
203
- registration = "user-scope";
204
- for (const [name, entry] of Object.entries(servers)) {
205
- const { outcome, notice } = await addServerToUserScope(cli, name, entry);
206
- notices.push(notice);
207
- if (outcome === "registered")
208
- serversRegistered.push(name);
209
- else if (outcome === "already-present")
210
- serversAlreadyPresent.push(name);
211
- else
212
- serversFailed.push(name);
258
+ notices.push(`Would register MCP server '${name}' in Claude user config (${io.configPath}): ${JSON.stringify(toUserScopeEntry(entry))}`);
213
259
  }
214
260
  }
215
261
  else {
216
- registration = "manual-fallback";
217
- notices.push("The `claude` CLI was not found on PATH — not modifying your global config automatically. " +
218
- "Register the MCP server(s) in user scope by running:");
219
- for (const [name, entry] of Object.entries(servers)) {
220
- notices.push(` ${manualAddCommand(name, entry)}`);
262
+ const read = await io.read();
263
+ if (!read.ok || !read.data) {
264
+ registration = "manual-fallback";
265
+ notices.push(`Could not read Claude user config at ${io.configPath} (${read.error ?? "unknown error"}). ` +
266
+ `Not modifying it automatically — add these entries to its "mcpServers" object by hand:`);
267
+ for (const [name, entry] of Object.entries(servers))
268
+ notices.push(manualEntryLine(name, entry));
269
+ }
270
+ else {
271
+ const merged = mergeUserScopeServers(read.data, servers);
272
+ serversAlreadyPresent.push(...merged.alreadyPresent);
273
+ for (const name of merged.alreadyPresent) {
274
+ notices.push(`MCP server '${name}' already in user config — left untouched.`);
275
+ }
276
+ if (merged.registered.length === 0) {
277
+ registration = "user-scope";
278
+ }
279
+ else {
280
+ const written = await io.writeWithBackup(merged.config);
281
+ if (written.ok) {
282
+ registration = "user-scope";
283
+ serversRegistered.push(...merged.registered);
284
+ notices.push(`Registered MCP server(s) [${merged.registered.join(", ")}] in Claude user config ${io.configPath}` +
285
+ (written.backupPath ? ` (backup: ${written.backupPath})` : "") +
286
+ ". Restart your Claude Code session to load them.");
287
+ }
288
+ else {
289
+ registration = "manual-fallback";
290
+ serversFailed.push(...merged.registered);
291
+ notices.push(`Failed to write Claude user config at ${io.configPath} (${written.error ?? "unknown error"}). ` +
292
+ `No changes made — add these entries to its "mcpServers" object by hand:`);
293
+ for (const name of merged.registered)
294
+ notices.push(manualEntryLine(name, servers[name]));
295
+ }
296
+ }
221
297
  }
222
298
  }
223
299
  const cfg = await writeCodeAiConfig(opts.destinationDir, {
@@ -229,7 +305,7 @@ export async function setupMcp(opts, cli = defaultClaudeCli) {
229
305
  mempalaceInstallAttempted,
230
306
  mempalaceInstallSucceeded,
231
307
  pythonRuntime,
232
- claudeCliAvailable,
308
+ userConfigPath: io.configPath,
233
309
  registration,
234
310
  serversRegistered,
235
311
  serversAlreadyPresent,
@@ -246,54 +322,86 @@ export async function setupMcp(opts, cli = defaultClaudeCli) {
246
322
  */
247
323
  const INSTALLER_OWNED_SERVERS = ["code-ai-mcp"];
248
324
  /**
249
- * Remove the installer-owned MCP server(s) from Claude's USER scope via
250
- * `claude mcp remove --scope user`. Idempotent — a server that isn't registered
251
- * is reported as "nothing to remove". If the `claude` CLI is absent, prints the
252
- * manual commands instead of touching the config. `cli` is injectable for tests.
325
+ * Remove the installer-owned MCP server(s) from the Claude user config's
326
+ * `mcpServers`. Idempotent — a server that isn't present is reported as
327
+ * "nothing to remove". If the config can't be read/written, prints guidance
328
+ * instead of guessing. `mempalace` is never touched. `io` is injectable.
253
329
  *
254
330
  * NOTE: the registration is global (shared across all projects), so this removes
255
331
  * code-ai-mcp for every project. That is the chosen behaviour (symmetric with
256
332
  * install); a multi-project user re-runs the installer to restore it.
257
333
  */
258
- export async function teardownMcp(opts, cli = defaultClaudeCli) {
334
+ export async function teardownMcp(opts, io = defaultUserConfigIO) {
259
335
  const notices = [];
260
336
  const serversRemoved = [];
261
337
  const serversNotPresent = [];
262
338
  const serversFailed = [];
263
339
  if (opts.dryRun) {
264
340
  for (const name of INSTALLER_OWNED_SERVERS) {
265
- notices.push(`Would remove MCP server '${name}' from user scope: claude ${buildClaudeRemoveArgs(name).join(" ")}`);
341
+ notices.push(`Would remove MCP server '${name}' from Claude user config (${io.configPath}).`);
266
342
  }
267
- return { claudeCliAvailable: false, removal: "user-scope", serversRemoved, serversNotPresent, serversFailed, notices };
343
+ return {
344
+ userConfigPath: io.configPath,
345
+ removal: "user-scope",
346
+ serversRemoved,
347
+ serversNotPresent,
348
+ serversFailed,
349
+ notices,
350
+ };
268
351
  }
269
- const claudeCliAvailable = await detectClaudeCli(cli);
270
- if (!claudeCliAvailable) {
271
- notices.push("The `claude` CLI was not found on PATH not modifying your global config automatically. " +
272
- "Remove the MCP server(s) from user scope by running:");
273
- for (const name of INSTALLER_OWNED_SERVERS) {
274
- notices.push(` claude ${buildClaudeRemoveArgs(name).join(" ")}`);
275
- }
276
- return { claudeCliAvailable, removal: "manual-fallback", serversRemoved, serversNotPresent, serversFailed, notices };
352
+ const read = await io.read();
353
+ if (!read.ok || !read.data) {
354
+ notices.push(`Could not read Claude user config at ${io.configPath} (${read.error ?? "unknown error"}). ` +
355
+ `Remove the server(s) [${INSTALLER_OWNED_SERVERS.join(", ")}] from its "mcpServers" object by hand.`);
356
+ return {
357
+ userConfigPath: io.configPath,
358
+ removal: "manual-fallback",
359
+ serversRemoved,
360
+ serversNotPresent,
361
+ serversFailed,
362
+ notices,
363
+ };
277
364
  }
278
- for (const name of INSTALLER_OWNED_SERVERS) {
279
- if (!(await isRegisteredInUserScope(cli, name))) {
280
- serversNotPresent.push(name);
281
- notices.push(`MCP server '${name}' is not registered in user scope — nothing to remove.`);
282
- continue;
283
- }
284
- const res = await cli.run(buildClaudeRemoveArgs(name));
285
- if (res.ok) {
286
- serversRemoved.push(name);
287
- notices.push(`Removed MCP server '${name}' from user scope.`);
288
- }
289
- else {
290
- serversFailed.push(name);
291
- notices.push(`Failed to remove '${name}' from user scope. Run it manually:\n` +
292
- ` claude ${buildClaudeRemoveArgs(name).join(" ")}\n` +
293
- ` Output:\n${res.output}`);
294
- }
365
+ const result = removeUserScopeServers(read.data, INSTALLER_OWNED_SERVERS);
366
+ serversNotPresent.push(...result.notPresent);
367
+ for (const name of result.notPresent) {
368
+ notices.push(`MCP server '${name}' is not in user config — nothing to remove.`);
369
+ }
370
+ if (result.removed.length === 0) {
371
+ return {
372
+ userConfigPath: io.configPath,
373
+ removal: "user-scope",
374
+ serversRemoved,
375
+ serversNotPresent,
376
+ serversFailed,
377
+ notices,
378
+ };
379
+ }
380
+ const written = await io.writeWithBackup(result.config);
381
+ if (written.ok) {
382
+ serversRemoved.push(...result.removed);
383
+ notices.push(`Removed MCP server(s) [${result.removed.join(", ")}] from Claude user config ${io.configPath}` +
384
+ (written.backupPath ? ` (backup: ${written.backupPath})` : "") +
385
+ ".");
386
+ return {
387
+ userConfigPath: io.configPath,
388
+ removal: "user-scope",
389
+ serversRemoved,
390
+ serversNotPresent,
391
+ serversFailed,
392
+ notices,
393
+ };
295
394
  }
296
- return { claudeCliAvailable, removal: "user-scope", serversRemoved, serversNotPresent, serversFailed, notices };
395
+ serversFailed.push(...result.removed);
396
+ notices.push(`Failed to write Claude user config at ${io.configPath} (${written.error ?? "unknown error"}). No changes made.`);
397
+ return {
398
+ userConfigPath: io.configPath,
399
+ removal: "manual-fallback",
400
+ serversRemoved,
401
+ serversNotPresent,
402
+ serversFailed,
403
+ notices,
404
+ };
297
405
  }
298
406
  // ─── Subprocess helpers ─────────────────────────────────────────────────────
299
407
  async function spawnExitZero(command, args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-ai-installer",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Production-ready CLI to install code-ai agents and skills for multiple AI coding assistants. Bundles the code-ai-mcp MCP server for Claude Code.",
5
5
  "license": "MIT",
6
6
  "author": "Denish1209",