@playwo/opencode-cursor-oauth 0.0.0-dev.a9d6c62f0dd9 → 0.0.0-dev.b8e6dd72a8b6

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/README.md CHANGED
@@ -44,7 +44,7 @@ OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
44
44
  ## How it works
45
45
 
46
46
  1. OAuth — browser-based login to Cursor via PKCE.
47
- 2. Model discovery — queries Cursor's gRPC API for all available models and fails plugin loading visibly if discovery does not succeed.
47
+ 2. Model discovery — queries Cursor's gRPC API for all available models; if discovery fails, the plugin disables the Cursor provider for that load and shows a visible error toast instead of crashing OpenCode.
48
48
  3. Local proxy — translates `POST /v1/chat/completions` into Cursor's
49
49
  protobuf/Connect protocol.
50
50
  4. Native tool routing — rejects Cursor's built-in filesystem/shell tools and
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
2
2
  import { getCursorModels } from "./models";
3
- import { startProxy } from "./proxy";
3
+ import { startProxy, stopProxy } from "./proxy";
4
4
  const CURSOR_PROVIDER_ID = "cursor";
5
+ let lastModelDiscoveryError = null;
5
6
  /**
6
7
  * OpenCode plugin that provides Cursor authentication and model access.
7
8
  * Register in opencode.json: { "plugin": ["opencode-cursor-oauth"] }
@@ -29,7 +30,25 @@ export const CursorAuthPlugin = async (input) => {
29
30
  });
30
31
  accessToken = refreshed.access;
31
32
  }
32
- const models = await getCursorModels(accessToken);
33
+ let models;
34
+ try {
35
+ models = await getCursorModels(accessToken);
36
+ lastModelDiscoveryError = null;
37
+ }
38
+ catch (error) {
39
+ const message = error instanceof Error
40
+ ? error.message
41
+ : "Cursor model discovery failed.";
42
+ stopProxy();
43
+ if (provider) {
44
+ provider.models = {};
45
+ }
46
+ if (message !== lastModelDiscoveryError) {
47
+ lastModelDiscoveryError = message;
48
+ await showDiscoveryFailureToast(input, message);
49
+ }
50
+ return buildDisabledProviderConfig(message);
51
+ }
33
52
  const port = await startProxy(async () => {
34
53
  const currentAuth = await getAuth();
35
54
  if (currentAuth.type !== "oauth") {
@@ -145,6 +164,37 @@ function buildCursorProviderModels(models, port) {
145
164
  },
146
165
  ]));
147
166
  }
167
+ async function showDiscoveryFailureToast(input, message) {
168
+ try {
169
+ await input.client.tui.showToast({
170
+ body: {
171
+ title: "Cursor plugin disabled",
172
+ message,
173
+ variant: "error",
174
+ duration: 8_000,
175
+ },
176
+ });
177
+ }
178
+ catch { }
179
+ }
180
+ function buildDisabledProviderConfig(message) {
181
+ return {
182
+ baseURL: "http://127.0.0.1/cursor-disabled/v1",
183
+ apiKey: "cursor-disabled",
184
+ async fetch() {
185
+ return new Response(JSON.stringify({
186
+ error: {
187
+ message,
188
+ type: "server_error",
189
+ code: "cursor_model_discovery_failed",
190
+ },
191
+ }), {
192
+ status: 503,
193
+ headers: { "Content-Type": "application/json" },
194
+ });
195
+ },
196
+ };
197
+ }
148
198
  // $/M token rates from cursor.com/docs/models-and-pricing
149
199
  const MODEL_COST_TABLE = {
150
200
  // Anthropic
package/dist/models.js CHANGED
@@ -73,10 +73,13 @@ export function clearModelCache() {
73
73
  }
74
74
  function buildDiscoveryHttpError(exitCode, body) {
75
75
  const detail = extractDiscoveryErrorDetail(body);
76
+ const protocolHint = exitCode === 464
77
+ ? " Likely protocol mismatch: Cursor appears to expect an HTTP/2 Connect unary request."
78
+ : "";
76
79
  if (!detail) {
77
- return `Cursor model discovery failed with HTTP ${exitCode}.`;
80
+ return `Cursor model discovery failed with HTTP ${exitCode}.${protocolHint}`;
78
81
  }
79
- return `Cursor model discovery failed with HTTP ${exitCode}: ${detail}`;
82
+ return `Cursor model discovery failed with HTTP ${exitCode}: ${detail}.${protocolHint}`;
80
83
  }
81
84
  function extractDiscoveryErrorDetail(body) {
82
85
  if (body.length === 0)
package/dist/proxy.d.ts CHANGED
@@ -4,6 +4,7 @@ interface CursorUnaryRpcOptions {
4
4
  requestBody: Uint8Array;
5
5
  url?: string;
6
6
  timeoutMs?: number;
7
+ transport?: "auto" | "fetch" | "http2";
7
8
  }
8
9
  export declare function callCursorUnaryRpc(options: CursorUnaryRpcOptions): Promise<{
9
10
  body: Uint8Array;
package/dist/proxy.js CHANGED
@@ -16,8 +16,10 @@ import { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protob
16
16
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
17
17
  import { AgentClientMessageSchema, AgentRunRequestSchema, AgentServerMessageSchema, BidiRequestIdSchema, ClientHeartbeatSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolDefinitionSchema, McpToolResultContentItemSchema, ModelDetailsSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, UserMessageSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "./proto/agent_pb";
18
18
  import { createHash } from "node:crypto";
19
+ import { connect as connectHttp2 } from "node:http2";
19
20
  const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
20
21
  const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
22
+ const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
21
23
  const CONNECT_END_STREAM_FLAG = 0b00000010;
22
24
  const SSE_HEADERS = {
23
25
  "Content-Type": "text/event-stream",
@@ -47,18 +49,19 @@ function frameConnectMessage(data, flags = 0) {
47
49
  return frame;
48
50
  }
49
51
  function buildCursorHeaders(options, contentType, extra = {}) {
50
- const headers = new Headers({
52
+ const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
53
+ return headers;
54
+ }
55
+ function buildCursorHeaderValues(options, contentType, extra = {}) {
56
+ return {
51
57
  authorization: `Bearer ${options.accessToken}`,
52
58
  "content-type": contentType,
53
59
  "x-ghost-mode": "true",
54
60
  "x-cursor-client-version": CURSOR_CLIENT_VERSION,
55
61
  "x-cursor-client-type": "cli",
56
62
  "x-request-id": crypto.randomUUID(),
57
- });
58
- for (const [key, value] of Object.entries(extra)) {
59
- headers.set(key, value);
60
- }
61
- return headers;
63
+ ...extra,
64
+ };
62
65
  }
63
66
  function encodeVarint(value) {
64
67
  if (!Number.isSafeInteger(value) || value < 0) {
@@ -236,6 +239,17 @@ async function createCursorSession(options) {
236
239
  };
237
240
  }
238
241
  export async function callCursorUnaryRpc(options) {
242
+ const target = new URL(options.rpcPath, options.url ?? CURSOR_API_URL);
243
+ const transport = options.transport ?? "auto";
244
+ if (transport === "http2" || (transport === "auto" && target.protocol === "https:")) {
245
+ const http2Result = await callCursorUnaryRpcOverHttp2(options, target);
246
+ if (transport === "http2" || http2Result.timedOut || http2Result.exitCode !== 1) {
247
+ return http2Result;
248
+ }
249
+ }
250
+ return callCursorUnaryRpcOverFetch(options, target);
251
+ }
252
+ async function callCursorUnaryRpcOverFetch(options, target) {
239
253
  let timedOut = false;
240
254
  const timeoutMs = options.timeoutMs ?? 5_000;
241
255
  const controller = new AbortController();
@@ -246,9 +260,13 @@ export async function callCursorUnaryRpc(options) {
246
260
  }, timeoutMs)
247
261
  : undefined;
248
262
  try {
249
- const response = await fetch(new URL(options.rpcPath, options.url ?? CURSOR_API_URL), {
263
+ const response = await fetch(target, {
250
264
  method: "POST",
251
- headers: buildCursorHeaders(options, "application/proto"),
265
+ headers: buildCursorHeaders(options, "application/proto", {
266
+ accept: "application/proto, application/json",
267
+ "connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
268
+ "connect-timeout-ms": String(timeoutMs),
269
+ }),
252
270
  body: toFetchBody(options.requestBody),
253
271
  signal: controller.signal,
254
272
  });
@@ -271,6 +289,96 @@ export async function callCursorUnaryRpc(options) {
271
289
  clearTimeout(timeout);
272
290
  }
273
291
  }
292
+ async function callCursorUnaryRpcOverHttp2(options, target) {
293
+ const timeoutMs = options.timeoutMs ?? 5_000;
294
+ const authority = `${target.protocol}//${target.host}`;
295
+ return new Promise((resolve) => {
296
+ let settled = false;
297
+ let timedOut = false;
298
+ let session;
299
+ let stream;
300
+ const finish = (result) => {
301
+ if (settled)
302
+ return;
303
+ settled = true;
304
+ if (timeout)
305
+ clearTimeout(timeout);
306
+ try {
307
+ stream?.close();
308
+ }
309
+ catch { }
310
+ try {
311
+ session?.close();
312
+ }
313
+ catch { }
314
+ resolve(result);
315
+ };
316
+ const timeout = timeoutMs > 0
317
+ ? setTimeout(() => {
318
+ timedOut = true;
319
+ finish({
320
+ body: new Uint8Array(),
321
+ exitCode: 124,
322
+ timedOut: true,
323
+ });
324
+ }, timeoutMs)
325
+ : undefined;
326
+ try {
327
+ session = connectHttp2(authority);
328
+ session.once("error", () => {
329
+ finish({
330
+ body: new Uint8Array(),
331
+ exitCode: timedOut ? 124 : 1,
332
+ timedOut,
333
+ });
334
+ });
335
+ const headers = {
336
+ ":method": "POST",
337
+ ":path": `${target.pathname}${target.search}`,
338
+ ...buildCursorHeaderValues(options, "application/proto", {
339
+ accept: "application/proto, application/json",
340
+ "connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
341
+ "connect-timeout-ms": String(timeoutMs),
342
+ }),
343
+ };
344
+ stream = session.request(headers);
345
+ let statusCode = 0;
346
+ const chunks = [];
347
+ stream.once("response", (responseHeaders) => {
348
+ const statusHeader = responseHeaders[":status"];
349
+ statusCode = typeof statusHeader === "number"
350
+ ? statusHeader
351
+ : Number(statusHeader ?? 0);
352
+ });
353
+ stream.on("data", (chunk) => {
354
+ chunks.push(Buffer.from(chunk));
355
+ });
356
+ stream.once("end", () => {
357
+ const body = new Uint8Array(Buffer.concat(chunks));
358
+ finish({
359
+ body,
360
+ exitCode: statusCode >= 200 && statusCode < 300 ? 0 : (statusCode || 1),
361
+ timedOut,
362
+ });
363
+ });
364
+ stream.once("error", () => {
365
+ finish({
366
+ body: new Uint8Array(),
367
+ exitCode: timedOut ? 124 : 1,
368
+ timedOut,
369
+ });
370
+ });
371
+ stream.end(Buffer.from(options.requestBody));
372
+ }
373
+ catch {
374
+ finish({
375
+ body: new Uint8Array(),
376
+ exitCode: timedOut ? 124 : 1,
377
+ timedOut,
378
+ });
379
+ }
380
+ });
381
+ }
274
382
  let proxyServer;
275
383
  let proxyPort;
276
384
  let proxyAccessTokenProvider;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwo/opencode-cursor-oauth",
3
- "version": "0.0.0-dev.a9d6c62f0dd9",
3
+ "version": "0.0.0-dev.b8e6dd72a8b6",
4
4
  "description": "OpenCode plugin that connects Cursor's API to OpenCode via OAuth, model discovery, and a local OpenAI-compatible proxy.",
5
5
  "license": "MIT",
6
6
  "type": "module",