amicus 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/CHANGELOG.md +86 -0
  4. package/LICENSE +22 -1
  5. package/README.md +14 -3
  6. package/bin/amicus.js +17 -162
  7. package/electron/ipc-setup.js +30 -9
  8. package/electron/main.js +13 -5
  9. package/electron/preload.js +30 -10
  10. package/electron/setup-ui-keys.js +9 -0
  11. package/electron/setup-ui-model.js +33 -23
  12. package/electron/setup-ui-styles.js +6 -1
  13. package/electron/setup-ui.js +91 -38
  14. package/electron/toolbar.js +4 -5
  15. package/package.json +7 -5
  16. package/scripts/postinstall.js +16 -7
  17. package/skills/second-opinion/COUNCIL-DESIGN.md +36 -34
  18. package/skills/second-opinion/MODEL-NOTES.md +23 -17
  19. package/skills/second-opinion/SKILL.md +84 -51
  20. package/{skill → skills/sidecar}/SKILL.md +14 -4
  21. package/src/cli-handlers-council.js +59 -0
  22. package/src/cli-handlers-doctor.js +173 -0
  23. package/src/cli-handlers-run.js +196 -0
  24. package/src/cli-handlers.js +66 -1
  25. package/src/cli.js +16 -2
  26. package/src/council/findings.js +48 -0
  27. package/src/council/ledger.js +82 -0
  28. package/src/council/tally.js +108 -0
  29. package/src/council/verdict.js +48 -0
  30. package/src/headless.js +43 -149
  31. package/src/mcp-server.js +6 -0
  32. package/src/sidecar/budget.js +83 -0
  33. package/src/sidecar/conversation-mirror.js +128 -0
  34. package/src/sidecar/fanout-leg.js +4 -1
  35. package/src/sidecar/fanout.js +34 -7
  36. package/src/sidecar/interactive-mirror.js +66 -0
  37. package/src/sidecar/interactive.js +35 -21
  38. package/src/sidecar/models.js +41 -10
  39. package/src/sidecar/session-finalize.js +26 -0
  40. package/src/sidecar/session-utils.js +5 -5
  41. package/src/sidecar/setup.js +55 -42
  42. package/src/sidecar/start.js +19 -6
  43. package/src/utils/activity-poller.js +47 -0
  44. package/src/utils/alias-resolver.js +1 -1
  45. package/src/utils/config.js +4 -4
  46. package/src/utils/curated-models.js +88 -45
  47. package/src/utils/error-doc.js +55 -0
  48. package/src/utils/lifecycle.js +1 -1
  49. package/src/utils/model-catalog.js +1 -1
  50. package/src/utils/model-fetcher.js +16 -2
  51. package/src/utils/pricing.js +93 -0
  52. package/src/utils/quick-picks.js +81 -0
  53. package/src/utils/result-schema.js +21 -2
  54. package/src/utils/session-abort.js +40 -13
  55. package/src/utils/validators.js +17 -17
