@evanovation/open-cursor 2.4.15

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 (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. package/src/utils/perf.ts +76 -0
@@ -0,0 +1,293 @@
1
+ import { spawn } from 'child_process';
2
+ import { LineBuffer } from '../streaming/line-buffer.js';
3
+ import { parseStreamJsonLine } from '../streaming/parser.js';
4
+ import {
5
+ extractText,
6
+ isAssistantText,
7
+ type StreamJsonEvent,
8
+ } from '../streaming/types.js';
9
+ import { createLogger } from '../utils/logger.js';
10
+ import { formatShellCommandForPlatform, resolveCursorAgentBinary } from '../utils/binary.js';
11
+
12
+ export interface CursorClientConfig {
13
+ timeout?: number;
14
+ maxRetries?: number;
15
+ streamOutput?: boolean;
16
+ cursorAgentPath?: string;
17
+ }
18
+
19
+ export interface CursorResponse {
20
+ content: string;
21
+ done: boolean;
22
+ error?: string;
23
+ }
24
+
25
+ export class SimpleCursorClient {
26
+ private config: Required<CursorClientConfig>;
27
+ private log: ReturnType<typeof createLogger>;
28
+
29
+ constructor(config: CursorClientConfig = {}) {
30
+ this.config = {
31
+ timeout: 30000,
32
+ maxRetries: 3,
33
+ streamOutput: true,
34
+ cursorAgentPath: resolveCursorAgentBinary(),
35
+ ...config
36
+ };
37
+
38
+ this.log = createLogger('cursor-client');
39
+ }
40
+
41
+ async *executePromptStream(prompt: string, options: {
42
+ cwd?: string;
43
+ model?: string;
44
+ mode?: 'default' | 'plan' | 'ask';
45
+ resumeId?: string;
46
+ } = {}): AsyncGenerator<StreamJsonEvent, void, unknown> {
47
+ // Input validation
48
+ if (!prompt || typeof prompt !== 'string') {
49
+ throw new Error('Invalid prompt: must be a non-empty string');
50
+ }
51
+
52
+ const {
53
+ cwd = process.cwd(),
54
+ model = 'auto',
55
+ mode = 'default',
56
+ resumeId
57
+ } = options;
58
+
59
+ const args = [
60
+ '--print',
61
+ '--output-format',
62
+ 'stream-json',
63
+ '--stream-partial-output',
64
+ '--model',
65
+ model
66
+ ];
67
+
68
+ if (mode === 'plan') {
69
+ args.push('--plan');
70
+ } else if (mode === 'ask') {
71
+ args.push('--mode', 'ask');
72
+ }
73
+
74
+ if (resumeId) {
75
+ args.push('--resume', resumeId);
76
+ }
77
+
78
+ this.log.debug('Executing prompt stream', { promptLength: prompt.length, mode, model });
79
+
80
+ const child = spawn(formatShellCommandForPlatform(this.config.cursorAgentPath), args, {
81
+ cwd,
82
+ stdio: ['pipe', 'pipe', 'pipe'],
83
+ shell: process.platform === 'win32',
84
+ });
85
+
86
+ if (prompt) {
87
+ child.stdin.write(prompt);
88
+ child.stdin.end();
89
+ }
90
+
91
+ let processError: Error | null = null;
92
+ const lineBuffer = new LineBuffer();
93
+
94
+ // Add stderr handling
95
+ child.stderr.on('data', (data) => {
96
+ const errorMsg = data.toString();
97
+ this.log.error('cursor-agent stderr', { error: errorMsg });
98
+ processError = new Error(errorMsg);
99
+ });
100
+
101
+ // Add timeout
102
+ const timeoutId = setTimeout(() => {
103
+ child.kill('SIGTERM');
104
+ processError = new Error(`Timeout after ${this.config.timeout}ms`);
105
+ }, this.config.timeout);
106
+
107
+ const streamEnded = new Promise<number | null>((resolve) => {
108
+ child.on('close', (code) => {
109
+ clearTimeout(timeoutId);
110
+ if (code !== 0 && !processError) {
111
+ this.log.error('cursor-agent exited with non-zero code', { code });
112
+ processError = new Error(`cursor-agent exited with code ${code}`);
113
+ }
114
+ resolve(code);
115
+ });
116
+
117
+ child.on('error', (error) => {
118
+ clearTimeout(timeoutId);
119
+ this.log.error('cursor-agent process error', { error: error.message });
120
+ processError = error;
121
+ resolve(null);
122
+ });
123
+ });
124
+
125
+ for await (const chunk of child.stdout) {
126
+ for (const line of lineBuffer.push(chunk)) {
127
+ const event = parseStreamJsonLine(line);
128
+ if (event) {
129
+ yield event;
130
+ } else {
131
+ this.log.warn('Invalid JSON from cursor-agent', { line: line.substring(0, 100) });
132
+ }
133
+ }
134
+ }
135
+
136
+ for (const line of lineBuffer.flush()) {
137
+ const event = parseStreamJsonLine(line);
138
+ if (event) {
139
+ yield event;
140
+ } else {
141
+ this.log.warn('Invalid JSON from cursor-agent', { line: line.substring(0, 100) });
142
+ }
143
+ }
144
+
145
+ await streamEnded;
146
+
147
+ if (processError) {
148
+ throw processError;
149
+ }
150
+ }
151
+
152
+ async executePrompt(prompt: string, options: {
153
+ cwd?: string;
154
+ model?: string;
155
+ mode?: 'default' | 'plan' | 'ask';
156
+ resumeId?: string;
157
+ } = {}): Promise<CursorResponse> {
158
+ // Input validation
159
+ if (!prompt || typeof prompt !== 'string') {
160
+ throw new Error('Invalid prompt: must be a non-empty string');
161
+ }
162
+
163
+ const {
164
+ cwd = process.cwd(),
165
+ model = 'auto',
166
+ mode = 'default',
167
+ resumeId
168
+ } = options;
169
+
170
+ const args = [
171
+ '--print',
172
+ '--output-format',
173
+ 'stream-json',
174
+ '--stream-partial-output',
175
+ '--model',
176
+ model
177
+ ];
178
+
179
+ if (mode === 'plan') {
180
+ args.push('--plan');
181
+ } else if (mode === 'ask') {
182
+ args.push('--mode', 'ask');
183
+ }
184
+
185
+ if (resumeId) {
186
+ args.push('--resume', resumeId);
187
+ }
188
+
189
+ this.log.debug('Executing prompt', { promptLength: prompt.length, mode, model });
190
+
191
+ return new Promise((resolve, reject) => {
192
+ const child = spawn(formatShellCommandForPlatform(this.config.cursorAgentPath), args, {
193
+ cwd,
194
+ stdio: ['pipe', 'pipe', 'pipe'],
195
+ shell: process.platform === 'win32',
196
+ });
197
+
198
+ let stdoutBuffer = '';
199
+ let stderrBuffer = '';
200
+
201
+ if (prompt) {
202
+ child.stdin.write(prompt);
203
+ child.stdin.end();
204
+ }
205
+
206
+ const timeout = setTimeout(() => {
207
+ child.kill('SIGTERM');
208
+ reject(new Error(`Timeout after ${this.config.timeout}ms`));
209
+ }, this.config.timeout);
210
+
211
+ child.stdout.on('data', (data) => {
212
+ stdoutBuffer += data.toString();
213
+ });
214
+
215
+ child.stderr.on('data', (data) => {
216
+ stderrBuffer += data.toString();
217
+ });
218
+
219
+ child.on('close', (code) => {
220
+ clearTimeout(timeout);
221
+
222
+ if (code !== 0) {
223
+ reject(new Error(`cursor-agent exited with code ${code}: ${stderrBuffer}`));
224
+ return;
225
+ }
226
+
227
+ try {
228
+ const lines = stdoutBuffer.trim().split('\n');
229
+ let content = '';
230
+
231
+ for (const line of lines) {
232
+ if (line.trim()) {
233
+ const event = parseStreamJsonLine(line);
234
+ if (event && isAssistantText(event)) {
235
+ content = extractText(event);
236
+ }
237
+ }
238
+ }
239
+
240
+ resolve({
241
+ content,
242
+ done: true
243
+ });
244
+ } catch (error) {
245
+ reject(new Error(`Failed to parse cursor-agent output: ${error}`));
246
+ }
247
+ });
248
+
249
+ child.on('error', (error) => {
250
+ clearTimeout(timeout);
251
+ reject(error);
252
+ });
253
+ });
254
+ }
255
+
256
+ async getAvailableModels(): Promise<Array<{ id: string; name: string }>> {
257
+ return [
258
+ { id: 'auto', name: 'Cursor Agent Auto' },
259
+ { id: 'composer-1.5', name: 'Composer 1.5' },
260
+ { id: 'opus-4.6-thinking', name: 'Claude 4.6 Opus (Thinking)' },
261
+ { id: 'opus-4.6', name: 'Claude 4.6 Opus' },
262
+ { id: 'sonnet-4.6', name: 'Claude 4.6 Sonnet' },
263
+ { id: 'sonnet-4.6-thinking', name: 'Claude 4.6 Sonnet (Thinking)' },
264
+ { id: 'opus-4.5', name: 'Claude 4.5 Opus' },
265
+ { id: 'opus-4.5-thinking', name: 'Claude 4.5 Opus (Thinking)' },
266
+ { id: 'sonnet-4.5', name: 'Claude 4.5 Sonnet' },
267
+ { id: 'sonnet-4.5-thinking', name: 'Claude 4.5 Sonnet (Thinking)' },
268
+ { id: 'gpt-5.4-high', name: 'GPT-5.4 High' },
269
+ { id: 'gpt-5.4-medium', name: 'GPT-5.4' },
270
+ { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' },
271
+ { id: 'gpt-5.2', name: 'GPT-5.2' },
272
+ { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro' },
273
+ { id: 'gemini-3-pro', name: 'Gemini 3 Pro' },
274
+ { id: 'gemini-3-flash', name: 'Gemini 3 Flash' },
275
+ { id: 'grok', name: 'Grok' },
276
+ { id: 'kimi-k2.5', name: 'Kimi K2.5' },
277
+ ];
278
+ }
279
+
280
+ async validateInstallation(): Promise<boolean> {
281
+ try {
282
+ const testResponse = await this.executePrompt('test', { model: 'auto' });
283
+ return !!testResponse.content;
284
+ } catch (error) {
285
+ this.log.error('Cursor installation validation failed:', error);
286
+ return false;
287
+ }
288
+ }
289
+ }
290
+
291
+ export const createSimpleCursorClient = (config: CursorClientConfig = {}) => {
292
+ return new SimpleCursorClient(config);
293
+ };
@@ -0,0 +1,39 @@
1
+ // src/commands/status.ts
2
+
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { getAuthFilePath } from "../auth";
5
+ import { createLogger } from "../utils/logger";
6
+
7
+ const log = createLogger("status");
8
+
9
+ export interface AuthStatus {
10
+ authenticated: boolean;
11
+ authFilePath: string;
12
+ message: string;
13
+ }
14
+
15
+ export function checkAuthStatus(): AuthStatus {
16
+ const authFilePath = getAuthFilePath();
17
+ const exists = existsSync(authFilePath);
18
+
19
+ log.debug("Checking auth status", { path: authFilePath });
20
+
21
+ if (exists) {
22
+ return {
23
+ authenticated: true,
24
+ authFilePath,
25
+ message: `✓ Cursor: Authenticated\n Auth file: ${authFilePath}`,
26
+ };
27
+ }
28
+
29
+ return {
30
+ authenticated: false,
31
+ authFilePath,
32
+ message: `✗ Cursor: Not authenticated\n Run: opencode auth login cursor-acp`,
33
+ };
34
+ }
35
+
36
+ export function formatStatusOutput(): string {
37
+ const status = checkAuthStatus();
38
+ return status.message;
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ export { CursorPlugin } from "./plugin.js";
2
+ export { createCursorProvider, cursor } from "./provider.js";
3
+ export type { ProviderOptions } from "./provider.js";
4
+ export { createProxyServer, findAvailablePort } from "./proxy/server.js";
5
+ export { parseOpenAIRequest } from "./proxy/handler.js";
6
+ export type { ParsedRequest } from "./proxy/handler.js";
7
+ export { createChatCompletionResponse, createChatCompletionChunk } from "./proxy/formatter.js";
8
+ export { verifyCursorAuth } from "./auth.js";
9
+ export type { AuthResult } from "./auth.js";
10
+ export { checkAuthStatus, formatStatusOutput } from "./commands/status";
11
+ export type { AuthStatus } from "./commands/status";
12
+
13
+ // Utilities
14
+ export { createLogger } from "./utils/logger";
15
+ export type { Logger } from "./utils/logger";
16
+ export { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors";
17
+ export type { ParsedError, ErrorType } from "./utils/errors";
18
+
19
+ // Streaming utilities
20
+ export { LineBuffer } from "./streaming/line-buffer.js";
21
+ export { parseStreamJsonLine } from "./streaming/parser.js";
22
+ export { DeltaTracker } from "./streaming/delta-tracker.js";
23
+ export { StreamToSseConverter, formatSseChunk, formatSseDone } from "./streaming/openai-sse.js";
24
+ export { StreamToAiSdkParts } from "./streaming/ai-sdk-parts.js";
25
+ export type {
26
+ StreamJsonAssistantEvent,
27
+ StreamJsonEvent,
28
+ StreamJsonResultEvent,
29
+ StreamJsonSystemEvent,
30
+ StreamJsonThinkingEvent,
31
+ StreamJsonToolCallEvent,
32
+ StreamJsonUserEvent,
33
+ } from "./streaming/types.js";
34
+
35
+ // Default export for OpenCode plugin usage
36
+ export { CursorPlugin as default } from "./plugin.js";
37
+
38
+ // Backward compatibility
39
+ export { default as createCursorProviderCompat } from "./provider.js";
@@ -0,0 +1,166 @@
1
+ import { createLogger } from "../utils/logger.js";
2
+ import type { McpServerConfig } from "./config.js";
3
+
4
+ const log = createLogger("mcp:client-manager");
5
+
6
+ export interface McpToolInfo {
7
+ name: string;
8
+ description?: string;
9
+ inputSchema?: Record<string, unknown>;
10
+ }
11
+
12
+ interface DiscoveredTool extends McpToolInfo {
13
+ serverName: string;
14
+ }
15
+
16
+ interface ServerConnection {
17
+ client: any;
18
+ tools: McpToolInfo[];
19
+ }
20
+
21
+ interface McpClientManagerDeps {
22
+ createClient: () => any;
23
+ createTransport: (config: McpServerConfig) => any;
24
+ }
25
+
26
+ let defaultDeps: McpClientManagerDeps | null = null;
27
+
28
+ async function loadDefaultDeps(): Promise<McpClientManagerDeps> {
29
+ if (defaultDeps) return defaultDeps;
30
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
31
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
32
+
33
+ defaultDeps = {
34
+ createClient: () =>
35
+ new Client({ name: "open-cursor", version: "1.0.0" }, { capabilities: {} }),
36
+ createTransport: (config: McpServerConfig) => {
37
+ if (config.type === "local") {
38
+ return new StdioClientTransport({
39
+ command: config.command[0],
40
+ args: config.command.slice(1),
41
+ env: { ...process.env, ...(config.environment ?? {}) },
42
+ stderr: "pipe",
43
+ });
44
+ }
45
+ // Remote servers: StreamableHTTPClientTransport can be added later.
46
+ throw new Error(`Remote MCP transport not yet implemented for ${config.name}`);
47
+ },
48
+ };
49
+ return defaultDeps;
50
+ }
51
+
52
+ export class McpClientManager {
53
+ private connections = new Map<string, ServerConnection>();
54
+ private deps: McpClientManagerDeps | null;
55
+
56
+ constructor(deps?: McpClientManagerDeps) {
57
+ this.deps = deps ?? null;
58
+ }
59
+
60
+ async connectServer(config: McpServerConfig): Promise<void> {
61
+ if (this.connections.has(config.name)) {
62
+ log.debug("Server already connected, skipping", { server: config.name });
63
+ return;
64
+ }
65
+
66
+ // Lazy-load MCP SDK if no deps were injected
67
+ if (!this.deps) {
68
+ try {
69
+ this.deps = await loadDefaultDeps();
70
+ } catch (err) {
71
+ log.warn("Failed to load MCP SDK", { error: String(err) });
72
+ return;
73
+ }
74
+ }
75
+
76
+ const deps = this.deps;
77
+ let client: any;
78
+ try {
79
+ client = deps.createClient();
80
+ const transport = deps.createTransport(config);
81
+ await client.connect(transport);
82
+ } catch (err) {
83
+ log.warn("MCP server connection failed", {
84
+ server: config.name,
85
+ error: String(err),
86
+ });
87
+ return;
88
+ }
89
+
90
+ let tools: McpToolInfo[] = [];
91
+ try {
92
+ const result = await client.listTools();
93
+ tools = result?.tools ?? [];
94
+ log.info("MCP server connected", {
95
+ server: config.name,
96
+ tools: tools.length,
97
+ });
98
+ } catch (err) {
99
+ log.warn("MCP tool discovery failed", {
100
+ server: config.name,
101
+ error: String(err),
102
+ });
103
+ }
104
+
105
+ this.connections.set(config.name, { client, tools });
106
+ }
107
+
108
+ listTools(): DiscoveredTool[] {
109
+ const all: DiscoveredTool[] = [];
110
+ for (const [serverName, conn] of this.connections) {
111
+ for (const tool of conn.tools) {
112
+ all.push({ ...tool, serverName });
113
+ }
114
+ }
115
+ return all;
116
+ }
117
+
118
+ async callTool(
119
+ serverName: string,
120
+ toolName: string,
121
+ args: Record<string, unknown>,
122
+ ): Promise<string> {
123
+ const conn = this.connections.get(serverName);
124
+ if (!conn) {
125
+ return `Error: MCP server "${serverName}" not connected`;
126
+ }
127
+
128
+ try {
129
+ const result = await conn.client.callTool({
130
+ name: toolName,
131
+ arguments: args,
132
+ });
133
+
134
+ // MCP callTool returns { content: Array<{ type, text }> }
135
+ if (Array.isArray(result?.content)) {
136
+ return result.content
137
+ .map((c: any) => (c.type === "text" ? c.text : JSON.stringify(c)))
138
+ .join("\n");
139
+ }
140
+ return typeof result === "string" ? result : JSON.stringify(result);
141
+ } catch (err: any) {
142
+ log.warn("MCP tool call failed", {
143
+ server: serverName,
144
+ tool: toolName,
145
+ error: String(err?.message || err),
146
+ });
147
+ return `Error: MCP tool "${toolName}" failed: ${err?.message || err}`;
148
+ }
149
+ }
150
+
151
+ async disconnectAll(): Promise<void> {
152
+ for (const [name, conn] of this.connections) {
153
+ try {
154
+ await conn.client.close();
155
+ log.debug("MCP server disconnected", { server: name });
156
+ } catch (err) {
157
+ log.debug("MCP server disconnect failed", { server: name, error: String(err) });
158
+ }
159
+ }
160
+ this.connections.clear();
161
+ }
162
+
163
+ get connectedServers(): string[] {
164
+ return Array.from(this.connections.keys());
165
+ }
166
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ existsSync as nodeExistsSync,
3
+ readFileSync as nodeReadFileSync,
4
+ } from "node:fs";
5
+ import { resolveOpenCodeConfigPath } from "../plugin-toggle.js";
6
+ import { createLogger } from "../utils/logger.js";
7
+
8
+ const log = createLogger("mcp:config");
9
+
10
+ export type McpLocalServerConfig = {
11
+ name: string;
12
+ type: "local";
13
+ command: string[];
14
+ environment?: Record<string, string>;
15
+ timeout?: number;
16
+ };
17
+
18
+ export type McpRemoteServerConfig = {
19
+ name: string;
20
+ type: "remote";
21
+ url: string;
22
+ headers?: Record<string, string>;
23
+ timeout?: number;
24
+ };
25
+
26
+ export type McpServerConfig = McpLocalServerConfig | McpRemoteServerConfig;
27
+
28
+ interface ReadMcpConfigsDeps {
29
+ configJson?: string;
30
+ existsSync?: (path: string) => boolean;
31
+ readFileSync?: (path: string, enc: BufferEncoding) => string;
32
+ env?: NodeJS.ProcessEnv;
33
+ }
34
+
35
+ export function readMcpConfigs(deps: ReadMcpConfigsDeps = {}): McpServerConfig[] {
36
+ let raw: string;
37
+
38
+ if (deps.configJson != null) {
39
+ raw = deps.configJson;
40
+ } else {
41
+ const exists = deps.existsSync ?? nodeExistsSync;
42
+ const readFile = deps.readFileSync ?? nodeReadFileSync;
43
+ const configPath = resolveOpenCodeConfigPath(deps.env ?? process.env);
44
+ if (!exists(configPath)) return [];
45
+ try {
46
+ raw = readFile(configPath, "utf8");
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ let parsed: Record<string, unknown>;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ } catch {
56
+ return [];
57
+ }
58
+
59
+ const mcpSection = parsed.mcp;
60
+ if (!mcpSection || typeof mcpSection !== "object" || Array.isArray(mcpSection)) {
61
+ return [];
62
+ }
63
+
64
+ const configs: McpServerConfig[] = [];
65
+
66
+ for (const [name, entry] of Object.entries(mcpSection as Record<string, unknown>)) {
67
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
68
+ const e = entry as Record<string, unknown>;
69
+
70
+ if (e.enabled === false) continue;
71
+
72
+ if (e.type === "local" && Array.isArray(e.command) && e.command.length > 0) {
73
+ configs.push({
74
+ name,
75
+ type: "local",
76
+ command: e.command as string[],
77
+ environment: isStringRecord(e.environment) ? e.environment : undefined,
78
+ timeout: typeof e.timeout === "number" ? e.timeout : undefined,
79
+ });
80
+ } else if (e.type === "remote" && typeof e.url === "string") {
81
+ configs.push({
82
+ name,
83
+ type: "remote",
84
+ url: e.url,
85
+ headers: isStringRecord(e.headers) ? e.headers : undefined,
86
+ timeout: typeof e.timeout === "number" ? e.timeout : undefined,
87
+ });
88
+ } else {
89
+ log.debug("Skipping unrecognised MCP config entry", { name, type: e.type });
90
+ }
91
+ }
92
+
93
+ return configs;
94
+ }
95
+
96
+ let _subagentCache: { names: string[]; expiry: number } | null = null;
97
+ const SUBAGENT_CACHE_TTL_MS = 60_000;
98
+
99
+ /** Clear cached subagent names (for testing only). */
100
+ export function _resetSubagentCache(): void {
101
+ _subagentCache = null;
102
+ }
103
+
104
+ interface ReadSubagentNamesDeps {
105
+ configJson?: string;
106
+ existsSync?: (path: string) => boolean;
107
+ readFileSync?: (path: string, enc: BufferEncoding) => string;
108
+ env?: NodeJS.ProcessEnv;
109
+ }
110
+
111
+ export function readSubagentNames(deps: ReadSubagentNamesDeps = {}): string[] {
112
+ const useCache = deps.configJson == null;
113
+ if (useCache && _subagentCache && Date.now() < _subagentCache.expiry) {
114
+ return _subagentCache.names;
115
+ }
116
+
117
+ const result = readSubagentNamesUncached(deps);
118
+
119
+ if (useCache) {
120
+ _subagentCache = { names: result, expiry: Date.now() + SUBAGENT_CACHE_TTL_MS };
121
+ }
122
+ return result;
123
+ }
124
+
125
+ function readSubagentNamesUncached(deps: ReadSubagentNamesDeps): string[] {
126
+ let raw: string;
127
+
128
+ if (deps.configJson != null) {
129
+ raw = deps.configJson;
130
+ } else {
131
+ const exists = deps.existsSync ?? nodeExistsSync;
132
+ const readFile = deps.readFileSync ?? nodeReadFileSync;
133
+ const configPath = resolveOpenCodeConfigPath(deps.env ?? process.env);
134
+ if (!exists(configPath)) return ["general-purpose"];
135
+ try {
136
+ raw = readFile(configPath, "utf8");
137
+ } catch {
138
+ return ["general-purpose"];
139
+ }
140
+ }
141
+
142
+ let parsed: Record<string, unknown>;
143
+ try {
144
+ parsed = JSON.parse(raw);
145
+ } catch {
146
+ return ["general-purpose"];
147
+ }
148
+
149
+ const agentSection = parsed.agent;
150
+ if (!agentSection || typeof agentSection !== "object" || Array.isArray(agentSection)) {
151
+ return ["general-purpose"];
152
+ }
153
+
154
+ const agents = agentSection as Record<string, unknown>;
155
+ const names = Object.keys(agents);
156
+ if (names.length === 0) return ["general-purpose"];
157
+
158
+ const subagentNames = names.filter((name) => {
159
+ const entry = agents[name];
160
+ return entry && typeof entry === "object" && !Array.isArray(entry)
161
+ && (entry as Record<string, unknown>).mode === "subagent";
162
+ });
163
+
164
+ return subagentNames.length > 0 ? subagentNames : names;
165
+ }
166
+
167
+ function isStringRecord(v: unknown): v is Record<string, string> {
168
+ return typeof v === "object" && v !== null && !Array.isArray(v);
169
+ }