amicus 4.1.1 → 4.2.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/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/README.md +20 -1
  4. package/bin/amicus.js +10 -0
  5. package/electron/ipc-setup-local.js +109 -0
  6. package/electron/ipc-setup.js +14 -2
  7. package/electron/preload-setup.js +3 -1
  8. package/electron/setup-ui-local-script.js +114 -0
  9. package/electron/setup-ui-local.js +75 -0
  10. package/electron/setup-ui-styles.js +15 -1
  11. package/electron/setup-ui.js +6 -1
  12. package/package.json +1 -1
  13. package/scripts/postinstall.js +12 -211
  14. package/src/cli-handlers-doctor.js +12 -0
  15. package/src/cli-handlers-init.js +83 -0
  16. package/src/cli-handlers-key-local.js +144 -0
  17. package/src/cli-handlers-provider.js +212 -0
  18. package/src/cli-handlers.js +31 -3
  19. package/src/cli.js +21 -0
  20. package/src/sidecar/setup-local.js +75 -0
  21. package/src/sidecar/setup.js +101 -6
  22. package/src/utils/api-key-store.js +12 -62
  23. package/src/utils/claude-register.js +267 -0
  24. package/src/utils/config.js +82 -10
  25. package/src/utils/doctor-local-providers-check.js +62 -0
  26. package/src/utils/doctor-summary.js +33 -0
  27. package/src/utils/env-loader.js +13 -0
  28. package/src/utils/env-raw-store.js +111 -0
  29. package/src/utils/gateway-router.js +65 -2
  30. package/src/utils/lifecycle.js +2 -1
  31. package/src/utils/local-probe.js +109 -0
  32. package/src/utils/local-providers.js +141 -0
  33. package/src/utils/model-catalog.js +6 -2
  34. package/src/utils/model-fetcher.js +11 -1
  35. package/src/utils/pricing.js +23 -9
  36. package/src/utils/provider-default-picker.js +17 -2
  37. package/src/utils/provider-default-prompt.js +7 -4
  38. package/src/utils/quick-picks.js +35 -12
  39. package/src/utils/route-error.js +10 -3
  40. package/src/utils/route-launch.js +8 -65
  41. package/src/utils/route-suggestions.js +85 -0
@@ -6,6 +6,12 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { validateApiKey, validateOpenRouterKey, VALIDATION_ENDPOINTS } = require('./api-key-validation');
8
8
  const { PROVIDER_ENV_MAP } = require('./provider-registry');
9
+ // v4.2 (B2/D3): the .env line-merge logic lives once in env-raw-store.js. saveApiKey/
10
+ // removeApiKey below reuse it via upsertEnvLine/deleteEnvLine; saveRawEnv/removeRawEnv
11
+ // (arbitrary-name local-provider bearer writes) are re-exported so every documented
12
+ // `require('./api-key-store').saveRawEnv` call site keeps working. No load-time cycle:
13
+ // env-raw-store requires THIS module only lazily, inside its functions.
14
+ const { saveRawEnv, removeRawEnv, upsertEnvLine, deleteEnvLine } = require('./env-raw-store');
9
15
 
10
16
  /** Legacy key names that have been renamed (old -> new) */
11
17
  const LEGACY_KEY_NAMES = {
@@ -143,47 +149,10 @@ function saveApiKey(provider, key) {
143
149
  if (!envVar) {
144
150
  return { success: false, error: `Unknown provider: ${provider}` };
145
151
  }
146
-
147
- const envPath = getEnvPath();
148
- const envDir = path.dirname(envPath);
149
- fs.mkdirSync(envDir, { recursive: true });
150
-
151
- // Read existing .env content, preserving comments and other lines
152
- let lines = [];
153
- try {
154
- if (fs.existsSync(envPath)) {
155
- const content = fs.readFileSync(envPath, 'utf-8');
156
- lines = content.split('\n');
157
- }
158
- } catch (_err) {
159
- // Start fresh
160
- }
161
-
162
- // Find and replace the line for this env var, or append
163
- let found = false;
164
- for (let i = 0; i < lines.length; i++) {
165
- const trimmed = lines[i].trim();
166
- if (trimmed.startsWith(envVar + '=')) {
167
- lines[i] = `${envVar}=${key}`;
168
- found = true;
169
- break;
170
- }
171
- }
172
- if (!found) {
173
- // Remove trailing empty lines before appending
174
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
175
- lines.pop();
176
- }
177
- lines.push(`${envVar}=${key}`);
178
- }
179
-
180
- // Write with trailing newline
181
- const output = lines.join('\n') + '\n';
182
- fs.writeFileSync(envPath, output, { mode: 0o600 });
183
-
152
+ // Shared merge helper (preserves comments/other lines, dedups, 0600, trailing NL).
153
+ upsertEnvLine(getEnvPath(), envVar, key);
184
154
  // Also set process.env so the key is immediately available
185
155
  process.env[envVar] = key;
186
-
187
156
  return { success: true };
188
157
  }
