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

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; if discovery fails, the plugin disables the Cursor provider for that load and shows a visible error toast instead of crashing OpenCode.
47
+ 2. Model discovery — queries Cursor's gRPC API for all available models.
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,8 +1,7 @@
1
1
  import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
2
2
  import { getCursorModels } from "./models";
3
- import { startProxy, stopProxy } from "./proxy";
3
+ import { startProxy } from "./proxy";
4
4
  const CURSOR_PROVIDER_ID = "cursor";
5
- let lastModelDiscoveryError = null;
6
5
  /**
7
6
  * OpenCode plugin that provides Cursor authentication and model access.
8
7
  * Register in opencode.json: { "plugin": ["opencode-cursor-oauth"] }
@@ -30,25 +29,7 @@ export const CursorAuthPlugin = async (input) => {
30
29
  });
31
30
  accessToken = refreshed.access;
32
31
  }
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
- }
32
+ const models = await getCursorModels(accessToken);
52
33
  const port = await startProxy(async () => {
53
34
  const currentAuth = await getAuth();
54
35
  if (currentAuth.type !== "oauth") {
@@ -164,37 +145,6 @@ function buildCursorProviderModels(models, port) {
164
145
  },
165
146
  ]));
166
147
  }
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
- }
198
148
  // $/M token rates from cursor.com/docs/models-and-pricing
199
149
  const MODEL_COST_TABLE = {
200
150
  // Anthropic
package/dist/models.d.ts CHANGED
@@ -5,9 +5,6 @@ export interface CursorModel {
5
5
  contextWindow: number;
6
6
  maxTokens: number;
7
7
  }
8
- export declare class CursorModelDiscoveryError extends Error {
9
- constructor(message: string);
10
- }
11
8
  export declare function getCursorModels(apiKey: string): Promise<CursorModel[]>;
12
9
  /** @internal Test-only. */
13
10
  export declare function clearModelCache(): void;
package/dist/models.js CHANGED
@@ -1,9 +1,13 @@
1
+ /**
2
+ * Cursor model discovery via GetUsableModels.
3
+ * Uses the H2 bridge for transport. Falls back to a hardcoded list
4
+ * when discovery fails.
5
+ */
1
6
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
2
7
  import { z } from "zod";
3
8
  import { callCursorUnaryRpc } from "./proxy";
4
9
  import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema, } from "./proto/agent_pb";
5
10
  const GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
6
- const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
7
11
  const DEFAULT_CONTEXT_WINDOW = 200_000;
8
12
  const DEFAULT_MAX_TOKENS = 64_000;
