amicus 4.1.2 → 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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +25 -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 +99 -3
  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 +53 -1
  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/route-error.js +10 -3
  39. package/src/utils/route-launch.js +8 -65
  40. 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
+ };
@@ -240,6 +240,17 @@ function tryResolveModel(modelArg) {
240
240
  }
241
241
  }
242
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
+
243
254
  /** Build OpenCode provider.models config from sidecar aliases, plus the
244
255
  * actually-resolved launch route(s). The alias-derived entries let the UI
245
256
  * model picker show every configured model (single source of truth for the
@@ -289,7 +300,16 @@ function buildProviderModels(resolvedRoutes = []) {
289
300
  const providerID = parts[0];
290
301
  const modelID = parts.slice(1).join('/');
291
302
 
292
- 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)) {
293
313
  providers[providerID] = { models: {} };
294
314
  }
295
315
  providers[providerID].models[modelID] = {};
@@ -324,6 +344,31 @@ function buildProviderModels(resolvedRoutes = []) {
324
344
  addRoute(resolved);
325
345
  }
326
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
+
327
372
  return providers;
328
373
  }
329
374
 
@@ -383,11 +428,18 @@ function resolveCouncilMembers(name, catalog = []) {
383
428
  }
384
429
  const aliases = getEffectiveAliases();
385
430
  const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
431
+ const { isLocalProvider } = require('./local-providers');
386
432
  const models = [];
387
433
  const dropped = [];
388
434
  for (const member of members) {
389
435
  const id = member.includes('/') ? member : aliases[member];
390
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; }
391
443
  if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
392
444
  models.push(member);
393
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 };