189
158
 
@@ -194,29 +163,8 @@ function removeApiKey(provider) {
194
163
  return { success: false, error: `Unknown provider: ${provider}` };
195
164
  }
196
165
 
197
- const envPath = getEnvPath();
198
- try {
199
- if (!fs.existsSync(envPath)) {
200
- const { checkAuthJson } = require('./auth-json');
201
- delete process.env[envVar];
202
- return { success: true, alsoInAuthJson: checkAuthJson(provider) };
203
- }
204
- const content = fs.readFileSync(envPath, 'utf-8');
205
- const lines = content.split('\n').filter(line => {
206
- return !line.trim().startsWith(envVar + '=');
207
- });
208
-
209
- // Remove trailing empty lines
210
- while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
211
- lines.pop();
212
- }
213
-
214
- const output = lines.length > 0 ? lines.join('\n') + '\n' : '';
215
- fs.writeFileSync(envPath, output, { mode: 0o600 });
216
- } catch (_err) {
217
- // Ignore
218
- }
219
-
166
+ // Shared merge helper (no-op when the file is absent; preserves other lines, 0600).
167
+ deleteEnvLine(getEnvPath(), envVar);
220
168
  delete process.env[envVar];
221
169
 
222
170
  // Check if key also exists in auth.json (caller decides whether to clean)
