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 CHANGED
@@ -5,6 +5,39 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.1.0] - 2026-06-11
9
+
10
+ ### Added
11
+ - **DeepSeek as a direct API provider**: DeepSeek card and API key step in the setup wizard,
12
+ live model fetch from DeepSeek's `/models`, and a direct `deepseek/...` route used
13
+ automatically when no OpenRouter key is configured.
14
+ - **`amicus key`**: headless API key management — `amicus key` lists configured providers with
15
+ masked hints, `amicus key <provider> <key>` validates and saves, `--remove` deletes. No GUI
16
+ required.
17
+ - **Live quick picks in the setup wizard (Step 2)**: recommended models resolve per family
18
+ against the live catalog when the window opens (no stale pinned ids), with always-visible
19
+ labeled search and a write-preview showing exactly which alias will change.
20
+
21
+ ### Changed
22
+ - **Setup wizard finish is now read-modify-write**: picking a model sets the default and
23
+ upgrades only that one alias; untouched aliases are never rewritten and deleted aliases stay
24
+ deleted. (Previously, finishing setup could silently rewrite every card alias.)
25
+ - Readline (no-Electron) setup parity: free-form model ids and the same no-clobber behavior.
26
+ `amicus models --check` now also warns when a curated pinned fallback drifts from the live
27
+ catalog.
28
+ - Council skill (Stage 6): the proposed MODEL-NOTES diff is written to a run-folder file and
29
+ the approval prompt carries the file path — approval dialogs can hide chat text.
30
+ - Chat skill docs: single-model sidecars default to interactive (GUI) mode; headless remains
31
+ the default for fanouts and bulk runs.
32
+ - Attribution: npm package author is Christian Wagner; "Inspired by" fork wording in
33
+ CONTRIBUTING.
34
+
35
+ ### Fixed
36
+ - **Electron preload crash on every page**: `window.sidecar` (contextBridge) is now exposed
37
+ before DOM injection, and the injected CSS guards against a null `documentElement` — the
38
+ silent TypeError previously killed both the bridge and the anti-white-flash styling.
39
+ - DeepSeek provider pill showed `undefined` in the wizard model step.
40
+
8
41
  ## [1.0.0] - 2026-06-10
9
42
 
10
43
  Everything since the fork from upstream `claude-sidecar` v0.5.2 — the Amicus launch line.
package/README.md CHANGED
@@ -420,6 +420,7 @@ Most Claude-adjacent tooling assumes macOS/Linux; Amicus doesn't.
420
420
  | Symptom | Likely cause | Fix |
421
421
  |---------|--------------|-----|
422
422
  | "council review this" does nothing | The `second-opinion` skill isn't installed | Check `~/.claude/skills/second-opinion/SKILL.md` exists; re-run `npm install -g amicus` (postinstall installs both skills) |
423
+ | `npm install -g amicus` fails with `EEXIST: … claude-sidecar` | The old upstream `claude-sidecar` package is still installed globally; npm won't overwrite another package's bin shims | `npm uninstall -g claude-sidecar`, then `npm install -g amicus`. Your config and sessions carry over (legacy paths are still read). |
423
424
  | `401` / auth error | API key missing, or the model prefix doesn't match the key you have | Run `amicus setup`; make sure the prefix (`openrouter/…` vs `google/…` vs `openai/…` vs `anthropic/…`) matches the credentials you configured. |
424
425
  | Session not found | No session matches the given ID | Run `amicus list`, or omit `--session-id` to use the most recent. |
425
426
  | No conversation history found | Project-path encoding | Check `~/.claude/projects/`; `/` and `_` in the project path are encoded as `-` in the directory name. |
