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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +49 -0
- package/README.md +1 -1
- package/electron/ipc-setup.js +33 -0
- package/electron/preload-setup.js +2 -1
- package/electron/setup-ui-keys-script.js +8 -1
- package/electron/setup-ui-provider-default.js +101 -0
- package/electron/setup-ui-styles.js +13 -1
- package/electron/setup-ui.js +5 -1
- package/package.json +1 -1
- package/src/cli-handlers-doctor.js +11 -0
- package/src/cli-handlers-run.js +9 -1
- package/src/cli-handlers.js +40 -0
- package/src/opencode-client.js +5 -1
- package/src/sidecar/setup.js +80 -11
- package/src/utils/config.js +59 -0
- package/src/utils/curated-models.js +4 -2
- package/src/utils/doctor-engine-check.js +71 -0
- package/src/utils/engine-install-scan.js +142 -0
- package/src/utils/mcp-discovery.js +27 -0
- package/src/utils/model-tiers.js +120 -0
- package/src/utils/path-setup.js +78 -25
- package/src/utils/provider-default-picker.js +234 -0
- package/src/utils/provider-default-prompt.js +118 -0
- package/src/utils/route-launch.js +116 -36
- package/src/utils/start-helpers.js +53 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "3.
|
|
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
package/electron/ipc-setup.js
CHANGED
|
@@ -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() {
|
package/electron/setup-ui.js
CHANGED
|
@@ -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.
|
|
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 };
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -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');
|
package/src/cli-handlers.js
CHANGED
|
@@ -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 = {
|
package/src/opencode-client.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/sidecar/setup.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
246
|
-
* 3.
|
|
247
|
-
* 4.
|
|
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
|
-
|
|
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];
|