amicus 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +2 -1
- package/bin/amicus.js +4 -1
- package/electron/ipc-setup.js +30 -9
- package/electron/main.js +13 -5
- package/electron/preload.js +30 -10
- package/electron/setup-ui-keys.js +9 -0
- package/electron/setup-ui-model.js +33 -23
- package/electron/setup-ui-styles.js +6 -1
- package/electron/setup-ui.js +91 -38
- package/electron/toolbar.js +4 -5
- package/package.json +2 -2
- package/skill/SKILL.md +14 -4
- package/skills/second-opinion/COUNCIL-DESIGN.md +3 -1
- package/skills/second-opinion/SKILL.md +7 -6
- package/src/cli-handlers.js +65 -0
- package/src/cli.js +6 -1
- package/src/sidecar/models.js +31 -1
- package/src/sidecar/setup.js +53 -40
- package/src/utils/curated-models.js +88 -45
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/model-fetcher.js +16 -2
- package/src/utils/quick-picks.js +81 -0
package/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
|
|
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;
|
package/electron/ipc-setup.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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:
|
|
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(
|
|
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
|
}
|
package/electron/preload.js
CHANGED
|
@@ -7,16 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
const { contextBridge, ipcRenderer } = require('electron');
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
53
|
-
*
|
|
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"
|
|
55
|
+
return `<div id="model-search-section">
|
|
56
|
+
<div class="search-label">…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">↻</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:
|
|
70
|
-
*
|
|
71
|
-
* @param {
|
|
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 =
|
|
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
|
|
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,
|
|
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 };
|
package/electron/setup-ui.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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(
|
|
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(
|
|
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]) {
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 :
|
|
342
|
-
var
|
|
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
|
-
|
|
372
|
+
aliasWrites[k] = aliasEdits[k];
|
|
353
373
|
});
|
|
354
|
-
|
|
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) {
|
package/electron/toolbar.js
CHANGED
|
@@ -8,12 +8,11 @@
|
|
|
8
8
|
const TOOLBAR_H = 40;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Get the brand name
|
|
12
|
-
* @
|
|
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(
|
|
16
|
-
return
|
|
14
|
+
function getBrandName() {
|
|
15
|
+
return 'Amicus';
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
/**
|