amicus 1.7.6 → 1.8.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.
@@ -0,0 +1,119 @@
1
+ // src/utils/legacy-mcp-migration.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * Legacy 'sidecar' MCP registration cleanup (Phase 4 tool de-bloat).
6
+ *
7
+ * Through v1.7.x scripts/postinstall.js registered the SAME stdio MCP server
8
+ * under two names — 'amicus' and legacy 'sidecar' — in Claude Code
9
+ * (~/.claude.json) and Claude Desktop/Cowork (claude_desktop_config.json).
10
+ * Combined with the in-server sidecar_* tool aliases this quadrupled the
11
+ * client-visible tool surface (13 real tools -> 52).
12
+ *
13
+ * This module removes a legacy 'sidecar' server entry, but ONLY when it is
14
+ * identical-in-effect to the amicus registration: its command must resolve to
15
+ * an amicus MCP invocation per isAmicusMcpConfig() (./mcp-self-identity,
16
+ * Phase 1). A 'sidecar' entry pointing anywhere else is user customization
17
+ * and is NEVER touched.
18
+ *
19
+ * Consumers: scripts/postinstall.js (one-shot migration on install/upgrade)
20
+ * and src/cli-handlers-doctor.js (duplicate check + `doctor --fix`).
21
+ * All functions are synchronous, never throw, and report via return values.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+ const { writeFileAtomic } = require('./atomic-write');
28
+
29
+ /** ~/.claude.json — where BOTH Claude Code registration paths (CLI + file fallback) land. */
30
+ function claudeCodeConfigPath() {
31
+ return path.join(os.homedir(), '.claude.json');
32
+ }
33
+
34
+ /** claude_desktop_config.json — platform-aware; mirrors postinstall registerClaudeDesktop. */
35
+ function claudeDesktopConfigPath() {
36
+ if (process.platform === 'darwin') {
37
+ return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
38
+ }
39
+ if (process.platform === 'win32') {
40
+ return path.join(process.env.APPDATA || '', 'Claude', 'claude_desktop_config.json');
41
+ }
42
+ return path.join(os.homedir(), '.config', 'claude', 'claude_desktop_config.json');
43
+ }
44
+
45
+ function defaultTargets(deps = {}) {
46
+ return [
47
+ { target: 'Claude Code', configPath: deps.codePath || claudeCodeConfigPath() },
48
+ { target: 'Claude Desktop', configPath: deps.desktopPath || claudeDesktopConfigPath() },
49
+ ];
50
+ }
51
+
52
+ /**
53
+ * Inspect one config file for a legacy 'sidecar' MCP entry.
54
+ * @returns {{status:'absent'|'removable'|'customized'|'unreadable', config?:object}}
55
+ */
56
+ function inspectLegacySidecarEntry(configPath, deps = {}) {
57
+ const isAmicus = deps.isAmicusMcpConfig
58
+ || require('./mcp-self-identity').isAmicusMcpConfig;
59
+ let parsed;
60
+ try {
61
+ if (!fs.existsSync(configPath)) { return { status: 'absent' }; }
62
+ parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
63
+ } catch {
64
+ return { status: 'unreadable' };
65
+ }
66
+ const entry = parsed && parsed.mcpServers ? parsed.mcpServers.sidecar : undefined;
67
+ if (!entry) { return { status: 'absent' }; }
68
+ // 'removable' means VERIFIED duplicate, not just amicus-shaped. Two edges
69
+ // must fall back to 'customized' (left alone) instead:
70
+ // (a) no 'amicus' twin present — this may be the user's ONLY working
71
+ // registration (scripts-skipped / manual pre-rebrand install); deleting
72
+ // it would leave them with nothing.
73
+ // (b) the sidecar entry carries a non-empty `env` (API keys,
74
+ // AMICUS_LEGACY_ALIASES itself) — not identical-in-effect to the bare
75
+ // 'amicus' entry, so removing it would silently lose configuration.
76
+ const hasAmicusTwin = !!(parsed.mcpServers && parsed.mcpServers.amicus);
77
+ const hasNonEmptyEnv = !!(entry.env && typeof entry.env === 'object' && Object.keys(entry.env).length > 0);
78
+ if (!hasAmicusTwin || hasNonEmptyEnv) { return { status: 'customized', config: entry }; }
79
+ return isAmicus(entry)
80
+ ? { status: 'removable', config: entry }
81
+ : { status: 'customized', config: entry };
82
+ }
83
+
84
+ /**
85
+ * Remove the legacy 'sidecar' entry from one config file — ONLY when it is an
86
+ * amicus self-invocation. Preserves every other key in the file.
87
+ * @returns {'absent'|'removed'|'customized'|'unreadable'|'write-failed'}
88
+ */
89
+ function removeLegacySidecarEntry(configPath, deps = {}) {
90
+ const inspected = inspectLegacySidecarEntry(configPath, deps);
91
+ if (inspected.status !== 'removable') { return inspected.status; }
92
+ try {
93
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
94
+ delete parsed.mcpServers.sidecar;
95
+ // Atomic temp+rename write — a crash mid-write must never corrupt the
96
+ // user's main Claude Code state file. 0o600 is a no-op on NTFS; kept for
97
+ // parity with addMcpToConfigFile.
98
+ writeFileAtomic(configPath, JSON.stringify(parsed, null, 2), { mode: 0o600 });
99
+ return 'removed';
100
+ } catch {
101
+ return 'write-failed';
102
+ }
103
+ }
104
+
105
+ /** Inspect every known registry (doctor check). */
106
+ function inspectAllLegacySidecarEntries(deps = {}) {
107
+ return defaultTargets(deps).map((t) => ({ ...t, ...inspectLegacySidecarEntry(t.configPath, deps) }));
108
+ }
109
+
110
+ /** Remove identical-in-effect legacy entries everywhere. Idempotent. */
111
+ function migrateLegacySidecar(deps = {}) {
112
+ return defaultTargets(deps).map((t) => ({ ...t, result: removeLegacySidecarEntry(t.configPath, deps) }));
113
+ }
114
+
115
+ module.exports = {
116
+ claudeCodeConfigPath, claudeDesktopConfigPath,
117
+ inspectLegacySidecarEntry, removeLegacySidecarEntry,
118
+ inspectAllLegacySidecarEntries, migrateLegacySidecar,
119
+ };
@@ -12,7 +12,7 @@
12
12
  // when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
