amicus 1.0.0 → 1.1.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.
- package/CHANGELOG.md +33 -0
- package/README.md +2 -1
- package/bin/amicus.js +4 -1
- package/electron/ipc-setup.js +30 -9
- package/electron/main.js +13 -5
- package/electron/preload.js +30 -10
- package/electron/setup-ui-keys.js +9 -0
- package/electron/setup-ui-model.js +33 -23
- package/electron/setup-ui-styles.js +6 -1
- package/electron/setup-ui.js +91 -38
- package/electron/toolbar.js +4 -5
- package/package.json +2 -2
- package/skill/SKILL.md +14 -4
- package/skills/second-opinion/COUNCIL-DESIGN.md +3 -1
- package/skills/second-opinion/SKILL.md +7 -6
- package/src/cli-handlers.js +65 -0
- package/src/cli.js +6 -1
- package/src/sidecar/models.js +31 -1
- package/src/sidecar/setup.js +53 -40
- package/src/utils/curated-models.js +88 -45
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/model-fetcher.js +16 -2
- package/src/utils/quick-picks.js +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
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
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"second-opinion",
|
|
18
18
|
"fanout"
|
|
19
19
|
],
|
|
20
|
-
"author": "
|
|
20
|
+
"author": "Christian Wagner",
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"repository": {
|
|
23
23
|
"type": "git",
|
package/skill/SKILL.md
CHANGED
|
@@ -24,6 +24,10 @@ description: >
|
|
|
24
24
|
--prompt-file <path> --json` (one headless wave, one JSON result) instead of N
|
|
25
25
|
separate start calls. Different prompts per model → separate parallel
|
|
26
26
|
`amicus start --no-ui` calls.
|
|
27
|
+
(7) For a SINGLE-model sidecar, DEFAULT to interactive — omit --no-ui so the
|
|
28
|
+
Electron UI opens and the user can watch, converse, and click Fold. Use --no-ui
|
|
29
|
+
for a single model only when the user asks for headless/autonomous, or for
|
|
30
|
+
unattended bulk automation. Interactive launches still use run_in_background: true.
|
|
27
31
|
---
|
|
28
32
|
|
|
29
33
|
# Amicus: Multi-Model Sidecar Tool
|
|
@@ -608,13 +612,15 @@ amicus start --model gemini --prompt "Implement the login feature" --agent Build
|
|
|
608
612
|
|
|
609
613
|
### Interactive (Default)
|
|
610
614
|
|
|
615
|
+
**The default for single-model sidecars — omit `--no-ui`.** Reach for headless only when the user asks for it, the run is part of a multi-model wave, or the task is unattended bulk automation.
|
|
616
|
+
|
|
611
617
|
- Opens a GUI window
|
|
612
618
|
- User can converse with the sidecar
|
|
613
619
|
- **Model Picker:** Click the model name in the input area to switch models mid-conversation
|
|
614
620
|
- Click **FOLD** when done to generate summary
|
|
615
621
|
- Summary returns to your context via stdout
|
|
616
622
|
|
|
617
|
-
**Use for:**
|
|
623
|
+
**Use for:** Any single-model sidecar — debugging, exploration, second opinions, reviews, architectural discussions
|
|
618
624
|
|
|
619
625
|
**Mid-Conversation Model Switching:**
|
|
620
626
|
In interactive mode, you can change models without restarting:
|
|
@@ -635,6 +641,7 @@ This is useful when you want to:
|
|
|
635
641
|
- Summary returns automatically
|
|
636
642
|
- **Default agent is `build`** — `chat` agent requires interactive UI and will stall in headless mode
|
|
637
643
|
- **Always use headless when spawning multiple sidecars at once** (see Multi-LLM rule below)
|
|
644
|
+
- **Not the default for single-model runs** — a single-model sidecar opens the UI unless the user asks for headless or the task is unattended bulk work
|
|
638
645
|
|
|
639
646
|
**Multi-LLM Rule:** When the SAME prompt goes to N models, use `amicus fanout` (see [Fan Out One Prompt to N Models](#fan-out-one-prompt-to-n-models)) — one headless wave, one JSON result. When prompts differ per model, use separate parallel `amicus start --no-ui` calls with `run_in_background: true`. Only switch to interactive if the user explicitly asks.
|
|
640
647
|
|
|
@@ -656,7 +663,7 @@ amicus start --model gemini --prompt "..." --agent chat --no-ui
|
|
|
656
663
|
# → Error: --agent chat requires interactive mode (remove --no-ui or use --agent build)
|
|
657
664
|
```
|
|
658
665
|
|
|
659
|
-
**Use for:**
|
|
666
|
+
**Use for:** Multi-model waves, bulk tasks, test generation, documentation, linting — or when the user explicitly asks for headless
|
|
660
667
|
|
|
661
668
|
```bash
|
|
662
669
|
amicus start \
|
|
@@ -679,11 +686,14 @@ amicus start \
|
|
|
679
686
|
**Example invocation pattern:**
|
|
680
687
|
```
|
|
681
688
|
Bash tool:
|
|
682
|
-
command: "amicus start --model gemini --prompt '...'
|
|
689
|
+
command: "amicus start --model gemini --prompt '...'"
|
|
683
690
|
run_in_background: true
|
|
684
691
|
```
|
|
685
692
|
|
|
686
|
-
After launching, tell the user:
|
|
693
|
+
After launching an interactive sidecar, tell the user:
|
|
694
|
+
> "The Amicus window is open — chat with it there and click FOLD when you're done. I'll pick up the summary here."
|
|
695
|
+
|
|
696
|
+
After launching a headless sidecar, tell the user:
|
|
687
697
|
> "Amicus is running in the background. I'll share the results when it completes."
|
|
688
698
|
|
|
689
699
|
**When the background task completes**, you will be automatically notified. Use the `TaskOutput` tool with the task ID to read the sidecar's summary output, then present it to the user. Do NOT poll or sleep — the notification arrives automatically.
|
|
@@ -120,7 +120,9 @@ Run as ordered phases; track as todos. **Three sequential waves of model calls**
|
|
|
120
120
|
### Stage 6 — Capture lessons (compounding)
|
|
121
121
|
- Reflect on failures/mitigations and briefing wording, as today.
|
|
122
122
|
- **Additionally** update the per-model **reviewer-reliability** table (§7).
|
|
123
|
-
- **
|
|
123
|
+
- **Write the proposed MODEL-NOTES diff to a run-folder file and get approval before writing.**
|
|
124
|
+
The approval prompt carries the diff file's path; chat text alone is not sufficient (an approval
|
|
125
|
+
dialog can hide the chat transcript). Keep it tight.
|
|
124
126
|
|
|
125
127
|
## 5. Key mechanics
|
|
126
128
|
|
|
@@ -279,13 +279,13 @@ Draft new or updated entries for the per-model sections of `MODEL-NOTES.md` that
|
|
|
279
279
|
- **confirm-rate** — incorporate this run's share of each model's findings that ended up Confirmed.
|
|
280
280
|
- Merge into the existing row for that model; prune the notes column to stay tight.
|
|
281
281
|
|
|
282
|
-
**Compose the proposed MODEL-NOTES diff.** Combine the run-lessons updates and the reviewer-reliability table updates into a single proposed diff (old → new for every changed section).
|
|
282
|
+
**Compose the proposed MODEL-NOTES diff.** Combine the run-lessons updates and the reviewer-reliability table updates into a single proposed diff (old → new for every changed section). **Write the full diff to a file in the run folder** — `_tmp-proposed-model-notes-update.md` — so the user can open and review it before deciding. Presenting the diff as chat text alone is **not sufficient**: an approval dialog can hide the chat transcript, so the user may be asked to decide on a diff they never saw.
|
|
283
283
|
|
|
284
|
-
**Wait for explicit approval before writing anything.**
|
|
284
|
+
**Wait for explicit approval before writing anything.** Ask, with the diff file's path inside the approval prompt itself:
|
|
285
285
|
|
|
286
|
-
> Approve this MODEL-NOTES update? (yes / no / edit)
|
|
286
|
+
> Proposed MODEL-NOTES update written to `<run-folder>/_tmp-proposed-model-notes-update.md` — open it to review. Approve this MODEL-NOTES update? (yes / no / edit)
|
|
287
287
|
|
|
288
|
-
If the user approves, write the changes. If they say "edit", incorporate their corrections and
|
|
288
|
+
If the user approves, write the changes. If they say "edit", incorporate their corrections, rewrite the diff file, and re-present its path for approval before writing. Do not write any partial update — write only after the full diff is approved.
|
|
289
289
|
|
|
290
290
|
**Keep MODEL-NOTES tight.** Do not append new bullets when an existing entry covers the same ground — merge or reword instead. If a note has been superseded by a better mitigation, prune the old one. The goal is a compact, authoritative reference, not a changelog.
|
|
291
291
|
|
|
@@ -377,8 +377,9 @@ Always **rank recommendations by fit**, state the trade-off for each option, and
|
|
|
377
377
|
- `report.md` — synthesis + decision log + what was applied (+ the "How Claude's review fared" readout when the toggle is on) + a
|
|
378
378
|
**run-stats table**: one row per model call — **stage** (which stage you launched the call for) plus **model, status, durationMs** read from the wave/run JSON documents. The schema carries no cost data — do not invent cost figures.
|
|
379
379
|
- Reviewed copy: `<stem>-reviewed.<ext>`, next to the source.
|
|
380
|
-
- Temp working files (`_tmp-*.md`: extracts, stage briefings, red-team brief, bundle, chair packet
|
|
381
|
-
run folder and are cleaned up at the end of the run
|
|
380
|
+
- Temp working files (`_tmp-*.md`: extracts, stage briefings, red-team brief, bundle, chair packet, proposed
|
|
381
|
+
MODEL-NOTES diff) live in the run folder and are cleaned up at the end of the run — the proposed-diff file
|
|
382
|
+
only after the Stage-6 approval decision is resolved.
|
|
382
383
|
|
|
383
384
|
---
|
|
384
385
|
|
package/src/cli-handlers.js
CHANGED
|
@@ -180,9 +180,74 @@ async function handleMcp() {
|
|
|
180
180
|
await startMcpServer();
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Handle 'amicus key' command
|
|
185
|
+
* Lists, saves, or removes API keys for a provider without opening the Electron wizard.
|
|
186
|
+
*/
|
|
187
|
+
async function handleKey(args) {
|
|
188
|
+
const { readApiKeys, readApiKeyHints, saveApiKey, removeApiKey, PROVIDER_ENV_MAP } = require('./utils/api-key-store');
|
|
189
|
+
const { validateApiKey } = require('./utils/api-key-validation');
|
|
190
|
+
|
|
191
|
+
const provider = args._[1];
|
|
192
|
+
const keyArg = args._[2];
|
|
193
|
+
|
|
194
|
+
// List mode: no provider given
|
|
195
|
+
if (!provider) {
|
|
196
|
+
const configured = readApiKeys();
|
|
197
|
+
const hints = readApiKeyHints();
|
|
198
|
+
const knownProviders = Object.keys(PROVIDER_ENV_MAP);
|
|
199
|
+
console.log('');
|
|
200
|
+
console.log('Configured API keys:');
|
|
201
|
+
for (const p of knownProviders) {
|
|
202
|
+
const status = configured[p] ? `✓ ${hints[p]}` : '✗ not set';
|
|
203
|
+
console.log(` ${p.padEnd(12)} ${status}`);
|
|
204
|
+
}
|
|
205
|
+
console.log('');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Validate provider
|
|
210
|
+
if (!PROVIDER_ENV_MAP[provider]) {
|
|
211
|
+
console.error(`Error: Unknown provider "${provider}". Known providers: ${Object.keys(PROVIDER_ENV_MAP).join(', ')}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Remove mode
|
|
216
|
+
if (args.remove) {
|
|
217
|
+
const result = removeApiKey(provider);
|
|
218
|
+
if (!result.success) {
|
|
219
|
+
console.error(`Error: ${result.error}`);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
console.log(`${provider} key removed.`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Save mode: key required
|
|
227
|
+
if (!keyArg) {
|
|
228
|
+
console.error(`Error: API key is required. Usage: amicus key ${provider} <apikey>`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log(`Validating ${provider} key...`);
|
|
233
|
+
const validation = await validateApiKey(provider, keyArg);
|
|
234
|
+
if (!validation.valid) {
|
|
235
|
+
console.error(`Error: ${validation.error}`);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = saveApiKey(provider, keyArg);
|
|
240
|
+
if (!result.success) {
|
|
241
|
+
console.error(`Error: ${result.error}`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
console.log(`${provider} key validated and saved.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
183
247
|
module.exports = {
|
|
184
248
|
handleSetup,
|
|
185
249
|
handleAbort,
|
|
186
250
|
handleUpdate,
|
|
187
251
|
handleMcp,
|
|
252
|
+
handleKey,
|
|
188
253
|
};
|
package/src/cli.js
CHANGED
|
@@ -110,7 +110,8 @@ function isBooleanFlag(key) {
|
|
|
110
110
|
'help',
|
|
111
111
|
'api-keys',
|
|
112
112
|
'validate-model',
|
|
113
|
-
'no-validate-model'
|
|
113
|
+
'no-validate-model',
|
|
114
|
+
'remove' // used by 'key' command only; other handlers ignore it
|
|
114
115
|
];
|
|
115
116
|
return booleanFlags.includes(key);
|
|
116
117
|
}
|
|
@@ -302,6 +303,10 @@ Commands:
|
|
|
302
303
|
setup Configure default model and aliases
|
|
303
304
|
--api-keys Open API key setup window
|
|
304
305
|
--add-alias <name=model> Add a model alias without the full wizard
|
|
306
|
+
key Manage API keys from the command line
|
|
307
|
+
<provider> <apikey> Validate and save a key
|
|
308
|
+
<provider> --remove Remove a saved key
|
|
309
|
+
(no args) List all configured providers
|
|
305
310
|
update Update to latest version
|
|
306
311
|
mcp Start MCP server (stdio transport)
|
|
307
312
|
|
package/src/sidecar/models.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
|
|
16
16
|
const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
|
|
17
17
|
const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
|
|
18
|
+
const { getFamilies } = require('../utils/curated-models');
|
|
19
|
+
const { pickCurrent } = require('../utils/quick-picks');
|
|
18
20
|
|
|
19
21
|
const CHECK_EXIT_CAP = 100;
|
|
20
22
|
|
|
@@ -105,8 +107,13 @@ async function runCheck(args) {
|
|
|
105
107
|
}), null, 2) + '\n');
|
|
106
108
|
return Math.min(stale.length, CHECK_EXIT_CAP);
|
|
107
109
|
}
|
|
110
|
+
const driftLines = buildFallbackDriftReport(catalog);
|
|
108
111
|
if (stale.length === 0) {
|
|
109
112
|
process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
|
|
113
|
+
if (driftLines.length > 0) {
|
|
114
|
+
process.stdout.write('Pinned fallback drift:\n');
|
|
115
|
+
for (const l of driftLines) { process.stdout.write(l + '\n'); }
|
|
116
|
+
}
|
|
110
117
|
return 0;
|
|
111
118
|
}
|
|
112
119
|
for (const s of stale) {
|
|
@@ -118,9 +125,32 @@ async function runCheck(args) {
|
|
|
118
125
|
process.stdout.write(' no same-vendor candidates in catalog\n');
|
|
119
126
|
}
|
|
120
127
|
}
|
|
128
|
+
if (driftLines.length > 0) {
|
|
129
|
+
process.stdout.write('Pinned fallback drift:\n');
|
|
130
|
+
for (const l of driftLines) { process.stdout.write(l + '\n'); }
|
|
131
|
+
}
|
|
121
132
|
return Math.min(stale.length, CHECK_EXIT_CAP);
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Non-blocking drift report: pinned family fallbacks vs live resolution.
|
|
137
|
+
* Empty catalog → [] (cannot check). Never affects the exit code.
|
|
138
|
+
* @param {Array<{id:string}>} catalog
|
|
139
|
+
* @returns {string[]} human-readable warning lines
|
|
140
|
+
*/
|
|
141
|
+
function buildFallbackDriftReport(catalog) {
|
|
142
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
143
|
+
const lines = [];
|
|
144
|
+
for (const f of getFamilies()) {
|
|
145
|
+
const live = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
|
|
146
|
+
if (live && f.fallback.openrouter && live !== f.fallback.openrouter) {
|
|
147
|
+
lines.push(
|
|
148
|
+
` pinned fallback drift: ${f.alias} → ${f.fallback.openrouter} (live: ${live}) — update curated-models.js`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
/** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
|
|
125
155
|
async function handleModels(args) {
|
|
126
156
|
if (args.search === true) {
|
|
@@ -132,4 +162,4 @@ async function handleModels(args) {
|
|
|
132
162
|
return runList(args);
|
|
133
163
|
}
|
|
134
164
|
|
|
135
|
-
module.exports = { handleModels };
|
|
165
|
+
module.exports = { handleModels, buildFallbackDriftReport };
|
package/src/sidecar/setup.js
CHANGED
|
@@ -13,15 +13,6 @@ const readline = require('readline');
|
|
|
13
13
|
const { loadConfig, saveConfig, getDefaultAliases, getConfigDir } = require('../utils/config');
|
|
14
14
|
const { logger } = require('../utils/logger');
|
|
15
15
|
|
|
16
|
-
const { getCuratedModels } = require('../utils/curated-models');
|
|
17
|
-
/**
|
|
18
|
-
* Model choices presented during readline setup — derived from curated-models (F5).
|
|
19
|
-
* @type {Array<{number: number, alias: string, label: string}>}
|
|
20
|
-
*/
|
|
21
|
-
const MODEL_CHOICES = getCuratedModels().map((c, i) => ({
|
|
22
|
-
number: i + 1, alias: c.alias, label: `${c.label} (${c.blurb})`
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
16
|
/**
|
|
26
17
|
* Add a model alias to the existing config (or create config if none exists)
|
|
27
18
|
* @param {string} name - Alias name
|
|
@@ -68,7 +59,7 @@ function createDefaultConfig(defaultModel) {
|
|
|
68
59
|
|
|
69
60
|
/**
|
|
70
61
|
* Detect available API keys from .env file and process.env
|
|
71
|
-
* @returns {{openrouter: boolean, google: boolean, openai: boolean, anthropic: boolean}}
|
|
62
|
+
* @returns {{openrouter: boolean, google: boolean, openai: boolean, anthropic: boolean, deepseek: boolean}}
|
|
72
63
|
*/
|
|
73
64
|
function detectApiKeys() {
|
|
74
65
|
const { readApiKeys } = require('../utils/api-key-store');
|
|
@@ -90,21 +81,28 @@ function askQuestion(rl, prompt) {
|
|
|
90
81
|
}
|
|
91
82
|
|
|
92
83
|
/**
|
|
93
|
-
* Resolve
|
|
94
|
-
* @
|
|
95
|
-
*
|
|
84
|
+
* Resolve readline input against the live picks.
|
|
85
|
+
* @returns {{alias?: string, modelId?: string, noUpgrade?: boolean}|null}
|
|
86
|
+
* alias → numbered/named quick pick (upgrades that alias unless noUpgrade)
|
|
87
|
+
* modelId → free-form full model id (default only, no alias writes)
|
|
96
88
|
*/
|
|
97
|
-
function resolveChoice(input) {
|
|
89
|
+
function resolveChoice(input, picks, catalog) {
|
|
98
90
|
const num = parseInt(input, 10);
|
|
99
|
-
if (num >= 1 && num <=
|
|
100
|
-
return
|
|
91
|
+
if (num >= 1 && num <= picks.length) {
|
|
92
|
+
return { alias: picks[num - 1].alias };
|
|
101
93
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
94
|
+
if (input.includes('/')) {
|
|
95
|
+
const known = (catalog || []).some(m => m && m.id === input);
|
|
96
|
+
if (!known) {
|
|
97
|
+
console.log(`Warning: '${input}' not found in the model catalog (offline or new model) — using it anyway.`); // eslint-disable-line no-console
|
|
98
|
+
}
|
|
99
|
+
return { modelId: input };
|
|
100
|
+
}
|
|
101
|
+
const cfg = loadConfig();
|
|
102
|
+
const aliases = { ...getDefaultAliases(), ...((cfg && cfg.aliases) || {}) };
|
|
103
|
+
if (aliases[input] !== undefined) {
|
|
104
|
+
return { alias: input, noUpgrade: true };
|
|
106
105
|
}
|
|
107
|
-
|
|
108
106
|
return null;
|
|
109
107
|
}
|
|
110
108
|
|
|
@@ -159,8 +157,8 @@ async function seedCatalog(print) {
|
|
|
159
157
|
*
|
|
160
158
|
* Guides the user through:
|
|
161
159
|
* 1. API key detection
|
|
162
|
-
* 2. Default model selection
|
|
163
|
-
* 3. Config file
|
|
160
|
+
* 2. Default model selection from live quick-picks (read-modify-write, no clobber)
|
|
161
|
+
* 3. Config file save
|
|
164
162
|
*/
|
|
165
163
|
async function runReadlineSetup() {
|
|
166
164
|
const rl = readline.createInterface({
|
|
@@ -187,34 +185,50 @@ async function runReadlineSetup() {
|
|
|
187
185
|
}
|
|
188
186
|
console.log('');
|
|
189
187
|
|
|
188
|
+
const { getCatalog } = require('../utils/model-catalog');
|
|
189
|
+
const { resolveQuickPicks, toLiveSeedAliases } = require('../utils/quick-picks');
|
|
190
|
+
let catalog = [];
|
|
191
|
+
try { catalog = await getCatalog(); } catch (_err) { /* offline: pinned */ }
|
|
192
|
+
const picks = resolveQuickPicks(catalog);
|
|
193
|
+
|
|
190
194
|
console.log('Choose your default model:');
|
|
191
195
|
console.log('');
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
picks.forEach((p, i) => {
|
|
197
|
+
const badge = p.source === 'fallback' ? ' [offline list]' : '';
|
|
198
|
+
console.log(` ${i + 1}) ${p.alias} - ${p.label} (${p.blurb}) → ${p.routes.openrouter}${badge}`);
|
|
199
|
+
});
|
|
195
200
|
console.log('');
|
|
196
201
|
|
|
197
|
-
const answer = await askQuestion(rl,
|
|
198
|
-
|
|
202
|
+
const answer = await askQuestion(rl,
|
|
203
|
+
`Pick a default (1-${picks.length}, alias name, or any full model id): `);
|
|
204
|
+
const chosen = resolveChoice(answer, picks, catalog);
|
|
199
205
|
|
|
200
206
|
if (!chosen) {
|
|
201
|
-
console.log(`Invalid choice: "${answer}".
|
|
202
|
-
const cfg = createDefaultConfig('gemini');
|
|
203
|
-
await seedCatalog();
|
|
204
|
-
const aliasCount = Object.keys(cfg.aliases).length;
|
|
205
|
-
console.log('');
|
|
206
|
-
console.log(`Config created with ${aliasCount} aliases.`);
|
|
207
|
-
console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
|
|
207
|
+
console.log(`Invalid choice: "${answer}". Keeping configuration unchanged.`);
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
// Read-modify-write — never rebuild the alias table (no-clobber rule).
|
|
212
|
+
const cfg = loadConfig() || { aliases: toLiveSeedAliases(catalog) };
|
|
213
|
+
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
214
|
+
if (chosen.alias) {
|
|
215
|
+
cfg.default = chosen.alias;
|
|
216
|
+
const pick = picks.find(p => p.alias === chosen.alias);
|
|
217
|
+
if (pick && !chosen.noUpgrade) {
|
|
218
|
+
cfg.aliases[chosen.alias] = pick.routes.openrouter || Object.values(pick.routes)[0];
|
|
219
|
+
} else if (cfg.aliases[chosen.alias] === undefined) {
|
|
220
|
+
const fallback = getDefaultAliases()[chosen.alias];
|
|
221
|
+
if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
cfg.default = chosen.modelId;
|
|
225
|
+
}
|
|
226
|
+
saveConfig(cfg);
|
|
212
227
|
await seedCatalog();
|
|
213
|
-
const aliasCount = Object.keys(cfg.aliases).length;
|
|
214
228
|
|
|
215
229
|
console.log('');
|
|
216
|
-
console.log(`Default model set to: ${
|
|
217
|
-
console.log(`Config
|
|
230
|
+
console.log(`Default model set to: ${cfg.default}`);
|
|
231
|
+
console.log(`Config saved (${Object.keys(cfg.aliases).length} aliases).`);
|
|
218
232
|
console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
|
|
219
233
|
} finally {
|
|
220
234
|
rl.close();
|
|
@@ -276,5 +290,4 @@ module.exports = {
|
|
|
276
290
|
runReadlineSetup,
|
|
277
291
|
runApiKeySetup,
|
|
278
292
|
seedCatalog,
|
|
279
|
-
MODEL_CHOICES,
|
|
280
293
|
};
|
|
@@ -1,77 +1,120 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
1
|
+
/** Family definitions + pinned fallbacks for the wizard model picker (v2). */
|
|
2
|
+
/*
|
|
3
|
+
* Families are MATCH RULES over the live catalog, not pinned truths:
|
|
4
|
+
* src/utils/quick-picks.js resolves each family to the current catalog
|
|
5
|
+
* flagship at setup time. The pinned `fallback` ids are used only when
|
|
6
|
+
* the catalog cannot resolve a route (offline / unkeyed provider) and to
|
|
7
|
+
* derive the static DEFAULT_ALIASES (runtime alias resolution must never
|
|
8
|
+
* wait on the network). `amicus models --check` audits every pinned route
|
|
9
|
+
* here against the live catalog AND warns when a fallback falls behind
|
|
10
|
+
* the live resolution.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
'use strict';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Wizard quick-pick families. `idPattern` matches the model segment after
|
|
17
|
+
* `<vendorPath>/` (openrouter ns) or `<provider>/` (direct ns).
|
|
18
|
+
* `directProviders` lists direct namespaces the quick-picks resolver may
|
|
19
|
+
* resolve live from the catalog. A per-provider `fallback` entry is
|
|
20
|
+
* OPTIONAL: when absent and the catalog cannot resolve that namespace,
|
|
21
|
+
* the direct route is omitted (no pinned guess is better than a wrong one).
|
|
22
|
+
* `gpt`'s pattern intentionally matches any plain numeric flagship id
|
|
23
|
+
* (gpt-5.5, gpt-6) and excludes suffixed variants (-pro/-mini/-codex).
|
|
24
|
+
* Pinned ids verified against the live catalog 2026-06-11.
|
|
19
25
|
*/
|
|
20
|
-
const
|
|
21
|
-
{ alias: 'gemini', label: 'Gemini
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ alias: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
const FAMILIES = [
|
|
27
|
+
{ alias: 'gemini', label: 'Gemini Flash-class', blurb: 'fast, large context',
|
|
28
|
+
vendorPath: 'google',
|
|
29
|
+
idPattern: /^gemini-[\d.]+-flash(-preview|-exp|-latest)?$/,
|
|
30
|
+
directProviders: ['google'],
|
|
31
|
+
fallback: { openrouter: 'openrouter/google/gemini-3.5-flash',
|
|
32
|
+
google: 'google/gemini-3.5-flash' } },
|
|
33
|
+
{ alias: 'gemini-pro', label: 'Gemini Pro-class', blurb: 'advanced reasoning',
|
|
34
|
+
vendorPath: 'google',
|
|
35
|
+
idPattern: /^gemini-[\d.]+-pro(-preview|-exp|-latest)?$/,
|
|
36
|
+
directProviders: ['google'],
|
|
37
|
+
fallback: { openrouter: 'openrouter/google/gemini-3.1-pro-preview' } },
|
|
38
|
+
{ alias: 'gpt', label: 'GPT flagship', blurb: 'strong coding',
|
|
39
|
+
vendorPath: 'openai',
|
|
40
|
+
idPattern: /^gpt-[\d.]+$/,
|
|
41
|
+
directProviders: ['openai'],
|
|
42
|
+
fallback: { openrouter: 'openrouter/openai/gpt-5.5' } },
|
|
43
|
+
{ alias: 'opus', label: 'Claude Opus-class', blurb: 'deep analysis',
|
|
44
|
+
vendorPath: 'anthropic',
|
|
45
|
+
idPattern: /^claude-opus-[\d.-]+$/,
|
|
46
|
+
directProviders: ['anthropic'],
|
|
47
|
+
fallback: { openrouter: 'openrouter/anthropic/claude-opus-4.8',
|
|
48
|
+
anthropic: 'anthropic/claude-opus-4-6' } },
|
|
49
|
+
{ alias: 'deepseek', label: 'DeepSeek flagship', blurb: 'open-source',
|
|
50
|
+
vendorPath: 'deepseek',
|
|
51
|
+
idPattern: /^deepseek-v[\d.]+(-pro)?$/,
|
|
52
|
+
directProviders: ['deepseek'],
|
|
53
|
+
fallback: { openrouter: 'openrouter/deepseek/deepseek-v4-pro',
|
|
54
|
+
deepseek: 'deepseek/deepseek-chat' } },
|
|
35
55
|
];
|
|
36
56
|
|
|
37
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Alias-only entries (no wizard quick pick); openrouter route only.
|
|
59
|
+
* Refreshed against the live catalog 2026-06-11.
|
|
60
|
+
*/
|
|
38
61
|
const CARDLESS = [
|
|
39
|
-
{ alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.
|
|
62
|
+
{ alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.5-pro' } },
|
|
40
63
|
// codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
|
|
41
64
|
{ alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
|
|
42
65
|
{ alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
|
|
43
66
|
{ alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
|
|
44
67
|
{ alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5' } },
|
|
45
|
-
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.
|
|
68
|
+
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.7-max' } },
|
|
46
69
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
47
|
-
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.
|
|
48
|
-
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-
|
|
70
|
+
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
71
|
+
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
|
|
49
72
|
{ alias: 'devstral', routes: { openrouter: 'openrouter/mistralai/devstral-2512' } },
|
|
50
|
-
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5' } },
|
|
51
|
-
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.
|
|
73
|
+
{ alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5.1' } },
|
|
74
|
+
{ alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.7' } },
|
|
52
75
|
{ alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
|
|
53
|
-
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.
|
|
54
|
-
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-
|
|
76
|
+
{ alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.6' } },
|
|
77
|
+
{ alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-lite' } },
|
|
55
78
|
];
|
|
56
79
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
80
|
+
/**
|
|
81
|
+
* @returns {Array} shallow-spread copies of the family definitions;
|
|
82
|
+
* `idPattern` is intentionally a shared RegExp reference — safe because
|
|
83
|
+
* none use the g/y flags (no lastIndex state) and callers treat it read-only.
|
|
84
|
+
*/
|
|
85
|
+
function getFamilies() {
|
|
86
|
+
return FAMILIES.map(f => ({
|
|
87
|
+
...f,
|
|
88
|
+
directProviders: [...f.directProviders],
|
|
89
|
+
fallback: { ...f.fallback },
|
|
90
|
+
}));
|
|
60
91
|
}
|
|
61
92
|
|
|
62
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* @returns {Object<string,string>} alias → pinned route (openrouter first). STATIC — runtime-safe.
|
|
95
|
+
*/
|
|
63
96
|
function toDefaultAliases() {
|
|
64
97
|
const out = {};
|
|
65
|
-
for (const
|
|
98
|
+
for (const f of FAMILIES) {
|
|
99
|
+
out[f.alias] = f.fallback.openrouter || Object.values(f.fallback)[0];
|
|
100
|
+
}
|
|
101
|
+
for (const e of CARDLESS) {
|
|
66
102
|
out[e.alias] = e.routes.openrouter || Object.values(e.routes)[0];
|
|
67
103
|
}
|
|
68
104
|
return out;
|
|
69
105
|
}
|
|
70
106
|
|
|
71
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* @returns {Array<{alias,provider,model}>} every pinned route, flattened (for the alias audit).
|
|
109
|
+
*/
|
|
72
110
|
function listCuratedRoutes() {
|
|
73
111
|
const out = [];
|
|
74
|
-
for (const
|
|
112
|
+
for (const f of FAMILIES) {
|
|
113
|
+
for (const [provider, model] of Object.entries(f.fallback)) {
|
|
114
|
+
out.push({ alias: f.alias, provider, model });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const e of CARDLESS) {
|
|
75
118
|
for (const [provider, model] of Object.entries(e.routes)) {
|
|
76
119
|
out.push({ alias: e.alias, provider, model });
|
|
77
120
|
}
|
|
@@ -79,4 +122,4 @@ function listCuratedRoutes() {
|
|
|
79
122
|
return out;
|
|
80
123
|
}
|
|
81
124
|
|
|
82
|
-
module.exports = {
|
|
125
|
+
module.exports = { getFamilies, toDefaultAliases, listCuratedRoutes };
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
|
|
13
13
|
// `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
|
|
14
14
|
// interactive Electron flow that must never be force-exited).
|
|
15
|
-
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models']);
|
|
15
|
+
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models', 'key' /* local-only: no OpenCode server, no stray handles */]);
|
|
16
16
|
|
|
17
17
|
/** @param {string} command @returns {boolean} */
|
|
18
18
|
function isOneShotCommand(command) {
|
|
@@ -20,7 +20,8 @@ const PROVIDER_FAMILY_NAMES = {
|
|
|
20
20
|
openrouter: 'OpenRouter',
|
|
21
21
|
google: 'Google',
|
|
22
22
|
openai: 'OpenAI',
|
|
23
|
-
anthropic: 'Anthropic'
|
|
23
|
+
anthropic: 'Anthropic',
|
|
24
|
+
deepseek: 'DeepSeek'
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
/** Provider API configs for fetching model lists */
|
|
@@ -68,6 +69,19 @@ const PROVIDER_FETCH_CONFIG = {
|
|
|
68
69
|
pricing: null
|
|
69
70
|
}));
|
|
70
71
|
}
|
|
72
|
+
},
|
|
73
|
+
deepseek: {
|
|
74
|
+
url: 'https://api.deepseek.com/models',
|
|
75
|
+
authHeader: (key) => ({ 'Authorization': `Bearer ${key}` }),
|
|
76
|
+
normalize: (body) => {
|
|
77
|
+
const data = JSON.parse(body);
|
|
78
|
+
return (data.data || []).map(m => ({
|
|
79
|
+
id: `deepseek/${m.id}`,
|
|
80
|
+
name: m.id,
|
|
81
|
+
contextLength: null,
|
|
82
|
+
pricing: null
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
71
85
|
}
|
|
72
86
|
};
|
|
73
87
|
|
|
@@ -75,7 +89,7 @@ const FETCH_TIMEOUT_MS = 5000;
|
|
|
75
89
|
|
|
76
90
|
/**
|
|
77
91
|
* Fetch models from a single provider API
|
|
78
|
-
* @param {string} provider - Provider name (openrouter, google, openai, anthropic)
|
|
92
|
+
* @param {string} provider - Provider name (openrouter, google, openai, anthropic, deepseek)
|
|
79
93
|
* @param {string} key - API key
|
|
80
94
|
* @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: {prompt: string|null, completion: string|null}|null}>>} Normalized model list
|
|
81
95
|
*/
|