amicus 3.1.1 → 3.2.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.2.1] - 2026-07-17
9
+
10
+ ### Fixed
11
+
12
+ - **`opencode` engine now resolves under the npx-launched MCP.** The MCP server is
13
+ registered as `npx -y amicus@latest mcp`, which npm installs with the
14
+ `opencode-windows-*` engine packages **hoisted** beside `amicus/` rather than
15
+ nested under it. The resolver only probed the nested location, so a present,
16
+ runnable engine read as missing and every `amicus_fanout` / `amicus_start` leg
17
+ failed instantly with `engineMissing`. Both the PATH builder and the shared
18
+ binary resolver now probe the hoisted root too (#69).
19
+ - **The AVX2 (default) `opencode` build is searched before the `-baseline`
20
+ fallback.** `ensureNodeModulesBinInPath()` prepended PATH one entry at a time, so
21
+ the search order was the reverse of the source order and AVX2-capable machines
22
+ silently ran the slower pre-AVX2 baseline build. PATH is now built as one ordered
23
+ group (default → baseline → `.bin`, across every candidate root).
24
+
25
+ ### Added
26
+
27
+ - **`amicus doctor` cross-install engine check.** A new "OpenCode engine (MCP launch
28
+ path)" check enumerates every install that could serve the MCP — running, global,
29
+ and each npx-cache copy — and verifies the engine in each, so a green doctor can
30
+ no longer hide a broken copy the MCP actually launches. A broken single npx copy
31
+ (the unambiguous failure) is an error; ambiguous cases warn and name the exact
32
+ path.
33
+ - The runtime `engineMissing` error now prints the roots it searched, making an
34
+ npx-cache-vs-global install divergence visible at the point of failure.
35
+
36
+ ## [3.2.0] - 2026-07-16
37
+
38
+ ### Added
39
+
40
+ - **Cost-aware per-provider default model picker at key-add.** Adding a provider API key (`amicus key
41
+ <provider>`, the setup wizard, or the Electron key step) now offers a picker that pre-selects a
42
+ **balanced**-tier model instead of the priciest flagship, shows live `$/M` input pricing, and writes
43
+ your choice as a vendor-named alias (e.g. `--model anthropic`) — seeding your overall default
44
+ (`config.default`) on your first key. Applies to direct model vendors (OpenAI, Anthropic, Google,
45
+ DeepSeek).
46
+ - **`routing.tier` preference** (`frontier` | `balanced` | `economy`, default `balanced`) biasing the
47
+ picker's per-vendor pre-selection.
48
+ - A one-time onboarding tip on `amicus start` pointing existing users to the new picker.
49
+
50
+ ### Changed
51
+
52
+ - Model aliases now resolve their per-gateway executable ids **by model**, so user-defined and
53
+ vendor-named aliases route correctly across the direct and OpenRouter gateways (extends the v3.1.1
54
+ gateway-correct-ids fix beyond the curated defaults). An alias whose value is an explicit
55
+ `openrouter/…` id still forces OpenRouter.
56
+
8
57
  ## [3.1.1] - 2026-07-16
9
58
 
10
59
  ### Fixed
package/README.md CHANGED
@@ -319,7 +319,7 @@ $ amicus status demo123 --json
319
319
  "taskId": "demo123",
320
320
  "status": "complete",
321
321
  "elapsed": "5m 0s",
322
- "version": "3.1.1",
322
+ "version": "3.2.1",
323
323
  "model": "google/gemini-2.5-flash",
324
324
  "phase": "terminal"
325
325
  }
@@ -42,6 +42,26 @@ function registerSetupHandlers(getMainWindow) {
42
42
  require('../src/utils/model-catalog').refreshCatalog().catch(() => {});
43
43
  } catch { /* best-effort */ }
44
44
  });