13
13
  // `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
14
14
  // interactive Electron flow that must never be force-exited).
15
- const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
15
+ const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
16
16
 
17
17
  /** @param {string} command @returns {boolean} */
18
18
  function isOneShotCommand(command) {
@@ -12,6 +12,7 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const os = require('os');
14
14
  const { logger } = require('./logger');
15
+ const { stripSelfMcpEntries } = require('./mcp-self-identity');
15
16
 
16
17
  /**
17
18
  * Normalize .mcp.json to a flat { name: config } map.
@@ -70,15 +71,13 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
70
71
  const settingsPath = path.join(baseDir, 'settings.json');
71
72
  if (!fs.existsSync(settingsPath)) {
72
73
  // No settings.json — skip plugin discovery, may still have claude.json servers
73
- const merged = { ...claudeJsonServers };
74
- delete merged.sidecar;
74
+ const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
75
75
  return Object.keys(merged).length > 0 ? merged : null;
76
76
  }
77
77
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
78
78
  const enabledPlugins = settings.enabledPlugins;
79
79
  if (!enabledPlugins || typeof enabledPlugins !== 'object') {
80
- const merged = { ...claudeJsonServers };
81
- delete merged.sidecar;
80
+ const merged = stripSelfMcpEntries({ ...claudeJsonServers }, logger);
82
81
  return Object.keys(merged).length > 0 ? merged : null;
83
82
  }
84
83
 
@@ -133,11 +132,9 @@ function discoverClaudeCodeMcps(claudeDir, claudeJsonPath) {
133
132
  logger.debug('Failed to read Claude Code settings', { error: err.message });
134
133
  }
135
134
 
136
- // Merge: plugin servers first, then claude.json overwrites (higher priority)
137
- const merged = { ...pluginServers, ...claudeJsonServers };
138
-
139
- // Always exclude sidecar itself to prevent recursive spawning
140
- delete merged.sidecar;
135
+ // Merge: plugin servers first, then claude.json overwrites (higher priority).
136
+ // Recursive-spawn guard: drop every entry that resolves to amicus itself.
137
+ const merged = stripSelfMcpEntries({ ...pluginServers, ...claudeJsonServers }, logger);
141
138
 
142
139
  return Object.keys(merged).length > 0 ? merged : null;
143
140
  }
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module mcp-self-identity
5
+ * Recursive-spawn guard. A child sidecar that inherits an MCP entry launching
6
+ * amicus itself would spawn amicus inside amicus, forever. The shipped server
7
+ * registers as 'amicus' (scripts/postinstall.js, .claude-plugin/plugin.json)
8
+ * plus a deprecated 'sidecar' shim — and users can alias it under ANY name —
9
+ * so we exclude both reserved names AND any entry whose command+args resolve
10
+ * to an amicus MCP invocation.
11
+ */
12
+
13
+ /** Server names amicus registers itself under. */
14
+ const SELF_MCP_NAMES = Object.freeze(['amicus', 'sidecar']);
15
+
16
+ /** Shipped bin aliases (package.json "bin") → ./bin/amicus.js */
17
+ const SELF_BIN_NAMES = new Set(['amicus', 'am', 'sidecar', 'claude-sidecar']);
18
+
19
+ /**
20
+ * Normalize one command/arg token for identity matching: lower-case, forward
21
+ * slashes, basename, strip a trailing .exe/.cmd/.js, strip an @version spec.
22
+ * 'C:\\x\\bin\\amicus.js' → 'amicus'; 'amicus@latest' → 'amicus'; 'npx' → 'npx'.
23
+ * @param {unknown} token
24
+ * @returns {string}
25
+ */
26
+ function normalizeToken(token) {
27
+ const t = String(token).toLowerCase().replace(/\\/g, '/');
28
+ const base = t.includes('/') ? t.slice(t.lastIndexOf('/') + 1) : t;
29
+ return base.replace(/\.(exe|cmd|js)$/, '').replace(/@[^@]*$/, '');
30
+ }
31
+
32
+ /**
33
+ * True when this MCP server config would launch amicus's own MCP server:
34
+ * some non-flag token resolves to an amicus binary/package and a LATER token
35
+ * is 'mcp'. URL-only (command-less) configs are never self.
36
+ * @param {{command?:string, args?:unknown[]}|null|undefined} config
37
+ * @returns {boolean}
38
+ */
39
+ function isAmicusMcpConfig(config) {
40
+ if (!config || typeof config !== 'object' || !config.command) { return false; }
41
+ const tokens = [config.command, ...(Array.isArray(config.args) ? config.args : [])].map(String);
42
+ for (let i = 0; i < tokens.length; i++) {
43
+ if (tokens[i].startsWith('-')) { continue; } // flags (-y, --yes) are never the binary
44
+ if (SELF_BIN_NAMES.has(normalizeToken(tokens[i]))) {
45
+ return tokens.slice(i + 1).some((t) => String(t).toLowerCase() === 'mcp');
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Delete every self entry (reserved name OR command identity) from an
53
+ * mcpServers map. Mutates and returns the same object.
54
+ * @param {object|null|undefined} mcpServers
55
+ * @param {{debug?:Function}} [log]
56
+ * @returns {object|null|undefined}
57
+ */
58
+ function stripSelfMcpEntries(mcpServers, log) {
59
+ if (!mcpServers || typeof mcpServers !== 'object') { return mcpServers; }
60
+ for (const name of Object.keys(mcpServers)) {
61
+ if (SELF_MCP_NAMES.includes(name) || isAmicusMcpConfig(mcpServers[name])) {
62
+ delete mcpServers[name];
63
+ if (log && log.debug) { log.debug('Auto-excluded amicus MCP entry (recursive spawn prevention)', { name }); }
64
+ }
65
+ }
66
+ return mcpServers;
67
+ }
68
+
69
+ module.exports = { SELF_MCP_NAMES, isAmicusMcpConfig, stripSelfMcpEntries, normalizeToken };
@@ -64,6 +64,14 @@ const REMEDIATION_HINTS = Object.freeze({
64
64
  * can't loop the way `npm install -g amicus` could when the rollback recurs.
65
65
  */
66
66
  doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
67
+
68
+ /**
69
+ * Duplicate legacy 'sidecar' MCP registration (Phase 4 de-bloat): pre-1.8
70
+ * postinstalls registered the same server twice. `doctor --fix` removes the
71
+ * twin only when it points at amicus; a customized entry is never touched.
72
+ */
73
+ removeLegacySidecar:
74
+ "amicus doctor --fix (removes the duplicate legacy 'sidecar' MCP entry — same server registered twice; the 'amicus' entry stays)",
67
75
  });
68
76
 
69
77
  module.exports = REMEDIATION_HINTS;
@@ -11,6 +11,7 @@ const { getCompatEnv } = require('./env-compat');
11
11
  const MAX_RESTARTS = 3;
12
12
  const RESTART_WINDOW = 5 * 60 * 1000;
13
13
  const RESTART_BACKOFF = 2000;
14
+ const CRASH_POLL_INTERVAL = 5000; // ms; env-tunable via AMICUS_CRASH_POLL_MS
14
15
 
15
16
  /**
16
17
  * SharedServerManager - manages a single shared OpenCode server for MCP sessions.
@@ -45,6 +46,16 @@ class SharedServerManager {
45
46
 
46
47
  /** @type {number[]} Timestamps of recent restart attempts */
47
48
  this._restartTimestamps = [];
49
+
50
+ /** @type {NodeJS.Timeout|null} goPid liveness poll (H7) */
51
+ this._crashPoll = null;
52
+
53
+ /** @type {NodeJS.Timeout|null} Pending crash-restart backoff timer */
54
+ this._restartTimer = null;
55
+
56
+ /** Pid-liveness probe (test seam). Lazy default keeps construction light. */
57
+ this._isProcessAlive = options.isProcessAlive
58
+ || ((pid) => require('../sidecar/session-utils').isProcessAlive(pid));
48
59
  }
49
60
 
50
61
  /**
@@ -163,6 +174,8 @@ class SharedServerManager {
163
174
  this._serverWatchdog.cancel();
164
175
  this._serverWatchdog = null;
165
176
  }
177
+ this._stopCrashPoll();
178
+ if (this._restartTimer) { clearTimeout(this._restartTimer); this._restartTimer = null; }
166
179
  if (this.server) {
167
180
  this.server.close();
168
181
  this.server = null;
@@ -171,12 +184,10 @@ class SharedServerManager {
171
184
  }
172
185
 
173
186
  /**
174
- * Wire crash detection onto a freshly started server handle so an unexpected
175
- * exit triggers _onServerCrash (and the restart machinery). The OpenCode
176
- * server handle is a plain wrapper; it may surface lifecycle events either on
177
- * itself or on an underlying child `process`. Attach to whichever is an event
178
- * emitter, guarding against double-wiring across restarts.
179
- *
187
+ * Wire crash detection onto a freshly started server handle. The REAL handle
188
+ * from buildServerHandle is { url, goPid, close } — it surfaces NO lifecycle
189
+ * events, so the emitter path alone was dead code (H7). Detection now polls
190
+ * the Go engine pid; emitter wiring is kept for handles that do expose events.
180
191
  * @param {object} server - Server handle returned by _doStartServer
181
192
  */
182
193
  _wireCrashListener(server) {
@@ -185,19 +196,38 @@ class SharedServerManager {
185
196
  : (server && server.process && typeof server.process.on === 'function')
186
197
  ? server.process
187
198
  : null;
188
- if (!emitter || emitter._amicusCrashWired) {
189
- return;
199
+ if (emitter && !emitter._amicusCrashWired) {
200
+ emitter._amicusCrashWired = true;
201
+ const onExit = (code) => {
202
+ if (this.server !== server) { return; } // stale handle already replaced/closed
203
+ this._onServerCrash(code);
204
+ };
205
+ emitter.on('exit', onExit);
206
+ emitter.on('close', onExit);
207
+ }
208
+ if (server && server.goPid) {
209
+ this._startCrashPoll(server);
210
+ } else if (!emitter) {
211
+ this.logger.debug?.('Server handle has no goPid and no emitter — crash detection unavailable');
190
212
  }
191
- emitter._amicusCrashWired = true;
192
- const onExit = (code) => {
193
- // Ignore exits from a stale handle we have already replaced/closed.
194
- if (this.server !== server) {
195
- return;
213
+ }
214
+
215
+ /** Poll the Go engine pid; pid death IS the crash signal (H7). */
216
+ _startCrashPoll(server) {
217
+ this._stopCrashPoll();
218
+ const interval = Number(getCompatEnv('CRASH_POLL_MS')) || CRASH_POLL_INTERVAL;
219
+ this._crashPoll = setInterval(() => {
220
+ if (this.server !== server) { this._stopCrashPoll(); return; }
221
+ if (!this._isProcessAlive(server.goPid)) {
222
+ this._stopCrashPoll();
223
+ this._onServerCrash(null);
196
224
  }
197
- this._onServerCrash(code);
198
- };
199
- emitter.on('exit', onExit);
200
- emitter.on('close', onExit);
225
+ }, interval);
226
+ if (this._crashPoll.unref) { this._crashPoll.unref(); }
227
+ }
228
+
229
+ _stopCrashPoll() {
230
+ if (this._crashPoll) { clearInterval(this._crashPoll); this._crashPoll = null; }
201
231
  }
202
232
 
203
233
  /**
@@ -206,13 +236,15 @@ class SharedServerManager {
206
236
  * @param {number} exitCode - Process exit code from the crashed server
207
237
  */
208
238
  _onServerCrash(exitCode) {
239
+ this._stopCrashPoll();
209
240
  this.logger.error?.('Shared server crashed', { exitCode });
210
241
  for (const [id] of this._sessionWatchdogs) {
211
242
  this.logger.warn?.('Session interrupted by server crash', { sessionId: id });
212
243
  }
213
244
  this.server = null;
214
245
  this.client = null;
215
- setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
246
+ this._restartTimer = setTimeout(() => this._handleRestart(), RESTART_BACKOFF);
247
+ if (this._restartTimer.unref) { this._restartTimer.unref(); }
216
248
  }
217
249
 
218
250
  /**