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,288 @@
1
+ /**
2
+ * Sidecar Session Utilities - Shared functionality for session management
3
+ * Consolidates duplicated code from start.js, resume.js, continue.js
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const { detectConflicts, formatConflictWarning } = require('../conflict');
10
+ const { logger } = require('../utils/logger');
11
+ const {
12
+ SESSIONS_DIR,
13
+ getSessionDir,
14
+ resolveExistingSessionDir
15
+ } = require('../session-manager');
16
+
17
+ /** Standard heartbeat interval in milliseconds */
18
+ const HEARTBEAT_INTERVAL = 15000;
19
+
20
+ /** Session path utilities - eliminates magic strings across modules */
21
+ const SessionPaths = {
22
+ /** Get canonical root sessions directory (amicus) — used for WRITES. */
23
+ rootDir(project) {
24
+ return path.join(project, '.claude', SESSIONS_DIR);
25
+ },
26
+
27
+ /** Get canonical session directory for a specific task — used for WRITES. */
28
+ sessionDir(project, taskId) {
29
+ return getSessionDir(project, taskId);
30
+ },
31
+
32
+ /**
33
+ * Resolve an EXISTING session directory for READS: prefer amicus, fall back
34
+ * to legacy `sidecar_sessions` (backward-compat shim). Use this when
35
+ * resuming/continuing an existing session so pre-rebrand sessions are found.
36
+ */
37
+ resolveSessionDir(project, taskId) {
38
+ return resolveExistingSessionDir(project, taskId);
39
+ },
40
+
41
+ /** Get metadata.json path */
42
+ metadataFile(sessionDir) {
43
+ return path.join(sessionDir, 'metadata.json');
44
+ },
45
+
46
+ /** Get conversation.jsonl path */
47
+ conversationFile(sessionDir) {
48
+ return path.join(sessionDir, 'conversation.jsonl');
49
+ },
50
+
51
+ /** Get summary.md path */
52
+ summaryFile(sessionDir) {
53
+ return path.join(sessionDir, 'summary.md');
54
+ },
55
+
56
+ /** Get initial_context.md path */
57
+ contextFile(sessionDir) {
58
+ return path.join(sessionDir, 'initial_context.md');
59
+ }
60
+ };
61
+
62
+ /** Save system prompt and user message to initial_context.md */
63
+ function saveInitialContext(sessionDir, systemPrompt, userMessage) {
64
+ const content = `# System Prompt\n\n${systemPrompt}\n\n# User Message (Task)\n\n${userMessage}`;
65
+ fs.writeFileSync(SessionPaths.contextFile(sessionDir), content, { mode: 0o600 });
66
+ }
67
+
68
+ /** Finalize session - detect conflicts, save summary, update metadata */
69
+ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
70
+ const metaPath = SessionPaths.metadataFile(sessionDir);
71
+
72
+ // Detect file conflicts
73
+ const conflicts = detectConflicts(
74
+ { written: metadata.filesWritten },
75
+ project,
76
+ new Date(metadata.createdAt)
77
+ );
78
+
79
+ if (conflicts.length > 0) {
80
+ const conflictWarning = formatConflictWarning(conflicts);
81
+ if (opts.quietStdout) {
82
+ // JSON mode: stdout must stay pure JSON (F4) — warn on stderr instead.
83
+ process.stderr.write(`\n${conflictWarning}\n`);
84
+ } else {
85
+ console.log(`\n${conflictWarning}\n`);
86
+ }
87
+ metadata.conflicts = conflicts;
88
+ }
89
+
90
+ // Save summary
91
+ fs.writeFileSync(SessionPaths.summaryFile(sessionDir), summary, { mode: 0o600 });
92
+
93
+ // Update metadata to complete
94
+ metadata.status = 'complete';
95
+ metadata.completedAt = new Date().toISOString();
96
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
97
+
98
+ logger.info('Session complete', { taskId: metadata.taskId });
99
+ }
100
+
101
+ /** Output summary to stdout with standard formatting */
102
+ function outputSummary(summary) {
103
+ console.log(summary);
104
+ }
105
+
106
+ /**
107
+ * Create a heartbeat that writes status to stderr periodically.
108
+ * When sessionDir is provided, includes message count and latest activity.
109
+ *
110
+ * @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
111
+ * @param {string} [sessionDir] - Session directory to read progress from
112
+ * @returns {{ stop: () => void }}
113
+ */
114
+ function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
115
+ const startTime = Date.now();
116
+ const intervalId = setInterval(() => {
117
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
118
+ const mins = Math.floor(elapsed / 60);
119
+ const secs = elapsed % 60;
120
+ const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
121
+
122
+ if (sessionDir) {
123
+ const { readProgress } = require('./progress');
124
+ const progress = readProgress(sessionDir);
125
+ process.stderr.write(`[sidecar] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
126
+ } else {
127
+ process.stderr.write(`[sidecar] still running... ${ts} elapsed\n`);
128
+ }
129
+ }, interval);
130
+
131
+ return {
132
+ stop() {
133
+ clearInterval(intervalId);
134
+ }
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Execute sidecar in either headless or interactive mode
140
+ * Consolidates the if/else pattern duplicated across start, resume, continue
141
+ */
142
+ async function executeMode(options) {
143
+ const {
144
+ headless,
145
+ runHeadless,
146
+ runInteractive,
147
+ model,
148
+ systemPrompt,
149
+ userMessage,
150
+ taskId,
151
+ project,
152
+ timeout,
153
+ agent,
154
+ extraOptions = {},
155
+ defaultSummary = '## Sidecar Results: No Output\n\nSession completed without summary.',
156
+ operationType = 'task'
157
+ } = options;
158
+
159
+ let result;
160
+
161
+ if (headless) {
162
+ result = await runHeadless(
163
+ model,
164
+ systemPrompt,
165
+ userMessage,
166
+ taskId,
167
+ project,
168
+ timeout * 60 * 1000,
169
+ agent,
170
+ extraOptions
171
+ );
172
+
173
+ result.summary = result.summary || defaultSummary;
174
+
175
+ if (result.timedOut) {
176
+ logger.warn(`${operationType} timed out`, { taskId });
177
+ }
178
+ if (result.error) {
179
+ logger.error(`${operationType} error`, { taskId, error: result.error });
180
+ }
181
+ } else {
182
+ logger.info(`Launching interactive ${operationType}`, { taskId, model, agent });
183
+
184
+ result = await runInteractive(
185
+ model,
186
+ systemPrompt,
187
+ userMessage,
188
+ taskId,
189
+ project,
190
+ { agent, ...extraOptions }
191
+ );
192
+
193
+ result.summary = result.summary || '';
194
+
195
+ if (result.error) {
196
+ logger.error(`Interactive ${operationType} error`, { taskId, error: result.error });
197
+ }
198
+ }
199
+
200
+ return result;
201
+ }
202
+
203
+ /**
204
+ * Start OpenCode server, wait for health, return client+server.
205
+ * Shared by headless and interactive modes.
206
+ *
207
+ * @param {object} [mcpConfig] - Optional MCP server configuration
208
+ * @param {object} [options] - Additional server options
209
+ * @param {string} [options.client] - Client type (e.g. 'cowork', 'code-local')
210
+ * @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
211
+ * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
212
+ * @returns {Promise<{client: object, server: object}>}
213
+ * @throws {Error} If server fails to start or health check fails
214
+ */
215
+ async function startOpenCodeServer(mcpConfig, options = {}) {
216
+ const { checkHealth, startServer } = require('../opencode-client');
217
+ const { ensureNodeModulesBinInPath } = require('../utils/path-setup');
218
+ const { ensurePortAvailable } = require('../utils/server-setup');
219
+ const { waitForServer } = require('../headless');
220
+
221
+ ensureNodeModulesBinInPath();
222
+
223
+ // Use specified port, or 0 to let the OS auto-assign (enables parallel sessions)
224
+ const port = options.port || 0;
225
+ if (port > 0) {
226
+ ensurePortAvailable(port);
227
+ }
228
+
229
+ const serverOptions = { port };
230
+ if (mcpConfig) { serverOptions.mcp = mcpConfig; }
231
+ if (options.client) { serverOptions.client = options.client; }
232
+ if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
233
+ if (options.agentName) { serverOptions.agentName = options.agentName; }
234
+
235
+ const { client, server } = await startServer(serverOptions);
236
+ logger.debug('OpenCode server started', { url: server.url });
237
+
238
+ const ready = await waitForServer(client, checkHealth);
239
+ if (!ready) {
240
+ server.close();
241
+ throw new Error('OpenCode server failed to become ready');
242
+ }
243
+
244
+ return { client, server };
245
+ }
246
+
247
+ /**
248
+ * Check if a process with the given PID is still alive.
249
+ * @param {number|null} pid
250
+ * @returns {boolean}
251
+ */
252
+ function isProcessAlive(pid) {
253
+ if (!pid) { return false; }
254
+ try {
255
+ process.kill(pid, 0);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Check if a session's processes are alive.
264
+ * @param {Object} metadata - Session metadata with pid and goPid
265
+ * @returns {'alive'|'server-dead'|'dead'}
266
+ */
267
+ function checkSessionLiveness(metadata) {
268
+ if (!metadata) { return 'dead'; }
269
+ const nodeAlive = isProcessAlive(metadata.pid);
270
+ const goAlive = isProcessAlive(metadata.goPid);
271
+
272
+ if (nodeAlive && goAlive) { return 'alive'; }
273
+ if (nodeAlive && !goAlive) { return 'server-dead'; }
274
+ return 'dead';
275
+ }
276
+
277
+ module.exports = {
278
+ HEARTBEAT_INTERVAL,
279
+ SessionPaths,
280
+ saveInitialContext,
281
+ finalizeSession,
282
+ outputSummary,
283
+ createHeartbeat,
284
+ executeMode,
285
+ startOpenCodeServer,
286
+ isProcessAlive,
287
+ checkSessionLiveness
288
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Setup Window Launcher
3
+ *
4
+ * Spawns the Electron window in setup mode (SIDECAR_MODE=setup)
5
+ * for API key configuration. Waits for the window to close and
6
+ * returns whether setup completed successfully.
7
+ */
8
+
9
+ const { spawn } = require('child_process');
10
+ const path = require('path');
11
+ const { logger } = require('../utils/logger');
12
+ const { getElectronPath } = require('./interactive');
13
+ const { getCompatEnv } = require('../utils/env-compat');
14
+
15
+ /**
16
+ * Launch the Electron setup window for API key entry
17
+ * @returns {Promise<{ success: boolean, error?: string }>}
18
+ */
19
+ function launchSetupWindow() {
20
+ return new Promise((resolve) => {
21
+ const electronPath = getElectronPath();
22
+ if (!electronPath) {
23
+ resolve({ success: false, error: 'Electron not installed' });
24
+ return;
25
+ }
26
+ const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
27
+
28
+ const env = {
29
+ ...process.env,
30
+ AMICUS_MODE: 'setup'
31
+ };
32
+
33
+ const debugPort = getCompatEnv('DEBUG_PORT');
34
+ const args = debugPort
35
+ ? [`--remote-debugging-port=${debugPort}`, mainPath]
36
+ : [mainPath];
37
+ logger.info('Launching setup window', { debugPort: debugPort || 'disabled' });
38
+
39
+ const proc = spawn(electronPath, args, {
40
+ env,
41
+ stdio: ['ignore', 'pipe', 'pipe']
42
+ });
43
+
44
+ let stdout = '';
45
+ proc.stdout.setEncoding('utf-8');
46
+ proc.stdout.on('data', (chunk) => { stdout += chunk; });
47
+
48
+ proc.stderr.setEncoding('utf-8');
49
+ proc.stderr.on('data', (chunk) => {
50
+ logger.debug('Setup window stderr', { data: chunk.trim() });
51
+ });
52
+
53
+ proc.on('close', (code) => {
54
+ logger.info('Setup window closed', { code });
55
+
56
+ // Check if setup completed (stdout contains JSON status)
57
+ if (stdout.includes('"status":"complete"')) {
58
+ // Parse enriched JSON for default model and keyCount
59
+ try {
60
+ const jsonLine = stdout.split('\n').find(l => l.includes('"status":"complete"'));
61
+ const data = JSON.parse(jsonLine);
62
+ const result = { success: true };
63
+ if (data.default) { result.default = data.default; }
64
+ if (data.keyCount) { result.keyCount = data.keyCount; }
65
+ resolve(result);
66
+ } catch (_err) {
67
+ resolve({ success: true });
68
+ }
69
+ } else {
70
+ resolve({
71
+ success: false,
72
+ error: 'Setup window closed without completing'
73
+ });
74
+ }
75
+ });
76
+ });
77
+ }
78
+
79
+ module.exports = { launchSetupWindow };
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Sidecar Setup Wizard
3
+ *
4
+ * Provides interactive setup, alias management, and API key detection
5
+ * for the sidecar configuration.
6
+ *
7
+ * runInteractiveSetup() is Electron-first: launches the GUI wizard,
8
+ * falls back to runReadlineSetup() for headless environments.
9
+ */
10
+
11
+ const path = require('path');
12
+ const readline = require('readline');
13
+ const { loadConfig, saveConfig, getDefaultAliases, getConfigDir } = require('../utils/config');
14
+ const { logger } = require('../utils/logger');
15
+
16
+ const { getCuratedModels } = require('../utils/curated-models');
17
+ /**
18
+ * Model choices presented during readline setup — derived from curated-models (F5).
19
+ * @type {Array<{number: number, alias: string, label: string}>}
20
+ */
21
+ const MODEL_CHOICES = getCuratedModels().map((c, i) => ({
22
+ number: i + 1, alias: c.alias, label: `${c.label} (${c.blurb})`
23
+ }));
24
+
25
+ /**
26
+ * Add a model alias to the existing config (or create config if none exists)
27
+ * @param {string} name - Alias name
28
+ * @param {string} modelString - Full model identifier
29
+ */
30
+ function addAlias(name, modelString) {
31
+ if (typeof name === 'string') { name = name.trim(); }
32
+ if (typeof modelString === 'string') { modelString = modelString.trim(); }
33
+ if (!name || typeof name !== 'string' || name === 'null') {
34
+ throw new Error(`Invalid alias name: '${name}'. Alias name must be a non-empty string.`);
35
+ }
36
+ if (!modelString || typeof modelString !== 'string' || modelString === 'null') {
37
+ throw new Error(
38
+ `Invalid model value for alias '${name}': '${modelString}'. ` +
39
+ 'Model must be a non-empty string (e.g., openrouter/google/gemini-3.1-pro-preview).'
40
+ );
41
+ }
42
+ const cfg = loadConfig() || { aliases: {} };
43
+ if (!cfg.aliases) {
44
+ cfg.aliases = {};
45
+ }
46
+ cfg.aliases[name] = modelString;
47
+ saveConfig(cfg);
48
+ logger.info('Alias added', { name, model: modelString });
49
+ }
50
+
51
+ /**
52
+ * Create a new config with all default aliases and the chosen default model
53
+ * @param {string} defaultModel - Default model alias or full model string
54
+ * @returns {object} The created config object
55
+ */
56
+ function createDefaultConfig(defaultModel) {
57
+ const cfg = {
58
+ default: defaultModel,
59
+ aliases: getDefaultAliases()
60
+ };
61
+ saveConfig(cfg);
62
+ logger.info('Default config created', {
63
+ default: defaultModel,
64
+ aliasCount: Object.keys(cfg.aliases).length
65
+ });
66
+ return cfg;
67
+ }
68
+
69
+ /**
70
+ * Detect available API keys from .env file and process.env
71
+ * @returns {{openrouter: boolean, google: boolean, openai: boolean, anthropic: boolean}}
72
+ */
73
+ function detectApiKeys() {
74
+ const { readApiKeys } = require('../utils/api-key-store');
75
+ return readApiKeys();
76
+ }
77
+
78
+ /**
79
+ * Prompt the user with a question via readline
80
+ * @param {readline.Interface} rl - Readline interface
81
+ * @param {string} prompt - Question text
82
+ * @returns {Promise<string>} User's answer
83
+ */
84
+ function askQuestion(rl, prompt) {
85
+ return new Promise((resolve) => {
86
+ rl.question(prompt, (answer) => {
87
+ resolve(answer.trim());
88
+ });
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Resolve user input to a model alias name
94
+ * @param {string} input - User input (number 1-5 or alias name)
95
+ * @returns {string|null} Resolved alias name, or null if invalid
96
+ */
97
+ function resolveChoice(input) {
98
+ const num = parseInt(input, 10);
99
+ if (num >= 1 && num <= MODEL_CHOICES.length) {
100
+ return MODEL_CHOICES[num - 1].alias;
101
+ }
102
+
103
+ const defaults = getDefaultAliases();
104
+ if (defaults[input] !== undefined) {
105
+ return input;
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Launch the Electron setup wizard
113
+ * @returns {Promise<{success: boolean, default?: string, keyCount?: number}>}
114
+ */
115
+ async function launchWizard() {
116
+ const { launchSetupWindow } = require('./setup-window');
117
+ return launchSetupWindow();
118
+ }
119
+
120
+ /**
121
+ * Standalone API key setup — launches the Electron window directly
122
+ * Used by `sidecar setup --api-keys`
123
+ * @returns {Promise<boolean>} true if keys were configured
124
+ */
125
+ async function runApiKeySetup() {
126
+ try {
127
+ const result = await launchWizard();
128
+ return result.success;
129
+ } catch (err) {
130
+ logger.warn('Could not launch setup window', { error: err.message });
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Seed/refresh the model catalog (F5). Never throws — setup must complete offline.
137
+ * A floor-only/offline refresh returns [] (see model-catalog), reported as unavailable.
138
+ * @param {(line: string) => void} [print] - defaults to console.log
139
+ */
140
+ /* eslint-disable no-console -- CLI wizard requires direct console output */
141
+ async function seedCatalog(print) {
142
+ const log = print ?? console.log;
143
+ try {
144
+ log('Refreshing model catalog...');
145
+ const { refreshCatalog } = require('../utils/model-catalog');
146
+ const models = await refreshCatalog();
147
+ if (models.length > 0) {
148
+ log(`Model catalog seeded (${models.length} models).`);
149
+ return;
150
+ }
151
+ } catch (err) {
152
+ logger.debug('Catalog seed failed', { error: err.message });
153
+ }
154
+ log('Model catalog unavailable (offline?) — it will refresh on first start.');
155
+ }
156
+
157
+ /**
158
+ * Run the readline-based setup wizard (headless fallback)
159
+ *
160
+ * Guides the user through:
161
+ * 1. API key detection
162
+ * 2. Default model selection
163
+ * 3. Config file creation
164
+ */
165
+ async function runReadlineSetup() {
166
+ const rl = readline.createInterface({
167
+ input: process.stdin,
168
+ output: process.stdout
169
+ });
170
+
171
+ try {
172
+ console.log('');
173
+ console.log('=== Sidecar Setup Wizard ===');
174
+ console.log('');
175
+
176
+ const keys = detectApiKeys();
177
+
178
+ const foundKeys = Object.entries(keys)
179
+ .filter(([, found]) => found)
180
+ .map(([provider]) => provider);
181
+
182
+ if (foundKeys.length > 0) {
183
+ console.log(`API keys detected: ${foundKeys.join(', ')}`);
184
+ } else {
185
+ console.log('No API keys detected.');
186
+ console.log('Set OPENROUTER_API_KEY to get started, or run: sidecar setup');
187
+ }
188
+ console.log('');
189
+
190
+ console.log('Choose your default model:');
191
+ console.log('');
192
+ for (const choice of MODEL_CHOICES) {
193
+ console.log(` ${choice.number}) ${choice.alias} - ${choice.label}`);
194
+ }
195
+ console.log('');
196
+
197
+ const answer = await askQuestion(rl, 'Pick a default (1-5 or alias name): ');
198
+ const chosen = resolveChoice(answer);
199
+
200
+ if (!chosen) {
201
+ console.log(`Invalid choice: "${answer}". Using "gemini" as default.`);
202
+ const cfg = createDefaultConfig('gemini');
203
+ await seedCatalog();
204
+ const aliasCount = Object.keys(cfg.aliases).length;
205
+ console.log('');
206
+ console.log(`Config created with ${aliasCount} aliases.`);
207
+ console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
208
+ return;
209
+ }
210
+
211
+ const cfg = createDefaultConfig(chosen);
212
+ await seedCatalog();
213
+ const aliasCount = Object.keys(cfg.aliases).length;
214
+
215
+ console.log('');
216
+ console.log(`Default model set to: ${chosen}`);
217
+ console.log(`Config created with ${aliasCount} aliases.`);
218
+ console.log(`Config path: ${path.join(getConfigDir(), 'config.json')}`);
219
+ } finally {
220
+ rl.close();
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Run the interactive setup wizard (Electron-first)
226
+ *
227
+ * Attempts to launch the Electron GUI wizard. If Electron is not
228
+ * available or fails, falls back to the readline-based setup.
229
+ */
230
+ async function runInteractiveSetup() {
231
+ try {
232
+ const result = await launchWizard();
233
+ if (result.success) {
234
+ // Wizard handled config creation; if it returned a default, ensure config exists
235
+ if (result.default) {
236
+ const existing = loadConfig();
237
+ if (!existing || !existing.default) {
238
+ createDefaultConfig(result.default);
239
+ }
240
+ }
241
+
242
+ await seedCatalog();
243
+
244
+ const configPath = path.join(getConfigDir(), 'config.json');
245
+ const keyLabel = result.keyCount
246
+ ? `${result.keyCount} API key(s) configured.`
247
+ : 'API keys configured.';
248
+ const modelLabel = result.default
249
+ ? `Default model: ${result.default}`
250
+ : '';
251
+
252
+ console.log('');
253
+ console.log('Setup complete!');
254
+ if (keyLabel) { console.log(keyLabel); }
255
+ if (modelLabel) { console.log(modelLabel); }
256
+ console.log(`Config: ${configPath}`);
257
+ return;
258
+ }
259
+ } catch (err) {
260
+ logger.debug('Electron wizard unavailable, falling back to readline', {
261
+ error: err.message
262
+ });
263
+ }
264
+
265
+ // Fallback to readline
266
+ await runReadlineSetup();
267
+ }
268
+
269
+ /* eslint-enable no-console */
270
+
271
+ module.exports = {
272
+ addAlias,
273
+ createDefaultConfig,
274
+ detectApiKeys,
275
+ runInteractiveSetup,
276
+ runReadlineSetup,
277
+ runApiKeySetup,
278
+ seedCatalog,
279
+ MODEL_CHOICES,
280
+ };