@playwo/opencode-cursor-oauth 0.0.0-dev.1b946f85e9b0 → 0.0.0-dev.65683458d3f1

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
@@ -1,103 +1,31 @@
1
- # @playwo/opencode-cursor-oauth
1
+ # opencode-cursor-oauth
2
2
 
3
- OpenCode plugin that connects to Cursor's API, giving you access to Cursor
4
- models inside OpenCode with full tool-calling support.
3
+ Use Cursor models (Claude, GPT, Gemini, etc.) inside [OpenCode](https://opencode.ai).
5
4
 
6
- ## Install in OpenCode
5
+ ## What it does
7
6
 
8
- Add this to `~/.config/opencode/opencode.json`:
7
+ - **OAuth login** to Cursor via browser
8
+ - **Model discovery** — automatically fetches your available Cursor models
9
+ - **Local proxy** — runs an OpenAI-compatible endpoint that translates to Cursor's gRPC protocol
10
+ - **Auto-refresh** — handles token expiration automatically
9
11
 
10
- ```jsonc
11
- {
12
- "$schema": "https://opencode.ai/config.json",
13
- "plugin": [
14
- "@playwo/opencode-cursor-oauth"
15
- ],
16
- "provider": {
17
- "cursor": {
18
- "name": "Cursor"
19
- }
20
- }
21
- }
22
- ```
23
-
24
- The `cursor` provider stub is required because OpenCode drops providers that do
25
- not already exist in its bundled provider catalog.
26
-
27
- OpenCode installs npm plugins automatically at startup, so users do not need to
28
- clone this repository.
29
-
30
- ## Authenticate
31
-
32
- ```sh
33
- opencode auth login --provider cursor
34
- ```
35
-
36
- This opens Cursor OAuth in the browser. Tokens are stored in
37
- `~/.local/share/opencode/auth.json` and refreshed automatically.
38
-
39
- ## Use
40
-
41
- Start OpenCode and select any Cursor model. The plugin starts a local
42
- OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
43
-
44
- ## How it works
45
-
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.
48
- 3. Local proxy — translates `POST /v1/chat/completions` into Cursor's
49
- protobuf/Connect protocol.
50
- 4. Native tool routing — rejects Cursor's built-in filesystem/shell tools and
51
- exposes OpenCode's tool surface via Cursor MCP instead.
52
-
53
- Cursor agent streaming uses Cursor's `RunSSE` + `BidiAppend` transport, so the
54
- plugin runs entirely inside OpenCode without a Node sidecar.
12
+ ## Install
55
13
 
56
- ## Architecture
14
+ Add to your `opencode.json`:
57
15
 
16
+ ```json
17
+ {
18
+ "plugin": ["@playwo/opencode-cursor-oauth"]
19
+ }
58
20
  ```
59
- OpenCode --> /v1/chat/completions --> Bun.serve (proxy)
60
- |
61
- RunSSE stream + BidiAppend writes
62
- |
63
- Cursor Connect/SSE transport
64
- |
65
- api2.cursor.sh gRPC
66
- ```
67
-
68
- ### Tool call flow
69
-
70
- ```
71
- 1. Cursor model receives OpenAI tools via RequestContext (as MCP tool defs)
72
- 2. Model tries native tools (readArgs, shellArgs, etc.)
73
- 3. Proxy rejects each with typed error (ReadRejected, ShellRejected, etc.)
74
- 4. Model falls back to MCP tool -> mcpArgs exec message
75
- 5. Proxy emits OpenAI tool_calls SSE chunk, pauses the Cursor stream
76
- 6. OpenCode executes tool, sends result in follow-up request
77
- 7. Proxy resumes the Cursor stream with mcpResult and continues streaming
78
- ```
79
-
80
- ## Develop locally
81
-
82
- ```sh
83
- bun install
84
- bun run build
85
- bun test/smoke.ts
86
- ```
87
-
88
- ## Publish
89
21
 
90
- GitHub Actions publishes this package with `.github/workflows/publish-npm.yml`.
22
+ Then authenticate via the OpenCode UI (Settings → Providers → Cursor → Login).
91
23
 
92
- - branch pushes publish a `dev` build as `0.0.0-dev.<sha>`
93
- - versioned releases publish `latest` using the `package.json` version and upload the packed `.tgz` to the GitHub release
94
-
95
- Repository secrets required:
24
+ ## Requirements
96
25
 
97
- - `NPM_TOKEN` for npm publish access
26
+ - Cursor account with API access
27
+ - OpenCode 1.2+
98
28
 
99
- ## Requirements
29
+ ## License
100
30
 
101
- - [OpenCode](https://opencode.ai)
102
- - [Bun](https://bun.sh)
103
- - Active [Cursor](https://cursor.com) subscription
31
+ MIT
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
2
2
  import { configurePluginLogger, errorDetails, logPluginError, logPluginWarn } from "./logger";
3
3
  import { getCursorModels } from "./models";
4
- import { startProxy, stopProxy } from "./proxy";
4
+ import { OPENCODE_AGENT_HEADER, OPENCODE_MESSAGE_ID_HEADER, OPENCODE_SESSION_ID_HEADER, startProxy, stopProxy, } from "./proxy";
5
5
  const CURSOR_PROVIDER_ID = "cursor";
6
6
  let lastModelDiscoveryError = null;
7
7
  /**
@@ -128,6 +128,13 @@ export const CursorAuthPlugin = async (input) => {
128
128
  },
129
129
  ],
130
130
  },
131
+ async "chat.headers"(input, output) {
132
+ if (input.model.providerID !== CURSOR_PROVIDER_ID)
133
+ return;
134
+ output.headers[OPENCODE_SESSION_ID_HEADER] = input.sessionID;
135
+ output.headers[OPENCODE_AGENT_HEADER] = input.agent;
136
+ output.headers[OPENCODE_MESSAGE_ID_HEADER] = input.message.id;
137
+ },
131
138
  };
132
139
  };
133
140
  function buildCursorProviderModels(models, port) {
package/dist/proxy.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export declare const OPENCODE_SESSION_ID_HEADER = "x-opencode-session-id";
2
+ export declare const OPENCODE_AGENT_HEADER = "x-opencode-agent";
3
+ export declare const OPENCODE_MESSAGE_ID_HEADER = "x-opencode-message-id";
1
4
  interface CursorUnaryRpcOptions {
2
5
  accessToken: string;
3
6
  rpcPath: string;
package/dist/proxy.js CHANGED
@@ -22,11 +22,15 @@ const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
22
22
  const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
23
23
  const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
24
24
  const CONNECT_END_STREAM_FLAG = 0b00000010;
25
+ export const OPENCODE_SESSION_ID_HEADER = "x-opencode-session-id";
26
+ export const OPENCODE_AGENT_HEADER = "x-opencode-agent";
27
+ export const OPENCODE_MESSAGE_ID_HEADER = "x-opencode-message-id";
25
28
  const SSE_HEADERS = {
26
29
  "Content-Type": "text/event-stream",
27
30
  "Cache-Control": "no-cache",
28
31
  Connection: "keep-alive",
29
32
  };
33
+ const EPHEMERAL_CURSOR_AGENTS = new Set(["title", "summary"]);
30
34
  // Active bridges keyed by a session token (derived from conversation state).
31
35
  // When tool_calls are returned, the bridge stays alive. The next request
32
36
  // with tool results looks up the bridge and sends mcpResult messages.
@@ -471,7 +475,11 @@ export async function startProxy(getAccessToken, models = []) {
471
475
  throw new Error("Cursor proxy access token provider not configured");
472
476
  }
473
477
  const accessToken = await proxyAccessTokenProvider();
474
- return handleChatCompletion(body, accessToken);
478
+ return handleChatCompletion(body, accessToken, {
479
+ sessionID: req.headers.get(OPENCODE_SESSION_ID_HEADER) ?? undefined,
480
+ agent: req.headers.get(OPENCODE_AGENT_HEADER) ?? undefined,
481
+ messageID: req.headers.get(OPENCODE_MESSAGE_ID_HEADER) ?? undefined,
482
+ });
475
483
  }
476
484
  catch (err) {
477
485
  const message = err instanceof Error ? err.message : String(err);
@@ -509,7 +517,7 @@ export function stopProxy() {
509
517
  activeBridges.clear();
510
518
  conversationStates.clear();
511
519
  }
512
- function handleChatCompletion(body, accessToken) {
520
+ function handleChatCompletion(body, accessToken, requestScope = {}) {
513
521
  const { systemPrompt, userText, turns, toolResults } = parseMessages(body.messages);
514
522
  const modelId = body.model;
515
523
  const tools = body.tools ?? [];
@@ -523,8 +531,8 @@ function handleChatCompletion(body, accessToken) {
523
531
  }
524
532
  // bridgeKey: model-specific, for active tool-call bridges
525
533
  // convKey: model-independent, for conversation state that survives model switches
526
- const bridgeKey = deriveBridgeKey(modelId, body.messages);
527
- const convKey = deriveConversationKey(body.messages);
534
+ const bridgeKey = deriveBridgeKey(modelId, body.messages, requestScope);
535
+ const convKey = deriveConversationKey(body.messages, requestScope);
528
536
  const activeBridge = activeBridges.get(bridgeKey);
529
537
  if (activeBridge && toolResults.length > 0) {
530
538
  activeBridges.delete(bridgeKey);
@@ -546,7 +554,7 @@ function handleChatCompletion(body, accessToken) {
546
554
  let stored = conversationStates.get(convKey);
547
555
  if (!stored) {
548
556
  stored = {
549
- conversationId: deterministicConversationId(convKey),
557
+ conversationId: crypto.randomUUID(),
550
558
  checkpoint: null,
551
559
  blobStore: new Map(),
552
560
  lastAccessMs: Date.now(),
@@ -908,6 +916,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
908
916
  const blobId = kvMsg.message.value.blobId;
909
917
  const blobIdKey = Buffer.from(blobId).toString("hex");
910
918
  const blobData = blobStore.get(blobIdKey);
919
+ if (!blobData) {
920
+ logPluginWarn("Cursor requested missing blob", {
921
+ blobId: blobIdKey,
922
+ knownBlobCount: blobStore.size,
923
+ });
924
+ }
911
925
  sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
912
926
  }
913
927
  else if (kvCase === "setBlobArgs") {
@@ -1073,16 +1087,25 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
1073
1087
  sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
1074
1088
  }
1075
1089
  /** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */
1076
- function deriveBridgeKey(modelId, messages) {
1090
+ function deriveBridgeKey(modelId, messages, requestScope) {
1077
1091
  const firstUserMsg = messages.find((m) => m.role === "user");
1078
1092
  const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1079
1093
  return createHash("sha256")
1080
- .update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
1094
+ .update(`bridge:${requestScope.sessionID ?? ""}:${requestScope.agent ?? ""}:${modelId}:${firstUserText.slice(0, 200)}`)
1081
1095
  .digest("hex")
1082
1096
  .slice(0, 16);
1083
1097
  }
1084
1098
  /** Derive a key for conversation state. Model-independent so context survives model switches. */
1085
- function deriveConversationKey(messages) {
1099
+ function deriveConversationKey(messages, requestScope) {
1100
+ if (requestScope.sessionID) {
1101
+ const scope = shouldIsolateConversation(requestScope)
1102
+ ? `${requestScope.sessionID}:${requestScope.agent ?? ""}:${requestScope.messageID ?? crypto.randomUUID()}`
1103
+ : `${requestScope.sessionID}:${requestScope.agent ?? "default"}`;
1104
+ return createHash("sha256")
1105
+ .update(`conv:${scope}`)
1106
+ .digest("hex")
1107
+ .slice(0, 16);
1108
+ }
1086
1109
  const firstUserMsg = messages.find((m) => m.role === "user");
1087
1110
  const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1088
1111
  return createHash("sha256")
@@ -1090,21 +1113,10 @@ function deriveConversationKey(messages) {
1090
1113
  .digest("hex")
1091
1114
  .slice(0, 16);
1092
1115
  }
1093
- /** Deterministic UUID derived from convKey so Cursor's server-side conversation
1094
- * persists across proxy restarts. Formats 16 bytes of SHA-256 as a v4-shaped UUID. */
1095
- function deterministicConversationId(convKey) {
1096
- const hex = createHash("sha256")
1097
- .update(`cursor-conv-id:${convKey}`)
1098
- .digest("hex")
1099
- .slice(0, 32);
1100
- // Format as UUID: xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx
1101
- return [
1102
- hex.slice(0, 8),
1103
- hex.slice(8, 12),
1104
- `4${hex.slice(13, 16)}`,
1105
- `${(0x8 | (parseInt(hex[16], 16) & 0x3)).toString(16)}${hex.slice(17, 20)}`,
1106
- hex.slice(20, 32),
1107
- ].join("-");
1116
+ function shouldIsolateConversation(requestScope) {
1117
+ return Boolean(requestScope.agent
1118
+ && EPHEMERAL_CURSOR_AGENTS.has(requestScope.agent)
1119
+ && requestScope.messageID);
1108
1120
  }
1109
1121
  /** Create an SSE streaming Response that reads from a live bridge. */
1110
1122
  function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
@@ -1156,6 +1168,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1156
1168
  };
1157
1169
  const tagFilter = createThinkingTagFilter();
1158
1170
  let mcpExecReceived = false;
1171
+ let endStreamError = null;
1159
1172
  const processChunk = createConnectFrameParser((messageBytes) => {
1160
1173
  try {
1161
1174
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
@@ -1215,9 +1228,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1215
1228
  // Skip unparseable messages
1216
1229
  }
1217
1230
  }, (endStreamBytes) => {
1218
- const endError = parseConnectEndStream(endStreamBytes);
1219
- if (endError) {
1220
- sendSSE(makeChunk({ content: `\n[Error: ${endError.message}]` }));
1231
+ endStreamError = parseConnectEndStream(endStreamBytes);
1232
+ if (endStreamError) {
1233
+ logPluginError("Cursor stream returned Connect end-stream error", {
1234
+ modelId,
1235
+ bridgeKey,
1236
+ convKey,
1237
+ ...errorDetails(endStreamError),
1238
+ });
1221
1239
  }
1222
1240
  });
1223
1241
  bridge.onData(processChunk);
@@ -1229,6 +1247,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1229
1247
  stored.blobStore.set(k, v);
1230
1248
  stored.lastAccessMs = Date.now();
1231
1249
  }
1250
+ if (endStreamError) {
1251
+ activeBridges.delete(bridgeKey);
1252
+ if (!closed) {
1253
+ closed = true;
1254
+ controller.error(endStreamError);
1255
+ }
1256
+ return;
1257
+ }
1232
1258
  if (!mcpExecReceived) {
1233
1259
  const flushed = tagFilter.flush();
1234
1260
  if (flushed.reasoning)
@@ -1318,7 +1344,7 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
1318
1344
  async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
1319
1345
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1320
1346
  const created = Math.floor(Date.now() / 1000);
1321
- const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
1347
+ const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
1322
1348
  return new Response(JSON.stringify({
1323
1349
  id: completionId,
1324
1350
  object: "chat.completion",
@@ -1334,9 +1360,10 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
1334
1360
  usage,
1335
1361
  }), { headers: { "Content-Type": "application/json" } });
1336
1362
  }
1337
- async function collectFullResponse(payload, accessToken, convKey) {
1338
- const { promise, resolve } = Promise.withResolvers();
1363
+ async function collectFullResponse(payload, accessToken, modelId, convKey) {
1364
+ const { promise, resolve, reject } = Promise.withResolvers();
1339
1365
  let fullText = "";
1366
+ let endStreamError = null;
1340
1367
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1341
1368
  const state = {
1342
1369
  toolCallIndex: 0,
@@ -1364,7 +1391,16 @@ async function collectFullResponse(payload, accessToken, convKey) {
1364
1391
  catch {
1365
1392
  // Skip
1366
1393
  }
1367
- }, () => { }));
1394
+ }, (endStreamBytes) => {
1395
+ endStreamError = parseConnectEndStream(endStreamBytes);
1396
+ if (endStreamError) {
1397
+ logPluginError("Cursor non-streaming response returned Connect end-stream error", {
1398
+ modelId,
1399
+ convKey,
1400
+ ...errorDetails(endStreamError),
1401
+ });
1402
+ }
1403
+ }));
1368
1404
  bridge.onClose(() => {
1369
1405
  clearInterval(heartbeatTimer);
1370
1406
  const stored = conversationStates.get(convKey);
@@ -1375,6 +1411,10 @@ async function collectFullResponse(payload, accessToken, convKey) {
1375
1411
  }
1376
1412
  const flushed = tagFilter.flush();
1377
1413
  fullText += flushed.content;
1414
+ if (endStreamError) {
1415
+ reject(endStreamError);
1416
+ return;
1417
+ }
1378
1418
  const usage = computeUsage(state);
1379
1419
  resolve({
1380
1420
  text: fullText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwo/opencode-cursor-oauth",
3
- "version": "0.0.0-dev.1b946f85e9b0",
3
+ "version": "0.0.0-dev.65683458d3f1",
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",