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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- 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
|
+
};
|