@@ -472,6 +473,6 @@ Amicus is a harness built on top of [**OpenCode**](https://opencode.ai), the ope
472
473
 
473
474
  ## Attribution & License
474
475
 
475
- Amicus is an independent fork of [**Claude Sidecar**](https://github.com/jrenaldi79/sidecar) by [John Renaldi](https://github.com/jrenaldi79), used under the MIT License. The original copyright (© 2025 John Renaldi) is preserved in full in [LICENSE](./LICENSE). The engine modifications, the multi-model council, and the skill bundling are © 2026 BourbonDog, also under the MIT License. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE) for the complete attribution.
476
+ Amicus is inspired by [**Claude Sidecar**](https://github.com/jrenaldi79/sidecar) by [John Renaldi](https://github.com/jrenaldi79), used under the MIT License. The original copyright (© 2025 John Renaldi) is preserved in full in [LICENSE](./LICENSE). The engine modifications, the multi-model council, and the skill bundling are © 2026 Christian Wagner, also under the MIT License. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE) for the complete attribution.
476
477
 
477
478
  **MIT.**
package/bin/amicus.js CHANGED
@@ -14,7 +14,7 @@ loadCredentials();
14
14
  const { parseArgs, validateStartArgs, getUsage } = require('../src/cli');
15
15
  const { validateTaskId } = require('../src/utils/validators');
16
16
  const { resolveModelFromArgs, validateFallbackModel } = require('../src/utils/start-helpers');
17
- const { handleSetup, handleAbort, handleUpdate, handleMcp } = require('../src/cli-handlers');
17
+ const { handleSetup, handleAbort, handleUpdate, handleMcp, handleKey } = require('../src/cli-handlers');
18
18
  const { isOneShotCommand, armExitWatchdog } = require('../src/utils/lifecycle');
19
19
  const { logger } = require('../src/utils/logger');
20
20
 
@@ -98,6 +98,9 @@ async function main() {
98
98
  case 'setup':
99
99
  await handleSetup(args);
100
100
  break;
101
+ case 'key':
102
+ await handleKey(args);
103
+ break;
101
104
  case 'abort':
102
105
  await handleAbort(args);
103
106
  break;
@@ -7,14 +7,14 @@
7
7
  * fetch-models, get-catalog, and refresh-catalog.
8
8
  */
9
9
 
10
+ const { ipcMain } = require('electron');
10
11
  const { logger } = require('../src/utils/logger');
11
12
 
12
13
  /**
13
14
  * Register all setup-related IPC handlers
14
- * @param {Electron.IpcMain} ipcMain - Electron IPC main
15
15
  * @param {function} getMainWindow - Returns the current main BrowserWindow
16
16
  */
17
- function registerSetupHandlers(ipcMain, getMainWindow) {
17
+ function registerSetupHandlers(getMainWindow) {
18
18
  ipcMain.handle('sidecar:validate-key', async (_event, provider, key) => {
19
19
  try {
20
20
  const { validateApiKey } = require('../src/utils/api-key-store');
@@ -95,14 +95,35 @@ function registerSetupHandlers(ipcMain, getMainWindow) {
95
95
  }
96
96
  });
97
97
 
98
- ipcMain.handle('sidecar:save-config', (_event, defaultModel, aliasOverrides) => {
99
- const { saveConfig, getDefaultAliases } = require('../src/utils/config');
100
- const aliases = getDefaultAliases();
101
- if (aliasOverrides && typeof aliasOverrides === 'object') {
102
- Object.assign(aliases, aliasOverrides);
98
+ // Read-modify-write: never rewrite an alias the renderer didn't send.
99
+ // aliasWrites values: string = set, null = delete. First run seeds live.
100
+ ipcMain.handle('sidecar:save-config', async (_event, defaultModel, aliasWrites) => {
101
+ try {
102
+ const { loadConfig, saveConfig } = require('../src/utils/config');
103
+ let cfg = loadConfig();
104
+ if (!cfg) {
105
+ const { toLiveSeedAliases } = require('../src/utils/quick-picks');
106
+ let catalog = [];
107
+ try {
108
+ catalog = await require('../src/utils/model-catalog').getCatalog();
109
+ } catch (_err) { /* offline: pinned seeds */ }
110
+ cfg = { aliases: toLiveSeedAliases(catalog) };
111
+ }
112
+ if (!cfg.aliases) { cfg.aliases = {}; }
113
+ if (defaultModel) { cfg.default = defaultModel; }
114
+ if (aliasWrites && typeof aliasWrites === 'object') {
115
+ for (const [alias, model] of Object.entries(aliasWrites)) {
116
+ if (model === null) { delete cfg.aliases[alias]; }
117
+ // empty string: ignore (use null to delete)
118
+ else if (typeof model === 'string' && model) { cfg.aliases[alias] = model; }
119
+ }
120
+ }
121
+ saveConfig(cfg);
122
+ return { success: true };
123
+ } catch (err) {
124
+ logger.error('save-config handler error', { error: err.message });
125
+ throw err; // renderer invoke() rejects; its catch re-enables Finish
103
126
  }
104
- saveConfig({ default: defaultModel, aliases });
105
- return { success: true };
106
127
  });
107
128
 
108
129
  ipcMain.handle('sidecar:get-config', () => {
package/electron/main.js CHANGED
@@ -93,7 +93,7 @@ function createAmicusWindow() {
93
93
  x: winX, y: winY,
94
94
  show: false,
95
95
  frame: true, backgroundColor: '#2D2B2A',
96
- title: CLIENT === 'cowork' ? 'Openwork Amicus' : 'Amicus',
96
+ title: 'Amicus',
97
97
  icon: ICON_PATH,
98
98
  webPreferences: {
99
99
  preload: path.join(__dirname, 'preload.js'),
@@ -270,9 +270,17 @@ function createAmicusWindow() {
270
270
  // Setup Window (API Key Form)
271
271
  // ============================================================================
272
272
 
273
- function createSetupWindow() {
273
+ async function createSetupWindow() {
274
274
  // Lazy-load setup UI to avoid loading it for sidecar mode
275
275
  const { buildSetupHTML } = require('./setup-ui');
276
+ const { resolveQuickPicks } = require('../src/utils/quick-picks');
277
+ let quickPicks;
278
+ try {
279
+ const catalog = await require('../src/utils/model-catalog').getCatalog();
280
+ quickPicks = resolveQuickPicks(catalog);
281
+ } catch (_err) {
282
+ quickPicks = undefined; // buildSetupHTML falls back to pinned
283
+ }
276
284
 
277
285
  mainWindow = new BrowserWindow({
278
286
  width: 560, height: 680, minWidth: 480, minHeight: 580,
@@ -286,7 +294,7 @@ function createSetupWindow() {
286
294
  }
287
295
  });
288
296
 
289
- const html = buildSetupHTML({ client: CLIENT });
297
+ const html = buildSetupHTML({ client: CLIENT, quickPicks });
290
298
  mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
291
299
  mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
292
300
 
@@ -417,7 +425,7 @@ ipcMain.handle('sidecar:resize-toolbar', (_event, height) => {
417
425
  });
418
426
 
419
427
  // Setup mode: all setup IPC handlers (extracted to ipc-setup.js)
420
- registerSetupHandlers(ipcMain, () => mainWindow);
428
+ registerSetupHandlers(() => mainWindow);
421
429
 
422
430
  // ============================================================================
423
431
  // Settings Child Window (opened from sidecar toolbar gear button)
@@ -456,7 +464,7 @@ app.whenReady().then(() => {
456
464
  }
457
465
 
458
466
  if (MODE === 'setup') {
459
- createSetupWindow();
467
+ createSetupWindow().catch((err) => { logger.error('createSetupWindow failed', err); });
460
468
  } else {
461
469
  createAmicusWindow();
462
470
  }
@@ -7,16 +7,9 @@
7
7
 
8
8
  const { contextBridge, ipcRenderer } = require('electron');
9
9
 
10
- // Inject CSS before page scripts run to hide OpenCode branding
11
- // and match window background color to prevent white flash on load
12
- const style = document.createElement('style');
13
- style.textContent = [
14
- 'html, body { background-color: #2D2B2A !important; }',
15
- '#root > div > header { display: none !important; }',
16
- 'svg[viewBox="0 0 234 42"] { display: none !important; }',
17
- ].join('\n');
18
- document.documentElement.appendChild(style);
19
-
10
+ // Bridge exposure needs no DOM and must run before any cosmetic step:
11
+ // the CSS injection below once threw at preload-evaluation time and killed
12
+ // the script before the bridge was exposed.
20
13
  contextBridge.exposeInMainWorld('sidecar', {
21
14
  /** Trigger fold: summarize and return to Claude Code */
22
15
  fold: () => ipcRenderer.invoke('sidecar:fold'),
@@ -31,3 +24,30 @@ contextBridge.exposeInMainWorld('sidecar', {
31
24
  /** Notify main process to resize toolbar area */
32
25
  resizeToolbar: (height) => ipcRenderer.invoke('sidecar:resize-toolbar', height),
33
26
  });
27
+
28
+ /**
29
+ * Inject CSS to hide OpenCode branding and match the window background color
30
+ * to prevent a white flash on load. Cosmetic only — must never throw, or it
31
+ * would kill the rest of the preload.
32
+ */
33
+ function injectBrandingCss() {
34
+ try {
35
+ const style = document.createElement('style');
36
+ style.textContent = [
37
+ 'html, body { background-color: #2D2B2A !important; }',
38
+ '#root > div > header { display: none !important; }',
39
+ 'svg[viewBox="0 0 234 42"] { display: none !important; }',
40
+ ].join('\n');
41
+ document.documentElement.appendChild(style);
42
+ } catch {
43
+ // cosmetic — a failed injection must not break the preload
44
+ }
45
+ }
46
+
47
+ // documentElement is still null while the preload evaluates; defer until the
48
+ // DOM exists so the injection cannot null-deref.
49
+ if (document.documentElement) {
50
+ injectBrandingCss();
51
+ } else {
52
+ document.addEventListener('DOMContentLoaded', injectBrandingCss);
53
+ }
@@ -43,6 +43,15 @@ const PROVIDERS = [
43
43
  helpUrl: 'https://console.anthropic.com/settings/keys',
44
44
  helpLabel: 'console.anthropic.com/settings/keys',
45
45
  recommended: false
46
+ },
47
+ {
48
+ id: 'deepseek',
49
+ name: 'DeepSeek',
50
+ description: 'Direct access to DeepSeek-V3 and DeepSeek-R1 models',
51
+ placeholder: 'sk-...',
52
+ helpUrl: 'https://platform.deepseek.com/api_keys',
53
+ helpLabel: 'platform.deepseek.com/api_keys',
54
+ recommended: false
46
55
  }
47
56
  ];
48
57
 
@@ -2,24 +2,22 @@
2
2
  * Setup UI - Step 2: Default Model Selection
3
3
  *
4
4
  * Builds the HTML for the model selection step of the wizard.
5
- * Renders radio card choices with provider routing and pre-selection support.
6
- * Models are disabled when no configured API key matches their routes.
5
+ * Renders radio card choices with provider routing, write-preview,
6
+ * and offline-badge support.
7
+ *
8
+ * Choices are RESOLVED rows passed in at call time (see src/utils/quick-picks.js):
9
+ * { alias, label, blurb, source: 'live'|'fallback', routes: Object<string,string> }
10
+ * No curated-models import — this module is pure builder/renderer.
7
11
  */
8
12
 
9
- const { getCuratedModels } = require('../src/utils/curated-models');
10
- /**
11
- * Wizard quick-pick cards \u2014 derived from curated-models (F5).
12
- * @type {Array<{alias: string, label: string, routes: Object<string,string>}>}
13
- */
14
- const MODEL_CHOICES = getCuratedModels().map(c => ({
15
- alias: c.alias, label: `${c.label} \u2014 ${c.blurb}`, routes: c.routes
16
- }));
13
+ 'use strict';
17
14
 
18
15
  const PROVIDER_NAMES = {
19
16
  openrouter: 'OpenRouter',
20
17
  google: 'Google AI',
21
18
  openai: 'OpenAI',
22
- anthropic: 'Anthropic'
19
+ anthropic: 'Anthropic',
20
+ deepseek: 'DeepSeek'
23
21
  };
24
22
 
25
23
  /**
@@ -37,7 +35,7 @@ function isModelAvailable(providers, configuredKeys) {
37
35
  }
38
36
 
39
37
  /**
40
- * Find the best available provider for a model's static route text.
38
+ * Find the best available provider for a model's route display.
41
39
  * Prefers the first provider with a configured key; falls back to first provider.
42
40
  * @param {string[]} providers - Route provider IDs
43
41
  * @param {Object<string,boolean>} configuredKeys - Which providers have keys
@@ -49,12 +47,13 @@ function bestAvailableProvider(providers, configuredKeys) {
49
47
  }
50
48
 
51
49
  /**
52
- * Search-over-catalog section (F5). Hidden until the wizard script confirms
53
- * a non-empty catalog; rows are rendered client-side from the get-catalog IPC.
50
+ * Search-over-catalog section. Always visible no display:none gating.
51
+ * Rows are rendered client-side from the get-catalog IPC response.
54
52
  * @returns {string} HTML fragment
55
53
  */
56
54
  function buildModelSearchHTML() {
57
- return `<div id="model-search-section" style="display:none">
55
+ return `<div id="model-search-section">
56
+ <div class="search-label">&hellip;or pick any model from the catalog</div>
58
57
  <div class="search-head">
59
58
  <input type="text" id="model-search-input" placeholder="Search all models (id or name)..." autocomplete="off">
60
59
  <button class="icon-btn" id="model-search-refresh" title="Refresh catalog">&#x21bb;</button>
@@ -65,10 +64,11 @@ function buildModelSearchHTML() {
65
64
  }
66
65
 
67
66
  /**
68
- * Build the HTML fragment for Step 2 (Model Selection)
69
- * @param {Array<{alias: string, label: string, routes: Object<string,string>}>} choices
70
- * @param {string} [selectedAlias] - Pre-selected alias, defaults to first available choice
71
- * @param {Object<string,boolean>} [configuredKeys] - Provider IDs the user has keys for
67
+ * Build the HTML fragment for Step 2 (Model Selection).
68
+ * @param {Array<{alias:string, label:string, blurb:string, source:string, routes:Object<string,string>}>} choices
69
+ * Resolved rows from resolveQuickPicks(). Each row has separate label + blurb fields.
70
+ * @param {string} [selectedAlias] - Pre-selected alias; defaults to first available choice.
71
+ * @param {Object<string,boolean>} [configuredKeys] - Provider IDs the user has keys for.
72
72
  * @returns {string} HTML fragment
73
73
  */
74
74
  function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
@@ -100,12 +100,19 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
100
100
  const showToggle = available.length >= 2;
101
101
  const bestProvider = bestAvailableProvider(providers, configuredKeys);
102
102
 
103
+ // Resolved id for the write-preview (prefer bestProvider route)
104
+ const previewId = c.routes[bestProvider] || Object.values(c.routes)[0] || '';
105
+
106
+ // Offline badge for fallback rows
107
+ const badge = c.source === 'fallback'
108
+ ? '<span class="pick-badge">offline list</span>' : '';
109
+
103
110
  let routeHtml = '';
104
111
  if (!modelAvailable) {
105
112
  routeHtml = '<span class="no-key-hint">No API key configured</span>';
106
113
  } else if (hasMultipleRoutes) {
107
114
  const pills = providers.map(p => {
108
- const isActive = (showToggle && p === bestProvider) || (!showToggle && p === bestProvider);
115
+ const isActive = p === bestProvider;
109
116
  const cls = isActive ? 'route-pill active' : 'route-pill';
110
117
  return `<button class="${cls}" data-alias="${c.alias}" data-provider="${p}">${PROVIDER_NAMES[p]}</button>`;
111
118
  }).join('');
@@ -116,17 +123,20 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
116
123
  } else {
117
124
  routeHtml = `<span class="route-static">via ${PROVIDER_NAMES[bestProvider]}</span>`;
118
125
  }
126
+
119
127
  return `<label class="${cardClass}">
120
128
  <input type="radio" name="default-model" value="${c.alias}" ${checked}${disabled}>
121
129
  <span class="model-alias">${c.alias}</span>
122
- <span class="model-label">${c.label}</span>
130
+ <span class="model-label">${c.label} — ${c.blurb}</span>${badge}
131
+ <span class="model-resolved">${previewId}</span>
123
132
  ${routeHtml}
133
+ <span class="write-preview" data-alias="${c.alias}">will set <code>${c.alias}</code> → <code class="write-preview-id">${previewId}</code></span>
124
134
  </label>`;
125
135
  }).join('\n ');
126
136
 
127
137
  return `<div class="step-content">
128
138
  <h1>Choose Default Model</h1>
129
- <p class="subtitle">Pick the model to use when no --model flag is given.</p>
139
+ <p class="subtitle">Current models resolved from the live catalog. Pick the default used when no --model flag is given.</p>
130
140
 
131
141
  <div class="model-list" id="model-list">
132
142
  ${cards}
@@ -135,4 +145,4 @@ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
135
145
  </div>`;
136
146
  }
137
147
 
138
- module.exports = { buildModelSearchHTML, buildModelStepHTML, MODEL_CHOICES, PROVIDER_NAMES };
148
+ module.exports = { buildModelSearchHTML, buildModelStepHTML, PROVIDER_NAMES };
@@ -321,7 +321,12 @@ function buildWizardCSS() {
321
321
  .search-row-sub { color: #A09B96; font-size: 11px; margin-top: 2px; }
322
322
  .icon-btn { background: none; border: 1px solid #3D3A38; border-radius: 6px; color: #A09B96; cursor: pointer; font-size: 14px; padding: 6px 10px; }
323
323
  .icon-btn:hover { border-color: #D97757; color: #D97757; }
324
- .icon-btn:disabled { opacity: 0.5; cursor: default; }`;
324
+ .icon-btn:disabled { opacity: 0.5; cursor: default; }
325
+ .search-label { margin: 14px 0 6px; font-size: 12px; opacity: 0.75; }
326
+ .pick-badge { font-size: 10px; padding: 1px 5px; border-radius: 3px; background: #5a4a35; margin-left: 6px; }
327
+ .model-resolved { display: block; font-size: 11px; opacity: 0.6; font-family: monospace; }
328
+ .write-preview { display: none; font-size: 11px; margin-top: 4px; }
329
+ .write-preview-active { display: block; }`;
325
330
  }
326
331
 
327
332
  module.exports = { buildWizardCSS };
@@ -1,26 +1,32 @@
1
1
  /** Setup UI - Wizard Orchestrator: API Keys → Models → Aliases → Review */
2
2
  const { buildKeysStepHTML, PROVIDERS } = require('./setup-ui-keys');
3
- const { buildModelStepHTML, MODEL_CHOICES, PROVIDER_NAMES } = require('./setup-ui-model');
3
+ const { buildModelStepHTML, PROVIDER_NAMES } = require('./setup-ui-model');
4
4
  const { buildAliasEditorHTML } = require('./setup-ui-aliases');
5
5
  const { buildWizardCSS } = require('./setup-ui-styles');
6
6
  const { buildKeysScript } = require('./setup-ui-keys-script');
7
7
  const { buildAliasScript } = require('./setup-ui-alias-script');
8
8
  const { getDefaultAliases } = require('../src/utils/config');
9
9
  const { getBrandName } = require('./toolbar');
10
+ const { resolveQuickPicks } = require('../src/utils/quick-picks');
10
11
 
11
12
  /**
12
13
  * @param {object} [options={}]
13
14
  * @param {string} [options.client='code-local'] - Client type for branding
15
+ * @param {Array} [options.quickPicks] - Resolved quick-pick rows from resolveQuickPicks(catalog).
16
+ * Defaults to pinned fallbacks when not provided.
14
17
  */
15
18
  function buildSetupHTML(options = {}) {
16
- const { client = 'code-local' } = options;
19
+ const {
20
+ client = 'code-local',
21
+ quickPicks = resolveQuickPicks([]), // pinned fallbacks when not provided
22
+ } = options;
17
23
  const brandName = getBrandName(client);
18
24
  const keysHtml = buildKeysStepHTML(PROVIDERS);
19
- const modelHtml = buildModelStepHTML(MODEL_CHOICES);
25
+ const modelHtml = buildModelStepHTML(quickPicks);
20
26
  const aliasHtml = buildAliasEditorHTML(getDefaultAliases());
21
27
  const css = buildWizardCSS();
22
28
  const providersJson = JSON.stringify(PROVIDERS);
23
- const modelChoicesJson = JSON.stringify(MODEL_CHOICES);
29
+ const modelChoicesJson = JSON.stringify(quickPicks);
24
30
  const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
25
31
  const defaultAliasesJson = JSON.stringify(getDefaultAliases());
26
32
  return `<!DOCTYPE html>
@@ -63,6 +69,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
63
69
  var defaultAliases = ${defaultAliasesJson};
64
70
  var routingChoices = {};
65
71
  var aliasEdits = {};
72
+ var aliasDisplay = {};
66
73
  window.availableModels = null;
67
74
  var keyValid = false, validatedKey = '';
68
75
  var $ = function(id) { return document.getElementById(id); };
@@ -128,7 +135,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
128
135
  }
129
136
  });
130
137
  Object.keys(cfg.aliases).forEach(function(k) {
131
- if (cfg.aliases[k] !== defaultAliases[k]) { aliasEdits[k] = cfg.aliases[k]; }
138
+ if (cfg.aliases[k] !== defaultAliases[k]) { aliasDisplay[k] = cfg.aliases[k]; }
132
139
  });
133
140
  applyAliasEditsToUI();
134
141
  }
@@ -136,6 +143,12 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
136
143
  })();
137
144
 
138
145
  function applyAliasEditsToUI() {
146
+ Object.keys(aliasDisplay).forEach(function(k) {
147
+ var row = document.querySelector('.alias-row[data-alias="' + k + '"]');
148
+ if (!row) { return; }
149
+ var modelSpan = row.querySelector('.alias-model');
150
+ if (modelSpan) { modelSpan.textContent = aliasDisplay[k]; }
151
+ });
139
152
  Object.keys(aliasEdits).forEach(function(k) {
140
153
  var row = document.querySelector('.alias-row[data-alias="' + k + '"]');
141
154
  if (!row) { return; }
@@ -172,6 +185,23 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
172
185
  } else { nextBtn.disabled = false; }
173
186
  }
174
187
 
188
+ // Single source of the route choice for a quick-pick row: explicit pill
189
+ // choice if its key still exists, else first provider with a key, else
190
+ // the row's first route. Returns the full model id or null.
191
+ function pickRouteFor(mc) {
192
+ if (!mc) { return null; }
193
+ var provs = Object.keys(mc.routes);
194
+ var prov = routingChoices[mc.alias];
195
+ if (!prov || !mc.routes[prov]) {
196
+ prov = null;
197
+ for (var i = 0; i < provs.length; i++) {
198
+ if (configuredKeys[provs[i]]) { prov = provs[i]; break; }
199
+ }
200
+ if (!prov) { prov = provs[0]; }
201
+ }
202
+ return mc.routes[prov] || null;
203
+ }
204
+
175
205
  function updateRoutingPills() {
176
206
  var hasAnyKey = Object.values(configuredKeys).some(function(v) { return v; });
177
207
  var firstAvailableAlias = null;
@@ -240,6 +270,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
240
270
  var fallback = document.querySelector('input[name="default-model"][value="' + firstAvailableAlias + '"]');
241
271
  if (fallback) { fallback.checked = true; }
242
272
  }
273
+ updateWritePreviews();
243
274
  }
244
275
 
245
276
  function updateAliasRoutes() {
@@ -275,15 +306,8 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
275
306
  if (row.querySelector('.alias-model-select')) { return; }
276
307
  var modelSpan = row.querySelector('.alias-model');
277
308
  if (!modelSpan) { return; }
278
- // For MODEL_CHOICES aliases: update text to match available routing
279
- if (routedModels[alias]) {
280
- modelSpan.textContent = routedModels[alias];
281
- aliasEdits[alias] = routedModels[alias];
282
- row.classList.remove('alias-no-key');
283
- return;
284
- }
285
309
  // Check if the model's provider has a configured key
286
- var model = aliasEdits[alias] || modelSpan.textContent;
310
+ var model = aliasEdits[alias] || aliasDisplay[alias] || modelSpan.textContent;
287
311
  var prefix = model.split('/')[0];
288
312
  var noKey = false;
289
313
  if (prefix === 'openrouter') {
@@ -301,17 +325,20 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
301
325
  kn.length > 0 ? kn.map(function(k) { return k + ' \\u2713'; }).join(', ') : 'None';
302
326
  var r = document.querySelector('input[name="default-model"]:checked');
303
327
  document.getElementById('review-model').textContent = window.customDefaultModel || (r ? r.value : 'Not selected');
304
- var routeLines = [];
305
- modelChoicesData.forEach(function(mc) {
306
- var prov = routingChoices[mc.alias];
307
- if (!prov) {
308
- var provs = Object.keys(mc.routes);
309
- prov = provs.find(function(p) { return configuredKeys[p]; }) || provs[0];
328
+ var writes = [];
329
+ var r2 = document.querySelector('input[name="default-model"]:checked');
330
+ if (!window.customDefaultModel && r2) {
331
+ var mc2 = null;
332
+ for (var i2 = 0; i2 < modelChoicesData.length; i2++) {
333
+ if (modelChoicesData[i2].alias === r2.value) { mc2 = modelChoicesData[i2]; break; }
310
334
  }
311
- var provName = providerNamesData[prov] || prov;
312
- routeLines.push(mc.alias + ' \\u2192 ' + provName);
313
- });
314
- document.getElementById('review-routing').textContent = routeLines.join(', ');
335
+ if (mc2) {
336
+ var routeId2 = pickRouteFor(mc2);
337
+ if (routeId2) { writes.push(mc2.alias + ' \\u2192 ' + routeId2); }
338
+ }
339
+ }
340
+ document.getElementById('review-routing').textContent =
341
+ writes.length > 0 ? writes.join(', ') : 'No alias changes';
315
342
  var editCount = Object.keys(aliasEdits).length;
316
343
  var reviewAliases = document.getElementById('review-aliases');
317
344
  if (reviewAliases) {
@@ -332,26 +359,31 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
332
359
  routingChoices[alias] = provider;
333
360
  var toggle = pill.parentElement;
334
361
  toggle.querySelectorAll('.route-pill').forEach(function(p) { p.classList.toggle('active', p === pill); });
362
+ updateWritePreviews();
335
363
  });
336
364
 
337
365
  finishBtn.addEventListener('click', async function() {
338
366
  finishBtn.disabled = true; finishBtn.textContent = 'Saving...';
339
367
  try {
340
368
  var r = document.querySelector('input[name="default-model"]:checked');
341
- var dm = window.customDefaultModel || (r ? r.value : 'gemini');
342
- var routingOverrides = {};
343
- modelChoicesData.forEach(function(mc) {
344
- var prov = routingChoices[mc.alias];
345
- if (!prov) {
346
- var provs = Object.keys(mc.routes);
347
- prov = provs.find(function(p) { return configuredKeys[p]; }) || provs[0];
348
- }
349
- routingOverrides[mc.alias] = mc.routes[prov];
350
- });
369
+ var dm = window.customDefaultModel || (r ? r.value : null);
370
+ var aliasWrites = {};
351
371
  Object.keys(aliasEdits).forEach(function(k) {
352
- routingOverrides[k] = aliasEdits[k];
372
+ aliasWrites[k] = aliasEdits[k];
353
373
  });
354
- await window.sidecarSetup.invoke('sidecar:save-config', dm, routingOverrides);
374
+ if (!window.customDefaultModel && r) {
375
+ // Selecting a quick pick = explicit touch: upgrade that ONE alias
376
+ // to the resolved id via the chosen route (user-locked decision #2).
377
+ var mc = null;
378
+ for (var i = 0; i < modelChoicesData.length; i++) {
379
+ if (modelChoicesData[i].alias === r.value) { mc = modelChoicesData[i]; break; }
380
+ }
381
+ if (mc) {
382
+ var routeId = pickRouteFor(mc);
383
+ if (routeId) { aliasWrites[mc.alias] = routeId; }
384
+ }
385
+ }
386
+ await window.sidecarSetup.invoke('sidecar:save-config', dm, aliasWrites);
355
387
  var kc = Object.values(configuredKeys).filter(function(v) { return v; }).length;
356
388
  await window.sidecarSetup.invoke('sidecar:setup-done', dm, kc);
357
389
  } catch (_e) { finishBtn.disabled = false; finishBtn.textContent = 'Finish'; }
@@ -379,11 +411,12 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
379
411
  function applyCatalog(info) {
380
412
  catalogRows = (info && info.models) || [];
381
413
  catalogFetchedAt = info && info.fetchedAt;
382
- var section = $('model-search-section');
383
- if (!section) { return; }
384
- section.style.display = catalogRows.length > 0 ? '' : 'none';
385
414
  renderSearchMeta();
386
415
  renderSearchResults();
416
+ if (catalogRows.length === 0) {
417
+ var meta = $('model-search-meta');
418
+ if (meta) { meta.textContent = 'Catalog unavailable (offline?) \\u2014 use \\u21bb to retry.'; }
419
+ }
387
420
  }
388
421
 
389
422
  function renderSearchMeta() {
@@ -434,6 +467,25 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
434
467
  var keep = box ? box.scrollTop : 0;
435
468
  renderSearchResults();
436
469
  if (box) { box.scrollTop = keep; }
470
+ updateWritePreviews();
471
+ }
472
+
473
+ function updateWritePreviews() {
474
+ var r = document.querySelector('input[name="default-model"]:checked');
475
+ var sel = (!window.customDefaultModel && r) ? r.value : null;
476
+ document.querySelectorAll('.write-preview').forEach(function(el) {
477
+ var alias = el.getAttribute('data-alias');
478
+ el.classList.toggle('write-preview-active', alias === sel);
479
+ if (alias !== sel) { return; }
480
+ var mc = null;
481
+ for (var i = 0; i < modelChoicesData.length; i++) {
482
+ if (modelChoicesData[i].alias === alias) { mc = modelChoicesData[i]; break; }
483
+ }
484
+ if (!mc) { return; }
485
+ var routeId = pickRouteFor(mc);
486
+ var idEl = el.querySelector('.write-preview-id');
487
+ if (idEl && routeId) { idEl.textContent = routeId; }
488
+ });
437
489
  }
438
490
 
439
491
  document.addEventListener('input', function(e) {
@@ -443,6 +495,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
443
495
  if (e.target && e.target.name === 'default-model' && e.target.checked) {
444
496
  window.customDefaultModel = null;
445
497
  renderSearchResults();
498
+ updateWritePreviews();
446
499
  }
447
500
  });
448
501
  document.addEventListener('click', async function(e) {
@@ -8,12 +8,11 @@
8
8
  const TOOLBAR_H = 40;
9
9
 
10
10
  /**
11
- * Get the brand name based on client type
12
- * @param {string} [client='code-local'] - Client type (code-local, code-web, cowork)
13
- * @returns {string} Brand name to display
11
+ * Get the brand name to display
12
+ * @returns {string} Always 'Amicus' all clients share one brand
14
13
  */
15
- function getBrandName(client) {
16
- return client === 'cowork' ? 'Openwork Amicus' : 'Amicus';
14
+ function getBrandName() {
15
+ return 'Amicus';
17
16
  }
18
17
 
19
18
  /**