@playwo/opencode-cursor-oauth 0.0.0-dev.de8f891a2e99 → 0.0.0-dev.e795e5ffd849
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 +19 -91
- package/dist/index.js +1 -1
- package/dist/proxy.js +66 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,103 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-cursor-oauth
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
5
|
+
## What it does
|
|
7
6
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
Then authenticate via the OpenCode UI (Settings → Providers → Cursor → Login).
|
|
91
23
|
|
|
92
|
-
|
|
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
|
-
-
|
|
26
|
+
- Cursor account with API access
|
|
27
|
+
- OpenCode 1.2+
|
|
98
28
|
|
|
99
|
-
##
|
|
29
|
+
## License
|
|
100
30
|
|
|
101
|
-
|
|
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 { startProxy, stopProxy, } from "./proxy";
|
|
5
5
|
const CURSOR_PROVIDER_ID = "cursor";
|
|
6
6
|
let lastModelDiscoveryError = null;
|
|
7
7
|
/**
|
package/dist/proxy.js
CHANGED
|
@@ -546,7 +546,7 @@ function handleChatCompletion(body, accessToken) {
|
|
|
546
546
|
let stored = conversationStates.get(convKey);
|
|
547
547
|
if (!stored) {
|
|
548
548
|
stored = {
|
|
549
|
-
conversationId:
|
|
549
|
+
conversationId: crypto.randomUUID(),
|
|
550
550
|
checkpoint: null,
|
|
551
551
|
blobStore: new Map(),
|
|
552
552
|
lastAccessMs: Date.now(),
|
|
@@ -766,6 +766,12 @@ function makeHeartbeatBytes() {
|
|
|
766
766
|
});
|
|
767
767
|
return toBinary(AgentClientMessageSchema, heartbeat);
|
|
768
768
|
}
|
|
769
|
+
function scheduleBridgeEnd(bridge) {
|
|
770
|
+
queueMicrotask(() => {
|
|
771
|
+
if (bridge.alive)
|
|
772
|
+
bridge.end();
|
|
773
|
+
});
|
|
774
|
+
}
|
|
769
775
|
/**
|
|
770
776
|
* Create a stateful parser for Connect protocol frames.
|
|
771
777
|
* Handles buffering partial data across chunks.
|
|
@@ -908,6 +914,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
|
|
|
908
914
|
const blobId = kvMsg.message.value.blobId;
|
|
909
915
|
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
910
916
|
const blobData = blobStore.get(blobIdKey);
|
|
917
|
+
if (!blobData) {
|
|
918
|
+
logPluginWarn("Cursor requested missing blob", {
|
|
919
|
+
blobId: blobIdKey,
|
|
920
|
+
knownBlobCount: blobStore.size,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
911
923
|
sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
|
|
912
924
|
}
|
|
913
925
|
else if (kvCase === "setBlobArgs") {
|
|
@@ -1083,28 +1095,16 @@ function deriveBridgeKey(modelId, messages) {
|
|
|
1083
1095
|
}
|
|
1084
1096
|
/** Derive a key for conversation state. Model-independent so context survives model switches. */
|
|
1085
1097
|
function deriveConversationKey(messages) {
|
|
1086
|
-
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1087
|
-
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1088
1098
|
return createHash("sha256")
|
|
1089
|
-
.update(
|
|
1099
|
+
.update(buildConversationFingerprint(messages))
|
|
1090
1100
|
.digest("hex")
|
|
1091
1101
|
.slice(0, 16);
|
|
1092
1102
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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("-");
|
|
1103
|
+
function buildConversationFingerprint(messages) {
|
|
1104
|
+
return messages.map((message) => {
|
|
1105
|
+
const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
|
|
1106
|
+
return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
|
|
1107
|
+
}).join("\n---\n");
|
|
1108
1108
|
}
|
|
1109
1109
|
/** Create an SSE streaming Response that reads from a live bridge. */
|
|
1110
1110
|
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
|
|
@@ -1156,6 +1156,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1156
1156
|
};
|
|
1157
1157
|
const tagFilter = createThinkingTagFilter();
|
|
1158
1158
|
let mcpExecReceived = false;
|
|
1159
|
+
let endStreamError = null;
|
|
1159
1160
|
const processChunk = createConnectFrameParser((messageBytes) => {
|
|
1160
1161
|
try {
|
|
1161
1162
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
@@ -1215,10 +1216,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1215
1216
|
// Skip unparseable messages
|
|
1216
1217
|
}
|
|
1217
1218
|
}, (endStreamBytes) => {
|
|
1218
|
-
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1219
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1220
|
+
if (endStreamError) {
|
|
1221
|
+
logPluginError("Cursor stream returned Connect end-stream error", {
|
|
1222
|
+
modelId,
|
|
1223
|
+
bridgeKey,
|
|
1224
|
+
convKey,
|
|
1225
|
+
...errorDetails(endStreamError),
|
|
1226
|
+
});
|
|
1221
1227
|
}
|
|
1228
|
+
scheduleBridgeEnd(bridge);
|
|
1222
1229
|
});
|
|
1223
1230
|
bridge.onData(processChunk);
|
|
1224
1231
|
bridge.onClose((code) => {
|
|
@@ -1229,6 +1236,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1229
1236
|
stored.blobStore.set(k, v);
|
|
1230
1237
|
stored.lastAccessMs = Date.now();
|
|
1231
1238
|
}
|
|
1239
|
+
if (endStreamError) {
|
|
1240
|
+
activeBridges.delete(bridgeKey);
|
|
1241
|
+
if (!closed) {
|
|
1242
|
+
closed = true;
|
|
1243
|
+
controller.error(endStreamError);
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1232
1247
|
if (!mcpExecReceived) {
|
|
1233
1248
|
const flushed = tagFilter.flush();
|
|
1234
1249
|
if (flushed.reasoning)
|
|
@@ -1240,16 +1255,17 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1240
1255
|
sendDone();
|
|
1241
1256
|
closeController();
|
|
1242
1257
|
}
|
|
1243
|
-
else
|
|
1244
|
-
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1245
|
-
// Close the SSE stream so the client doesn't hang forever.
|
|
1246
|
-
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1247
|
-
sendSSE(makeChunk({}, "stop"));
|
|
1248
|
-
sendSSE(makeUsageChunk());
|
|
1249
|
-
sendDone();
|
|
1250
|
-
closeController();
|
|
1251
|
-
// Remove stale entry so the next request doesn't try to resume it.
|
|
1258
|
+
else {
|
|
1252
1259
|
activeBridges.delete(bridgeKey);
|
|
1260
|
+
if (code !== 0 && !closed) {
|
|
1261
|
+
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1262
|
+
// Close the SSE stream so the client doesn't hang forever.
|
|
1263
|
+
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1264
|
+
sendSSE(makeChunk({}, "stop"));
|
|
1265
|
+
sendSSE(makeUsageChunk());
|
|
1266
|
+
sendDone();
|
|
1267
|
+
closeController();
|
|
1268
|
+
}
|
|
1253
1269
|
}
|
|
1254
1270
|
});
|
|
1255
1271
|
},
|
|
@@ -1318,7 +1334,7 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
|
|
|
1318
1334
|
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
|
|
1319
1335
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1320
1336
|
const created = Math.floor(Date.now() / 1000);
|
|
1321
|
-
const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
|
|
1337
|
+
const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
|
|
1322
1338
|
return new Response(JSON.stringify({
|
|
1323
1339
|
id: completionId,
|
|
1324
1340
|
object: "chat.completion",
|
|
@@ -1334,9 +1350,10 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
|
1334
1350
|
usage,
|
|
1335
1351
|
}), { headers: { "Content-Type": "application/json" } });
|
|
1336
1352
|
}
|
|
1337
|
-
async function collectFullResponse(payload, accessToken, convKey) {
|
|
1338
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
1353
|
+
async function collectFullResponse(payload, accessToken, modelId, convKey) {
|
|
1354
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1339
1355
|
let fullText = "";
|
|
1356
|
+
let endStreamError = null;
|
|
1340
1357
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1341
1358
|
const state = {
|
|
1342
1359
|
toolCallIndex: 0,
|
|
@@ -1364,7 +1381,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1364
1381
|
catch {
|
|
1365
1382
|
// Skip
|
|
1366
1383
|
}
|
|
1367
|
-
}, () => {
|
|
1384
|
+
}, (endStreamBytes) => {
|
|
1385
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1386
|
+
if (endStreamError) {
|
|
1387
|
+
logPluginError("Cursor non-streaming response returned Connect end-stream error", {
|
|
1388
|
+
modelId,
|
|
1389
|
+
convKey,
|
|
1390
|
+
...errorDetails(endStreamError),
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
scheduleBridgeEnd(bridge);
|
|
1394
|
+
}));
|
|
1368
1395
|
bridge.onClose(() => {
|
|
1369
1396
|
clearInterval(heartbeatTimer);
|
|
1370
1397
|
const stored = conversationStates.get(convKey);
|
|
@@ -1375,6 +1402,10 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1375
1402
|
}
|
|
1376
1403
|
const flushed = tagFilter.flush();
|
|
1377
1404
|
fullText += flushed.content;
|
|
1405
|
+
if (endStreamError) {
|
|
1406
|
+
reject(endStreamError);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1378
1409
|
const usage = computeUsage(state);
|
|
1379
1410
|
resolve({
|
|
1380
1411
|
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.
|
|
3
|
+
"version": "0.0.0-dev.e795e5ffd849",
|
|
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",
|