45
+ // Task 8: per-provider default picker choices for the key step.
46
+ // Per-provider defaults only make sense for DIRECT model vendors --
47
+ // openrouter is the GATEWAY, not a vendor, so it's skipped entirely
48
+ // (mirrors provider-default-prompt.js's runProviderDefaultFlow gate;
49
+ // this path calls the picker core directly instead of that
50
+ // readline-oriented helper, so it re-checks isDirectProvider itself).
51
+ const { isDirectProvider } = require('../src/utils/provider-registry');
52
+ if (isDirectProvider(provider)) {
53
+ try {
54
+ const { getCatalog } = require('../src/utils/model-catalog');
55
+ const { buildProviderDefaultChoices } = require('../src/utils/provider-default-picker');
56
+ const catalog = await getCatalog();
57
+ result.providerDefault = buildProviderDefaultChoices(provider, { catalog });
58
+ } catch (err) {
59
+ logger.error('save-key providerDefault error', { error: err.message });
60
+ result.providerDefault = null;
61
+ }
62
+ } else {
63
+ result.providerDefault = null;
64
+ }
45
65
  }
46
66
  return result;
47
67
  } catch (err) {
@@ -50,6 +70,19 @@ function registerSetupHandlers(getMainWindow) {
50
70
  }
51
71
  });
52
72
 