@@ -232,6 +180,8 @@ module.exports = {
232
180
  readApiKeyValues,
233
181
  saveApiKey,
234
182
  removeApiKey,
183
+ saveRawEnv,
184
+ removeRawEnv,
235
185
  validateApiKey,
236
186
  validateOpenRouterKey,
237
187
  PROVIDER_ENV_MAP,
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @module claude-register
3
+ * Registration core for amicus: skill install (chat skill + LLM Council),
4
+ * MCP server registration in Claude Code / Claude Desktop, and legacy MCP
5
+ * migration. Extracted from scripts/postinstall.js (Task 15, v4.2 §4.8/C2) so
6
+ * the SAME core can run at `npm install` time (via postinstall.js, now a
7
+ * thin re-exporting consumer of this module) AND on demand via `amicus init`
8
+ * (src/cli-handlers-init.js) — e.g. after a failed postinstall, a
9
+ * plugin-channel / --ignore-scripts install, or to repair deleted ~/.claude
10
+ * state.
11
+ *
12
+ * Behavior is byte-identical to the pre-extraction scripts/postinstall.js,
13
+ * with one deliberate exception (M18): registerClaudeCode/registerClaudeDesktop
14
+ * now RETURN their 'added'|'updated'|'unchanged' status instead of computing
15
+ * it and discarding it, so callers (amicus init) get real per-step status.
16
+ * scripts/postinstall.js's own callers ignore the return value, so this is
17
+ * behavior-safe for postinstall's "byte-identical" claim.
18
+ */
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const { execFileSync } = require('child_process');
25
+
26
+ // B13: sibling of this file under src/utils/ — was '../src/utils/mcp-self-identity'
27
+ // when this code lived in scripts/postinstall.js.
28
+ const { isAmicusMcpConfig } = require('./mcp-self-identity');
29
+
30
+ // B13: this file now lives at src/utils/, two levels below the repo root
31
+ // (scripts/ was only one level below) — both source paths need an extra '..'.
32
+ const SKILL_SOURCE = path.join(__dirname, '..', '..', 'skills', 'sidecar', 'SKILL.md');
33
+ const COUNCIL_SOURCE_DIR = path.join(__dirname, '..', '..', 'skills', 'second-opinion');
34
+
35
+ /** Council files + per-file install semantics: SKILL/COUNCIL-DESIGN/SEAT-BRIEFS are
36
+ * product code (overwrite on update, so upgrades keep them in sync with the package);
37
+ * MODEL-NOTES is user data — its reviewer-reliability table evolves per-run, so it is
38
+ * seeded once and never clobbered. */
39
+ const COUNCIL_FILES = [
40
+ { file: 'SKILL.md', mode: 'overwrite' },
41
+ { file: 'COUNCIL-DESIGN.md', mode: 'overwrite' },
42
+ { file: 'SEAT-BRIEFS.md', mode: 'overwrite' },
43
+ { file: 'MANUAL-ORCHESTRATION.md', mode: 'overwrite' },
44
+ { file: 'MODEL-NOTES.md', mode: 'if-missing' },
45
+ ];
46
+
47
+ function skillsRoot() {
48
+ return path.join(os.homedir(), '.claude', 'skills');
49
+ }
50
+
51
+ const MCP_CONFIG = { command: 'npx', args: ['-y', 'amicus@latest', 'mcp'] };
52
+
53
+ /**
54
+ * Add or update an MCP server in a JSON config file.
55
+ *
56
+ * Refreshes command/args on every install/upgrade. If the EXISTING entry at
57
+ * this key is already amicus-shaped (per isAmicusMcpConfig — Phase 1's single
58
+ * source of truth for "this is amicus"), we MERGE instead of overwrite: the
59
+ * user's `env` (and any other extra keys, e.g. a future `cwd`) survive the
60
+ * refresh. Without this, `npm i -g amicus` silently wiped
61
+ * "env": {"AMICUS_LEGACY_ALIASES":"1"} — the exact opt-in escape hatch Phase 4
62
+ * tells users to add — on every upgrade.
63
+ *
64
+ * A NON-amicus-shaped entry at this key is overwritten as before: 'amicus' is
65
+ * a reserved registration name, so a foreign entry there is reclaimed rather
66
+ * than merged with.
67
+ *
68
+ * @param {string} configPath - Path to the JSON config file
69
+ * @param {string} name - MCP server name
70
+ * @param {object} config - MCP server config object
71
+ * @returns {string} 'added', 'updated', or 'unchanged'
72
+ */
73
+ function addMcpToConfigFile(configPath, name, config) {
74
+ let existing = {};
75
+ try {
76
+ existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
77
+ } catch {
78
+ // File doesn't exist or invalid JSON — start fresh
79
+ }
80
+
81
+ if (!existing.mcpServers) { existing.mcpServers = {}; }
82
+
83
+ const prev = existing.mcpServers[name];
84
+ const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...config } : config;
85
+ const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(nextConfig) ? 'updated' : 'unchanged';
86
+
87
+ existing.mcpServers[name] = nextConfig;
88
+ if (status !== 'unchanged') {
89
+ const dir = path.dirname(configPath);
90
+ if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); }
91
+ fs.writeFileSync(configPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
92
+ }
93
+ return status;
94
+ }
95
+
96
+ /** Install the chat skill to ~/.claude/skills/sidecar/ */
97
+ function installSkill() {
98
+ try {
99
+ const destDir = path.join(skillsRoot(), 'sidecar');
100
+ fs.mkdirSync(destDir, { recursive: true });
101
+ fs.copyFileSync(SKILL_SOURCE, path.join(destDir, 'SKILL.md'));
102
+ console.log('[amicus] Chat skill installed to ~/.claude/skills/sidecar/');
103
+ } catch (err) {
104
+ console.error(`[amicus] Warning: Could not install chat skill: ${err.message}`);
105
+ }
106
+ }
107
+
108
+ /** Install the LLM Council skill to ~/.claude/skills/second-opinion/ */
109
+ function installCouncilSkill(sourceDir = COUNCIL_SOURCE_DIR) {
110
+ const destDir = path.join(skillsRoot(), 'second-opinion');
111
+ try {
112
+ fs.mkdirSync(destDir, { recursive: true });
113
+ } catch (err) {
114
+ console.error(`[amicus] Warning: Could not create council skill dir: ${err.message}`);
115
+ return;
116
+ }
117
+ let failed = 0;
118
+ for (const { file, mode } of COUNCIL_FILES) {
119
+ try {
120
+ const dest = path.join(destDir, file);
121
+ if (mode === 'if-missing' && fs.existsSync(dest)) { continue; }
122
+ fs.copyFileSync(path.join(sourceDir, file), dest);
123
+ } catch (err) {
124
+ failed++;
125
+ console.error(`[amicus] Warning: Could not install council file ${file}: ${err.message}`);
126
+ }
127
+ }
128
+ if (failed === COUNCIL_FILES.length) {
129
+ console.error('[amicus] Warning: Council skill NOT installed (all files failed — see warnings above).');
130
+ } else if (failed > 0) {
131
+ console.log(`[amicus] Council skill partially installed (${failed}/${COUNCIL_FILES.length} files failed — see warnings above).`);
132
+ } else {
133
+ console.log('[amicus] Council skill installed to ~/.claude/skills/second-opinion/');
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Read the previous 'amicus' entry from ~/.claude.json, if any — used by the
139
+ * CLI add-json path to merge env the same way the file-fallback path does.
140
+ * Never throws: a missing/unreadable file just means "no previous entry".
141
+ * @returns {object|undefined}
142
+ */
143
+ function readPrevClaudeCodeAmicusEntry() {
144
+ try {
145
+ const parsed = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf-8'));
146
+ return parsed && parsed.mcpServers ? parsed.mcpServers.amicus : undefined;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Register MCP server in Claude Code config.
154
+ * @returns {'added'|'updated'|'unchanged'} the registration status (M18: both
155
+ * branches now report a real status instead of discarding it).
156
+ */
157
+ function registerClaudeCode() {
158
+ // Try the CLI first
159
+ try {
160
+ // Merge the PREVIOUS registration's env into the add-json payload — same
161
+ // merge semantics as addMcpToConfigFile's file-fallback path (`{ ...prev,
162
+ // ...config }`: prev env keys survive, canonical MCP_CONFIG keys win on
163
+ // collision). Without this, a user's custom env (API key, AMICUS_* tuning
164
+ // knobs) on the old registration was silently dropped whenever the
165
+ // `claude` CLI was present, because the CLI path built its JSON payload
166
+ // from the bare MCP_CONFIG and delegated overwrite semantics to the
167
+ // claude binary — which has no idea about the user's previous entry.
168
+ const prev = readPrevClaudeCodeAmicusEntry();
169
+ const nextConfig = (prev && isAmicusMcpConfig(prev)) ? { ...prev, ...MCP_CONFIG } : MCP_CONFIG;
170
+ // M18: the same status formula addMcpToConfigFile uses, computed from
171
+ // data already in hand. The `claude` CLI itself reports no status, but we
172
+ // know exactly what it is about to write (nextConfig) vs what was there
173
+ // before (prev) — add-json writes to the SAME key the file-fallback path
174
+ // below would, so this is the truth, not a guess.
175
+ const status = !prev ? 'added' : JSON.stringify(prev) !== JSON.stringify(nextConfig) ? 'updated' : 'unchanged';
176
+ const mcpJson = JSON.stringify(nextConfig);
177
+ execFileSync('claude', ['mcp', 'add-json', 'amicus', mcpJson, '--scope', 'user'], {
178
+ stdio: 'pipe',
179
+ timeout: 10000,
180
+ });
181
+ console.log('[amicus] MCP registered in Claude Code (via CLI).');
182
+
183
+ return status;
184
+ } catch {
185
+ // CLI not available or failed — fall back to file edit
186
+ }
187
+
188
+ // Fallback: direct file edit
189
+ const claudeConfigPath = path.join(os.homedir(), '.claude.json');
190
+ const status = addMcpToConfigFile(claudeConfigPath, 'amicus', MCP_CONFIG);
191
+ if (status === 'added') {
192
+ console.log('[amicus] MCP registered in Claude Code (~/.claude.json).');
193
+ } else if (status === 'updated') {
194
+ console.log('[amicus] MCP config updated in Claude Code (~/.claude.json).');
195
+ } else {
196
+ console.log('[amicus] MCP already registered in Claude Code.');
197
+ }
198
+ return status;
199
+ }
200
+
201
+ /**
202
+ * Register MCP server in Claude Desktop / Cowork config.
203
+ * @returns {'added'|'updated'|'unchanged'} the registration status (M18: was
204
+ * computed and used for the console.log branch below, then discarded).
205
+ */
206
+ function registerClaudeDesktop() {
207
+ let configDir;
208
+ if (process.platform === 'darwin') {
209
+ configDir = path.join(os.homedir(), 'Library', 'Application Support', 'Claude');
210
+ } else if (process.platform === 'win32') {
211
+ configDir = path.join(process.env.APPDATA || '', 'Claude');
212
+ } else {
213
+ configDir = path.join(os.homedir(), '.config', 'claude');
214
+ }
215
+
216
+ const configPath = path.join(configDir, 'claude_desktop_config.json');
217
+ const status = addMcpToConfigFile(configPath, 'amicus', MCP_CONFIG);
218
+ if (status === 'added') {
219
+ console.log('[amicus] MCP registered in Claude Desktop.');
220
+ } else if (status === 'updated') {
221
+ console.log('[amicus] MCP config updated in Claude Desktop.');
222
+ } else {
223
+ console.log('[amicus] MCP already registered in Claude Desktop.');
224
+ }
225
+ return status;
226
+ }
227
+
228
+ /**
229
+ * One-shot migration: drop the duplicate legacy 'sidecar' MCP entry that
230
+ * pre-1.8 postinstalls registered alongside 'amicus' (same server twice —
231
+ * doubled the client-visible tool list). Only removes an entry whose command
232
+ * is an amicus MCP invocation; a customized 'sidecar' entry is left alone.
233
+ * Covers both files the three legacy registration paths wrote to:
234
+ * ~/.claude.json (CLI + file fallback) and claude_desktop_config.json.
235
+ * Never throws (postinstall must always exit 0).
236
+ */
237
+ function migrateLegacyMcp(deps = {}) {
238
+ try {
239
+ // B13: sibling of this file under src/utils/ — was
240
+ // '../src/utils/legacy-mcp-migration' when this lived in scripts/postinstall.js.
241
+ const impl = deps.migrateLegacySidecar
242
+ || require('./legacy-mcp-migration').migrateLegacySidecar;
243
+ for (const r of impl()) {
244
+ if (r.result === 'removed') {
245
+ console.log(`[amicus] Removed duplicate legacy 'sidecar' MCP entry from ${r.target} (same server — kept as 'amicus').`);
246
+ } else if (r.result === 'customized') {
247
+ console.log(`[amicus] Kept custom 'sidecar' MCP entry in ${r.target} (does not point at amicus).`);
248
+ } else if (r.result === 'write-failed') {
249
+ console.warn(`[amicus] Warning: could not remove the legacy 'sidecar' MCP entry from ${r.target} — run: amicus doctor --fix`);
250
+ }
251
+ }
252
+ } catch (err) {
253
+ console.warn(`[amicus] Warning: legacy MCP cleanup skipped: ${err && err.message}`);
254
+ }
255
+ }
256
+
257
+ module.exports = {
258
+ MCP_CONFIG,
259
+ addMcpToConfigFile,
260
+ installSkill,
261
+ installCouncilSkill,
262
+ readPrevClaudeCodeAmicusEntry,
263
+ registerClaudeCode,
264
+ registerClaudeDesktop,
265
+ migrateLegacyMcp,
266
+ COUNCIL_FILES,
267
+ };
@@ -12,8 +12,9 @@ const { autoRepairAlias } = require('./alias-resolver');
12
12
  const { isDirectProvider } = require('./provider-registry');
13
13
 
14
14
  /** Default model alias map — derived from the curated-models single source (F5) */
15
- const { toDefaultAliases } = require('./curated-models');
15
+ const { toDefaultAliases, toGatewayRoutes } = require('./curated-models');
16
16
  const DEFAULT_ALIASES = toDefaultAliases();
17
+ const CURATED_ROUTES = toGatewayRoutes();
17
18
 
18
19
  /** Built-in council benches (B23) — consulted only when a name is absent from user config. */
19
20
  const { resolveBuiltinCouncil } = require('./council-presets');
@@ -239,6 +240,17 @@ function tryResolveModel(modelArg) {
239
240
  }
240
241
  }
241
242
 
243
+ /**
244
+ * Request timeout (ms) for local (@ai-sdk/openai-compatible) provider blocks.
245
+ * opencode's default HTTP request timeout is too short for cold local
246
+ * inference: prefilling a large agent prompt (~26k tokens) on a cold local
247
+ * model can exceed it, killing the request during prefill before any stream
248
+ * data arrives -- opencode never creates the assistant message, and the
249
+ * caller polls out its own much longer timeout waiting for a response that
250
+ * will never come. 300000ms (5 minutes) covers cold-start prefill headroom.
251
+ */
252
+ const LOCAL_REQUEST_TIMEOUT_MS = 300000;
253
+
242
254
  /** Build OpenCode provider.models config from sidecar aliases, plus the
243
255
  * actually-resolved launch route(s). The alias-derived entries let the UI
244
256
  * model picker show every configured model (single source of truth for the
@@ -261,10 +273,19 @@ function tryResolveModel(modelArg) {
261
273
  * threading only that first session's resolved id is insufficient. So for
262
274
  * every alias that resolves to a BARE direct-capable-vendor id (post-#61
263
275
  * default aliases are bare, e.g. `openai/gpt-5.5`), this ALSO registers the
264
- * `openrouter/<vendor>/<model>` form — broadening the catalog to cover BOTH
265
- * routes the router might pick, regardless of which session created the
266
- * server. This does NOT change what the alias itself resolves to (still
267
- * bare, still direct-first) — it only widens what's pre-registered.
276
+ * alias's OpenRouter form — broadening the catalog to cover BOTH routes the
277
+ * router might pick, regardless of which session created the server. This
278
+ * does NOT change what the alias itself resolves to (still bare, still
279
+ * direct-first) — it only widens what's pre-registered.
280
+ *
281
+ * The mirror is read from the alias's authored gateway route, NOT built by
282
+ * prepending `openrouter/`. For DIVERGENT_VENDORS the two ids are different
283
+ * strings, not merely differently prefixed — OpenRouter serves
284
+ * `anthropic/claude-opus-4.8` while the direct API serves
285
+ * `anthropic/claude-opus-4-8`. v4.1.1 made `toDefaultAliases()` emit the
286
+ * direct form, at which point prepending produced an id OpenRouter does not
287
+ * serve (fixed in v4.1.2). Aliases the user has overridden fall back to the
288
+ * prefix form, since no authored gateway route describes them.
268
289
  * @param {string[]} [resolvedRoutes] executable model id(s) actually launched
269
290
  * @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
270
291
  function buildProviderModels(resolvedRoutes = []) {
@@ -279,19 +300,38 @@ function buildProviderModels(resolvedRoutes = []) {
279
300
  const providerID = parts[0];
280
301
  const modelID = parts.slice(1).join('/');
281
302
 
282
- if (!providers[providerID]) {
303
+ // providerID is an unvalidated vendor segment split off a user-supplied
304
+ // model string (alias or resolved route). A bare `!providers[providerID]`
305
+ // check walks the prototype chain, so a vendor literally named
306
+ // 'constructor' (a valid, non-reserved local-provider id — see
307
+ // local-providers.js's ID_RE/RESERVED_IDS) reads the inherited
308
+ // Object.prototype.constructor (truthy), skips the init below, and the
309
+ // next line throws on the resulting undefined. Same bug class already
310
+ // fixed in gateway-router.js (19aade4) and local-providers.js +
311
+ // route-suggestions.js (2cba73b).
312
+ if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
283
313
  providers[providerID] = { models: {} };
284
314
  }
285
315
  providers[providerID].models[modelID] = {};
286
316
  };
287
317
 
288
- for (const fullModel of Object.values(aliases)) {
318
+ for (const [alias, fullModel] of Object.entries(aliases)) {
289
319
  addRoute(fullModel);
290
320
 
291
321
  // Broaden: a bare direct-capable-vendor route also gets an OpenRouter
292
- // mirror registered (see catalog-broadening note above). Gateway-only
293
- // aliases (already `openrouter/...`, e.g. grok/qwen/x-ai) are untouched
294
- // OpenRouter is their only possible route anyway, already covered above.
322
+ // mirror registered (see catalog-broadening note above). Prefer the
323
+ // alias's AUTHORED OpenRouter route for divergent vendors it is a
324
+ // different id, not a prefixed one. Guarded on the alias still holding its
325
+ // shipped direct value so a user override never inherits a curated route.
326
+ const curated = CURATED_ROUTES[alias];
327
+ if (curated && curated.openrouter && curated.direct === fullModel) {
328
+ addRoute(curated.openrouter);
329
+ continue;
330
+ }
331
+
332
+ // Fallback for user-defined aliases. Gateway-only aliases (already
333
+ // `openrouter/...`, e.g. grok/qwen/x-ai) are untouched — OpenRouter is
334
+ // their only possible route anyway, already covered above.
295
335
  if (typeof fullModel === 'string' && !fullModel.startsWith('openrouter/')) {
296
336
  const vendor = fullModel.split('/')[0];
297
337
  if (isDirectProvider(vendor)) {
@@ -304,6 +344,31 @@ function buildProviderModels(resolvedRoutes = []) {
304
344
  addRoute(resolved);
305
345
  }
306
346
 
347
+ // v4.2 §4.3: for every provider id that is a configured LOCAL provider AND was
348
+ // registered by the loops above, attach the OpenCode openai-compatible block.
349
+ // {env:VAR} interpolation keeps key material out of the config object.
350
+ const { getLocalProviders } = require('./local-providers');
351
+ const localAll = getLocalProviders();
352
+ for (const [id, block] of Object.entries(providers)) {
353
+ // Guarded the same way as the accumulator init above: `localAll` is a
354
+ // plain {} when no local providers are configured, so a bare
355
+ // `localAll[id]` for id === 'constructor' would read the inherited
356
+ // Object.prototype.constructor (truthy) and fabricate a fake local block
357
+ // for a vendor that isn't actually configured as local.
358
+ const entry = Object.prototype.hasOwnProperty.call(localAll, id) ? localAll[id] : undefined;
359
+ if (!entry) { continue; }
360
+ block.npm = '@ai-sdk/openai-compatible';
361
+ block.name = entry.name || id;
362
+ block.options = {
363
+ baseURL: entry.baseURL,
364
+ // @ai-sdk/openai-compatible wants a non-empty apiKey string even for
365
+ // servers that don't require auth (Ollama/LM Studio/llama.cpp ignore
366
+ // it) -- an omitted apiKey is not the same as an accepted empty one.
367
+ apiKey: entry.apiKeyEnv ? `{env:${entry.apiKeyEnv}}` : 'not-needed',
368
+ timeout: LOCAL_REQUEST_TIMEOUT_MS,
369
+ };
370
+ }
371
+
307
372
  return providers;
308
373
  }
309
374
 
@@ -363,11 +428,18 @@ function resolveCouncilMembers(name, catalog = []) {
363
428
  }
364
429
  const aliases = getEffectiveAliases();
365
430
  const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
431
+ const { isLocalProvider } = require('./local-providers');
366
432
  const models = [];
367
433
  const dropped = [];
368
434
  for (const member of members) {
369
435
  const id = member.includes('/') ? member : aliases[member];
370
436
  if (!id) { dropped.push(member); continue; } // alias no longer resolves
437
+ const vendor = typeof id === 'string' ? id.split('/')[0] : '';
438
+ // v4.2 §4.4: a local server may simply have been off at the last catalog
439
+ // refresh — that is "unknown", not "delisted". Never drop a local-vendor
440
+ // member on catalog absence; the leg itself fails pre-flight with the
441
+ // actionable local_endpoint_unreachable error if the server is truly down.
442
+ if (isLocalProvider(vendor)) { models.push(member); continue; }
371
443
  if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
372
444
  models.push(member);
373
445
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @module doctor-local-providers-check
3
+ * The `local-providers` doctor check (v4.2 §4.7 C8), split out of
4
+ * src/cli-handlers-doctor.js to keep that file under the 300-line size gate
5
+ * (mirrors doctor-mcp-checks.js / doctor-engine-check.js).
6
+ *
7
+ * Reachability is WARN-never-ERROR (parity with the electron and
8
+ * openrouter-credit checks): a configured-but-unreachable local server (e.g.
9
+ * `ollama serve` simply not running) is informational, not a doctor failure
10
+ * that flips `amicus doctor`'s exit code. No configured local providers at
11
+ * all is a plain 'ok' ("none configured"), never a warning.
12
+ *
13
+ * Each configured provider is probed once, bounded by probeLocalProvider's
14
+ * own timeout (2s here) -- doctor never introduces an unbounded wait, no
15
+ * matter how many local providers are configured.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ /**
21
+ * @param {{getLocalProviders: () => Object<string, {id:string, baseURL:string, flavor?:string, apiKeyEnv?:string}>, probeLocalProvider: (entry:object, opts:{timeoutMs:number, bearer?:string}) => Promise<{status:'ok'|'unreachable', models:string[]}>}} d
22
+ * @returns {Promise<{id:string, name:string, status:string, message:string, hint:?string}>}
23
+ */
24
+ async function evaluateLocalProviders(d) {
25
+ const id = 'local-providers';
26
+ const name = 'Local providers';
27
+ const map = d.getLocalProviders();
28
+ // Object.keys (never `for..in` / `map.hasOwnProperty`) -- local-providers.js
29
+ // stamps entries on via a plain `out[id] = ...` assignment, so a provider id
30
+ // like 'constructor' is a real OWN property that shadows the inherited one;
31
+ // Object.keys + bracket access below resolves it correctly either way.
32
+ const ids = Object.keys(map);
33
+ if (ids.length === 0) {
34
+ return { id, name, status: 'ok', message: 'none configured', hint: null };
35
+ }
36
+
37
+ const parts = [];
38
+ let anyDown = false;
39
+ for (const providerId of ids) {
40
+ const entry = map[providerId];
41
+ const bearer = entry.apiKeyEnv ? process.env[entry.apiKeyEnv] : undefined;
42
+ const probe = await d.probeLocalProvider(entry, { timeoutMs: 2000, bearer });
43
+ if (probe.status === 'ok') {
44
+ parts.push(`${providerId}: ${probe.models.length} models @ ${entry.baseURL}`);
45
+ } else {
46
+ anyDown = true;
47
+ parts.push(`${providerId}: unreachable @ ${entry.baseURL}`);
48
+ }
49
+ }
50
+
51
+ // WARN, never ERROR (parity with electron/openrouter-credit -- a napping
52
+ // local server must not fail CI).
53
+ const flavorHint = ids.map((i) => map[i].flavor).find((f) => f === 'lmstudio')
54
+ ? 'Start the LM Studio server (Developer → Start Server), or `ollama serve`.'
55
+ : 'Start the local server, e.g. `ollama serve`.';
56
+
57
+ return anyDown
58
+ ? { id, name, status: 'warn', message: parts.join('; '), hint: flavorHint }
59
+ : { id, name, status: 'ok', message: parts.join('; '), hint: null };
60
+ }
61
+
62
+ module.exports = { evaluateLocalProviders };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @module doctor-summary
3
+ * Compact doctor summary (v4.2 §4.7 C8). All-ok -> one line; otherwise the
4
+ * non-ok lines + counts + a pointer to the full command. Shared by the setup
5
+ * wizard finale (src/sidecar/setup.js) and, next, `amicus init` (Task 15) --
6
+ * kept in its own leaf module (rather than inside cli-handlers-doctor.js) so
7
+ * both callers can require it without pulling in the doctor check machinery,
8
+ * and so cli-handlers-doctor.js stays under the 300-line size gate.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ /**
14
+ * @param {Array<{status:string,name?:string,id?:string,message?:string}>} checks
15
+ * @returns {string}
16
+ */
17
+ function summarizeDoctor(checks) {
18
+ const list = Array.isArray(checks) ? checks : [];
19
+ const nonOk = list.filter((c) => c && c.status !== 'ok');
20
+ if (nonOk.length === 0) {
21
+ return `doctor: all ${list.length} checks pass`;
22
+ }
23
+ const errors = nonOk.filter((c) => c.status === 'error').length;
24
+ const warns = nonOk.filter((c) => c.status === 'warn').length;
25
+ const lines = nonOk.map((c) => {
26
+ const marker = c.status === 'error' ? '✗' : '⚠';
27
+ return ` ${marker} ${c.name || c.id}: ${c.message || ''}`.trimEnd();
28
+ });
29
+ lines.push(`${errors} error(s), ${warns} warning(s) — run \`amicus doctor\` for details`);
30
+ return lines.join('\n');
31
+ }
32
+
33
+ module.exports = { summarizeDoctor };
@@ -48,6 +48,19 @@ function loadCredentials() {
48
48
  logger.info(`Loaded ${envVar} from auth.json`);
49
49
  }
50
50
  }
51
+
52
+ // v4.2: project each configured local-provider apiKeyEnv from amicus .env into
53
+ // process.env (same never-overwrite rule) so the engine's {env:VAR} finds it.
54
+ try {
55
+ const { getLocalProviders } = require('./local-providers');
56
+ const fileEntries = loadEnvEntries();
57
+ for (const entry of Object.values(getLocalProviders())) {
58
+ if (entry.apiKeyEnv && !process.env[entry.apiKeyEnv]) {
59
+ const v = fileEntries.get(entry.apiKeyEnv);
60
+ if (v && v.length > 0) { process.env[entry.apiKeyEnv] = v; logger.info(`Loaded ${entry.apiKeyEnv} from amicus .env`); }
61
+ }
62
+ }
63
+ } catch (_err) { /* best-effort: a bad providers block must never break credential load */ }
51
64
  }
52
65
 
53
66
  module.exports = { loadCredentials };