@@ -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({
@@ -170,7 +168,7 @@ async function runReadlineSetup() {
170
168
 
171
169
  try {
172
170
  console.log('');
173
- console.log('=== Sidecar Setup Wizard ===');
171
+ console.log('=== Amicus Setup Wizard ===');
174
172
  console.log('');
175
173
 
176
174
  const keys = detectApiKeys();
@@ -183,38 +181,54 @@ async function runReadlineSetup() {
183
181
  console.log(`API keys detected: ${foundKeys.join(', ')}`);
184
182
  } else {
185
183
  console.log('No API keys detected.');
186
- console.log('Set OPENROUTER_API_KEY to get started, or run: sidecar setup');
184
+ console.log('Set OPENROUTER_API_KEY to get started, or run: amicus setup');
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
  };
@@ -218,15 +218,26 @@ async function startSidecar(options) {
218
218
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
219
219
  }
220
220
 
221
- // Mark error results as 'error' instead of 'complete'
222
- if (result && result.error) {
221
+ // Map the run result to a definitive terminal status + exit code (single source of truth).
222
+ const { resolveTerminalState } = require('./session-finalize');
223
+ const terminal = resolveTerminalState(result);
224
+ if (terminal.status === 'error') {
223
225
  meta.status = 'error';
224
- meta.reason = result.error;
226
+ meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
225
227
  meta.completedAt = new Date().toISOString();
226
228
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
227
- logger.error('Session completed with error', { taskId, error: result.error });
229
+ logger.error('Session completed with error', { taskId, error: meta.reason });
228
230
  } else {
229
- finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json });
231
+ // complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
232
+ finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status });
233
+ }
234
+
235
+ const { resolveUsage } = require('../utils/pricing');
236
+ const runUsage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
237
+ if (runUsage) {
238
+ const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
239
+ m.usage = runUsage;
240
+ fs.writeFileSync(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
230
241
  }
231
242
 
232
243
  if (json) {
@@ -234,10 +245,12 @@ async function startSidecar(options) {
234
245
  const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
235
246
  const doc = buildRunResult({
236
247
  taskId, metadata: finalMeta, result, summary,
237
- modelInput, sessionDir: sessDir,
248
+ modelInput, sessionDir: sessDir, usage: runUsage,
238
249
  });
239
250
  console.log(JSON.stringify(doc, null, 2));
240
251
  }
252
+
253
+ return terminal.exitCode;
241
254
  }
242
255
 
243
256
  module.exports = {
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Poll an async status source on an interval and fire onActivity() on any non-idle state.
5
+ * Best-effort: getStatus errors are swallowed and polling continues. Timers are unref'd
6
+ * so the poller never keeps the process alive.
7
+ *
8
+ * @param {object} opts
9
+ * @param {() => Promise<{type?:string}>} opts.getStatus
10
+ * @param {() => void} opts.onActivity
11
+ * @param {number} [opts.intervalMs=30000]
12
+ * @returns {{ stop: () => void }}
13
+ */
14
+ function createActivityPoller({ getStatus, onActivity, intervalMs = 30000 }) {
15
+ let timer = null;
16
+ let stopped = false;
17
+
18
+ const schedule = () => {
19
+ if (stopped) { return; }
20
+ timer = setTimeout(tick, intervalMs);
21
+ if (timer.unref) { timer.unref(); }
22
+ };
23
+
24
+ async function tick() {
25
+ if (stopped) { return; }
26
+ try {
27
+ const status = await getStatus();
28
+ if (status && status.type && status.type !== 'idle') { onActivity(); }
29
+ } catch { /* best-effort */ }
30
+ schedule();
31
+ }
32
+
33
+ schedule();
34
+ return {
35
+ stop() {
36
+ stopped = true;
37
+ if (timer) { clearTimeout(timer); timer = null; }
38
+ },
39
+ };
40
+ }
41
+
42
+ /** SIGTERM a child process if it exists and isn't already killed. */
43
+ function killIfAlive(child) {
44
+ if (child && !child.killed) { child.kill('SIGTERM'); }
45
+ }
46
+
47
+ module.exports = { createActivityPoller, killIfAlive };
@@ -67,7 +67,7 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
67
67
  }
68
68
  throw new Error(
69
69
  `Alias '${alias}' is configured but has no model value. ` +
70
- `Fix with: sidecar setup --add-alias ${alias}=provider/model`
70
+ `Fix with: amicus setup --add-alias ${alias}=provider/model`
71
71
  );
72
72
  }
73
73
 
@@ -93,7 +93,7 @@ function getDefaultAliases() {
93
93
  * Resolution order:
94
94
  * 1. If modelArg contains '/' -> return as-is (full model string)
95
95
  * 2. If modelArg is a key in config.aliases -> return resolved string
96
- * 3. If modelArg is unknown alias -> throw Error mentioning 'sidecar setup'
96
+ * 3. If modelArg is unknown alias -> throw Error mentioning 'amicus setup'
97
97
  * 4. If modelArg is undefined and config.default exists -> resolve that alias
98
98
  * 5. If no default -> throw Error
99
99
  *
@@ -124,14 +124,14 @@ function resolveModel(modelArg) {
124
124
 
125
125
  // Unknown alias
126
126
  throw new Error(
127
- `Unknown model alias '${modelArg}'. Run 'sidecar setup' to configure aliases.`
127
+ `Unknown model alias '${modelArg}'. Run 'amicus setup' to configure aliases.`
128
128
  );
129
129
  }
130
130
 
131
131
  // modelArg is undefined - use default
132
132
  if (!config || !config.default) {
133
133
  throw new Error(
134
- 'No model specified and no default configured. Run \'sidecar setup\' to set a default model.'
134
+ 'No model specified and no default configured. Run \'amicus setup\' to set a default model.'
135
135
  );
136
136
  }
137
137
 
@@ -153,7 +153,7 @@ function resolveModel(modelArg) {
153
153
 
154
154
  // Default alias not found anywhere
155
155
  throw new Error(
156
- `Default alias '${defaultValue}' not found in aliases. Run 'sidecar setup' to fix configuration.`
156
+ `Default alias '${defaultValue}' not found in aliases. Run 'amicus setup' to fix configuration.`
157
157
  );
158
158
  }
159
159
 
@@ -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 };
@@ -0,0 +1,55 @@
1
+ // src/utils/error-doc.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module error-doc
6
+ * Structured error envelope for the `--json` contract (WS-2 #6). Every
7
+ * pre-flight failure under --json writes one of these to STDOUT (with a stable
8
+ * code) so an automation caller doing JSON.parse(stdout) gets a typed result
9
+ * instead of an empty string. Non-JSON callers keep human text on stderr.
10
+ */
11
+
12
+ const { SCHEMA_VERSION } = require('./result-schema');
13
+
14
+ /** Frozen — adding a code later is additive; renaming/removing is breaking. */
15
+ const ERROR_CODES = Object.freeze({
16
+ BAD_ARGS: 'BAD_ARGS', // bad/empty flag, mutually-exclusive flags, bad numeric/enum value
17
+ MISSING_PROMPT: 'MISSING_PROMPT', // no/empty --prompt or --prompt-file
18
+ BAD_MODEL: 'BAD_MODEL', // bad model format, not on provider, not in catalog
19
+ MISSING_KEY: 'MISSING_KEY', // provider API key absent
20
+ BAD_SESSION: 'BAD_SESSION', // task id missing / invalid / not found
21
+ BUDGET_EXCEEDED: 'BUDGET_EXCEEDED', // the WS-2 #10 spend gate
22
+ INTERNAL: 'INTERNAL', // unexpected pre-flight throw
23
+ });
24
+
25
+ /**
26
+ * @param {{code: string, message: string, hint?: string, command?: string}} opts
27
+ * @returns {object} error document
28
+ */
29
+ function buildErrorDoc({ code, message, hint = null, command = null }) {
30
+ return {
31
+ schemaVersion: SCHEMA_VERSION,
32
+ type: 'error',
33
+ ok: false,
34
+ error: { code, message, hint: hint || null, command: command || null },
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Emit a pre-flight failure: JSON envelope to stdout when useJson, else the
40
+ * human message to stderr. Returns the exit code (always 1) so callers can
41
+ * `process.exit(failJson(...))`.
42
+ * @param {boolean} useJson
43
+ * @param {{code: string, message: string, hint?: string, command?: string}} opts
44
+ * @returns {number}
45
+ */
46
+ function failJson(useJson, { code, message, hint = null, command = null }) {
47
+ if (useJson) {
48
+ process.stdout.write(JSON.stringify(buildErrorDoc({ code, message, hint, command }), null, 2) + '\n');
49
+ } else {
50
+ process.stderr.write(message + '\n');
51
+ }
52
+ return 1;
53
+ }
54
+
55
+ module.exports = { ERROR_CODES, buildErrorDoc, failJson };
@@ -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', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
16
16
 
17
17
  /** @param {string} command @returns {boolean} */
18
18
  function isOneShotCommand(command) {
@@ -100,4 +100,4 @@ async function getCatalogInfo(opts = {}) {
100
100
  return { models, fetchedAt: cache ? cache.fetchedAt : null };
101
101
  }
102
102
 
103
- module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, CATALOG_SCHEMA_VERSION };
103
+ module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION };
@@ -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
  */