73
+ // Task 8: apply a per-provider default picker choice. Read-modify-write,
74
+ // no-clobber -- applyProviderDefault only ever writes aliases[vendor] and
75
+ // seeds config.default when absent (see provider-default-picker.js).
76
+ ipcMain.handle('sidecar:set-provider-default', (_event, provider, chosenId) => {
77
+ try {
78
+ const { applyProviderDefault } = require('../src/utils/provider-default-picker');
79
+ return applyProviderDefault(provider, chosenId, { seedDefaultIfAbsent: true });
80
+ } catch (err) {
81
+ logger.error('set-provider-default handler error', { error: err.message });
82
+ return { success: false, error: err.message };
83
+ }
84
+ });
85
+
53
86
  ipcMain.handle('sidecar:remove-key', async (_event, provider) => {
54
87
  try {
55
88
  const { removeApiKey } = require('../src/utils/api-key-store');
@@ -22,7 +22,8 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
22
22
  'sidecar:get-api-keys',
23
23
  'sidecar:get-catalog',
24
24
  'sidecar:refresh-catalog',
25
- 'sidecar:fetch-free-models'
25
+ 'sidecar:fetch-free-models',
26
+ 'sidecar:set-provider-default'
26
27
  ];
27
28
  if (!allowedChannels.includes(channel)) {
28
29
  throw new Error(`IPC channel not allowed: ${channel}`);
@@ -19,6 +19,8 @@ function buildKeysScript() {
19
19
  var prov = providers.find(function(p) { return p.id === id; });
20
20
  if (!prov) { return; }
21
21
  selectedProvider = prov;
22
+ // Task 8: a picker rendered for the previously-selected provider is stale here.
23
+ if (window.hideProviderDefaultPicker) { window.hideProviderDefaultPicker(); }
22
24
  document.querySelectorAll('.provider-btn').forEach(function(b) { b.classList.remove('selected'); });
23
25
  this.classList.add('selected');
24
26
  keySection.classList.add('visible');
@@ -64,7 +66,7 @@ function buildKeysScript() {
64
66
  try {
65
67
  var res = await window.sidecarSetup.invoke('sidecar:validate-key', selectedProvider.id, key);
66
68
  if (res.valid) {
67
- await window.sidecarSetup.invoke('sidecar:save-key', selectedProvider.id, key);
69
+ var saveResult = await window.sidecarSetup.invoke('sidecar:save-key', selectedProvider.id, key);
68
70
  configuredKeys[selectedProvider.id] = true;
69
71
  var c = document.getElementById('check-' + selectedProvider.id);
70
72
  if (c) { c.textContent = '\\u2713'; }
@@ -72,6 +74,10 @@ function buildKeysScript() {
72
74
  setInputState('valid'); keyValid = true; validatedKey = key;
73
75
  keyHints[selectedProvider.id] = key.slice(0, 8) + '\\u2022'.repeat(Math.min(key.length - 8, 12));
74
76
  removeBtn.style.display = ''; updateNextState();
77
+ // Task 8: inline per-provider default picker beneath the just-saved key.
78
+ if (window.renderProviderDefaultPicker) {
79
+ window.renderProviderDefaultPicker(selectedProvider.id, selectedProvider.name, saveResult && saveResult.providerDefault);
80
+ }
75
81
  } else {
76
82
  statusMsg.textContent = res.error || 'Invalid key'; statusMsg.className = 'status-invalid';
77
83
  setInputState('invalid'); keyValid = false;
@@ -107,6 +113,7 @@ function buildKeysScript() {
107
113
  keyInput.value = ''; keyInput.type = 'password'; keyValid = false; setInputState(null);
108
114
  statusMsg.textContent = 'Key removed'; statusMsg.className = 'status-testing';
109
115
  removeBtn.style.display = 'none'; updateNextState();
116
+ if (window.hideProviderDefaultPicker) { window.hideProviderDefaultPicker(); }
110
117
  } catch (_e) { statusMsg.textContent = 'Failed to remove'; statusMsg.className = 'status-invalid'; }
111
118
  removeBtn.disabled = false;
112
119
  });`;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Setup UI — Per-provider default model picker (Part 2, Task 8).
3
+ *
4
+ * Rendered inline in the key step (Step 1), beneath the just-saved key, once
5
+ * `sidecar:save-key` (ipc-setup.js) returns a non-null `providerDefault` for a
6
+ * DIRECT provider (google/openai/anthropic/deepseek — never the openrouter
7
+ * gateway; see ipc-setup.js's own isDirectProvider guard, mirroring the skip
8
+ * rule in provider-default-prompt.js's runProviderDefaultFlow).
9
+ *
10
+ * Split into its own file (rather than growing setup-ui-keys.js /
11
+ * setup-ui-keys-script.js) per the existing setup-ui-* convention (see
12
+ * setup-ui-council.js, another Step-content add-on with its own HTML +
13
+ * script pair).
14
+ *
15
+ * Row data (from src/utils/provider-default-picker.js's
16
+ * buildProviderDefaultChoices): { preselectedId, rows: [{id, name,
17
+ * contextLength, pricePerMInput, isPreselected}] }. Row name/pricing come
18
+ * straight from the live model catalog, so the runtime script below builds
19
+ * rows via createElement/textContent (never innerHTML string interpolation)
20
+ * — the same convention Step 2's renderSearchResults uses for catalog rows.
21
+ */
22
+ 'use strict';
23
+
24
+ /** Static (initially hidden) container the key step's picker renders into. */
25
+ function buildProviderDefaultSectionHTML() {
26
+ return `<div class="provider-default-section" id="provider-default-section" style="display:none">
27
+ <div class="provider-default-label" id="provider-default-label">Default model</div>
28
+ <div class="provider-default-list" id="provider-default-list"></div>
29
+ </div>`;
30
+ }
31
+
32
+ /**
33
+ * Browser-only JS: exposes window.renderProviderDefaultPicker(provider,
34
+ * providerName, providerDefault) and window.hideProviderDefaultPicker(),
35
+ * wired to apply the pick via sidecar:set-provider-default
36
+ * (src/utils/provider-default-picker.js's applyProviderDefault,
37
+ * read-modify-write, no-clobber — see ipc-setup.js). Selecting a row (or
38
+ * leaving the preselection untouched) both apply the same way: the
39
+ * preselected id is applied immediately on render, and a change event
40
+ * re-applies whichever row the user picks instead.
41
+ * @returns {string} JavaScript source (no <script> tags)
42
+ */
43
+ function buildProviderDefaultScript() {
44
+ return `
45
+ // Task 8: per-provider default picker, inline in the key step.
46
+ (function() {
47
+ var section = document.getElementById('provider-default-section');
48
+ var label = document.getElementById('provider-default-label');
49
+ var list = document.getElementById('provider-default-list');
50
+
51
+ function fmtPrice(p) { return (p === null || p === undefined) ? 'n/a' : '$' + Number(p).toFixed(2) + '/M in'; }
52
+ function fmtCtx(n) { return (n === null || n === undefined) ? '' : ' \\u00b7 ctx ' + n; }
53
+
54
+ function applyChoice(provider, chosenId) {
55
+ if (!provider || !chosenId) { return; }
56
+ window.sidecarSetup.invoke('sidecar:set-provider-default', provider, chosenId).catch(function() {});
57
+ }
58
+
59
+ function buildRow(provider, row) {
60
+ var rowEl = document.createElement('label');
61
+ rowEl.className = 'provider-default-row';
62
+ var radio = document.createElement('input');
63
+ radio.type = 'radio'; radio.name = 'provider-default-' + provider;
64
+ radio.value = row.id; radio.checked = !!row.isPreselected;
65
+ var text = document.createElement('span');
66
+ text.className = 'provider-default-text';
67
+ text.textContent = row.name + fmtCtx(row.contextLength) + ' \\u00b7 ' + fmtPrice(row.pricePerMInput);
68
+ rowEl.appendChild(radio); rowEl.appendChild(text);
69
+ if (row.isPreselected) {
70
+ var badge = document.createElement('span');
71
+ badge.className = 'pick-badge'; badge.textContent = 'recommended';
72
+ rowEl.appendChild(badge);
73
+ }
74
+ radio.addEventListener('change', function() { if (radio.checked) { applyChoice(provider, row.id); } });
75
+ return rowEl;
76
+ }
77
+
78
+ window.hideProviderDefaultPicker = function() {
79
+ if (!section) { return; }
80
+ section.style.display = 'none';
81
+ if (list) { list.innerHTML = ''; }
82
+ };
83
+
84
+ window.renderProviderDefaultPicker = function(provider, providerName, providerDefault) {
85
+ if (!section || !list) { return; }
86
+ if (!providerDefault || !Array.isArray(providerDefault.rows) || providerDefault.rows.length === 0) {
87
+ window.hideProviderDefaultPicker(); return;
88
+ }
89
+ if (label) { label.textContent = 'Default model for ' + (providerName || provider); }
90
+ list.innerHTML = '';
91
+ providerDefault.rows.forEach(function(row) { list.appendChild(buildRow(provider, row)); });
92
+ section.style.display = '';
93
+ // No-selection case: the preselected row is already checked above, so
94
+ // applying it immediately covers "advance without touching the list".
95
+ applyChoice(provider, providerDefault.preselectedId);
96
+ };
97
+ })();
98
+ `;
99
+ }
100
+
101
+ module.exports = { buildProviderDefaultSectionHTML, buildProviderDefaultScript };
@@ -415,7 +415,19 @@ function __rawWizardCSS() {
415
415
  content: ''; position: absolute; inset: 0;
416
416
  border-radius: 50%; background: var(--ok);
417
417
  animation: amicusPulse 1.6s var(--ease-out) infinite;
418
- }`;
418
+ }
419
+
420
+ /* Per-provider default picker (Step 1, Task 8) */
421
+ .provider-default-section { margin-top: 14px; }
422
+ .provider-default-label { font-size: 12px; color: var(--text-muted); margin-bottom: 6px; }
423
+ .provider-default-list { display: flex; flex-direction: column; gap: 2px; }
424
+ .provider-default-row {
425
+ display: flex; align-items: center; gap: 8px;
426
+ padding: 6px 10px; font-size: 12px; color: var(--text); cursor: pointer;
427
+ border-radius: var(--r-6);
428
+ }
429
+ .provider-default-row:hover { background: var(--surface-hover); }
430
+ .provider-default-row input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; }`;
419
431
  }
420
432
 
421
433
  function buildWizardCSS() {
@@ -6,6 +6,7 @@ const { buildWizardCSS } = require('./setup-ui-styles');
6
6
  const { buildKeysScript } = require('./setup-ui-keys-script');
7
7
  const { buildAliasScript } = require('./setup-ui-alias-script');
8
8
  const { buildCouncilSectionHTML, buildCouncilScript } = require('./setup-ui-council');
9
+ const { buildProviderDefaultSectionHTML, buildProviderDefaultScript } = require('./setup-ui-provider-default');
9
10
  const { getDefaultAliases } = require('../src/utils/config');
10
11
  const { getBrandName } = require('./toolbar');
11
12
  const { resolveQuickPicks } = require('../src/utils/quick-picks');
@@ -40,7 +41,7 @@ function buildSetupHTML(options = {}) {
40
41
  <div class="header"><svg width="18" height="18" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg><span class="header-title">${brandName} Setup</span></div>
41
42
  <div class="progress-bar"><div class="progress-step active" id="step-1"><span class="progress-dot">1</span><span>API Keys</span></div><div class="progress-connector"></div><div class="progress-step" id="step-2"><span class="progress-dot">2</span><span>Models</span></div><div class="progress-connector"></div><div class="progress-step" id="step-3"><span class="progress-dot">3</span><span>Routing</span></div><div class="progress-connector"></div><div class="progress-step" id="step-4"><span class="progress-dot">4</span><span>Review</span></div></div>
42
43
  <div class="content">
43
- <div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}</div>
44
+ <div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}${buildProviderDefaultSectionHTML()}</div>
44
45
  <div class="wizard-step" id="wizard-step-2">${modelHtml}${buildCouncilSectionHTML()}</div>
45
46
  <div class="wizard-step" id="wizard-step-3">${aliasHtml}</div>
46
47
  <div class="wizard-step" id="wizard-step-4">
@@ -63,6 +64,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
63
64
  const keysJs = buildKeysScript();
64
65
  const aliasJs = buildAliasScript();
65
66
  const councilJs = buildCouncilScript();
67
+ const providerDefaultJs = buildProviderDefaultScript();
66
68
  return `<script>
67
69
  window.onerror = function(msg, src, line, col, err) { console.error('WIZARD ERROR:', msg, 'at', src, line, col, err); };
68
70
  window.onunhandledrejection = function(e) { console.error('WIZARD UNHANDLED REJECTION:', e.reason); };
@@ -574,6 +576,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
574
576
  ${keysJs}
575
577
 
576
578
  ${councilJs}
579
+
580
+ ${providerDefaultJs}
577
581
  </script>`;
578
582
  }
579
583
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
6
6
  "keywords": [
@@ -4,6 +4,9 @@
4
4
  const HINTS = require('./utils/remediation-hints');
5
5
  // B14/4.3: 'mcp' + 'mcp-legacy' check bodies (mirrors the B15 tmpSweep split — see file header).
6
6
  const mcpChecks = require('./utils/doctor-mcp-checks');
7
+ // engine-mcp check body — verifies the engine in the npx-cache copies the MCP
8
+ // actually launches (bug report #1). Split out to keep this file under the gate.
9
+ const engineCheck = require('./utils/doctor-engine-check');
7
10
 
8
11
  const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
9
12
 
@@ -36,6 +39,9 @@ function realDeps() {
36
39
  ensureNodeModulesBinInPath();
37
40
  return hasOpencodeBinary();
38
41
  },
42
+ // engine-mcp check: probe the engine in every install that could serve the
43
+ // MCP (running/global/npx-cache), not just the one doctor runs from (#1).
44
+ scanEngineInstalls: () => require('./utils/engine-install-scan').scanEngineInstalls(),
39
45
  getElectronPath: () => require('./sidecar/interactive-process').getElectronPath(),
40
46
  // #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
41
47
  // stays separate; repair only runs when fix is requested.
@@ -141,6 +147,11 @@ async function runDoctorChecks(depsOverride = {}) {
141
147
  : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngineAv }
142
148
  )));
143
149
 
150
+ // Cross-install: verify the engine in the npx-cache copies the MCP actually
151
+ // launches (`npx -y amicus@latest mcp`), so a green 'opencode-bin' (the running
152
+ // install) can't hide a broken copy the MCP would spawn (bug report #1/#4).
153
+ checks.push(guard('engine-mcp', 'OpenCode engine (MCP launch path)', () => engineCheck.evaluateEngineInstalls(d)));
154
+
144
155
  checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
145
156
  if (d.getElectronPath()) {
146
157
  return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
@@ -14,7 +14,7 @@
14
14
 
15
15
  const { validateStartArgs } = require('./cli');
16
16
  const { validateTaskId } = require('./utils/validators');
17
- const { resolveLaunchModel } = require('./utils/start-helpers');
17
+ const { resolveLaunchModel, maybeOfferProviderDefaults } = require('./utils/start-helpers');
18
18
  const { failJson, ERROR_CODES } = require('./utils/error-doc');
19
19
  const { requireNoUiForJson } = require('./utils/cli-preflight');
20
20
  const { GATEWAY_MODES } = require('./utils/model-descriptor');
@@ -55,6 +55,14 @@ async function handleStart(args) {
55
55
  process.exit(failJson(useJson, { code: validation.code || ERROR_CODES.BAD_ARGS, message: validation.error }));
56
56
  }
57
57
 
58
+ // Existing-user one-time onboarding offer (Part 2, Task 9): a non-blocking
59
+ // notice, printed at most once ever, pointing users who already have direct
60
+ // provider keys at the per-provider cost-aware default picker. No-ops on
61
+ // any non-interactive/--json run (see maybeOfferProviderDefaults). Fired
62
+ // only after validateStartArgs succeeds so a failed first `amicus start`
63
+ // doesn't burn the one-time flag.
64
+ maybeOfferProviderDefaults(args);
65
+
58
66
  // Budget gate for solo start
59
67
  if (!args['no-cost-gate']) {
60
68
  const { lookupPricing } = require('./utils/pricing');
@@ -159,6 +159,46 @@ async function handleKey(args) {
159
159
  process.exit(1);
160
160
  }
161
161
  console.log(`${provider} key validated and saved.`);
162
+
163
+ await offerProviderDefault(provider, args);
164
+ }
165
+
166
+ /**
167
+ * After a successful key save, run the per-provider cost-aware default
168
+ * picker (Part 2, Task 6) and print its one-line summary. Wrapped end-to-end
169
+ * in try/catch: the key is ALREADY saved by the time this runs, so a picker
170
+ * failure (offline catalog, bad input, etc.) must never abort `amicus key`
171
+ * -- it just prints a soft notice instead.
172
+ * @param {string} provider
173
+ * @param {object} args parsed CLI args (checked for --json/--quiet to gate interactivity)
174
+ */
175
+ async function offerProviderDefault(provider, args) {
176
+ let rl = null;
177
+ try {
178
+ const { runProviderDefaultFlow } = require('./utils/provider-default-prompt');
179
+ const { getCatalog } = require('./utils/model-catalog');
180
+
181
+ const interactive = !!process.stdin.isTTY && !args.json && !args.quiet;
182
+
183
+ let catalog = [];
184
+ try { catalog = await getCatalog(); } catch { /* offline/empty: handled gracefully by the picker */ }
185
+
186
+ let ask;
187
+ if (interactive) {
188
+ const readline = require('readline');
189
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
190
+ ask = (q) => new Promise((resolve) => rl.question(q, (answer) => resolve((answer || '').trim())));
191
+ }
192
+
193
+ const { summaryLine } = await runProviderDefaultFlow(provider, {
194
+ interactive, ask, catalog, print: console.log,
195
+ });
196
+ console.log(summaryLine);
197
+ } catch (err) {
198
+ console.log(`Note: couldn't set a per-provider default (${err.message}). Run 'amicus key ${provider}' again later.`);
199
+ } finally {
200
+ if (rl) { rl.close(); }
201
+ }
162
202
  }
163
203
 
164
204
  module.exports = {
@@ -636,7 +636,11 @@ async function startServer(options = {}) {
636
636
  || require('./utils/path-setup').hasOpencodeBinary;
637
637
  if (!hasOpencodeBinary()) {
638
638
  const HINTS = require('./utils/remediation-hints');
639
- throw new Error(HINTS.engineMissing);
639
+ // Append the roots we actually probed so an npx-cache-vs-global divergence is
640
+ // visible at the point of failure, not just in `amicus doctor` (report #4).
641
+ const opencodeRoots = options._opencodeRoots
642
+ || require('./utils/path-setup').opencodeRoots;
643
+ throw new Error(`${HINTS.engineMissing} Searched: ${opencodeRoots().join(', ')}`);
640
644
  }
641
645
 
642
646
  const createOpencodeServer = await getCreateOpencodeServer();
@@ -187,8 +187,12 @@ async function seedCatalog(print) {
187
187
  * (Enter = the vendor-diverse default), seeds aliases + councils.free, and
188
188
  * never touches config.default.
189
189
  * @param {readline.Interface} rl
190
+ * @param {Array<object>} [catalogArg] pre-fetched catalog from the caller
191
+ * (`runReadlineSetup` already fetches one for the per-provider phase) --
192
+ * reused as-is to avoid a second `getCatalog()` round trip. Falls back to
193
+ * fetching its own when omitted (e.g. direct unit-test callers).
190
194
  */
191
- async function runFreeCouncilBranch(rl) {
195
+ async function runFreeCouncilBranch(rl, catalogArg) {
192
196
  const keys = detectApiKeys();
193
197
  if (!keys.openrouter) {
194
198
  console.log('');
@@ -196,10 +200,14 @@ async function runFreeCouncilBranch(rl) {
196
200
  console.log('Set OPENROUTER_API_KEY and re-run: amicus setup. No changes made.');
197
201
  return;
198
202
  }
199
- const { getCatalog } = require('../utils/model-catalog');
200
203
  const { listFreeModels, suggestFreeCouncil, PINNED_FREE_MODELS } = require('../utils/free-models');
201
204
  let catalog = [];
202
- try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
205
+ if (Array.isArray(catalogArg)) {
206
+ catalog = catalogArg;
207
+ } else {
208
+ const { getCatalog } = require('../utils/model-catalog');
209
+ try { catalog = await getCatalog(); } catch (_e) { /* offline */ }
210
+ }
203
211
  let free = listFreeModels(catalog);
204
212
  if (free.length === 0) {
205
213
  console.log('Live free-model list unavailable (offline?) — using a small pinned set.');
@@ -237,14 +245,63 @@ async function runFreeCouncilBranch(rl) {
237
245
  console.log('unless you enable data-sharing at openrouter.ai/settings/privacy.');
238
246
  }
239
247
 
248
+ /**
249
+ * Run the shared per-provider default-model picker (Task 6, `runProviderDefaultFlow`)
250
+ * once for each keyed provider, in `foundKeys` detection order -- whichever
251
+ * comes first seeds `config.default` (`applyProviderDefault`'s
252
+ * seed-only-when-absent rule; see `provider-default-picker.js`). Each provider
253
+ * is its own read-modify-write against the real config file, so an alias
254
+ * written here is on disk before the standard model step's own `loadConfig()`
255
+ * runs -- no clobber, no shared in-memory object to race.
256
+ *
257
+ * Skippable and crash-proof: `runProviderDefaultFlow` already degrades to a
258
+ * graceful summary line on an empty/offline catalog (never calls `ask`), and
259
+ * a per-provider try/catch means one provider's failure can't block the rest
260
+ * of setup (mirrors `cli-handlers.js`'s `offerProviderDefault`). It's also a
261
+ * graceful no-op for a gateway provider (`openrouter`) -- see
262
+ * `runProviderDefaultFlow`'s `isDirectProvider` gate.
263
+ * @param {readline.Interface} rl
264
+ * @param {string[]} foundKeys keyed providers, in detection order
265
+ * @param {Array<object>} catalog
266
+ * @returns {Promise<Set<string>>} vendor alias NAMES actually written this run
267
+ * (i.e. `runProviderDefaultFlow` returned a non-null `chosenId`) -- the
268
+ * standard model step must not clobber these when a quick-pick family
269
+ * alias collides with one (see the standard-model-step alias-upgrade guard).
270
+ */
271
+ async function runProviderDefaultPickers(rl, foundKeys, catalog) {
272
+ const written = new Set();
273
+ if (foundKeys.length === 0) { return written; }
274
+ const { runProviderDefaultFlow } = require('../utils/provider-default-prompt');
275
+ for (const provider of foundKeys) {
276
+ try {
277
+ const { chosenId, summaryLine } = await runProviderDefaultFlow(provider, {
278
+ interactive: true,
279
+ ask: (q) => askQuestion(rl, q),
280
+ catalog,
281
+ print: console.log,
282
+ });
283
+ if (chosenId) { written.add(provider); }
284
+ console.log(summaryLine);
285
+ } catch (err) {
286
+ console.log(
287
+ `Note: couldn't set a default for ${provider} (${err.message}). ` +
288
+ `Run \`amicus key ${provider}\` again later.`
289
+ );
290
+ }
291
+ }
292
+ console.log('');
293
+ return written;
294
+ }
295
+
240
296
  /**
241
297
  * Run the readline-based setup wizard (headless fallback)
242
298
  *
243
299
  * Guides the user through:
244
300
  * 1. API key detection
245
- * 2. Mode selection (standard or free council)
246
- * 3. Default model selection from live quick-picks (read-modify-write, no clobber)
247
- * 4. Config file save
301
+ * 2. Per-provider default-model picker (Task 7) -- once per keyed provider
302
+ * 3. Mode selection (standard or free council)
303
+ * 4. Default model selection from live quick-picks (read-modify-write, no clobber)
304
+ * 5. Config file save
248
305
  */
249
306
  async function runReadlineSetup() {
250
307
  const rl = readline.createInterface({
@@ -279,18 +336,25 @@ async function runReadlineSetup() {
279
336
  await warnOnLowOpenRouterCredit();
280
337
  }
281
338
 
339
+ const { getCatalog } = require('../utils/model-catalog');
340
+ let catalog = [];
341
+ try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
342
+
343
+ // Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
344
+ // provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
345
+ // `vendorAliasesWritten` is consulted by the standard model step below so
346
+ // it never clobbers a vendor alias this phase just wrote (Fix 2).
347
+ const vendorAliasesWritten = await runProviderDefaultPickers(rl, foundKeys, catalog);
348
+
282
349
  const mode = await askQuestion(rl,
283
350
  'Setup mode — 1) Standard (pick a default model) 2) Free OpenRouter council: ');
284
351
  if (mode === '2') {
285
- await runFreeCouncilBranch(rl);
352
+ await runFreeCouncilBranch(rl, catalog);
286
353
  return;
287
354
  }
288
355
 
289
- const { getCatalog } = require('../utils/model-catalog');
290
356
  const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
291
357
  const { toCanonicalDefault } = require('../utils/curated-models');
292
- let catalog = [];
293
- try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
294
358
  const picks = resolveQuickPicks(catalog);
295
359
 
296
360
  console.log('Choose your default model:');
@@ -316,7 +380,12 @@ async function runReadlineSetup() {
316
380
  if (chosen.alias) {
317
381
  cfg.default = chosen.alias;
318
382
  const pick = picks.find(p => p.alias === chosen.alias);
319
- if (pick && !chosen.noUpgrade) {
383
+ // Fix 2: a quick-pick family alias (e.g. 'deepseek') can collide with a
384
+ // vendor alias the per-provider phase just wrote this run. `config.default`
385
+ // pointing at that alias name is fine (the user's explicit overall-default
386
+ // choice), but the alias's VALUE must stay the vendor phase's tier choice --
387
+ // skip the curated-flagship upgrade so it isn't discarded.
388
+ if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
320
389
  cfg.aliases[chosen.alias] = toCanonicalDefault(pick.routes.openrouter || Object.values(pick.routes)[0]);
321
390
  } else if (cfg.aliases[chosen.alias] === undefined) {
322
391
  const fallback = getDefaultAliases()[chosen.alias];