amicus 1.0.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. package/src/utils/validators.js +300 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Setup UI - Alias Editor Script
3
+ *
4
+ * Returns the inline JS for alias search, inline edit,
5
+ * delete, and add custom alias handlers.
6
+ * Extracted from setup-ui.js to keep file sizes under 300 lines.
7
+ */
8
+
9
+ /**
10
+ * Build the alias editor JS for inline inclusion in the wizard script
11
+ * @returns {string} JavaScript source (no <script> tags)
12
+ */
13
+ function buildAliasScript() {
14
+ return `
15
+ // Alias editor: search
16
+ var aliasSearchInput = $('alias-search');
17
+ if (aliasSearchInput) {
18
+ aliasSearchInput.addEventListener('input', function() {
19
+ var q = this.value.toLowerCase();
20
+ document.querySelectorAll('.alias-group').forEach(function(group) {
21
+ var rows = group.querySelectorAll('.alias-row');
22
+ var anyVisible = false;
23
+ rows.forEach(function(row) {
24
+ var name = row.getAttribute('data-alias') || '';
25
+ var model = (row.querySelector('.alias-model') || {}).textContent || '';
26
+ var match = !q || name.includes(q) || model.toLowerCase().includes(q);
27
+ row.style.display = match ? '' : 'none';
28
+ if (match) { anyVisible = true; }
29
+ });
30
+ group.style.display = anyVisible ? '' : 'none';
31
+ if (q && anyVisible) { group.open = true; }
32
+ if (!q) { group.open = false; }
33
+ });
34
+ });
35
+ }
36
+
37
+ // Helper: filter model groups by a keyword (alias name)
38
+ // Splits keyword on hyphens so "gemini-pro" matches "gemini-3-pro-preview"
39
+ function filterModels(groups, keyword) {
40
+ if (!keyword || !groups || groups.length === 0) { return groups; }
41
+ var parts = keyword.toLowerCase().split('-').filter(function(p) { return p.length > 0; });
42
+ var filtered = [];
43
+ groups.forEach(function(g) {
44
+ var matching = g.models.filter(function(m) {
45
+ var id = m.id.toLowerCase();
46
+ var name = (m.name || '').toLowerCase();
47
+ return parts.every(function(p) { return id.includes(p) || name.includes(p); });
48
+ });
49
+ if (matching.length > 0) {
50
+ filtered.push({ family: g.family, models: matching });
51
+ }
52
+ });
53
+ // Fall back to all models when no matches found
54
+ if (filtered.length === 0) { return groups; }
55
+ return filtered;
56
+ }
57
+
58
+ // Helper: build a model <select> dropdown from window.availableModels
59
+ function buildModelSelect(currentValue, cls, filterKeyword) {
60
+ var select = document.createElement('select');
61
+ select.className = cls || 'alias-model-select';
62
+ var groups = filterModels(window.availableModels, filterKeyword);
63
+ if (groups && groups.length > 0) {
64
+ groups.forEach(function(g) {
65
+ var optgroup = document.createElement('optgroup');
66
+ optgroup.label = g.family;
67
+ g.models.forEach(function(m) {
68
+ var opt = document.createElement('option');
69
+ opt.value = m.id;
70
+ opt.textContent = m.name || m.id;
71
+ if (m.id === currentValue) { opt.selected = true; }
72
+ optgroup.appendChild(opt);
73
+ });
74
+ select.appendChild(optgroup);
75
+ });
76
+ } else {
77
+ // Fallback: flat list from DEFAULT_ALIASES values
78
+ var seen = {};
79
+ Object.values(defaultAliases).forEach(function(v) {
80
+ if (!seen[v]) {
81
+ var opt = document.createElement('option');
82
+ opt.value = v; opt.textContent = v;
83
+ if (v === currentValue) { opt.selected = true; }
84
+ select.appendChild(opt);
85
+ seen[v] = true;
86
+ }
87
+ });
88
+ }
89
+ // If current value not in options, add it
90
+ if (currentValue && !select.querySelector('option[value="' + CSS.escape(currentValue) + '"]')) {
91
+ var custom = document.createElement('option');
92
+ custom.value = currentValue; custom.textContent = currentValue; custom.selected = true;
93
+ select.insertBefore(custom, select.firstChild);
94
+ }
95
+ return select;
96
+ }
97
+
98
+ // Alias editor: inline edit
99
+ document.addEventListener('click', function(e) {
100
+ var nameSpan = e.target.closest('.alias-name');
101
+ var modelSpan = e.target.closest('.alias-model');
102
+ var span = nameSpan || modelSpan;
103
+ if (!span) { return; }
104
+ var row = span.closest('.alias-row');
105
+ if (!row || row.classList.contains('alias-deleted')) { return; }
106
+ var isName = !!nameSpan;
107
+ var origValue = span.textContent;
108
+ var origAlias = row.getAttribute('data-alias');
109
+
110
+ if (isName) {
111
+ var input = document.createElement('input');
112
+ input.className = 'alias-name-input';
113
+ input.value = origValue;
114
+ span.replaceWith(input);
115
+ input.focus();
116
+ function commitName() {
117
+ var newVal = input.value.trim();
118
+ var newSpan = document.createElement('span');
119
+ newSpan.className = 'alias-name';
120
+ newSpan.textContent = newVal || origValue;
121
+ input.replaceWith(newSpan);
122
+ if (newVal && newVal !== origAlias) {
123
+ aliasEdits[origAlias] = null;
124
+ var modelEl = row.querySelector('.alias-model') || row.querySelector('.alias-model-select');
125
+ var modelVal = modelEl ? (modelEl.value || modelEl.textContent) : '';
126
+ aliasEdits[newVal] = modelVal || defaultAliases[origAlias];
127
+ row.setAttribute('data-alias', newVal);
128
+ var delBtn = row.querySelector('.alias-delete');
129
+ if (delBtn) { delBtn.setAttribute('data-alias', newVal); }
130
+ }
131
+ }
132
+ input.addEventListener('blur', commitName);
133
+ input.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { input.blur(); } });
134
+ } else {
135
+ var select = buildModelSelect(origValue, 'alias-model-select', origAlias);
136
+ span.replaceWith(select);
137
+ select.focus();
138
+ function commitModel() {
139
+ var newVal = select.value;
140
+ var newSpan = document.createElement('span');
141
+ newSpan.className = 'alias-model';
142
+ newSpan.textContent = newVal || origValue;
143
+ select.replaceWith(newSpan);
144
+ if (newVal && newVal !== origValue) {
145
+ aliasEdits[origAlias] = newVal;
146
+ }
147
+ }
148
+ select.addEventListener('change', commitModel);
149
+ select.addEventListener('blur', commitModel);
150
+ }
151
+ });
152
+
153
+ // Alias editor: delete
154
+ document.addEventListener('click', function(e) {
155
+ var btn = e.target.closest('.alias-delete');
156
+ if (!btn) { return; }
157
+ var alias = btn.getAttribute('data-alias');
158
+ if (!alias) { return; }
159
+ var row = btn.closest('.alias-row');
160
+ if (!row) { return; }
161
+ row.classList.add('alias-deleted');
162
+ if (defaultAliases[alias]) {
163
+ aliasEdits[alias] = null;
164
+ } else {
165
+ delete aliasEdits[alias];
166
+ }
167
+ });
168
+
169
+ // Alias editor: add custom shortcut
170
+ var addBtn = $('alias-add-btn');
171
+ if (addBtn) {
172
+ addBtn.addEventListener('click', function() {
173
+ var row = document.createElement('div');
174
+ row.className = 'alias-row';
175
+ var nameInput = document.createElement('input');
176
+ nameInput.className = 'alias-name-input';
177
+ nameInput.placeholder = 'shortcut';
178
+ var arrow = document.createElement('span');
179
+ arrow.className = 'alias-arrow';
180
+ arrow.textContent = '\\u2192';
181
+ var modelSelect = buildModelSelect('', 'alias-model-select');
182
+ var delBtn = document.createElement('button');
183
+ delBtn.className = 'alias-delete';
184
+ delBtn.textContent = '\\u00d7';
185
+ row.appendChild(nameInput);
186
+ row.appendChild(arrow);
187
+ row.appendChild(modelSelect);
188
+ row.appendChild(delBtn);
189
+ var editor = document.querySelector('.alias-editor');
190
+ if (editor) { editor.insertBefore(row, addBtn); }
191
+ nameInput.focus();
192
+ function commitNew() {
193
+ var n = nameInput.value.trim();
194
+ var m = modelSelect.value;
195
+ if (n && m) {
196
+ aliasEdits[n] = m;
197
+ row.setAttribute('data-alias', n);
198
+ var ns = document.createElement('span');
199
+ ns.className = 'alias-name'; ns.textContent = n;
200
+ nameInput.replaceWith(ns);
201
+ var ms = document.createElement('span');
202
+ ms.className = 'alias-model'; ms.textContent = m;
203
+ modelSelect.replaceWith(ms);
204
+ delBtn.setAttribute('data-alias', n);
205
+ }
206
+ }
207
+ modelSelect.addEventListener('change', commitNew);
208
+ nameInput.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { nameInput.blur(); commitNew(); } });
209
+ delBtn.addEventListener('click', function() {
210
+ var a = row.getAttribute('data-alias');
211
+ if (a) { delete aliasEdits[a]; }
212
+ row.remove();
213
+ });
214
+ });
215
+ }`;
216
+ }
217
+
218
+ module.exports = { buildAliasScript };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Setup UI - Alias Editor
3
+ *
4
+ * Builds collapsible alias groups with search, inline editing,
5
+ * delete, and add functionality for the setup wizard Step 3.
6
+ */
7
+
8
+ /** Grouping metadata for the 20 default aliases */
9
+ const ALIAS_GROUPS = [
10
+ { name: 'Gemini', keys: ['gemini', 'gemini-pro'] },
11
+ { name: 'GPT', keys: ['gpt', 'gpt-pro', 'codex'] },
12
+ { name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku'] },
13
+ { name: 'DeepSeek', keys: ['deepseek'] },
14
+ { name: 'Qwen', keys: ['qwen', 'qwen-coder', 'qwen-flash'] },
15
+ { name: 'Mistral', keys: ['mistral', 'devstral'] },
16
+ { name: 'Other', keys: ['glm', 'minimax', 'grok', 'kimi', 'seed'] },
17
+ ];
18
+
19
+ /**
20
+ * Build the HTML fragment for the alias editor section
21
+ * @param {Object<string,string>} aliases - Map of alias name to model string
22
+ * @returns {string} HTML fragment with search, groups, rows, and add button
23
+ */
24
+ function buildAliasEditorHTML(aliases) {
25
+ const searchInput = `<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">`;
26
+
27
+ const groups = ALIAS_GROUPS.map(group => {
28
+ const rows = group.keys
29
+ .filter(key => aliases[key] !== undefined)
30
+ .map(key => {
31
+ const model = aliases[key];
32
+ return `<div class="alias-row" data-alias="${key}">` +
33
+ `<span class="alias-name">${key}</span>` +
34
+ `<span class="alias-arrow">\u2192</span>` +
35
+ `<span class="alias-model">${model}</span>` +
36
+ `<button class="alias-delete" data-alias="${key}">\u00d7</button>` +
37
+ `</div>`;
38
+ }).join('\n ');
39
+
40
+ const count = group.keys.filter(key => aliases[key] !== undefined).length;
41
+
42
+ return `<details class="alias-group">
43
+ <summary>${group.name} <span class="alias-count">(${count})</span></summary>
44
+ ${rows}
45
+ </details>`;
46
+ }).join('\n ');
47
+
48
+ // Pick a representative example from actual aliases
49
+ const exampleAlias = 'gemini';
50
+ // Fallback literal kept in sync with curated-models (toDefaultAliases().gemini)
51
+ const exampleModel = aliases[exampleAlias] || 'openrouter/google/gemini-3.1-flash-lite-preview';
52
+
53
+ // SVG icons for the example box
54
+ const terminalIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke="#D97757" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke="#D97757" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" stroke="#5A5550" stroke-width="1.5" stroke-linecap="round"/></svg>`;
55
+ const arrowIcon = `<svg width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke="#D97757" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke="#D97757" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
56
+ const modelIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke="#6BBF6B" stroke-width="1.5"/><path d="M8 8v3" stroke="#6BBF6B" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke="#6BBF6B" stroke-width="1" stroke-linecap="round"/></svg>`;
57
+
58
+ const exampleBox = `<div class="routing-example">
59
+ <div class="example-label">How it works</div>
60
+ <div class="example-flow">
61
+ <div class="example-step">
62
+ ${terminalIcon}
63
+ <span class="example-cmd">amicus start --model <strong>${exampleAlias}</strong></span>
64
+ </div>
65
+ <div class="example-connector">${arrowIcon}</div>
66
+ <div class="example-step">
67
+ ${modelIcon}
68
+ <span class="example-model">${exampleModel}</span>
69
+ </div>
70
+ </div>
71
+ </div>`;
72
+
73
+ return `<div class="step-content">
74
+ <h1>Model Routing</h1>
75
+ <p class="subtitle">When you ask Amicus for help, you can pick which LLM to collaborate with or offload tasks to. These names on the left route to the specific model on the right.</p>
76
+ ${exampleBox}
77
+ <div class="alias-editor">
78
+ ${searchInput}
79
+ ${groups}
80
+ <button class="alias-add-btn" id="alias-add-btn">+ Add Custom Route</button>
81
+ </div>
82
+ </div>`;
83
+ }
84
+
85
+ module.exports = { ALIAS_GROUPS, buildAliasEditorHTML };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Setup UI - Step 1 Key Management Script
3
+ *
4
+ * Returns the inline JS for provider selection, key input,
5
+ * test/save, eye toggle, and remove handlers.
6
+ * Extracted from setup-ui.js to keep file sizes under 300 lines.
7
+ */
8
+
9
+ /**
10
+ * Build the key management JS for inline inclusion in the wizard script
11
+ * @returns {string} JavaScript source (no <script> tags)
12
+ */
13
+ function buildKeysScript() {
14
+ return `
15
+ // Step 1: Provider selection & key management
16
+ document.querySelectorAll('.provider-btn').forEach(function(btn) {
17
+ btn.addEventListener('click', function() {
18
+ var id = this.getAttribute('data-provider');
19
+ var prov = providers.find(function(p) { return p.id === id; });
20
+ if (!prov) { return; }
21
+ selectedProvider = prov;
22
+ document.querySelectorAll('.provider-btn').forEach(function(b) { b.classList.remove('selected'); });
23
+ this.classList.add('selected');
24
+ keySection.classList.add('visible');
25
+ keyLabel.textContent = prov.name + ' API Key';
26
+ keyInput.placeholder = prov.placeholder;
27
+ if (keyHints[prov.id]) {
28
+ keyInput.value = keyHints[prov.id]; keyInput.type = 'text'; keyValid = false;
29
+ setInputState('valid');
30
+ statusMsg.textContent = 'Key configured \\u2714'; statusMsg.className = 'status-valid';
31
+ removeBtn.style.display = '';
32
+ } else {
33
+ keyInput.value = ''; keyInput.type = 'password'; keyValid = false;
34
+ setInputState(null);
35
+ statusMsg.textContent = ''; statusMsg.className = '';
36
+ removeBtn.style.display = 'none';
37
+ }
38
+ eyeBtn.classList.remove('active'); keyInput.focus();
39
+ var a = document.createElement('a');
40
+ a.href = prov.helpUrl; a.textContent = prov.helpLabel;
41
+ helpLink.textContent = "Don't have a key? Get one at ";
42
+ helpLink.appendChild(a);
43
+ a.addEventListener('click', function(e) {
44
+ e.preventDefault();
45
+ if (window.sidecarSetup && window.sidecarSetup.openExternal) { window.sidecarSetup.openExternal(this.href); }
46
+ });
47
+ });
48
+ });
49
+
50
+ function setInputState(state) {
51
+ keyInput.classList.remove('input-valid', 'input-invalid', 'input-testing');
52
+ if (state) { keyInput.classList.add('input-' + state); }
53
+ }
54
+
55
+ testBtn.addEventListener('click', async function() {
56
+ if (!selectedProvider) { return; }
57
+ var key = keyInput.value.trim();
58
+ if (!key) {
59
+ statusMsg.textContent = 'Please enter an API key'; statusMsg.className = 'status-invalid';
60
+ setInputState('invalid'); return;
61
+ }
62
+ testBtn.disabled = true; testBtn.textContent = 'Testing...';
63
+ statusMsg.textContent = ''; statusMsg.className = ''; setInputState('testing');
64
+ try {
65
+ var res = await window.sidecarSetup.invoke('sidecar:validate-key', selectedProvider.id, key);
66
+ if (res.valid) {
67
+ await window.sidecarSetup.invoke('sidecar:save-key', selectedProvider.id, key);
68
+ configuredKeys[selectedProvider.id] = true;
69
+ var c = document.getElementById('check-' + selectedProvider.id);
70
+ if (c) { c.textContent = '\\u2713'; }
71
+ statusMsg.textContent = 'Saved \\u2713'; statusMsg.className = 'status-valid';
72
+ setInputState('valid'); keyValid = true; validatedKey = key;
73
+ keyHints[selectedProvider.id] = key.slice(0, 8) + '\\u2022'.repeat(Math.min(key.length - 8, 12));
74
+ removeBtn.style.display = ''; updateNextState();
75
+ } else {
76
+ statusMsg.textContent = res.error || 'Invalid key'; statusMsg.className = 'status-invalid';
77
+ setInputState('invalid'); keyValid = false;
78
+ }
79
+ } catch (_e) {
80
+ statusMsg.textContent = 'Connection failed'; statusMsg.className = 'status-invalid';
81
+ setInputState('invalid'); keyValid = false;
82
+ }
83
+ testBtn.disabled = false; testBtn.textContent = 'Save \\u0026 Test';
84
+ });
85
+
86
+ keyInput.addEventListener('input', function() {
87
+ if (keyInput.type === 'text') { keyInput.type = 'password'; }
88
+ if (keyValid && keyInput.value.trim() !== validatedKey) { keyValid = false; statusMsg.textContent = ''; setInputState(null); }
89
+ if (!keyInput.value.trim()) { statusMsg.textContent = ''; statusMsg.className = ''; setInputState(null); }
90
+ });
91
+ keyInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') { testBtn.click(); } });
92
+
93
+ eyeBtn.addEventListener('click', function() {
94
+ if (keyInput.type === 'password') { keyInput.type = 'text'; eyeBtn.classList.add('active'); }
95
+ else { keyInput.type = 'password'; eyeBtn.classList.remove('active'); }
96
+ keyInput.focus();
97
+ });
98
+
99
+ removeBtn.addEventListener('click', async function() {
100
+ if (!selectedProvider) { return; }
101
+ removeBtn.disabled = true;
102
+ try {
103
+ await window.sidecarSetup.invoke('sidecar:remove-key', selectedProvider.id);
104
+ delete configuredKeys[selectedProvider.id]; delete keyHints[selectedProvider.id];
105
+ var c = document.getElementById('check-' + selectedProvider.id);
106
+ if (c) { c.textContent = ''; }
107
+ keyInput.value = ''; keyInput.type = 'password'; keyValid = false; setInputState(null);
108
+ statusMsg.textContent = 'Key removed'; statusMsg.className = 'status-testing';
109
+ removeBtn.style.display = 'none'; updateNextState();
110
+ } catch (_e) { statusMsg.textContent = 'Failed to remove'; statusMsg.className = 'status-invalid'; }
111
+ removeBtn.disabled = false;
112
+ });`;
113
+ }
114
+
115
+ module.exports = { buildKeysScript };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Setup UI - Step 1: API Keys
3
+ *
4
+ * Builds the HTML for the API key configuration step of the wizard.
5
+ * Supports multiple providers with checkmarks for configured keys,
6
+ * test connection, and save per provider.
7
+ */
8
+
9
+ /** Provider metadata for the setup form */
10
+ const PROVIDERS = [
11
+ {
12
+ id: 'openrouter',
13
+ name: 'OpenRouter',
14
+ description: 'Access all models (Gemini, GPT, Claude, etc.) with one key',
15
+ placeholder: 'sk-or-v1-...',
16
+ helpUrl: 'https://openrouter.ai/keys',
17
+ helpLabel: 'openrouter.ai/keys',
18
+ recommended: true
19
+ },
20
+ {
21
+ id: 'google',
22
+ name: 'Google AI (Gemini)',
23
+ description: 'Direct access to Gemini models',
24
+ placeholder: 'AIza...',
25
+ helpUrl: 'https://aistudio.google.com/apikey',
26
+ helpLabel: 'aistudio.google.com/apikey',
27
+ recommended: false
28
+ },
29
+ {
30
+ id: 'openai',
31
+ name: 'OpenAI',
32
+ description: 'Direct access to GPT models',
33
+ placeholder: 'sk-...',
34
+ helpUrl: 'https://platform.openai.com/api-keys',
35
+ helpLabel: 'platform.openai.com/api-keys',
36
+ recommended: false
37
+ },
38
+ {
39
+ id: 'anthropic',
40
+ name: 'Anthropic',
41
+ description: 'Direct access to Claude models',
42
+ placeholder: 'sk-ant-...',
43
+ helpUrl: 'https://console.anthropic.com/settings/keys',
44
+ helpLabel: 'console.anthropic.com/settings/keys',
45
+ recommended: false
46
+ }
47
+ ];
48
+
49
+ /**
50
+ * Build the HTML fragment for Step 1 (API Keys)
51
+ * @param {Array} providers - Provider metadata array
52
+ * @returns {string} HTML fragment (not a full document)
53
+ */
54
+ function buildKeysStepHTML(providers) {
55
+ const providerCards = providers.map(p => {
56
+ const badge = p.recommended
57
+ ? '<span class="badge">Recommended</span>'
58
+ : '';
59
+ return `<button class="provider-btn" data-provider="${p.id}">
60
+ <span class="provider-name">${p.name}${badge}</span>
61
+ <span class="provider-desc">${p.description}</span>
62
+ <span class="provider-check" id="check-${p.id}"></span>
63
+ </button>`;
64
+ }).join('\n ');
65
+
66
+ return `<div class="step-content">
67
+ <h1>Configure API Keys</h1>
68
+ <p class="subtitle">Add at least one API key to get started.</p>
69
+
70
+ <div class="provider-list" id="provider-list">
71
+ ${providerCards}
72
+ </div>
73
+
74
+ <div class="key-section" id="key-section">
75
+ <label class="field-label" id="key-label" for="api-key-input">API Key</label>
76
+ <div class="input-row">
77
+ <div class="input-wrap">
78
+ <input id="api-key-input" type="password" placeholder="" autocomplete="off" spellcheck="false">
79
+ <button class="eye-btn" id="eye-btn" type="button" title="Show/hide key">
80
+ <svg id="eye-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
81
+ <path d="M8 3C4.4 3 1.4 5.4.5 8c.9 2.6 3.9 5 7.5 5s6.6-2.4 7.5-5c-.9-2.6-3.9-5-7.5-5z" stroke="currentColor" stroke-width="1.2" fill="none"/>
82
+ <circle cx="8" cy="8" r="2" stroke="currentColor" stroke-width="1.2" fill="none"/>
83
+ </svg>
84
+ </button>
85
+ </div>
86
+ <button class="test-btn" id="test-btn">Save &amp; Test</button>
87
+ </div>
88
+ <div class="key-actions">
89
+ <span id="status-msg"></span>
90
+ <button class="remove-btn" id="remove-btn" type="button" style="display:none">Remove key</button>
91
+ </div>
92
+ <p class="help-link" id="help-link"></p>
93
+ </div>
94
+ </div>`;
95
+ }
96
+
97
+ module.exports = { buildKeysStepHTML, PROVIDERS };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Setup UI - Step 2: Default Model Selection
3
+ *
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.
7
+ */
8
+
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
+ }));
17
+
18
+ const PROVIDER_NAMES = {
19
+ openrouter: 'OpenRouter',
20
+ google: 'Google AI',
21
+ openai: 'OpenAI',
22
+ anthropic: 'Anthropic'
23
+ };
24
+
25
+ /**
26
+ * Check if a model has at least one route with a configured key.
27
+ * When no keys are configured at all (empty configuredKeys), all models are available
28
+ * to allow the initial render before Step 1 completes.
29
+ * @param {string[]} providers - Route provider IDs for this model
30
+ * @param {Object<string,boolean>} configuredKeys - Which providers have keys
31
+ * @returns {boolean}
32
+ */
33
+ function isModelAvailable(providers, configuredKeys) {
34
+ const hasAnyKey = Object.values(configuredKeys).some(v => v);
35
+ if (!hasAnyKey) { return true; }
36
+ return providers.some(p => configuredKeys[p]);
37
+ }
38
+
39
+ /**
40
+ * Find the best available provider for a model's static route text.
41
+ * Prefers the first provider with a configured key; falls back to first provider.
42
+ * @param {string[]} providers - Route provider IDs
43
+ * @param {Object<string,boolean>} configuredKeys - Which providers have keys
44
+ * @returns {string} Provider ID to display
45
+ */
46
+ function bestAvailableProvider(providers, configuredKeys) {
47
+ const withKey = providers.find(p => configuredKeys[p]);
48
+ return withKey || providers[0];
49
+ }
50
+
51
+ /**
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.
54
+ * @returns {string} HTML fragment
55
+ */
56
+ function buildModelSearchHTML() {
57
+ return `<div id="model-search-section" style="display:none">
58
+ <div class="search-head">
59
+ <input type="text" id="model-search-input" placeholder="Search all models (id or name)..." autocomplete="off">
60
+ <button class="icon-btn" id="model-search-refresh" title="Refresh catalog">&#x21bb;</button>
61
+ </div>
62
+ <div id="model-search-meta" class="search-meta"></div>
63
+ <div id="model-search-results" class="search-results"></div>
64
+ </div>`;
65
+ }
66
+
67
+ /**
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
72
+ * @returns {string} HTML fragment
73
+ */
74
+ function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
75
+ // Determine availability for each model
76
+ const availability = choices.map(c => {
77
+ const providers = Object.keys(c.routes);
78
+ return { alias: c.alias, available: isModelAvailable(providers, configuredKeys) };
79
+ });
80
+
81
+ // Select: prefer selectedAlias if available, else first available model
82
+ const availableAliases = availability.filter(a => a.available).map(a => a.alias);
83
+ let selected;
84
+ if (selectedAlias && availableAliases.includes(selectedAlias)) {
85
+ selected = selectedAlias;
86
+ } else if (availableAliases.length > 0) {
87
+ selected = availableAliases[0];
88
+ } else {
89
+ selected = choices[0].alias;
90
+ }
91
+
92
+ const cards = choices.map(c => {
93
+ const providers = Object.keys(c.routes);
94
+ const available = providers.filter(p => configuredKeys[p]);
95
+ const modelAvailable = availability.find(a => a.alias === c.alias).available;
96
+ const checked = (c.alias === selected && modelAvailable) ? 'checked' : '';
97
+ const disabled = modelAvailable ? '' : ' disabled';
98
+ const cardClass = modelAvailable ? 'model-card' : 'model-card model-unavailable';
99
+ const hasMultipleRoutes = providers.length >= 2;
100
+ const showToggle = available.length >= 2;
101
+ const bestProvider = bestAvailableProvider(providers, configuredKeys);
102
+
103
+ let routeHtml = '';
104
+ if (!modelAvailable) {
105
+ routeHtml = '<span class="no-key-hint">No API key configured</span>';
106
+ } else if (hasMultipleRoutes) {
107
+ const pills = providers.map(p => {
108
+ const isActive = (showToggle && p === bestProvider) || (!showToggle && p === bestProvider);
109
+ const cls = isActive ? 'route-pill active' : 'route-pill';
110
+ return `<button class="${cls}" data-alias="${c.alias}" data-provider="${p}">${PROVIDER_NAMES[p]}</button>`;
111
+ }).join('');
112
+ const toggleDisplay = showToggle ? '' : ' style="display:none"';
113
+ const staticDisplay = showToggle ? ' style="display:none"' : '';
114
+ routeHtml = `<span class="route-toggle" data-alias="${c.alias}"${toggleDisplay}>${pills}</span>`;
115
+ routeHtml += `<span class="route-static" data-alias="${c.alias}"${staticDisplay}>via ${PROVIDER_NAMES[bestProvider]}</span>`;
116
+ } else {
117
+ routeHtml = `<span class="route-static">via ${PROVIDER_NAMES[bestProvider]}</span>`;
118
+ }
119
+ return `<label class="${cardClass}">
120
+ <input type="radio" name="default-model" value="${c.alias}" ${checked}${disabled}>
121
+ <span class="model-alias">${c.alias}</span>
122
+ <span class="model-label">${c.label}</span>
123
+ ${routeHtml}
124
+ </label>`;
125
+ }).join('\n ');
126
+
127
+ return `<div class="step-content">
128
+ <h1>Choose Default Model</h1>
129
+ <p class="subtitle">Pick the model to use when no --model flag is given.</p>
130
+
131
+ <div class="model-list" id="model-list">
132
+ ${cards}
133
+ </div>
134
+ ${buildModelSearchHTML()}
135
+ </div>`;
136
+ }
137
+
138
+ module.exports = { buildModelSearchHTML, buildModelStepHTML, MODEL_CHOICES, PROVIDER_NAMES };