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,615 @@
1
+ /**
2
+ * OpenCode SDK Client Wrapper
3
+ *
4
+ * Provides a clean interface for interacting with the @opencode-ai/sdk.
5
+ * Handles model string parsing and provides simplified API methods.
6
+ * Now uses SDK server creation instead of spawning CLI.
7
+ *
8
+ * Spec Reference: SDK Migration Plan
9
+ */
10
+
11
+ // Lazy-load SDK (ESM module) - all imports must be dynamic
12
+ let _sdk = null;
13
+ async function getSDK() {
14
+ if (!_sdk) {
15
+ _sdk = await import('@opencode-ai/sdk');
16
+ }
17
+ return _sdk;
18
+ }
19
+
20
+ async function getCreateOpencodeClient() {
21
+ const sdk = await getSDK();
22
+ return sdk.createOpencodeClient;
23
+ }
24
+
25
+ async function getCreateOpencodeServer() {
26
+ const sdk = await getSDK();
27
+ return sdk.createOpencodeServer;
28
+ }
29
+
30
+ /**
31
+ * Parse a model string into SDK format
32
+ *
33
+ * Converts from sidecar format (e.g., 'openrouter/google/gemini-2.5-flash')
34
+ * to SDK format ({ providerID: 'openrouter', modelID: 'google/gemini-2.5-flash' })
35
+ *
36
+ * @param {string|object} modelString - Model identifier or already-parsed object
37
+ * @returns {{providerID: string, modelID: string}} SDK model specification
38
+ */
39
+ function parseModelString(modelString) {
40
+ // If already an object, return as-is
41
+ if (typeof modelString === 'object' && modelString !== null) {
42
+ return modelString;
43
+ }
44
+
45
+ // Handle empty string
46
+ if (!modelString) {
47
+ return { providerID: 'openrouter', modelID: '' };
48
+ }
49
+
50
+ const parts = modelString.split('/');
51
+
52
+ // Single part (just model name) - default to openrouter
53
+ if (parts.length === 1) {
54
+ return { providerID: 'openrouter', modelID: modelString };
55
+ }
56
+
57
+ // Two or more parts - first is provider, rest is modelID
58
+ return {
59
+ providerID: parts[0],
60
+ modelID: parts.slice(1).join('/')
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Create an OpenCode SDK client
66
+ *
67
+ * @param {string} [baseUrl] - Base URL for the OpenCode server
68
+ * @returns {Promise<import('@opencode-ai/sdk').OpencodeClient>} SDK client instance
69
+ */
70
+ async function createClient(baseUrl) {
71
+ const createOpencodeClient = await getCreateOpencodeClient();
72
+ const config = baseUrl ? { baseUrl } : {};
73
+ return createOpencodeClient(config);
74
+ }
75
+
76
+ /**
77
+ * Create a new session
78
+ *
79
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
80
+ * @returns {Promise<string>} Session ID
81
+ * @throws {Error} If session creation fails
82
+ */
83
+ async function createSession(client) {
84
+ const result = await client.session.create({});
85
+
86
+ if (result.error) {
87
+ throw new Error(result.error.message || 'Failed to create session');
88
+ }
89
+
90
+ // Handle both direct ID and nested session.id
91
+ const sessionId = result.data?.id || result.data?.session?.id;
92
+
93
+ if (!sessionId) {
94
+ throw new Error('No session ID returned');
95
+ }
96
+
97
+ return sessionId;
98
+ }
99
+
100
+ /**
101
+ * Send a prompt to a session
102
+ *
103
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
104
+ * @param {string} sessionId - Session ID
105
+ * @param {object} options - Prompt options
106
+ * @param {string|object} options.model - Model identifier or SDK format object
107
+ * @param {string} [options.system] - System prompt
108
+ * @param {Array} options.parts - Message parts
109
+ * @param {string} [options.agent] - Agent to use (e.g., 'build', 'explore')
110
+ * @param {object} [options.tools] - Tool configuration
111
+ * @param {object} [options.reasoning] - Reasoning/thinking configuration
112
+ * @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
113
+ * @param {object} [options.watchdog] - IdleWatchdog instance to signal busy/idle around the API call
114
+ * @returns {Promise<object>} API response
115
+ */
116
+ async function sendPrompt(client, sessionId, options) {
117
+ const { model, system, parts, agent, tools, reasoning, watchdog } = options;
118
+
119
+ // Parse model string to SDK format
120
+ const modelSpec = parseModelString(model);
121
+
122
+ // Build request body
123
+ const body = {
124
+ model: modelSpec,
125
+ parts
126
+ };
127
+
128
+ // Add optional fields
129
+ if (system) {
130
+ body.system = system;
131
+ }
132
+
133
+ if (agent) {
134
+ // OpenCode API expects lowercase agent names
135
+ body.agent = agent.toLowerCase();
136
+ }
137
+
138
+ if (tools) {
139
+ body.tools = tools;
140
+ }
141
+
142
+ if (reasoning) {
143
+ body.reasoning = reasoning;
144
+ }
145
+
146
+ if (watchdog) {
147
+ watchdog.markBusy();
148
+ }
149
+
150
+ let result;
151
+ try {
152
+ result = await client.session.promptAsync({
153
+ path: { id: sessionId },
154
+ body
155
+ });
156
+ } finally {
157
+ if (watchdog) {
158
+ watchdog.markIdle();
159
+ }
160
+ }
161
+
162
+ // Log but don't throw on promptAsync errors.
163
+ // promptAsync is fire-and-forget: the server queues the prompt for async
164
+ // processing. Errors here may be informational (e.g., model config warnings)
165
+ // rather than fatal. The polling loop will detect real failures via timeout.
166
+ if (result.error) {
167
+ const { logger } = require('./utils/logger');
168
+ logger.error('promptAsync returned error (continuing to poll)', {
169
+ error: result.error.message || JSON.stringify(result.error),
170
+ sessionId
171
+ });
172
+ }
173
+
174
+ return result;
175
+ }
176
+
177
+ /**
178
+ * Get messages for a session
179
+ *
180
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
181
+ * @param {string} sessionId - Session ID
182
+ * @returns {Promise<Array>} Array of messages
183
+ */
184
+ async function getMessages(client, sessionId) {
185
+ const result = await client.session.messages({
186
+ path: { id: sessionId }
187
+ });
188
+
189
+ return result.data || [];
190
+ }
191
+
192
+ /**
193
+ * Check if the OpenCode server is healthy
194
+ *
195
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
196
+ * @returns {Promise<boolean>} True if server is healthy
197
+ */
198
+ async function checkHealth(client) {
199
+ try {
200
+ await client.config.get({});
201
+ return true;
202
+ } catch (error) {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Create a child session with a parent ID
209
+ *
210
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
211
+ * @param {string} parentId - Parent session ID
212
+ * @returns {Promise<string>} Child session ID
213
+ * @throws {Error} If child session creation fails
214
+ */
215
+ async function createChildSession(client, parentId) {
216
+ const result = await client.session.create({
217
+ body: { parentID: parentId }
218
+ });
219
+
220
+ if (result.error) {
221
+ throw new Error(result.error.message || 'Failed to create child session');
222
+ }
223
+
224
+ // Handle both direct ID and nested session.id
225
+ const sessionId = result.data?.id || result.data?.session?.id;
226
+
227
+ if (!sessionId) {
228
+ throw new Error('No session ID returned');
229
+ }
230
+
231
+ return sessionId;
232
+ }
233
+
234
+ /**
235
+ * Get child sessions for a parent session
236
+ *
237
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
238
+ * @param {string} parentId - Parent session ID
239
+ * @returns {Promise<Array>} Array of child sessions
240
+ */
241
+ async function getChildren(client, parentId) {
242
+ const result = await client.session.children({
243
+ path: { id: parentId }
244
+ });
245
+
246
+ return result.data || [];
247
+ }
248
+
249
+ /**
250
+ * List all sessions from the OpenCode server
251
+ *
252
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
253
+ * @returns {Promise<Array>} Array of sessions
254
+ */
255
+ async function listSessions(client) {
256
+ try {
257
+ const result = await client.session.list();
258
+ return result.data || [];
259
+ } catch (_error) {
260
+ return [];
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Abort a running session
266
+ *
267
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
268
+ * @param {string} sessionId - Session ID to abort
269
+ * @returns {Promise<void>}
270
+ */
271
+ async function abortSession(client, sessionId) {
272
+ await client.session.abort({ path: { id: sessionId } });
273
+ }
274
+
275
+ /**
276
+ * Get session status
277
+ *
278
+ * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
279
+ * @param {string} sessionId - Session ID
280
+ * @returns {Promise<Object>} Session status
281
+ */
282
+ async function getSessionStatus(client, sessionId) {
283
+ const result = await client.session.status({
284
+ path: { id: sessionId }
285
+ });
286
+
287
+ return result.data || {};
288
+ }
289
+
290
+ /**
291
+ * Build the server options object for createOpencodeServer.
292
+ * Extracted for testability (no SDK dependency).
293
+ *
294
+ * @param {object} [options] - Server options
295
+ * @param {number} [options.port] - Port to run on
296
+ * @param {string} [options.hostname='127.0.0.1'] - Hostname to bind to
297
+ * @param {AbortSignal} [options.signal] - Abort signal to stop server
298
+ * @param {object} [options.mcp] - MCP server configurations
299
+ * @param {string} [options.model] - Default model
300
+ * @param {string} [options.client] - Client type ('cowork', 'code-local', etc.)
301
+ * @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
302
+ * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
303
+ * @returns {object} Server options ready for createOpencodeServer
304
+ */
305
+ function buildServerOptions(options = {}) {
306
+ // Build config object for SDK
307
+ const config = {};
308
+ if (options.mcp) {
309
+ // Normalize MCP configs for OpenCode's discriminated union format.
310
+ //
311
+ // OpenCode accepts exactly two type values (ConfigInvalidError otherwise):
312
+ // { type: "local", enabled: true, command: ["cmd", ...args] }
313
+ // { type: "remote", enabled: true, url: "https://..." }
314
+ //
315
+ // Input formats we handle:
316
+ // Claude Code internal : { type: "stdio", command: "cmd", args: [...] }
317
+ // Claude Desktop : { command: "cmd", args: [...] } (no type field)
318
+ // Claude Code remote : { type: "http", url: "..." }
319
+ // { type: "sse", url: "..." }
320
+ // Already normalized : { type: "local"|"remote", ... } → pass through
321
+ //
322
+ // Note: OpenCode's "local" schema does NOT support an `env` field.
323
+ // Environment variables from the source config are intentionally dropped;
324
+ // MCP servers inherit the parent process environment which is sufficient.
325
+ const normalized = {};
326
+ for (const [name, serverConfig] of Object.entries(options.mcp)) {
327
+ const t = serverConfig.type;
328
+ if (t === 'stdio' || (!t && serverConfig.command)) {
329
+ // stdio process (Claude Code or Claude Desktop format) → local
330
+ if (!serverConfig.command) {
331
+ const { logger } = require('./utils/logger');
332
+ logger.warn(`MCP server "${name}": type "stdio" requires a command — skipping`);
333
+ continue;
334
+ }
335
+ const cmd = typeof serverConfig.command === 'string' ? serverConfig.command : String(serverConfig.command);
336
+ const args = Array.isArray(serverConfig.args) ? serverConfig.args : [];
337
+ normalized[name] = {
338
+ type: 'local',
339
+ enabled: true,
340
+ command: [cmd, ...args]
341
+ };
342
+ } else if (t === 'http' || t === 'sse') {
343
+ // HTTP/SSE remote server → remote
344
+ if (!serverConfig.url) {
345
+ const { logger } = require('./utils/logger');
346
+ logger.warn(`MCP server "${name}": type "${t}" requires a url — skipping`);
347
+ continue;
348
+ }
349
+ // Preserve extra remote options (headers, oauth, timeout, etc.)
350
+ // eslint-disable-next-line no-unused-vars
351
+ const { type: _t, args: _a, command: _c, ...rest } = serverConfig;
352
+ normalized[name] = {
353
+ ...rest,
354
+ type: 'remote',
355
+ enabled: rest.enabled !== undefined ? rest.enabled : true
356
+ };
357
+ } else {
358
+ // Already in OpenCode format (type: "local"|"remote") or unknown — pass through
359
+ if (t && t !== 'local' && t !== 'remote') {
360
+ const { logger } = require('./utils/logger');
361
+ logger.warn(`MCP server "${name}": unrecognized type "${t}" — passing through unchanged`);
362
+ }
363
+ normalized[name] = serverConfig;
364
+ }
365
+ }
366
+ config.mcp = normalized;
367
+ }
368
+ if (options.model) {
369
+ config.model = options.model;
370
+ }
371
+
372
+ // Sync sidecar aliases into OpenCode's provider.models so the UI
373
+ // model picker shows all configured models (single source of truth).
374
+ const { buildProviderModels } = require('./utils/config');
375
+ config.provider = buildProviderModels();
376
+
377
+ // Register custom 'chat' agent: reads auto-approved, writes/bash require permission
378
+ const chatAgent = {
379
+ description: 'Conversational agent — reads are auto-approved, writes and commands require permission',
380
+ mode: 'primary',
381
+ permission: {
382
+ edit: 'ask',
383
+ bash: 'ask',
384
+ webfetch: 'allow'
385
+ }
386
+ };
387
+
388
+ // When launched from Cowork, replace the SE-focused base prompt with a general-purpose one
389
+ if (options.client === 'cowork') {
390
+ const { buildCoworkAgentPrompt } = require('./prompts/cowork-agent-prompt');
391
+ chatAgent.prompt = buildCoworkAgentPrompt();
392
+ }
393
+
394
+ config.agent = {
395
+ ...(config.agent || {}),
396
+ chat: chatAgent
397
+ };
398
+
399
+ // Set system prompt on the target agent's config (hidden from UI).
400
+ // The promptAsync `system` field is rendered as a visible chat message,
401
+ // but agent.prompt is injected as the system instruction invisibly.
402
+ if (options.systemPrompt) {
403
+ const targetName = (options.agentName || 'chat').toLowerCase();
404
+ if (targetName === 'chat') {
405
+ // Chat is our custom agent — append to existing prompt (e.g., cowork)
406
+ chatAgent.prompt = chatAgent.prompt
407
+ ? `${chatAgent.prompt}\n\n${options.systemPrompt}`
408
+ : options.systemPrompt;
409
+ } else {
410
+ // Built-in agents (build, plan, etc.) — register with system prompt
411
+ config.agent[targetName] = {
412
+ ...(config.agent[targetName] || {}),
413
+ prompt: options.systemPrompt
414
+ };
415
+ }
416
+ }
417
+
418
+ const serverOptions = {
419
+ hostname: options.hostname || '127.0.0.1',
420
+ };
421
+
422
+ // Only include port/signal when explicitly set — passing undefined
423
+ // overrides the SDK's defaults via Object.assign, causing --port=undefined
424
+ if (options.port !== undefined) {
425
+ serverOptions.port = options.port;
426
+ }
427
+ if (options.signal !== undefined) {
428
+ serverOptions.signal = options.signal;
429
+ }
430
+
431
+ // Only add config if we have settings
432
+ if (Object.keys(config).length > 0) {
433
+ serverOptions.config = config;
434
+ }
435
+
436
+ return serverOptions;
437
+ }
438
+
439
+ const { findListenerPid } = require('./utils/port-pid');
440
+
441
+ /**
442
+ * Build the { url, goPid, close } server handle around a raw SDK server.
443
+ * Extracted + dependency-injected so the goPid capture and cross-platform
444
+ * force-kill (F3 #15) can be unit-tested without the SDK's dynamic import.
445
+ * @param {{url:string, close:Function, pid?:number, process?:{pid:number}}} sdkServer
446
+ * @param {{findListenerPid?:Function, kill?:Function, logger?:object}} [deps]
447
+ * @returns {{url:string, goPid:number|null, close:Function}}
448
+ */
449
+ function buildServerHandle(sdkServer, deps = {}) {
450
+ const findPid = deps.findListenerPid || findListenerPid;
451
+ const kill = deps.kill || ((pid, sig) => process.kill(pid, sig));
452
+ const log = deps.logger || require('./utils/logger').logger;
453
+ const serverPort = parseInt(new URL(sdkServer.url).port, 10);
454
+ const goPid = sdkServer.pid || (sdkServer.process && sdkServer.process.pid) || findPid(serverPort);
455
+ const server = {
456
+ url: sdkServer.url,
457
+ goPid,
458
+ close() {
459
+ sdkServer.close();
460
+ const fallback = setTimeout(() => {
461
+ const pid = server.goPid;
462
+ if (pid && pid !== process.pid) {
463
+ try { kill(pid, 'SIGKILL'); } catch { /* already dead */ }
464
+ log.debug('Force-killed OpenCode server', { port: serverPort, pid });
465
+ }
466
+ }, 2000);
467
+ if (fallback.unref) { fallback.unref(); }
468
+ }
469
+ };
470
+ return server;
471
+ }
472
+
473
+ /**
474
+ * Start an OpenCode server and return client + server handle
475
+ *
476
+ * @param {object} [options] - Server options
477
+ * @param {number} [options.port] - Port to run on
478
+ * @param {string} [options.hostname='127.0.0.1'] - Hostname to bind to
479
+ * @param {AbortSignal} [options.signal] - Abort signal to stop server
480
+ * @param {object} [options.mcp] - MCP server configurations
481
+ * @param {string} [options.model] - Default model
482
+ * @param {string} [options.client] - Client type ('cowork', 'code-local', etc.)
483
+ * @returns {Promise<{client: object, server: {url: string, close: Function}}>}
484
+ */
485
+ async function startServer(options = {}) {
486
+ const createOpencodeServer = await getCreateOpencodeServer();
487
+ const serverOptions = buildServerOptions(options);
488
+
489
+ const sdkServer = await createOpencodeServer(serverOptions);
490
+ const client = await createClient(sdkServer.url);
491
+
492
+ // Capture the Go server PID once so close() can force-kill it cross-platform
493
+ // (F3 #15). Prefer a PID the SDK exposes; fall back to the port listener.
494
+ // findListenerPid may return null if the port isn't bound yet (startup race);
495
+ // in that case close() degrades to SIGTERM-only, which is acceptable best-effort.
496
+ const server = buildServerHandle(sdkServer);
497
+
498
+ return { client, server };
499
+ }
500
+
501
+ /**
502
+ * Load MCP configuration from user's opencode.json
503
+ *
504
+ * @param {string} [configPath] - Optional path to config file
505
+ * @returns {object|null} MCP configuration or null if not found
506
+ */
507
+ function loadMcpConfig(configPath) {
508
+ const fs = require('fs');
509
+ const path = require('path');
510
+ const os = require('os');
511
+
512
+ // Check paths in order of precedence
513
+ const paths = [];
514
+
515
+ if (configPath) {
516
+ paths.push(configPath);
517
+ }
518
+
519
+ // Global config location
520
+ paths.push(path.join(os.homedir(), '.config', 'opencode', 'opencode.json'));
521
+
522
+ // Project-level config (cwd)
523
+ paths.push(path.join(process.cwd(), 'opencode.json'));
524
+
525
+ for (const configFile of paths) {
526
+ try {
527
+ if (fs.existsSync(configFile)) {
528
+ const content = fs.readFileSync(configFile, 'utf-8');
529
+ const config = JSON.parse(content);
530
+ if (config.mcp && Object.keys(config.mcp).length > 0) {
531
+ return config.mcp;
532
+ }
533
+ }
534
+ } catch (e) {
535
+ // Ignore parse errors, try next file
536
+ }
537
+ }
538
+
539
+ return null;
540
+ }
541
+
542
+ /**
543
+ * Parse MCP server specification from CLI format
544
+ *
545
+ * Supports formats:
546
+ * - name=url (remote server)
547
+ * - name=command (local server with simple command)
548
+ * - JSON string (full config)
549
+ *
550
+ * @param {string} spec - MCP server specification
551
+ * @returns {{name: string, config: object}|null} Parsed MCP config or null
552
+ */
553
+ function parseMcpSpec(spec) {
554
+ // Try JSON first
555
+ if (spec.startsWith('{')) {
556
+ try {
557
+ const parsed = JSON.parse(spec);
558
+ const name = Object.keys(parsed)[0];
559
+ return { name, config: parsed[name] };
560
+ } catch (e) {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ // Try name=value format
566
+ const eqIndex = spec.indexOf('=');
567
+ if (eqIndex > 0) {
568
+ const name = spec.slice(0, eqIndex);
569
+ const value = spec.slice(eqIndex + 1);
570
+
571
+ // If value looks like a URL, treat as remote
572
+ if (value.startsWith('http://') || value.startsWith('https://')) {
573
+ return {
574
+ name,
575
+ config: {
576
+ type: 'remote',
577
+ url: value,
578
+ enabled: true
579
+ }
580
+ };
581
+ }
582
+
583
+ // Otherwise treat as local command
584
+ return {
585
+ name,
586
+ config: {
587
+ type: 'local',
588
+ command: value.split(' '),
589
+ enabled: true
590
+ }
591
+ };
592
+ }
593
+
594
+ return null;
595
+ }
596
+
597
+ module.exports = {
598
+ parseModelString,
599
+ createClient,
600
+ createSession,
601
+ createChildSession,
602
+ sendPrompt,
603
+ sendPromptAsync: sendPrompt, // Alias: sendPrompt already uses promptAsync internally
604
+ getMessages,
605
+ getChildren,
606
+ listSessions,
607
+ getSessionStatus,
608
+ abortSession,
609
+ checkHealth,
610
+ buildServerOptions,
611
+ buildServerHandle,
612
+ startServer,
613
+ loadMcpConfig,
614
+ parseMcpSpec
615
+ };