@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 +19 -91
- package/dist/index.js +8 -1
- package/dist/proxy.d.ts +3 -0
- package/dist/proxy.js +70 -30
- 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 { 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:
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
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.
|
|
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",
|