9
13
  const CursorModelDetailsSchema = z.object({
@@ -18,12 +22,24 @@ const CursorModelDetailsSchema = z.object({
18
22
  .transform((aliases) => (aliases ?? []).filter((alias) => typeof alias === "string")),
19
23
  thinkingDetails: z.unknown().optional(),
20
24
  });
21
- export class CursorModelDiscoveryError extends Error {
22
- constructor(message) {
23
- super(message);
24
- this.name = "CursorModelDiscoveryError";
25
- }
26
- }
25
+ const FALLBACK_MODELS = [
26
+ // Composer models
27
+ { id: "composer-1", name: "Composer 1", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
28
+ { id: "composer-1.5", name: "Composer 1.5", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
29
+ // Claude models
30
+ { id: "claude-4.6-opus-high", name: "Claude 4.6 Opus", reasoning: true, contextWindow: 200_000, maxTokens: 128_000 },
31
+ { id: "claude-4.6-sonnet-medium", name: "Claude 4.6 Sonnet", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
32
+ { id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
33
+ // GPT models
34
+ { id: "gpt-5.4-medium", name: "GPT-5.4", reasoning: true, contextWindow: 272_000, maxTokens: 128_000 },
35
+ { id: "gpt-5.2", name: "GPT-5.2", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
36
+ { id: "gpt-5.2-codex", name: "GPT-5.2 Codex", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
37
+ { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
38
+ { id: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark", reasoning: true, contextWindow: 128_000, maxTokens: 128_000 },
39
+ // Other models
40
+ { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", reasoning: true, contextWindow: 1_000_000, maxTokens: 64_000 },
41
+ { id: "grok-code-fast-1", name: "Grok Code Fast 1", reasoning: false, contextWindow: 128_000, maxTokens: 64_000 },
42
+ ];
27
43
  async function fetchCursorUsableModels(apiKey) {
28
44
  try {
29
45
  const requestPayload = create(GetUsableModelsRequestSchema, {});
@@ -32,31 +48,18 @@ async function fetchCursorUsableModels(apiKey) {
32
48
  accessToken: apiKey,
33
49
  rpcPath: GET_USABLE_MODELS_PATH,
34
50
  requestBody,
35
- timeoutMs: MODEL_DISCOVERY_TIMEOUT_MS,
36
51
  });
37
- if (response.timedOut) {
38
- throw new CursorModelDiscoveryError(`Cursor model discovery timed out after ${MODEL_DISCOVERY_TIMEOUT_MS}ms.`);
39
- }
40
- if (response.exitCode !== 0) {
41
- throw new CursorModelDiscoveryError(buildDiscoveryHttpError(response.exitCode, response.body));
42
- }
43
- if (response.body.length === 0) {
44
- throw new CursorModelDiscoveryError("Cursor model discovery returned an empty response.");
52
+ if (response.timedOut || response.exitCode !== 0 || response.body.length === 0) {
53
+ return null;
45
54
  }
46
55
  const decoded = decodeGetUsableModelsResponse(response.body);
47
- if (!decoded) {
48
- throw new CursorModelDiscoveryError("Cursor model discovery returned an unreadable response.");
49
- }
56
+ if (!decoded)
57
+ return null;
50
58
  const models = normalizeCursorModels(decoded.models);
51
- if (models.length === 0) {
52
- throw new CursorModelDiscoveryError("Cursor model discovery returned no usable models.");
53
- }
54
- return models;
59
+ return models.length > 0 ? models : null;
55
60
  }
56
- catch (error) {
57
- if (error instanceof CursorModelDiscoveryError)
58
- throw error;
59
- throw new CursorModelDiscoveryError("Cursor model discovery failed.");
61
+ catch {
62
+ return null;
60
63
  }
61
64
  }
62
65
  let cachedModels = null;
@@ -64,43 +67,13 @@ export async function getCursorModels(apiKey) {
64
67
  if (cachedModels)
65
68
  return cachedModels;
66
69
  const discovered = await fetchCursorUsableModels(apiKey);
67
- cachedModels = discovered;
70
+ cachedModels = discovered && discovered.length > 0 ? discovered : FALLBACK_MODELS;
68
71
  return cachedModels;
69
72
  }
70
73
  /** @internal Test-only. */
71
74
  export function clearModelCache() {
72
75
  cachedModels = null;
73
76
  }
74
- function buildDiscoveryHttpError(exitCode, body) {
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
- : "";
79
- if (!detail) {
80
- return `Cursor model discovery failed with HTTP ${exitCode}.${protocolHint}`;
81
- }
82
- return `Cursor model discovery failed with HTTP ${exitCode}: ${detail}.${protocolHint}`;
83
- }
84
- function extractDiscoveryErrorDetail(body) {
85
- if (body.length === 0)
86
- return null;
87
- const text = new TextDecoder().decode(body).trim();
88
- if (!text)
89
- return null;
90
- try {
91
- const parsed = JSON.parse(text);
92
- const code = typeof parsed.code === "string" ? parsed.code : undefined;
93
- const message = typeof parsed.message === "string" ? parsed.message : undefined;
94
- if (message && code)
95
- return `${message} (${code})`;
96
- if (message)
97
- return message;
98
- if (code)
99
- return code;
100
- }
101
- catch { }
102
- return text.length > 200 ? `${text.slice(0, 197)}...` : text;
103
- }
104
77
  function decodeGetUsableModelsResponse(payload) {
105
78
  try {
106
79
  return fromBinary(GetUsableModelsResponseSchema, payload);
package/dist/proxy.d.ts CHANGED
@@ -4,7 +4,6 @@ interface CursorUnaryRpcOptions {
4
4
  requestBody: Uint8Array;
5
5
  url?: string;
6
6
  timeoutMs?: number;
7
- transport?: "auto" | "fetch" | "http2";
8
7
  }
9
8
  export declare function callCursorUnaryRpc(options: CursorUnaryRpcOptions): Promise<{
10
9
  body: Uint8Array;
package/dist/proxy.js CHANGED
@@ -16,10 +16,8 @@ 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";
20
19
  const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
21
20
  const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
22
- const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
23
21
  const CONNECT_END_STREAM_FLAG = 0b00000010;
24
22
  const SSE_HEADERS = {
25
23
  "Content-Type": "text/event-stream",
@@ -49,19 +47,18 @@ function frameConnectMessage(data, flags = 0) {
49
47
  return frame;
50
48
  }
51
49
  function buildCursorHeaders(options, contentType, extra = {}) {
52
- const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
53
- return headers;
54
- }
55
- function buildCursorHeaderValues(options, contentType, extra = {}) {
56
- return {
50
+ const headers = new Headers({
57
51
  authorization: `Bearer ${options.accessToken}`,
58
52
  "content-type": contentType,
59
53
  "x-ghost-mode": "true",
60
54
  "x-cursor-client-version": CURSOR_CLIENT_VERSION,
61
55
  "x-cursor-client-type": "cli",
62
56
  "x-request-id": crypto.randomUUID(),
63
- ...extra,
64
- };
57
+ });
58
+ for (const [key, value] of Object.entries(extra)) {
59
+ headers.set(key, value);
60
+ }
61
+ return headers;
65
62
  }
66
63
  function encodeVarint(value) {
67
64
  if (!Number.isSafeInteger(value) || value < 0) {
@@ -239,17 +236,6 @@ async function createCursorSession(options) {
239
236
  };
240
237
  }
241
238
  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) {
253
239
  let timedOut = false;
254
240
  const timeoutMs = options.timeoutMs ?? 5_000;
255
241
  const controller = new AbortController();
@@ -260,13 +246,9 @@ async function callCursorUnaryRpcOverFetch(options, target) {
260
246
  }, timeoutMs)
261
247
  : undefined;
262
248
  try {
263
- const response = await fetch(target, {
249
+ const response = await fetch(new URL(options.rpcPath, options.url ?? CURSOR_API_URL), {
264
250
  method: "POST",
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
- }),
251
+ headers: buildCursorHeaders(options, "application/proto"),
270
252
  body: toFetchBody(options.requestBody),
271
253
  signal: controller.signal,
272
254
  });
@@ -289,96 +271,6 @@ async function callCursorUnaryRpcOverFetch(options, target) {
289
271
  clearTimeout(timeout);
290
272
  }
291
273
  }
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
- }
382
274
  let proxyServer;
383
275
  let proxyPort;
384
276
  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.b8e6dd72a8b6",
3
+ "version": "0.0.9",
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",