cursor-opencode-provider 0.2.6 → 0.2.8

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.
@@ -1,6 +1,6 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, delimiter, join } from "node:path";
4
4
  /** Cursor agent.v1 TimeoutBehavior enum values. */
5
5
  export const CURSOR_TIMEOUT_CANCEL = 1;
6
6
  export const CURSOR_TIMEOUT_BACKGROUND = 2;
@@ -14,9 +14,34 @@ const TIMEOUT_MARKER = "__CURSOR_SHELL_TIMEOUT__";
14
14
  export const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
15
15
  const policies = new Map();
16
16
  const outcomes = new Map();
17
- /** callIDs that need shell.env injectors (so args.command can stay display-original). */
17
+ /** callIDs that need shell.env injectors or a direct-command fallback. */
18
18
  const pendingEnvWraps = new Set();
19
19
  const activeEnvWraps = new Map();
20
+ let configuredShell;
21
+ /** Track OpenCode's configured shell from the classic config hook. */
22
+ export function setCursorShellPath(shell) {
23
+ configuredShell = shell?.trim() || undefined;
24
+ }
25
+ function executableOnPath(name) {
26
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
27
+ if (dir && existsSync(join(dir, name)))
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Mirror the relevant part of OpenCode Shell.acceptable(): fish/nu are denied,
34
+ * then POSIX falls back to bash when installed and `/bin/sh` otherwise.
35
+ */
36
+ export function resolveCursorShellKind(shell = configuredShell ?? process.env.SHELL) {
37
+ let name = shell ? basename(shell).toLowerCase().replace(/\.exe$/, "") : "";
38
+ if (name === "fish" || name === "nu" || !name) {
39
+ name = executableOnPath("bash") ? "bash" : "sh";
40
+ }
41
+ if (name === "bash" || name === "zsh" || name === "sh" || name === "dash")
42
+ return name;
43
+ return "other";
44
+ }
20
45
  function remember(map, key, value, onEvict) {
21
46
  map.delete(key);
22
47
  map.set(key, value);
@@ -128,37 +153,12 @@ export function buildBackgroundShellCommand(command) {
128
153
  `printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
129
154
  ].join("\n");
130
155
  }
131
- /**
132
- * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
133
- *
134
- * Important: do **not** replace `args.command` with the wrapper script.
135
- * OpenCode's bash UI renders `state.input.command`, and `ctx.metadata()`
136
- * persists the execute-time `args` object into that field. Mutating
137
- * `args.command` therefore leaks the private wrapper into the TUI/GUI.
138
- *
139
- * Wrapping is applied later via {@link cursorShellEnvForCall} (`shell.env`),
140
- * which uses BASH_ENV / ZDOTDIR injectors so bash/zsh `-c <original>` is
141
- * replaced with the wrapper while the stored/displayed command stays original.
142
- * Permissions also keep analyzing the real user command.
143
- */
144
- export function prepareCursorShellArgs(toolCallId, args) {
145
- const policy = policies.get(toolCallId);
146
- if (!policy)
147
- return;
148
- if (policy.backgroundSpawn) {
149
- pendingEnvWraps.add(toolCallId);
150
- return;
151
- }
152
- if (policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
153
- return;
154
- pendingEnvWraps.add(toolCallId);
155
- // The wrapper returns just after Cursor's foreground window. OpenCode's own
156
- // timeout is only an outer safety net and must not win the race.
157
- args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
158
- }
159
- /** Restore the model-facing command in OpenCode's completed tool title. */
160
- export function cursorShellOriginalCommand(toolCallId) {
161
- return policies.get(toolCallId)?.command || undefined;
156
+ function wrapperBodyForPolicy(policy) {
157
+ if (policy.backgroundSpawn)
158
+ return buildBackgroundShellCommand(policy.command);
159
+ if (policy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND)
160
+ return buildSoftBackgroundCommand(policy);
161
+ return undefined;
162
162
  }
163
163
  function writeShellEnvInjector(wrapperBody) {
164
164
  const dir = mkdtempSync(join(tmpdir(), "cursor-opencode-wrap-"));
@@ -166,13 +166,8 @@ function writeShellEnvInjector(wrapperBody) {
166
166
  const bashEnvPath = join(dir, "bashenv.sh");
167
167
  const zshenvPath = join(dir, ".zshenv");
168
168
  writeFileSync(wrapperPath, `${wrapperBody}\n`, { mode: 0o700 });
169
- // Sourced by non-interactive bash (BASH_ENV) or zsh (.zshenv via ZDOTDIR).
170
- // `exec` replaces the host shell so OpenCode's `-c <original>` body never runs.
171
- //
172
- // Do not gate on a sticky env flag: soft-background children inherit the
173
- // wrapper environment, and a leftover CURSOR_OPENCODE_WRAP_ACTIVE=1 would
174
- // make later injectors no-op (original `-c` runs unwrapped). Unsetting the
175
- // injector vars before `exec` is enough to prevent re-entry.
169
+ // Sourced by bash (BASH_ENV) or zsh (.zshenv via ZDOTDIR). `exec`
170
+ // replaces the host shell before OpenCode's `-c <original>` body runs.
176
171
  const injector = [
177
172
  "unset BASH_ENV ZDOTDIR ENV CURSOR_OPENCODE_WRAP_ACTIVE",
178
173
  `exec /bin/sh ${shellQuote(wrapperPath)}`,
@@ -181,6 +176,7 @@ function writeShellEnvInjector(wrapperBody) {
181
176
  writeFileSync(bashEnvPath, injector, { mode: 0o600 });
182
177
  writeFileSync(zshenvPath, injector, { mode: 0o600 });
183
178
  return {
179
+ wrapperPath,
184
180
  env: {
185
181
  BASH_ENV: bashEnvPath,
186
182
  ZDOTDIR: dir,
@@ -195,6 +191,55 @@ function writeShellEnvInjector(wrapperBody) {
195
191
  },
196
192
  };
197
193
  }
194
+ function ensureShellEnvWrap(toolCallId, policy) {
195
+ const existing = activeEnvWraps.get(toolCallId);
196
+ if (existing)
197
+ return existing;
198
+ const wrapperBody = wrapperBodyForPolicy(policy);
199
+ if (!wrapperBody)
200
+ return undefined;
201
+ const wrap = writeShellEnvInjector(wrapperBody);
202
+ remember(activeEnvWraps, toolCallId, wrap, (evicted) => evicted.cleanup());
203
+ return wrap;
204
+ }
205
+ /**
206
+ * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
207
+ *
208
+ * bash/zsh source the shell.env injector, so the original command remains in
209
+ * OpenCode's permission/UI state. sh/dash ignore those startup variables; for
210
+ * them, use a short `exec wrapper.sh` command that contains no user payload.
211
+ *
212
+ * background_shell_spawn may already contain the inline non-plugin fallback.
213
+ * The classic hook replaces it with the original command (bash/zsh) or the
214
+ * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
215
+ */
216
+ export function prepareCursorShellArgs(toolCallId, args) {
217
+ const policy = policies.get(toolCallId);
218
+ if (!policy)
219
+ return;
220
+ if (!policy.backgroundSpawn && policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
221
+ return;
222
+ pendingEnvWraps.add(toolCallId);
223
+ if (!policy.backgroundSpawn) {
224
+ // The wrapper returns just after Cursor's foreground window. OpenCode's own
225
+ // timeout is only an outer safety net and must not win the race.
226
+ args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
227
+ }
228
+ const shellKind = resolveCursorShellKind();
229
+ if (process.platform === "win32" || shellKind === "bash" || shellKind === "zsh") {
230
+ // Native Windows PowerShell/cmd wrapping remains unsupported; do not emit
231
+ // a POSIX /bin/sh command there. Git Bash still uses the env path above.
232
+ args.command = policy.command;
233
+ return;
234
+ }
235
+ const wrap = ensureShellEnvWrap(toolCallId, policy);
236
+ if (wrap)
237
+ args.command = `exec /bin/sh ${shellQuote(wrap.wrapperPath)}`;
238
+ }
239
+ /** Restore the model-facing command in OpenCode's completed tool title. */
240
+ export function cursorShellOriginalCommand(toolCallId) {
241
+ return policies.get(toolCallId)?.command || undefined;
242
+ }
198
243
  /** Drop injector temp files for a finished/abandoned Cursor shell call. */
199
244
  export function releaseCursorShellEnv(toolCallId) {
200
245
  pendingEnvWraps.delete(toolCallId);
@@ -205,8 +250,8 @@ export function releaseCursorShellEnv(toolCallId) {
205
250
  active.cleanup();
206
251
  }
207
252
  /**
208
- * Env vars for OpenCode's `shell.env` hook so bash/zsh execute the Cursor
209
- * wrapper while `args.command` (and therefore the bash UI) stay original.
253
+ * Env vars for OpenCode's shell.env hook. bash/zsh execute the injector; the
254
+ * same materialized wrapper backs the direct-command sh/dash fallback.
210
255
  */
211
256
  export function cursorShellEnvForCall(toolCallId) {
212
257
  if (typeof toolCallId !== "string" || !toolCallId || !pendingEnvWraps.has(toolCallId))
@@ -214,18 +259,9 @@ export function cursorShellEnvForCall(toolCallId) {
214
259
  const policy = policies.get(toolCallId);
215
260
  if (!policy)
216
261
  return undefined;
217
- const existing = activeEnvWraps.get(toolCallId);
218
- if (existing)
219
- return existing.env;
220
- const wrapperBody = policy.backgroundSpawn
221
- ? buildBackgroundShellCommand(policy.command)
222
- : policy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND
223
- ? buildSoftBackgroundCommand(policy)
224
- : undefined;
225
- if (!wrapperBody)
262
+ const wrap = ensureShellEnvWrap(toolCallId, policy);
263
+ if (!wrap)
226
264
  return undefined;
227
- const wrap = writeShellEnvInjector(wrapperBody);
228
- remember(activeEnvWraps, toolCallId, wrap, (evicted) => evicted.cleanup());
229
265
  pendingEnvWraps.delete(toolCallId);
230
266
  return wrap.env;
231
267
  }
@@ -430,4 +466,5 @@ export function resetCursorShellCalls() {
430
466
  pendingEnvWraps.clear();
431
467
  policies.clear();
432
468
  outcomes.clear();
469
+ configuredShell = undefined;
433
470
  }
@@ -5,6 +5,7 @@ export declare function unaryAvailableModels(token: string, options?: {
5
5
  apiBaseURL?: string;
6
6
  baseURL?: string;
7
7
  headers?: Record<string, string>;
8
+ timeoutMs?: number;
8
9
  }): Promise<Record<string, unknown>>;
9
10
  /**
10
11
  * Cursor Run stream hosts from GetServerConfig / agentBaseURL.
@@ -30,6 +31,7 @@ export declare function fetchAgentUrl(token: string, options?: {
30
31
  apiBaseURL?: string;
31
32
  baseURL?: string;
32
33
  telemetryEnabled?: boolean;
34
+ timeoutMs?: number;
33
35
  }): Promise<string>;
34
36
  export type BidiStream = {
35
37
  write(msg: Uint8Array): boolean | void;
@@ -5,8 +5,25 @@ import { getDeviceIds } from "../protocol/device-id.js";
5
5
  import { resolveClientVersion } from "../protocol/client-version.js";
6
6
  import { trace } from "../debug.js";
7
7
  import { CursorLocalCancellationError, CursorProtocolError, CursorTransportError, cursorGrpcError, cursorHttpError, errorCode, CursorProviderError, } from "../errors.js";
8
+ import { withAbortDeadline } from "../deadline.js";
8
9
  import http2 from "node:http2";
9
10
  const API_BASE = `https://${CURSOR_API_HOST}`;
11
+ const DEFAULT_UNARY_TIMEOUT_MS = 5_000;
12
+ const MAX_TIMER_MS = 2_147_483_647;
13
+ function unaryTimeoutMs(operation, value) {
14
+ const timeoutMs = value ?? DEFAULT_UNARY_TIMEOUT_MS;
15
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_MS) {
16
+ throw new CursorProtocolError(`${operation} timeout must be a positive integer no greater than ${MAX_TIMER_MS}`, { code: "CURSOR_INVALID_TIMEOUT" });
17
+ }
18
+ return timeoutMs;
19
+ }
20
+ async function withUnaryDeadline(operation, timeoutMs, timeoutCode, run) {
21
+ return withAbortDeadline(timeoutMs, () => new CursorTransportError(`${operation} timed out after ${timeoutMs}ms`, {
22
+ transient: true,
23
+ replaySafe: true,
24
+ code: timeoutCode,
25
+ }), run);
26
+ }
10
27
  function resolveApiBaseURL(options) {
11
28
  return new URL(options.apiBaseURL ?? options.baseURL ?? API_BASE).origin;
12
29
  }
@@ -33,42 +50,46 @@ export function buildBaseHeaders(token, clientVersion, extra) {
33
50
  export async function unaryAvailableModels(token, options = {}) {
34
51
  const base = resolveApiBaseURL(options);
35
52
  const url = `${base}/aiserver.v1.AiService/AvailableModels`;
36
- const clientVersion = await resolveClientVersion();
37
- const headers = buildBaseHeaders(token, clientVersion, options.headers);
38
- let res;
39
- try {
40
- res = await fetch(url, {
41
- method: "POST",
42
- headers: {
43
- ...headers,
44
- "content-type": "application/json",
45
- accept: "application/json",
46
- },
47
- // Request parameterized effort/context/fast variants like Cursor IDE.
48
- body: JSON.stringify({
49
- includeLongContextModels: true,
50
- useModelParameters: true,
51
- useCloudAgentEffortModes: true,
52
- }),
53
- });
54
- }
55
- catch (cause) {
56
- throw new CursorTransportError("AvailableModels network request failed", {
57
- transient: true,
58
- replaySafe: true,
59
- code: errorCode(cause),
60
- cause,
61
- });
62
- }
63
- if (!res.ok) {
64
- throw await cursorHttpResponseError("AvailableModels failed:", res);
65
- }
66
- try {
67
- return (await res.json());
68
- }
69
- catch (cause) {
70
- throw new CursorProtocolError("AvailableModels returned malformed JSON", { cause });
71
- }
53
+ const timeoutMs = unaryTimeoutMs("AvailableModels", options.timeoutMs);
54
+ return withUnaryDeadline("AvailableModels", timeoutMs, "CURSOR_AVAILABLE_MODELS_TIMEOUT", async (signal) => {
55
+ const clientVersion = await resolveClientVersion();
56
+ const headers = buildBaseHeaders(token, clientVersion, options.headers);
57
+ let res;
58
+ try {
59
+ res = await fetch(url, {
60
+ method: "POST",
61
+ headers: {
62
+ ...headers,
63
+ "content-type": "application/json",
64
+ accept: "application/json",
65
+ },
66
+ // Request parameterized effort/context/fast variants like Cursor IDE.
67
+ body: JSON.stringify({
68
+ includeLongContextModels: true,
69
+ useModelParameters: true,
70
+ useCloudAgentEffortModes: true,
71
+ }),
72
+ signal,
73
+ });
74
+ }
75
+ catch (cause) {
76
+ throw new CursorTransportError("AvailableModels network request failed", {
77
+ transient: true,
78
+ replaySafe: true,
79
+ code: errorCode(cause),
80
+ cause,
81
+ });
82
+ }
83
+ if (!res.ok) {
84
+ throw await cursorHttpResponseError("AvailableModels failed:", res);
85
+ }
86
+ try {
87
+ return (await res.json());
88
+ }
89
+ catch (cause) {
90
+ throw new CursorProtocolError("AvailableModels returned malformed JSON", { cause });
91
+ }
92
+ });
72
93
  }
73
94
  // ── Unary (GetServerConfig) ──
74
95
  /**
@@ -116,51 +137,55 @@ export function normalizeAgentRunOrigin(raw) {
116
137
  export async function fetchAgentUrl(token, options = {}) {
117
138
  const base = resolveApiBaseURL(options);
118
139
  const url = `${base}${SERVER_CONFIG_PATH}`;
119
- const clientVersion = await resolveClientVersion();
120
- // Match Cursor CLI: GetServerConfig uses base headers only session headers belong on Run.
121
- const headers = buildBaseHeaders(token, clientVersion);
122
- trace(`GetServerConfig POST ${url}`);
123
- let res;
124
- try {
125
- res = await fetch(url, {
126
- method: "POST",
127
- headers: {
128
- ...headers,
129
- "content-type": "application/json",
130
- accept: "application/json",
131
- },
132
- body: JSON.stringify({ telem_enabled: options.telemetryEnabled ?? false }),
133
- });
134
- }
135
- catch (cause) {
136
- throw new CursorTransportError("GetServerConfig network request failed", {
137
- transient: true,
138
- replaySafe: true,
139
- code: errorCode(cause),
140
- cause,
141
- });
142
- }
143
- if (!res.ok) {
144
- throw await cursorHttpResponseError("GetServerConfig failed:", res);
145
- }
146
- let body;
147
- try {
148
- body = (await res.json());
149
- }
150
- catch (cause) {
151
- throw new CursorProtocolError("GetServerConfig returned malformed JSON", { cause });
152
- }
153
- const cfg = body.agentUrlConfig;
154
- const raw = cfg?.agentnUrl || cfg?.agentUrl;
155
- const normalized = normalizeAgentRunOrigin(raw);
156
- trace(`GetServerConfig reply: agentnUrl=${cfg?.agentnUrl ?? "<missing>"} ` +
157
- `agentUrl=${cfg?.agentUrl ?? "<missing>"} ${normalized ?? "<invalid>"}`);
158
- if (!normalized) {
159
- throw new CursorProtocolError(raw
160
- ? "GetServerConfig returned an invalid Cursor agent URL"
161
- : "GetServerConfig response missing agentUrlConfig.agentnUrl");
162
- }
163
- return normalized;
140
+ const timeoutMs = unaryTimeoutMs("GetServerConfig", options.timeoutMs);
141
+ return withUnaryDeadline("GetServerConfig", timeoutMs, "CURSOR_AGENT_URL_TIMEOUT", async (signal) => {
142
+ const clientVersion = await resolveClientVersion();
143
+ // Match Cursor CLI: GetServerConfig uses base headers only — session headers belong on Run.
144
+ const headers = buildBaseHeaders(token, clientVersion);
145
+ trace(`GetServerConfig POST ${url}`);
146
+ let res;
147
+ try {
148
+ res = await fetch(url, {
149
+ method: "POST",
150
+ headers: {
151
+ ...headers,
152
+ "content-type": "application/json",
153
+ accept: "application/json",
154
+ },
155
+ body: JSON.stringify({ telem_enabled: options.telemetryEnabled ?? false }),
156
+ signal,
157
+ });
158
+ }
159
+ catch (cause) {
160
+ throw new CursorTransportError("GetServerConfig network request failed", {
161
+ transient: true,
162
+ replaySafe: true,
163
+ code: errorCode(cause),
164
+ cause,
165
+ });
166
+ }
167
+ if (!res.ok) {
168
+ throw await cursorHttpResponseError("GetServerConfig failed:", res);
169
+ }
170
+ let body;
171
+ try {
172
+ body = (await res.json());
173
+ }
174
+ catch (cause) {
175
+ throw new CursorProtocolError("GetServerConfig returned malformed JSON", { cause });
176
+ }
177
+ const cfg = body.agentUrlConfig;
178
+ const raw = cfg?.agentnUrl || cfg?.agentUrl;
179
+ const normalized = normalizeAgentRunOrigin(raw);
180
+ trace(`GetServerConfig reply: agentnUrl=${cfg?.agentnUrl ?? "<missing>"} ` +
181
+ `agentUrl=${cfg?.agentUrl ?? "<missing>"} ${normalized ?? "<invalid>"}`);
182
+ if (!normalized) {
183
+ throw new CursorProtocolError(raw
184
+ ? "GetServerConfig returned an invalid Cursor agent URL"
185
+ : "GetServerConfig response missing agentUrlConfig.agentnUrl");
186
+ }
187
+ return normalized;
188
+ });
164
189
  }
165
190
  export class CursorRunInterruptedError extends CursorTransportError {
166
191
  constructor(message = "Cursor Run ended before turn_ended", options) {
@@ -217,7 +242,6 @@ const CONNECT_TIMEOUT_MS = 15_000;
217
242
  const DEFAULT_SESSION_PING_TIMEOUT_MS = 5_000;
218
243
  const DEFAULT_READ_IDLE_MS = 120_000;
219
244
  const WRITE_DRAIN_TIMEOUT_CODE = "CURSOR_WRITE_DRAIN_TIMEOUT";
220
- const MAX_TIMER_MS = 2_147_483_647;
221
245
  export const HTTP2_SESSION_MAX_AGE_MS = 15 * 60_000;
222
246
  export function shouldReuseHttp2Session(state, createdAt, now = Date.now()) {
223
247
  return !state.destroyed && !state.closed && now - createdAt < HTTP2_SESSION_MAX_AGE_MS;
@@ -0,0 +1,34 @@
1
+ import { type ToolContext, type ToolResult } from "@opencode-ai/plugin";
2
+ export type OpenCodeWebSearchArgs = {
3
+ query: string;
4
+ numResults?: number;
5
+ livecrawl?: "fallback" | "preferred";
6
+ type?: "auto" | "fast" | "deep";
7
+ contextMaxCharacters?: number;
8
+ };
9
+ export declare function parseOpenCodeWebSearchResponse(raw: string): string | undefined;
10
+ export declare function executeOpenCodeWebSearch(args: OpenCodeWebSearchArgs, context: ToolContext, fetchImpl?: typeof fetch): Promise<ToolResult>;
11
+ export declare const openCodeWebSearchTool: {
12
+ description: string;
13
+ args: {
14
+ query: import("zod").ZodString;
15
+ numResults: import("zod").ZodOptional<import("zod").ZodNumber>;
16
+ livecrawl: import("zod").ZodOptional<import("zod").ZodEnum<{
17
+ fallback: "fallback";
18
+ preferred: "preferred";
19
+ }>>;
20
+ type: import("zod").ZodOptional<import("zod").ZodEnum<{
21
+ fast: "fast";
22
+ auto: "auto";
23
+ deep: "deep";
24
+ }>>;
25
+ contextMaxCharacters: import("zod").ZodOptional<import("zod").ZodNumber>;
26
+ };
27
+ execute(args: {
28
+ query: string;
29
+ numResults?: number | undefined;
30
+ livecrawl?: "fallback" | "preferred" | undefined;
31
+ type?: "fast" | "auto" | "deep" | undefined;
32
+ contextMaxCharacters?: number | undefined;
33
+ }, context: ToolContext): Promise<ToolResult>;
34
+ };
@@ -0,0 +1,127 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
3
+ const WEB_SEARCH_TIMEOUT_MS = 25_000;
4
+ function exaMcpUrl() {
5
+ const apiKey = process.env.EXA_API_KEY;
6
+ if (!apiKey)
7
+ return EXA_MCP_URL;
8
+ const url = new URL(EXA_MCP_URL);
9
+ url.searchParams.set("exaApiKey", apiKey);
10
+ return url.href;
11
+ }
12
+ function mcpText(value) {
13
+ if (!value || typeof value !== "object")
14
+ return undefined;
15
+ const result = value.result;
16
+ if (!result || typeof result !== "object")
17
+ return undefined;
18
+ const content = result.content;
19
+ if (!Array.isArray(content))
20
+ return undefined;
21
+ for (const item of content) {
22
+ if (item &&
23
+ typeof item === "object" &&
24
+ item.type === "text" &&
25
+ typeof item.text === "string") {
26
+ return item.text;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+ export function parseOpenCodeWebSearchResponse(raw) {
32
+ const trimmed = raw.trim();
33
+ if (!trimmed)
34
+ return undefined;
35
+ try {
36
+ const text = mcpText(JSON.parse(trimmed));
37
+ if (text)
38
+ return text;
39
+ }
40
+ catch {
41
+ // MCP may respond as an SSE stream instead of one JSON object.
42
+ }
43
+ for (const line of raw.split("\n")) {
44
+ if (!line.startsWith("data: "))
45
+ continue;
46
+ try {
47
+ const text = mcpText(JSON.parse(line.slice(6)));
48
+ if (text)
49
+ return text;
50
+ }
51
+ catch {
52
+ // Ignore non-JSON SSE events.
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ export async function executeOpenCodeWebSearch(args, context, fetchImpl = fetch) {
58
+ await context.ask({
59
+ permission: "websearch",
60
+ patterns: [args.query],
61
+ always: ["*"],
62
+ metadata: {
63
+ query: args.query,
64
+ numResults: args.numResults,
65
+ livecrawl: args.livecrawl,
66
+ type: args.type,
67
+ contextMaxCharacters: args.contextMaxCharacters,
68
+ provider: "exa",
69
+ },
70
+ });
71
+ const controller = new AbortController();
72
+ const abort = () => controller.abort(context.abort.reason);
73
+ if (context.abort.aborted)
74
+ abort();
75
+ else
76
+ context.abort.addEventListener("abort", abort, { once: true });
77
+ const timeout = setTimeout(() => controller.abort(new Error("Web search timed out")), WEB_SEARCH_TIMEOUT_MS);
78
+ try {
79
+ const response = await fetchImpl(exaMcpUrl(), {
80
+ method: "POST",
81
+ headers: {
82
+ accept: "application/json, text/event-stream",
83
+ "content-type": "application/json",
84
+ },
85
+ body: JSON.stringify({
86
+ jsonrpc: "2.0",
87
+ id: 1,
88
+ method: "tools/call",
89
+ params: {
90
+ name: "web_search_exa",
91
+ arguments: {
92
+ query: args.query,
93
+ type: args.type ?? "auto",
94
+ numResults: args.numResults ?? 8,
95
+ livecrawl: args.livecrawl ?? "fallback",
96
+ contextMaxCharacters: args.contextMaxCharacters,
97
+ },
98
+ },
99
+ }),
100
+ signal: controller.signal,
101
+ });
102
+ const raw = await response.text();
103
+ if (!response.ok)
104
+ throw new Error(`Web search failed (${response.status}): ${raw.slice(0, 500)}`);
105
+ const output = parseOpenCodeWebSearchResponse(raw) ?? "No search results found. Please try a different query.";
106
+ return {
107
+ title: `Exa Web Search: ${args.query}`,
108
+ output,
109
+ metadata: { provider: "exa" },
110
+ };
111
+ }
112
+ finally {
113
+ clearTimeout(timeout);
114
+ context.abort.removeEventListener("abort", abort);
115
+ }
116
+ }
117
+ export const openCodeWebSearchTool = tool({
118
+ description: "Search the web for current information using OpenCode's web search backend.",
119
+ args: {
120
+ query: tool.schema.string().describe("Web search query"),
121
+ numResults: tool.schema.number().int().min(1).max(20).optional(),
122
+ livecrawl: tool.schema.enum(["fallback", "preferred"]).optional(),
123
+ type: tool.schema.enum(["auto", "fast", "deep"]).optional(),
124
+ contextMaxCharacters: tool.schema.number().int().positive().optional(),
125
+ },
126
+ execute: executeOpenCodeWebSearch,
127
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,7 +61,13 @@
61
61
  "protobufjs": "^7.4.0"
62
62
  },
63
63
  "peerDependencies": {
64
- "@opencode-ai/plugin": "^1.17.13"
64
+ "@opencode-ai/plugin": "^1.17.13",
65
+ "@opencode-compat/profile": ">=0.1.0"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@opencode-compat/profile": {
69
+ "optional": true
70
+ }
65
71
  },
66
72
  "devDependencies": {
67
73
  "@opencode-ai/plugin": "^1.17.13",
@@ -69,4 +75,4 @@
69
75
  "@types/node": "^22.15.3",
70
76
  "typescript": "^5.8.3"
71
77
  }
72
- }
78
+ }