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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.0.0",
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": "BourbonDog",
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:** Debugging, exploration, architectural discussions
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:** Bulk tasks, test generation, documentation, linting
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 '...' --no-ui"
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
- - **Show the proposed MODEL-NOTES diff and get approval before writing.** Keep it tight.
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). Show it to the user in full.
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.** Present the diff and ask:
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 show the revised diff before writing. Do not write any partial update — write only after the full diff is approved.
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) live in the
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
 
@@ -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
 
@@ -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 };
@@ -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 user input to a model alias name
94
- * @param {string} input - User input (number 1-5 or alias name)
95
- * @returns {string|null} Resolved alias name, or null if invalid
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 <= MODEL_CHOICES.length) {
100
- return MODEL_CHOICES[num - 1].alias;
91
+ if (num >= 1 && num <= picks.length) {
92
+ return { alias: picks[num - 1].alias };
101
93
  }
102
-
103
- const defaults = getDefaultAliases();
104
- if (defaults[input] !== undefined) {
105
- return input;
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 creation
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
- for (const choice of MODEL_CHOICES) {
193
- console.log(` ${choice.number}) ${choice.alias} - ${choice.label}`);
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, 'Pick a default (1-5 or alias name): ');
198
- const chosen = resolveChoice(answer);
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}". Using "gemini" as default.`);
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
- const cfg = createDefaultConfig(chosen);
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: ${chosen}`);
217
- console.log(`Config created with ${aliasCount} aliases.`);
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
- * Curated Models — THE single source of truth for default model lists.
3
- *
4
- * Three consumers derive from this module (F5 anti-drift):
5
- * - src/utils/config.js DEFAULT_ALIASES (toDefaultAliases)
6
- * - electron/setup-ui-model.js MODEL_CHOICES (getCuratedModels)
7
- * - src/sidecar/setup.js MODEL_CHOICES (getCuratedModels)
8
- * Never hand-edit a model id anywhere else. `amicus models --check`
9
- * audits every route here against the live catalog.
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
- * Card entries (shown as wizard quick picks). `routes` maps provider
16
- * full model id; the openrouter route doubles as the default alias target.
17
- * Direct (non-openrouter) route ids MUST be verified against the provider
18
- * whenever they change.
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 CARDS = [
21
- { alias: 'gemini', label: 'Gemini 3.1 Flash Lite', blurb: 'fast, large context',
22
- routes: { openrouter: 'openrouter/google/gemini-3.1-flash-lite-preview',
23
- google: 'google/gemini-3.1-flash-lite-preview' } },
24
- { alias: 'gemini-pro', label: 'Gemini 3.1 Pro', blurb: 'advanced reasoning',
25
- routes: { openrouter: 'openrouter/google/gemini-3.1-pro-preview',
26
- google: 'google/gemini-3.1-pro-preview' } },
27
- { alias: 'gpt', label: 'GPT-5.4', blurb: 'strong coding',
28
- routes: { openrouter: 'openrouter/openai/gpt-5.4',
29
- openai: 'openai/gpt-5.4' } },
30
- { alias: 'opus', label: 'Claude Opus 4.6', blurb: 'deep analysis',
31
- routes: { openrouter: 'openrouter/anthropic/claude-opus-4.6',
32
- anthropic: 'anthropic/claude-opus-4-6' } },
33
- { alias: 'deepseek', label: 'DeepSeek v3.2', blurb: 'open-source',
34
- routes: { openrouter: 'openrouter/deepseek/deepseek-v3.2' } },
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
- /** Alias-only entries (no wizard card); openrouter route only. */
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.4-pro' } },
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.5-397b-a17b' } },
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.5-flash-02-23' } },
48
- { alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-large-2512' } },
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.5' } },
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.5' } },
54
- { alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-mini' } },
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
- /** @returns {Array<{alias,label,blurb,routes}>} card entries (wizard quick picks) */
58
- function getCuratedModels() {
59
- return CARDS.map(c => ({ ...c, routes: { ...c.routes } }));
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
- /** @returns {Object<string,string>} alias → preferred route (openrouter first) */
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 e of [...CARDS, ...CARDLESS]) {
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
- /** @returns {Array<{alias,provider,model}>} every route of every entry, flattened */
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 e of [...CARDS, ...CARDLESS]) {
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 = { getCuratedModels, toDefaultAliases, listCuratedRoutes };
125
+ module.exports = { getFamilies, toDefaultAliases, listCuratedRoutes };
@@ -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
  */