@playwo/opencode-cursor-oauth 0.0.0-dev.c80ebcb27754 → 0.0.0-dev.da5538092563
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 +32 -83
- package/dist/auth.js +27 -3
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/cursor/bidi-session.d.ts +12 -0
- package/dist/cursor/bidi-session.js +164 -0
- package/dist/cursor/config.d.ts +4 -0
- package/dist/cursor/config.js +4 -0
- package/dist/cursor/connect-framing.d.ts +10 -0
- package/dist/cursor/connect-framing.js +80 -0
- package/dist/cursor/headers.d.ts +6 -0
- package/dist/cursor/headers.js +16 -0
- package/dist/cursor/index.d.ts +5 -0
- package/dist/cursor/index.js +5 -0
- package/dist/cursor/unary-rpc.d.ts +12 -0
- package/dist/cursor/unary-rpc.js +124 -0
- package/dist/index.d.ts +2 -14
- package/dist/index.js +2 -229
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +150 -0
- package/dist/models.d.ts +3 -0
- package/dist/models.js +80 -54
- package/dist/openai/index.d.ts +3 -0
- package/dist/openai/index.js +3 -0
- package/dist/openai/messages.d.ts +39 -0
- package/dist/openai/messages.js +228 -0
- package/dist/openai/tools.d.ts +7 -0
- package/dist/openai/tools.js +58 -0
- package/dist/openai/types.d.ts +41 -0
- package/dist/openai/types.js +1 -0
- package/dist/plugin/cursor-auth-plugin.d.ts +3 -0
- package/dist/plugin/cursor-auth-plugin.js +139 -0
- package/dist/proto/agent_pb.js +637 -319
- package/dist/provider/index.d.ts +2 -0
- package/dist/provider/index.js +2 -0
- package/dist/provider/model-cost.d.ts +9 -0
- package/dist/provider/model-cost.js +206 -0
- package/dist/provider/models.d.ts +8 -0
- package/dist/provider/models.js +86 -0
- package/dist/proxy/bridge-close-controller.d.ts +6 -0
- package/dist/proxy/bridge-close-controller.js +37 -0
- package/dist/proxy/bridge-non-streaming.d.ts +3 -0
- package/dist/proxy/bridge-non-streaming.js +123 -0
- package/dist/proxy/bridge-session.d.ts +5 -0
- package/dist/proxy/bridge-session.js +11 -0
- package/dist/proxy/bridge-streaming.d.ts +5 -0
- package/dist/proxy/bridge-streaming.js +409 -0
- package/dist/proxy/bridge.d.ts +3 -0
- package/dist/proxy/bridge.js +3 -0
- package/dist/proxy/chat-completion.d.ts +2 -0
- package/dist/proxy/chat-completion.js +153 -0
- package/dist/proxy/conversation-meta.d.ts +12 -0
- package/dist/proxy/conversation-meta.js +1 -0
- package/dist/proxy/conversation-state.d.ts +35 -0
- package/dist/proxy/conversation-state.js +95 -0
- package/dist/proxy/cursor-request.d.ts +6 -0
- package/dist/proxy/cursor-request.js +101 -0
- package/dist/proxy/index.d.ts +12 -0
- package/dist/proxy/index.js +12 -0
- package/dist/proxy/server.d.ts +6 -0
- package/dist/proxy/server.js +107 -0
- package/dist/proxy/sse.d.ts +5 -0
- package/dist/proxy/sse.js +5 -0
- package/dist/proxy/state-sync.d.ts +2 -0
- package/dist/proxy/state-sync.js +17 -0
- package/dist/proxy/stream-dispatch.d.ts +42 -0
- package/dist/proxy/stream-dispatch.js +641 -0
- package/dist/proxy/stream-state.d.ts +7 -0
- package/dist/proxy/stream-state.js +1 -0
- package/dist/proxy/title.d.ts +1 -0
- package/dist/proxy/title.js +103 -0
- package/dist/proxy/types.d.ts +32 -0
- package/dist/proxy/types.js +1 -0
- package/dist/proxy.d.ts +2 -19
- package/dist/proxy.js +2 -1221
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,103 +1,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-cursor-oauth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
models inside OpenCode with full tool-calling support.
|
|
3
|
+
## Disclaimer
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This project is a **fork** of [ephraimduncan/opencode-cursor](https://github.com/ephraimduncan/opencode-cursor). Upstream may differ in behavior, features, or maintenance; treat this repository as its own line of development.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## What it does
|
|
9
9
|
|
|
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.
|
|
10
|
+
This is an [OpenCode](https://opencode.ai) plugin that lets you use **Cursor cloud models** (Claude, GPT, Gemini, and whatever your Cursor account exposes) from inside OpenCode.
|
|
38
11
|
|
|
39
|
-
|
|
12
|
+
- **OAuth login** to Cursor in the browser
|
|
13
|
+
- **Model discovery** — loads the models available to your Cursor account
|
|
14
|
+
- **Local OpenAI-compatible proxy** — translates OpenCode’s requests to Cursor’s gRPC API
|
|
15
|
+
- **Token refresh** — refreshes access tokens so sessions keep working
|
|
40
16
|
|
|
41
|
-
|
|
42
|
-
OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
|
|
17
|
+
There are **no extra runtime requirements** beyond what OpenCode already needs: you do not install Node, Python, or Docker separately for this plugin. Enable it in OpenCode’s config and complete login in the UI.
|
|
43
18
|
|
|
44
|
-
##
|
|
19
|
+
## Install
|
|
45
20
|
|
|
46
|
-
|
|
47
|
-
2. Model discovery — queries Cursor's gRPC API for all available models.
|
|
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.
|
|
21
|
+
Add the package to your OpenCode configuration (for example `opencode.json`):
|
|
52
22
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
```
|
|
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
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"plugin": ["@playwo/opencode-cursor-oauth"]
|
|
26
|
+
}
|
|
66
27
|
```
|
|
67
28
|
|
|
68
|
-
|
|
29
|
+
Install or update dependencies the way you normally do for OpenCode plugins (e.g. ensure the package is available to your OpenCode environment). You need **OpenCode 1.2+** and a **Cursor account** with API/model access.
|
|
69
30
|
|
|
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
|
-
```
|
|
31
|
+
## Connect auth and use it
|
|
79
32
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
bun run build
|
|
85
|
-
bun test/smoke.ts
|
|
86
|
-
```
|
|
33
|
+
1. Start OpenCode with the plugin enabled.
|
|
34
|
+
2. Open **Settings → Providers → Cursor** (wording may vary slightly by OpenCode version).
|
|
35
|
+
3. Choose **Login** (or equivalent) and complete **OAuth** in the browser when prompted.
|
|
36
|
+
4. After login, pick a Cursor-backed model from the model list and use OpenCode as usual.
|
|
87
37
|
|
|
88
|
-
|
|
38
|
+
If something fails, check that you are signed into the correct Cursor account and that your plan includes the models you expect.
|
|
89
39
|
|
|
90
|
-
|
|
40
|
+
## Compatibiliy Notes
|
|
91
41
|
|
|
92
|
-
|
|
93
|
-
|
|
42
|
+
Cursor is not a raw model endpoint like the others supported in Opencode. It brings its own system prompt tools and mechanics.
|
|
43
|
+
This plugin does try its best to make mcps, skills etc installed in Opencode work in Cursor.
|
|
94
44
|
|
|
95
|
-
|
|
45
|
+
There are some issues with Cursors system prompt in this environment tho. Cursor adds various tools to the agent which opencode does not have, hence when the agent calls these they will be rejected which can sometimes lead to the agent no longer responding. Am still looking for a way to fix this, till then when the agent stops responding for a while interrupt it and tell it to continue again.
|
|
96
46
|
|
|
97
|
-
|
|
47
|
+
## Stability and issues
|
|
98
48
|
|
|
99
|
-
|
|
49
|
+
This integration can be **buggy** or break when Cursor or OpenCode change their APIs or UI.
|
|
100
50
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
- Active [Cursor](https://cursor.com) subscription
|
|
51
|
+
> [!TIP]
|
|
52
|
+
> If you hit problems, missing models, or confusing errors, please **[open an issue](https://github.com/PoolPirate/opencode-cursor/issues)** on this repository with steps to reproduce and logs or screenshots when possible.
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generatePKCE } from "./pkce";
|
|
2
|
+
import { errorDetails, logPluginError, logPluginWarn } from "./logger";
|
|
2
3
|
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
|
|
3
4
|
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
|
|
4
5
|
const CURSOR_REFRESH_URL = process.env.CURSOR_REFRESH_URL ??
|
|
@@ -40,13 +41,32 @@ export async function pollCursorAuth(uuid, verifier) {
|
|
|
40
41
|
}
|
|
41
42
|
throw new Error(`Poll failed: ${response.status}`);
|
|
42
43
|
}
|
|
43
|
-
catch {
|
|
44
|
+
catch (error) {
|
|
44
45
|
consecutiveErrors++;
|
|
45
46
|
if (consecutiveErrors >= 3) {
|
|
47
|
+
logPluginError("Cursor auth polling failed repeatedly", {
|
|
48
|
+
stage: "oauth_poll",
|
|
49
|
+
uuid,
|
|
50
|
+
attempts: attempt + 1,
|
|
51
|
+
consecutiveErrors,
|
|
52
|
+
...errorDetails(error),
|
|
53
|
+
});
|
|
46
54
|
throw new Error("Too many consecutive errors during Cursor auth polling");
|
|
47
55
|
}
|
|
56
|
+
logPluginWarn("Cursor auth polling attempt failed", {
|
|
57
|
+
stage: "oauth_poll",
|
|
58
|
+
uuid,
|
|
59
|
+
attempt: attempt + 1,
|
|
60
|
+
consecutiveErrors,
|
|
61
|
+
...errorDetails(error),
|
|
62
|
+
});
|
|
48
63
|
}
|
|
49
64
|
}
|
|
65
|
+
logPluginError("Cursor authentication polling timed out", {
|
|
66
|
+
stage: "oauth_poll",
|
|
67
|
+
uuid,
|
|
68
|
+
attempts: POLL_MAX_ATTEMPTS,
|
|
69
|
+
});
|
|
50
70
|
throw new Error("Cursor authentication polling timeout");
|
|
51
71
|
}
|
|
52
72
|
export async function refreshCursorToken(refreshToken) {
|
|
@@ -60,6 +80,11 @@ export async function refreshCursorToken(refreshToken) {
|
|
|
60
80
|
});
|
|
61
81
|
if (!response.ok) {
|
|
62
82
|
const error = await response.text();
|
|
83
|
+
logPluginError("Cursor token refresh failed", {
|
|
84
|
+
stage: "token_refresh",
|
|
85
|
+
status: response.status,
|
|
86
|
+
responseBody: error,
|
|
87
|
+
});
|
|
63
88
|
throw new Error(`Cursor token refresh failed: ${error}`);
|
|
64
89
|
}
|
|
65
90
|
const data = (await response.json());
|
|
@@ -86,7 +111,6 @@ export function getTokenExpiry(token) {
|
|
|
86
111
|
return decoded.exp * 1000 - 5 * 60 * 1000;
|
|
87
112
|
}
|
|
88
113
|
}
|
|
89
|
-
catch {
|
|
90
|
-
}
|
|
114
|
+
catch { }
|
|
91
115
|
return Date.now() + 3600 * 1000;
|
|
92
116
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type CursorBaseRequestOptions } from "./headers";
|
|
2
|
+
export interface CursorSession {
|
|
3
|
+
write: (data: Uint8Array) => void;
|
|
4
|
+
end: () => void;
|
|
5
|
+
onData: (cb: (chunk: Buffer) => void) => void;
|
|
6
|
+
onClose: (cb: (code: number) => void) => void;
|
|
7
|
+
readonly alive: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface CreateCursorSessionOptions extends CursorBaseRequestOptions {
|
|
10
|
+
initialRequestBytes: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
export declare function createCursorSession(options: CreateCursorSessionOptions): Promise<CursorSession>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { connect as connectHttp2, } from "node:http2";
|
|
2
|
+
import { CURSOR_API_URL, CURSOR_CONNECT_PROTOCOL_VERSION } from "./config";
|
|
3
|
+
import { frameConnectMessage } from "./connect-framing";
|
|
4
|
+
import { buildCursorHeaderValues, } from "./headers";
|
|
5
|
+
import { errorDetails, logPluginError } from "../logger";
|
|
6
|
+
const CURSOR_BIDI_RUN_PATH = "/agent.v1.AgentService/Run";
|
|
7
|
+
export async function createCursorSession(options) {
|
|
8
|
+
if (options.initialRequestBytes.length === 0) {
|
|
9
|
+
throw new Error("Cursor sessions require an initial request message");
|
|
10
|
+
}
|
|
11
|
+
const target = new URL(CURSOR_BIDI_RUN_PATH, options.url ?? CURSOR_API_URL);
|
|
12
|
+
const authority = `${target.protocol}//${target.host}`;
|
|
13
|
+
const requestId = crypto.randomUUID();
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const cbs = {
|
|
16
|
+
data: null,
|
|
17
|
+
close: null,
|
|
18
|
+
};
|
|
19
|
+
let session;
|
|
20
|
+
let stream;
|
|
21
|
+
let alive = true;
|
|
22
|
+
let closeCode = 0;
|
|
23
|
+
let opened = false;
|
|
24
|
+
let settled = false;
|
|
25
|
+
let statusCode = 0;
|
|
26
|
+
const pendingChunks = [];
|
|
27
|
+
const errorChunks = [];
|
|
28
|
+
const closeTransport = () => {
|
|
29
|
+
try {
|
|
30
|
+
stream?.close();
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
try {
|
|
34
|
+
session?.close();
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
};
|
|
38
|
+
const finish = (code) => {
|
|
39
|
+
if (!alive)
|
|
40
|
+
return;
|
|
41
|
+
alive = false;
|
|
42
|
+
closeCode = code;
|
|
43
|
+
cbs.close?.(code);
|
|
44
|
+
closeTransport();
|
|
45
|
+
};
|
|
46
|
+
const rejectOpen = (error) => {
|
|
47
|
+
if (settled)
|
|
48
|
+
return;
|
|
49
|
+
settled = true;
|
|
50
|
+
alive = false;
|
|
51
|
+
closeTransport();
|
|
52
|
+
reject(error);
|
|
53
|
+
};
|
|
54
|
+
const resolveOpen = (sessionHandle) => {
|
|
55
|
+
if (settled)
|
|
56
|
+
return;
|
|
57
|
+
settled = true;
|
|
58
|
+
opened = true;
|
|
59
|
+
resolve(sessionHandle);
|
|
60
|
+
};
|
|
61
|
+
const handleTransportError = (message, error) => {
|
|
62
|
+
logPluginError(message, {
|
|
63
|
+
requestId,
|
|
64
|
+
url: target.toString(),
|
|
65
|
+
...errorDetails(error),
|
|
66
|
+
});
|
|
67
|
+
if (!opened) {
|
|
68
|
+
rejectOpen(new Error(error instanceof Error ? error.message : String(error ?? message)));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
finish(1);
|
|
72
|
+
};
|
|
73
|
+
const sessionHandle = {
|
|
74
|
+
get alive() {
|
|
75
|
+
return alive;
|
|
76
|
+
},
|
|
77
|
+
write(data) {
|
|
78
|
+
if (!alive || !stream)
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
stream.write(frameConnectMessage(data));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
handleTransportError("Cursor HTTP/2 write failed", error);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
end() {
|
|
88
|
+
finish(0);
|
|
89
|
+
},
|
|
90
|
+
onData(cb) {
|
|
91
|
+
cbs.data = cb;
|
|
92
|
+
while (pendingChunks.length > 0) {
|
|
93
|
+
cb(pendingChunks.shift());
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
onClose(cb) {
|
|
97
|
+
if (!alive) {
|
|
98
|
+
queueMicrotask(() => cb(closeCode));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
cbs.close = cb;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
session = connectHttp2(authority);
|
|
107
|
+
session.once("error", (error) => {
|
|
108
|
+
handleTransportError("Cursor HTTP/2 session failed", error);
|
|
109
|
+
});
|
|
110
|
+
const headers = {
|
|
111
|
+
":method": "POST",
|
|
112
|
+
":path": `${target.pathname}${target.search}`,
|
|
113
|
+
...buildCursorHeaderValues(options, "application/connect+proto", {
|
|
114
|
+
accept: "application/connect+proto",
|
|
115
|
+
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
stream = session.request(headers);
|
|
119
|
+
stream.once("response", (responseHeaders) => {
|
|
120
|
+
const statusHeader = responseHeaders[":status"];
|
|
121
|
+
statusCode =
|
|
122
|
+
typeof statusHeader === "number"
|
|
123
|
+
? statusHeader
|
|
124
|
+
: Number(statusHeader ?? 0);
|
|
125
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
126
|
+
resolveOpen(sessionHandle);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
stream.on("data", (chunk) => {
|
|
130
|
+
const buffer = Buffer.from(chunk);
|
|
131
|
+
if (!opened && statusCode >= 400) {
|
|
132
|
+
errorChunks.push(buffer);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (cbs.data) {
|
|
136
|
+
cbs.data(buffer);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
pendingChunks.push(buffer);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
stream.once("end", () => {
|
|
143
|
+
if (!opened) {
|
|
144
|
+
const errorBody = Buffer.concat(errorChunks).toString("utf8").trim();
|
|
145
|
+
logPluginError("Cursor HTTP/2 Run request failed", {
|
|
146
|
+
requestId,
|
|
147
|
+
status: statusCode,
|
|
148
|
+
responseBody: errorBody,
|
|
149
|
+
});
|
|
150
|
+
rejectOpen(new Error(`Run failed: ${statusCode || 1}${errorBody ? ` ${errorBody}` : ""}`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
finish(statusCode >= 200 && statusCode < 300 ? 0 : statusCode || 1);
|
|
154
|
+
});
|
|
155
|
+
stream.once("error", (error) => {
|
|
156
|
+
handleTransportError("Cursor HTTP/2 stream failed", error);
|
|
157
|
+
});
|
|
158
|
+
stream.write(frameConnectMessage(options.initialRequestBytes));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
handleTransportError("Cursor HTTP/2 transport setup failed", error);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Connect protocol frame: [1-byte flags][4-byte BE length][payload] */
|
|
2
|
+
export declare function frameConnectMessage(data: Uint8Array, flags?: number): Buffer;
|
|
3
|
+
export declare function decodeConnectUnaryBody(payload: Uint8Array): Uint8Array | null;
|
|
4
|
+
export declare function encodeVarint(value: number): Uint8Array;
|
|
5
|
+
export declare function encodeProtoField(tag: number, wireType: number, value: Uint8Array): Uint8Array;
|
|
6
|
+
export declare function encodeProtoStringField(tag: number, value: string): Uint8Array;
|
|
7
|
+
export declare function encodeProtoMessageField(tag: number, value: Uint8Array): Uint8Array;
|
|
8
|
+
export declare function encodeProtoVarintField(tag: number, value: number): Uint8Array;
|
|
9
|
+
export declare function concatBytes(parts: Uint8Array[]): Uint8Array;
|
|
10
|
+
export declare function toFetchBody(data: Uint8Array): ArrayBuffer;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { CONNECT_END_STREAM_FLAG } from "./config";
|
|
2
|
+
/** Connect protocol frame: [1-byte flags][4-byte BE length][payload] */
|
|
3
|
+
export function frameConnectMessage(data, flags = 0) {
|
|
4
|
+
const frame = Buffer.alloc(5 + data.length);
|
|
5
|
+
frame[0] = flags;
|
|
6
|
+
frame.writeUInt32BE(data.length, 1);
|
|
7
|
+
frame.set(data, 5);
|
|
8
|
+
return frame;
|
|
9
|
+
}
|
|
10
|
+
export function decodeConnectUnaryBody(payload) {
|
|
11
|
+
if (payload.length < 5)
|
|
12
|
+
return null;
|
|
13
|
+
let offset = 0;
|
|
14
|
+
while (offset + 5 <= payload.length) {
|
|
15
|
+
const flags = payload[offset];
|
|
16
|
+
const view = new DataView(payload.buffer, payload.byteOffset + offset, payload.byteLength - offset);
|
|
17
|
+
const messageLength = view.getUint32(1, false);
|
|
18
|
+
const frameEnd = offset + 5 + messageLength;
|
|
19
|
+
if (frameEnd > payload.length)
|
|
20
|
+
return null;
|
|
21
|
+
if ((flags & 0b0000_0001) !== 0)
|
|
22
|
+
return null;
|
|
23
|
+
if ((flags & CONNECT_END_STREAM_FLAG) === 0) {
|
|
24
|
+
return payload.subarray(offset + 5, frameEnd);
|
|
25
|
+
}
|
|
26
|
+
offset = frameEnd;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function encodeVarint(value) {
|
|
31
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
32
|
+
throw new Error(`Unsupported varint value: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
const bytes = [];
|
|
35
|
+
let current = value;
|
|
36
|
+
while (current >= 0x80) {
|
|
37
|
+
bytes.push((current & 0x7f) | 0x80);
|
|
38
|
+
current = Math.floor(current / 128);
|
|
39
|
+
}
|
|
40
|
+
bytes.push(current);
|
|
41
|
+
return Uint8Array.from(bytes);
|
|
42
|
+
}
|
|
43
|
+
export function encodeProtoField(tag, wireType, value) {
|
|
44
|
+
const key = encodeVarint((tag << 3) | wireType);
|
|
45
|
+
const out = new Uint8Array(key.length + value.length);
|
|
46
|
+
out.set(key, 0);
|
|
47
|
+
out.set(value, key.length);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function encodeProtoStringField(tag, value) {
|
|
51
|
+
const bytes = new TextEncoder().encode(value);
|
|
52
|
+
const len = encodeVarint(bytes.length);
|
|
53
|
+
const payload = new Uint8Array(len.length + bytes.length);
|
|
54
|
+
payload.set(len, 0);
|
|
55
|
+
payload.set(bytes, len.length);
|
|
56
|
+
return encodeProtoField(tag, 2, payload);
|
|
57
|
+
}
|
|
58
|
+
export function encodeProtoMessageField(tag, value) {
|
|
59
|
+
const len = encodeVarint(value.length);
|
|
60
|
+
const payload = new Uint8Array(len.length + value.length);
|
|
61
|
+
payload.set(len, 0);
|
|
62
|
+
payload.set(value, len.length);
|
|
63
|
+
return encodeProtoField(tag, 2, payload);
|
|
64
|
+
}
|
|
65
|
+
export function encodeProtoVarintField(tag, value) {
|
|
66
|
+
return encodeProtoField(tag, 0, encodeVarint(value));
|
|
67
|
+
}
|
|
68
|
+
export function concatBytes(parts) {
|
|
69
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
70
|
+
const out = new Uint8Array(total);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (const part of parts) {
|
|
73
|
+
out.set(part, offset);
|
|
74
|
+
offset += part.length;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
export function toFetchBody(data) {
|
|
79
|
+
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
80
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface CursorBaseRequestOptions {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function buildCursorHeaders(options: CursorBaseRequestOptions, contentType: string, extra?: Record<string, string>): Headers;
|
|
6
|
+
export declare function buildCursorHeaderValues(options: CursorBaseRequestOptions, contentType: string, extra?: Record<string, string>): Record<string, string>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CURSOR_CLIENT_VERSION } from "./config";
|
|
2
|
+
export function buildCursorHeaders(options, contentType, extra = {}) {
|
|
3
|
+
const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
|
|
4
|
+
return headers;
|
|
5
|
+
}
|
|
6
|
+
export function buildCursorHeaderValues(options, contentType, extra = {}) {
|
|
7
|
+
return {
|
|
8
|
+
authorization: `Bearer ${options.accessToken}`,
|
|
9
|
+
"content-type": contentType,
|
|
10
|
+
"x-ghost-mode": "true",
|
|
11
|
+
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
|
|
12
|
+
"x-cursor-client-type": "cli",
|
|
13
|
+
"x-request-id": crypto.randomUUID(),
|
|
14
|
+
...extra,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CURSOR_API_URL, CURSOR_CLIENT_VERSION, CURSOR_CONNECT_PROTOCOL_VERSION, CONNECT_END_STREAM_FLAG, } from "./config";
|
|
2
|
+
export { concatBytes, decodeConnectUnaryBody, encodeProtoMessageField, encodeProtoStringField, encodeProtoVarintField, encodeVarint, frameConnectMessage, toFetchBody, } from "./connect-framing";
|
|
3
|
+
export { buildCursorHeaders, buildCursorHeaderValues, type CursorBaseRequestOptions, } from "./headers";
|
|
4
|
+
export { createCursorSession, type CreateCursorSessionOptions, type CursorSession, } from "./bidi-session";
|
|
5
|
+
export { callCursorUnaryRpc, type CursorUnaryRpcOptions } from "./unary-rpc";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CURSOR_API_URL, CURSOR_CLIENT_VERSION, CURSOR_CONNECT_PROTOCOL_VERSION, CONNECT_END_STREAM_FLAG, } from "./config";
|
|
2
|
+
export { concatBytes, decodeConnectUnaryBody, encodeProtoMessageField, encodeProtoStringField, encodeProtoVarintField, encodeVarint, frameConnectMessage, toFetchBody, } from "./connect-framing";
|
|
3
|
+
export { buildCursorHeaders, buildCursorHeaderValues, } from "./headers";
|
|
4
|
+
export { createCursorSession, } from "./bidi-session";
|
|
5
|
+
export { callCursorUnaryRpc } from "./unary-rpc";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface CursorUnaryRpcOptions {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
rpcPath: string;
|
|
4
|
+
requestBody: Uint8Array;
|
|
5
|
+
url?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function callCursorUnaryRpc(options: CursorUnaryRpcOptions): Promise<{
|
|
9
|
+
body: Uint8Array;
|
|
10
|
+
exitCode: number;
|
|
11
|
+
timedOut: boolean;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { connect as connectHttp2, } from "node:http2";
|
|
2
|
+
import { CURSOR_API_URL, CURSOR_CONNECT_PROTOCOL_VERSION } from "./config";
|
|
3
|
+
import { buildCursorHeaderValues } from "./headers";
|
|
4
|
+
import { errorDetails, logPluginError } from "../logger";
|
|
5
|
+
export async function callCursorUnaryRpc(options) {
|
|
6
|
+
const target = new URL(options.rpcPath, options.url ?? CURSOR_API_URL);
|
|
7
|
+
return callCursorUnaryRpcOverHttp2(options, target);
|
|
8
|
+
}
|
|
9
|
+
async function callCursorUnaryRpcOverHttp2(options, target) {
|
|
10
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
11
|
+
const authority = `${target.protocol}//${target.host}`;
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
let timedOut = false;
|
|
15
|
+
let session;
|
|
16
|
+
let stream;
|
|
17
|
+
const finish = (result) => {
|
|
18
|
+
if (settled)
|
|
19
|
+
return;
|
|
20
|
+
settled = true;
|
|
21
|
+
if (timeout)
|
|
22
|
+
clearTimeout(timeout);
|
|
23
|
+
try {
|
|
24
|
+
stream?.close();
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
try {
|
|
28
|
+
session?.close();
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
resolve(result);
|
|
32
|
+
};
|
|
33
|
+
const timeout = timeoutMs > 0
|
|
34
|
+
? setTimeout(() => {
|
|
35
|
+
timedOut = true;
|
|
36
|
+
finish({
|
|
37
|
+
body: new Uint8Array(),
|
|
38
|
+
exitCode: 124,
|
|
39
|
+
timedOut: true,
|
|
40
|
+
});
|
|
41
|
+
}, timeoutMs)
|
|
42
|
+
: undefined;
|
|
43
|
+
try {
|
|
44
|
+
session = connectHttp2(authority);
|
|
45
|
+
session.once("error", (error) => {
|
|
46
|
+
logPluginError("Cursor unary HTTP/2 session failed", {
|
|
47
|
+
rpcPath: options.rpcPath,
|
|
48
|
+
url: target.toString(),
|
|
49
|
+
timedOut,
|
|
50
|
+
...errorDetails(error),
|
|
51
|
+
});
|
|
52
|
+
finish({
|
|
53
|
+
body: new Uint8Array(),
|
|
54
|
+
exitCode: timedOut ? 124 : 1,
|
|
55
|
+
timedOut,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
const headers = {
|
|
59
|
+
":method": "POST",
|
|
60
|
+
":path": `${target.pathname}${target.search}`,
|
|
61
|
+
...buildCursorHeaderValues(options, "application/proto", {
|
|
62
|
+
accept: "application/proto, application/json",
|
|
63
|
+
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
64
|
+
"connect-timeout-ms": String(timeoutMs),
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
stream = session.request(headers);
|
|
68
|
+
let statusCode = 0;
|
|
69
|
+
const chunks = [];
|
|
70
|
+
stream.once("response", (responseHeaders) => {
|
|
71
|
+
const statusHeader = responseHeaders[":status"];
|
|
72
|
+
statusCode =
|
|
73
|
+
typeof statusHeader === "number"
|
|
74
|
+
? statusHeader
|
|
75
|
+
: Number(statusHeader ?? 0);
|
|
76
|
+
});
|
|
77
|
+
stream.on("data", (chunk) => {
|
|
78
|
+
chunks.push(Buffer.from(chunk));
|
|
79
|
+
});
|
|
80
|
+
stream.once("end", () => {
|
|
81
|
+
const body = new Uint8Array(Buffer.concat(chunks));
|
|
82
|
+
finish({
|
|
83
|
+
body,
|
|
84
|
+
exitCode: statusCode >= 200 && statusCode < 300 ? 0 : statusCode || 1,
|
|
85
|
+
timedOut,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
stream.once("error", (error) => {
|
|
89
|
+
logPluginError("Cursor unary HTTP/2 stream failed", {
|
|
90
|
+
rpcPath: options.rpcPath,
|
|
91
|
+
url: target.toString(),
|
|
92
|
+
timedOut,
|
|
93
|
+
...errorDetails(error),
|
|
94
|
+
});
|
|
95
|
+
finish({
|
|
96
|
+
body: new Uint8Array(),
|
|
97
|
+
exitCode: timedOut ? 124 : 1,
|
|
98
|
+
timedOut,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
// Bun's node:http2 client currently breaks on end(Buffer.alloc(0)) against
|
|
102
|
+
// Cursor's HTTPS endpoint, but a header-only end() succeeds for empty unary bodies.
|
|
103
|
+
if (options.requestBody.length > 0) {
|
|
104
|
+
stream.end(Buffer.from(options.requestBody));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
stream.end();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
logPluginError("Cursor unary HTTP/2 setup failed", {
|
|
112
|
+
rpcPath: options.rpcPath,
|
|
113
|
+
url: target.toString(),
|
|
114
|
+
timedOut,
|
|
115
|
+
...errorDetails(error),
|
|
116
|
+
});
|
|
117
|
+
finish({
|
|
118
|
+
body: new Uint8Array(),
|
|
119
|
+
exitCode: timedOut ? 124 : 1,
|
|
120
|
+
timedOut,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|