@vidit19sharma/opencode-cursor-auth 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/bridge.d.ts +33 -0
- package/dist/bridge.js +458 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +128 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vidit Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-cursor-auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Use your **Cursor subscription** inside [opencode](https://opencode.ai) — no proxy server, no port, no separate API bill.
|
|
4
|
+
|
|
5
|
+
The plugin bridges opencode's OpenAI-style chat requests to the official [`@cursor/sdk`](https://www.npmjs.com/package/@cursor/sdk) **in-process**, so every model your Cursor plan includes (GPT, Claude, Gemini, Kimi, GLM, Composer, …) shows up as a `cursor/*` model in opencode.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Official SDK, not a reverse-engineered API** — auth, checksums, and client versioning handled by `@cursor/sdk`
|
|
10
|
+
- **In-process bridge** — no local HTTP proxy, no port to collide on, no extra daemon
|
|
11
|
+
- **Full tool loop** — opencode's native tools (read, edit, bash, MCP, …) work end-to-end via a held-run bridge: one Cursor run spans the whole tool exchange
|
|
12
|
+
- **Thinking blocks** — Cursor reasoning streams into opencode as proper thinking blocks (`reasoning_content`)
|
|
13
|
+
- **Session resume** — conversations map to native Cursor agents (`Agent.resume`), in-memory and persisted to disk for 7 days, so multi-turn chats don't replay history
|
|
14
|
+
- **System prompts** — delivered via `AGENTS.md` in a per-session workspace (the `--system-prompt` flag is server-gated by Cursor)
|
|
15
|
+
- **Dynamic model list** — fetched live from `Cursor.models.list()`, cached 24h
|
|
16
|
+
- **Zero cost display** — models are marked cost-free since you're on subscription billing
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Node >= 22.13 (opencode runs plugins on Bun, which also works)
|
|
21
|
+
- [`cursor-agent` CLI](https://cursor.com/docs/cli) installed (`curl https://cursor.com/install -fsS | bash`) — the SDK's local executor uses it
|
|
22
|
+
- A Cursor subscription
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Add the plugin to `~/.config/opencode/opencode.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"plugin": ["@vidit19sharma/opencode-cursor-auth@latest"]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The plugin auto-registers a `cursor` provider — no manual provider config needed.
|
|
35
|
+
|
|
36
|
+
## Authenticate
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
opencode auth login
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pick **Cursor**, then either:
|
|
43
|
+
|
|
44
|
+
- **Log in with Cursor (browser)** — opens cursor.com, mints a 90-day SDK key, stored at `~/.cursor/sdk/auth.json`
|
|
45
|
+
- **Paste a Cursor API key** — from [cursor.com/dashboard](https://cursor.com/dashboard)
|
|
46
|
+
|
|
47
|
+
Alternatively set `CURSOR_API_KEY` in your environment.
|
|
48
|
+
|
|
49
|
+
## Use
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
opencode run -m cursor/gpt-5.5 "hello"
|
|
53
|
+
opencode run -m cursor/claude-opus-5-5 "hello"
|
|
54
|
+
opencode run -m cursor/auto "hello"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or pick any `cursor/*` model from the opencode model selector.
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agent ──> cursor-agent ──> Cursor backend
|
|
63
|
+
<─(SSE stream)──────── bridge <──(typed deltas: text / thinking / tool calls)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- opencode calls the provider's `fetch` — the plugin intercepts it in-process; `http://cursor.local/v1` is a dummy URL that never resolves.
|
|
67
|
+
- Each opencode tool is registered as a Cursor **custom tool** whose `execute()` parks a promise, emits an OpenAI `tool_calls` chunk, and resumes the *same* Cursor run when opencode sends the tool result back.
|
|
68
|
+
- Conversation prefixes are hashed → mapped to Cursor agent IDs, so follow-up turns use `Agent.resume` instead of replaying history. The map persists at `~/.cache/opencode-cursor-auth/sessions.json`.
|
|
69
|
+
- System messages are written to `AGENTS.md` in a per-session temp workspace and loaded via Cursor's project rules (`settingSources: ["project"]`).
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
bun install
|
|
75
|
+
bun run build # tsc → dist/
|
|
76
|
+
bun run test # end-to-end bridge tests (requires Cursor login)
|
|
77
|
+
bun run spike # raw @cursor/sdk validation
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The test suite exercises: plain streaming, a full tool loop on one held run, in-memory session resume, AGENTS.md system-prompt delivery, and disk-persisted resume.
|
|
81
|
+
|
|
82
|
+
## Known limitations
|
|
83
|
+
|
|
84
|
+
- `systemPrompt` as a true harness replacement is feature-gated server-side by Cursor; system text is applied as a project rule (merged under Cursor's harness prompt).
|
|
85
|
+
- The SDK spawns a `cursor-agent` worker per turn (~1–2 s startup on resume).
|
|
86
|
+
- Usage/rate limits are whatever your Cursor plan enforces — the plugin adds none.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
[MIT](./LICENSE) © Vidit Sharma
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
type ContentPart = {
|
|
2
|
+
type: string;
|
|
3
|
+
text?: string;
|
|
4
|
+
};
|
|
5
|
+
type OaMessage = {
|
|
6
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
7
|
+
content: string | ContentPart[] | null;
|
|
8
|
+
tool_calls?: {
|
|
9
|
+
id: string;
|
|
10
|
+
function: {
|
|
11
|
+
name: string;
|
|
12
|
+
arguments: string;
|
|
13
|
+
};
|
|
14
|
+
}[];
|
|
15
|
+
tool_call_id?: string;
|
|
16
|
+
};
|
|
17
|
+
type OaTool = {
|
|
18
|
+
type: "function";
|
|
19
|
+
function: {
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
parameters?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
type OaBody = {
|
|
26
|
+
model: string;
|
|
27
|
+
messages: OaMessage[];
|
|
28
|
+
tools?: OaTool[];
|
|
29
|
+
stream?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export declare function _resetForTest(): void;
|
|
32
|
+
export declare function handleChatCompletion(body: OaBody, apiKey?: string): Promise<Response>;
|
|
33
|
+
export {};
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI chat-completions JSON <-> @cursor/sdk bridge, fully in-process.
|
|
3
|
+
*
|
|
4
|
+
* Tool loop ("held run" pattern, from pwnapplehat/Cursor-OpenAI):
|
|
5
|
+
* each opencode tool is registered as a Cursor customTool whose execute()
|
|
6
|
+
* PARKS on a promise. When the model requests tools we flush them to the
|
|
7
|
+
* client as OpenAI tool_calls and keep the Cursor run alive. The client's
|
|
8
|
+
* next HTTP request (role:"tool" messages) resolves the parked promises and
|
|
9
|
+
* the SAME run continues — one metered Cursor run per opencode tool loop.
|
|
10
|
+
*/
|
|
11
|
+
import { Agent } from "@cursor/sdk";
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir, tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
// @cursor/sdk's internal logger writes "HH:MM:SS.mmm INFO ..." lines via
|
|
18
|
+
// console.log unconditionally (no config/env to silence it, verified against
|
|
19
|
+
// the esm bundle). Filter exactly those lines, pass everything else through.
|
|
20
|
+
const SDK_LOG_LINE = /^\d{2}:\d{2}:\d{2}\.\d{3} (TRACE|DEBUG|INFO|WARN|ERROR)\s/;
|
|
21
|
+
const originalConsoleLog = console.log.bind(console);
|
|
22
|
+
console.log = (...args) => {
|
|
23
|
+
if (typeof args[0] === "string" && SDK_LOG_LINE.test(args[0]))
|
|
24
|
+
return;
|
|
25
|
+
originalConsoleLog(...args);
|
|
26
|
+
};
|
|
27
|
+
function textOf(content) {
|
|
28
|
+
if (typeof content === "string")
|
|
29
|
+
return content;
|
|
30
|
+
if (!content)
|
|
31
|
+
return "";
|
|
32
|
+
return content.map((p) => p.text ?? "").join("");
|
|
33
|
+
}
|
|
34
|
+
function chunk(id, model, delta, finish, usage) {
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
object: "chat.completion.chunk",
|
|
38
|
+
created: Math.floor(Date.now() / 1000),
|
|
39
|
+
model,
|
|
40
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
41
|
+
...(usage ? { usage } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function sseResponse(model) {
|
|
45
|
+
const id = `chatcmpl-${randomUUID()}`;
|
|
46
|
+
const enc = new TextEncoder();
|
|
47
|
+
let controller;
|
|
48
|
+
let closed = false;
|
|
49
|
+
const stream = new ReadableStream({
|
|
50
|
+
start(c) {
|
|
51
|
+
controller = c;
|
|
52
|
+
send({ role: "assistant", content: "" });
|
|
53
|
+
},
|
|
54
|
+
cancel() {
|
|
55
|
+
closed = true;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
function send(delta, finish = null, usage) {
|
|
59
|
+
if (closed)
|
|
60
|
+
return;
|
|
61
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(chunk(id, model, delta, finish, usage))}\n\n`));
|
|
62
|
+
}
|
|
63
|
+
function end() {
|
|
64
|
+
if (closed)
|
|
65
|
+
return;
|
|
66
|
+
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
67
|
+
controller.close();
|
|
68
|
+
closed = true;
|
|
69
|
+
}
|
|
70
|
+
const sink = {
|
|
71
|
+
text: (t) => send({ content: t }),
|
|
72
|
+
reasoning: (t) => send({ reasoning_content: t }),
|
|
73
|
+
toolCalls: (calls) => send({
|
|
74
|
+
tool_calls: calls.map((c, index) => ({
|
|
75
|
+
index,
|
|
76
|
+
id: c.id,
|
|
77
|
+
type: "function",
|
|
78
|
+
function: { name: c.name, arguments: c.argumentsJson },
|
|
79
|
+
})),
|
|
80
|
+
}),
|
|
81
|
+
finish: (reason, usage) => {
|
|
82
|
+
send({}, reason, usage);
|
|
83
|
+
end();
|
|
84
|
+
},
|
|
85
|
+
fail: (err) => {
|
|
86
|
+
send({ content: `\n\n[cursor error] ${err instanceof Error ? err.message : String(err)}` });
|
|
87
|
+
send({}, "stop");
|
|
88
|
+
end();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
response: new Response(stream, {
|
|
93
|
+
status: 200,
|
|
94
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
|
95
|
+
}),
|
|
96
|
+
sink,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function jsonCollector(model) {
|
|
100
|
+
const id = `chatcmpl-${randomUUID()}`;
|
|
101
|
+
let content = "";
|
|
102
|
+
let calls = [];
|
|
103
|
+
let resolve;
|
|
104
|
+
const promise = new Promise((r) => (resolve = r));
|
|
105
|
+
const sink = {
|
|
106
|
+
text: (t) => (content += t),
|
|
107
|
+
reasoning: () => { },
|
|
108
|
+
toolCalls: (c) => (calls = calls.concat(c)),
|
|
109
|
+
finish: (reason, usage) => resolve(Response.json({
|
|
110
|
+
id,
|
|
111
|
+
object: "chat.completion",
|
|
112
|
+
created: Math.floor(Date.now() / 1000),
|
|
113
|
+
model,
|
|
114
|
+
choices: [
|
|
115
|
+
{
|
|
116
|
+
index: 0,
|
|
117
|
+
message: {
|
|
118
|
+
role: "assistant",
|
|
119
|
+
content: content || null,
|
|
120
|
+
...(calls.length
|
|
121
|
+
? {
|
|
122
|
+
tool_calls: calls.map((c2) => ({
|
|
123
|
+
id: c2.id,
|
|
124
|
+
type: "function",
|
|
125
|
+
function: { name: c2.name, arguments: c2.argumentsJson },
|
|
126
|
+
})),
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
},
|
|
130
|
+
finish_reason: reason,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
134
|
+
})),
|
|
135
|
+
fail: (err) => resolve(Response.json({ error: { message: err instanceof Error ? err.message : String(err), type: "cursor_error" } }, { status: 500 })),
|
|
136
|
+
};
|
|
137
|
+
return { promise, sink };
|
|
138
|
+
}
|
|
139
|
+
const BATCH_SETTLE_MS = 300;
|
|
140
|
+
const TOOL_RESULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
141
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
142
|
+
const DISK_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
143
|
+
// ponytail: single-user plugin, small fixed caps; make configurable if ever shared
|
|
144
|
+
const MAX_SESSIONS = 8;
|
|
145
|
+
const SESSION_STORE_DIR = join(homedir(), ".cache", "opencode-cursor-auth");
|
|
146
|
+
const SESSION_STORE_PATH = join(SESSION_STORE_DIR, "sessions.json");
|
|
147
|
+
const sessions = new Map();
|
|
148
|
+
const heldByAgent = new Map();
|
|
149
|
+
const agentByToolCallId = new Map();
|
|
150
|
+
let diskSessions = new Map();
|
|
151
|
+
let sessionsLoaded = false;
|
|
152
|
+
function hashMessages(messages) {
|
|
153
|
+
const norm = messages.map((m) => [m.role, textOf(m.content), m.tool_call_id ?? "", JSON.stringify(m.tool_calls ?? null)]);
|
|
154
|
+
return createHash("sha256").update(JSON.stringify(norm)).digest("hex");
|
|
155
|
+
}
|
|
156
|
+
function collectDiskEntries() {
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
const entries = {};
|
|
159
|
+
for (const [k, v] of diskSessions)
|
|
160
|
+
if (now - v.lastUsedAt <= DISK_SESSION_TTL_MS)
|
|
161
|
+
entries[k] = v;
|
|
162
|
+
return entries;
|
|
163
|
+
}
|
|
164
|
+
async function loadSessions() {
|
|
165
|
+
if (sessionsLoaded)
|
|
166
|
+
return;
|
|
167
|
+
sessionsLoaded = true;
|
|
168
|
+
try {
|
|
169
|
+
const raw = JSON.parse(await readFile(SESSION_STORE_PATH, "utf8"));
|
|
170
|
+
if (raw.version !== 1 || !raw.entries)
|
|
171
|
+
return;
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
for (const [k, v] of Object.entries(raw.entries)) {
|
|
174
|
+
// disk entries stay out of the 30-min memory map so the memory sweep
|
|
175
|
+
// can't evict a still-valid 7-day entry; lookup falls back to diskSessions
|
|
176
|
+
if (now - v.lastUsedAt <= DISK_SESSION_TTL_MS)
|
|
177
|
+
diskSessions.set(k, v);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
}
|
|
182
|
+
function saveSessions() {
|
|
183
|
+
mkdirSync(SESSION_STORE_DIR, { recursive: true });
|
|
184
|
+
writeFileSync(SESSION_STORE_PATH, JSON.stringify({ version: 1, entries: collectDiskEntries() }));
|
|
185
|
+
}
|
|
186
|
+
function sweepSessions(save = true) {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
for (const [k, v] of sessions)
|
|
189
|
+
if (now - v.lastUsedAt > SESSION_TTL_MS)
|
|
190
|
+
sessions.delete(k);
|
|
191
|
+
while (sessions.size > MAX_SESSIONS) {
|
|
192
|
+
let oldestKey = "";
|
|
193
|
+
let oldest = Infinity;
|
|
194
|
+
for (const [k, v] of sessions)
|
|
195
|
+
if (v.lastUsedAt < oldest) {
|
|
196
|
+
oldest = v.lastUsedAt;
|
|
197
|
+
oldestKey = k;
|
|
198
|
+
}
|
|
199
|
+
sessions.delete(oldestKey);
|
|
200
|
+
}
|
|
201
|
+
if (save)
|
|
202
|
+
saveSessions();
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Re-key the session under the conversation state the NEXT request will
|
|
206
|
+
* present (current messages + the assistant message we just produced), since
|
|
207
|
+
* session-unaware clients like opencode resend full history every turn.
|
|
208
|
+
*/
|
|
209
|
+
function rememberSession(held, toolCalls) {
|
|
210
|
+
const assistant = {
|
|
211
|
+
role: "assistant",
|
|
212
|
+
content: held.textAcc || null,
|
|
213
|
+
...(toolCalls.length
|
|
214
|
+
? { tool_calls: toolCalls.map((c) => ({ id: c.id, function: { name: c.name, arguments: c.argumentsJson } })) }
|
|
215
|
+
: {}),
|
|
216
|
+
};
|
|
217
|
+
const key = hashMessages(held.messages.concat(assistant));
|
|
218
|
+
const entry = {
|
|
219
|
+
agentId: held.agent.agentId,
|
|
220
|
+
cwd: held.cwd,
|
|
221
|
+
lastUsedAt: Date.now(),
|
|
222
|
+
};
|
|
223
|
+
sessions.set(key, entry);
|
|
224
|
+
diskSessions.set(key, entry);
|
|
225
|
+
sweepSessions();
|
|
226
|
+
}
|
|
227
|
+
async function syncAgentsMd(cwd, messages) {
|
|
228
|
+
mkdirSync(cwd, { recursive: true });
|
|
229
|
+
const path = join(cwd, "AGENTS.md");
|
|
230
|
+
const systemText = messages
|
|
231
|
+
.filter((m) => m.role === "system")
|
|
232
|
+
.map((m) => textOf(m.content))
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join("\n\n");
|
|
235
|
+
if (systemText)
|
|
236
|
+
await writeFile(path, systemText);
|
|
237
|
+
else if (existsSync(path))
|
|
238
|
+
rmSync(path);
|
|
239
|
+
}
|
|
240
|
+
function mapUsage(u) {
|
|
241
|
+
if (!u)
|
|
242
|
+
return undefined;
|
|
243
|
+
const prompt = u.inputTokens ?? 0;
|
|
244
|
+
const completion = u.outputTokens ?? 0;
|
|
245
|
+
return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
|
|
246
|
+
}
|
|
247
|
+
// deltas arriving between tool flush and the continuation request get buffered
|
|
248
|
+
function emit(held, kind, t) {
|
|
249
|
+
if (held.sink)
|
|
250
|
+
(kind === "text" ? held.sink.text : held.sink.reasoning)(t);
|
|
251
|
+
else
|
|
252
|
+
held.buffer.push({ kind, t });
|
|
253
|
+
}
|
|
254
|
+
function attachSink(held, sink) {
|
|
255
|
+
held.sink = sink;
|
|
256
|
+
for (const b of held.buffer)
|
|
257
|
+
(b.kind === "text" ? sink.text : sink.reasoning)(b.t);
|
|
258
|
+
held.buffer = [];
|
|
259
|
+
}
|
|
260
|
+
function teardownHeld(agentId, held) {
|
|
261
|
+
held.done = true;
|
|
262
|
+
if (held.flushTimer)
|
|
263
|
+
clearTimeout(held.flushTimer);
|
|
264
|
+
if (held.idleTimer)
|
|
265
|
+
clearTimeout(held.idleTimer);
|
|
266
|
+
for (const id of held.parked.keys())
|
|
267
|
+
agentByToolCallId.delete(id);
|
|
268
|
+
heldByAgent.delete(agentId);
|
|
269
|
+
}
|
|
270
|
+
/** Model requested tools: emit tool_calls, end this HTTP segment, park the run. */
|
|
271
|
+
function flushToolCalls(agentId, held) {
|
|
272
|
+
held.flushTimer = null;
|
|
273
|
+
if (held.done || !held.pendingCalls.length)
|
|
274
|
+
return;
|
|
275
|
+
const calls = held.pendingCalls;
|
|
276
|
+
held.pendingCalls = [];
|
|
277
|
+
for (const c of calls)
|
|
278
|
+
agentByToolCallId.set(c.id, agentId);
|
|
279
|
+
const sink = held.sink;
|
|
280
|
+
held.sink = null;
|
|
281
|
+
sink?.toolCalls(calls);
|
|
282
|
+
sink?.finish("tool_calls");
|
|
283
|
+
rememberSession(held, calls);
|
|
284
|
+
held.idleTimer = setTimeout(() => {
|
|
285
|
+
teardownHeld(agentId, held);
|
|
286
|
+
held.run.cancel().catch(() => { });
|
|
287
|
+
try {
|
|
288
|
+
held.agent.close();
|
|
289
|
+
}
|
|
290
|
+
catch { }
|
|
291
|
+
}, TOOL_RESULT_TIMEOUT_MS);
|
|
292
|
+
held.idleTimer.unref?.();
|
|
293
|
+
}
|
|
294
|
+
async function wireRunCompletion(agentId, held) {
|
|
295
|
+
if (held.wired)
|
|
296
|
+
return;
|
|
297
|
+
held.wired = true;
|
|
298
|
+
try {
|
|
299
|
+
const result = await held.run.wait();
|
|
300
|
+
if (held.done)
|
|
301
|
+
return;
|
|
302
|
+
const usage = mapUsage(result.usage) ?? held.usage;
|
|
303
|
+
teardownHeld(agentId, held);
|
|
304
|
+
rememberSession(held, []);
|
|
305
|
+
held.sink?.finish("stop", usage);
|
|
306
|
+
try {
|
|
307
|
+
held.agent.close();
|
|
308
|
+
}
|
|
309
|
+
catch { }
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
if (held.done)
|
|
313
|
+
return;
|
|
314
|
+
teardownHeld(agentId, held);
|
|
315
|
+
held.sink?.fail(err);
|
|
316
|
+
try {
|
|
317
|
+
held.agent.close();
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
export function _resetForTest() {
|
|
323
|
+
sessions.clear();
|
|
324
|
+
diskSessions.clear();
|
|
325
|
+
heldByAgent.clear();
|
|
326
|
+
agentByToolCallId.clear();
|
|
327
|
+
sessionsLoaded = false;
|
|
328
|
+
}
|
|
329
|
+
export async function handleChatCompletion(body, apiKey) {
|
|
330
|
+
await loadSessions();
|
|
331
|
+
const streaming = body.stream !== false;
|
|
332
|
+
const sse = streaming ? sseResponse(body.model) : undefined;
|
|
333
|
+
const collected = streaming ? undefined : jsonCollector(body.model);
|
|
334
|
+
const sink = (sse?.sink ?? collected?.sink);
|
|
335
|
+
const reply = () => sse?.response ?? collected.promise;
|
|
336
|
+
// continuation: trailing tool results for a parked run
|
|
337
|
+
const lastMsg = body.messages[body.messages.length - 1];
|
|
338
|
+
if (lastMsg?.role === "tool" && lastMsg.tool_call_id && agentByToolCallId.has(lastMsg.tool_call_id)) {
|
|
339
|
+
const agentId = agentByToolCallId.get(lastMsg.tool_call_id);
|
|
340
|
+
const held = heldByAgent.get(agentId);
|
|
341
|
+
if (held && !held.done) {
|
|
342
|
+
if (held.idleTimer)
|
|
343
|
+
clearTimeout(held.idleTimer);
|
|
344
|
+
held.messages = body.messages;
|
|
345
|
+
held.textAcc = "";
|
|
346
|
+
attachSink(held, sink);
|
|
347
|
+
for (const m of body.messages) {
|
|
348
|
+
if (m.role !== "tool" || !m.tool_call_id)
|
|
349
|
+
continue;
|
|
350
|
+
const resolve = held.parked.get(m.tool_call_id);
|
|
351
|
+
if (resolve) {
|
|
352
|
+
held.parked.delete(m.tool_call_id);
|
|
353
|
+
agentByToolCallId.delete(m.tool_call_id);
|
|
354
|
+
resolve(textOf(m.content));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return reply();
|
|
358
|
+
}
|
|
359
|
+
sink.fail(new Error("cursor run for this tool call expired — please retry"));
|
|
360
|
+
return reply();
|
|
361
|
+
}
|
|
362
|
+
// new turn: reuse the agent whose last-seen conversation matches our prefix
|
|
363
|
+
const prefix = body.messages.slice(0, -1);
|
|
364
|
+
const key = hashMessages(prefix);
|
|
365
|
+
const entry = sessions.get(key) ?? diskSessions.get(key);
|
|
366
|
+
let agent;
|
|
367
|
+
let cwd;
|
|
368
|
+
let newSuffix = body.messages;
|
|
369
|
+
if (entry) {
|
|
370
|
+
sessions.delete(key);
|
|
371
|
+
cwd = entry.cwd;
|
|
372
|
+
agent = await Agent.resume(entry.agentId, {
|
|
373
|
+
model: { id: body.model },
|
|
374
|
+
tools: ["mcp"],
|
|
375
|
+
...(apiKey ? { apiKey } : {}),
|
|
376
|
+
local: { cwd, settingSources: ["project"], enableAgentRetries: true },
|
|
377
|
+
}).catch(() => {
|
|
378
|
+
sessions.delete(key);
|
|
379
|
+
diskSessions.delete(key);
|
|
380
|
+
saveSessions();
|
|
381
|
+
return undefined;
|
|
382
|
+
});
|
|
383
|
+
if (agent)
|
|
384
|
+
newSuffix = body.messages.slice(-1);
|
|
385
|
+
}
|
|
386
|
+
if (!agent) {
|
|
387
|
+
cwd = await mkdtemp(join(tmpdir(), "opencode-cursor-"));
|
|
388
|
+
agent = await Agent.create({
|
|
389
|
+
model: { id: body.model },
|
|
390
|
+
tools: ["mcp"],
|
|
391
|
+
...(apiKey ? { apiKey } : {}),
|
|
392
|
+
local: { cwd, settingSources: ["project"], enableAgentRetries: true },
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const held = {
|
|
396
|
+
agent,
|
|
397
|
+
cwd: cwd,
|
|
398
|
+
run: undefined,
|
|
399
|
+
messages: body.messages,
|
|
400
|
+
textAcc: "",
|
|
401
|
+
sink: null,
|
|
402
|
+
buffer: [],
|
|
403
|
+
pendingCalls: [],
|
|
404
|
+
parked: new Map(),
|
|
405
|
+
flushTimer: null,
|
|
406
|
+
idleTimer: null,
|
|
407
|
+
wired: false,
|
|
408
|
+
done: false,
|
|
409
|
+
};
|
|
410
|
+
attachSink(held, sink);
|
|
411
|
+
const agentIdRef = { id: "" };
|
|
412
|
+
const customTools = {};
|
|
413
|
+
for (const t of body.tools ?? []) {
|
|
414
|
+
customTools[t.function.name] = {
|
|
415
|
+
description: t.function.description ?? "",
|
|
416
|
+
inputSchema: t.function.parameters ?? { type: "object", properties: {} },
|
|
417
|
+
execute: (args, ctx) => {
|
|
418
|
+
const id = (ctx.toolCallId ?? `call_${randomUUID()}`).replace(/\s+/g, "_");
|
|
419
|
+
held.pendingCalls.push({ id, name: t.function.name, argumentsJson: JSON.stringify(args ?? {}) });
|
|
420
|
+
if (held.flushTimer)
|
|
421
|
+
clearTimeout(held.flushTimer);
|
|
422
|
+
held.flushTimer = setTimeout(() => flushToolCalls(agentIdRef.id, held), BATCH_SETTLE_MS);
|
|
423
|
+
return new Promise((resolve) => held.parked.set(id, resolve));
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
await syncAgentsMd(cwd, body.messages);
|
|
428
|
+
const prompt = newSuffix
|
|
429
|
+
.filter((m) => m.role !== "system")
|
|
430
|
+
.map((m) => (m.role === "user" ? textOf(m.content) : `${m.role.toUpperCase()}: ${textOf(m.content)}`))
|
|
431
|
+
.filter(Boolean)
|
|
432
|
+
.join("\n\n") || "(empty message)";
|
|
433
|
+
try {
|
|
434
|
+
held.run = await held.agent.send(prompt, {
|
|
435
|
+
onDelta: ({ update }) => {
|
|
436
|
+
if (update.type === "text-delta" && update.text) {
|
|
437
|
+
held.textAcc += update.text;
|
|
438
|
+
emit(held, "text", update.text);
|
|
439
|
+
}
|
|
440
|
+
else if (update.type === "thinking-delta" && update.text) {
|
|
441
|
+
emit(held, "reasoning", update.text);
|
|
442
|
+
}
|
|
443
|
+
else if (update.type === "turn-ended") {
|
|
444
|
+
held.usage = mapUsage(update.usage);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
local: { customTools: customTools },
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
sink.fail(err);
|
|
452
|
+
return reply();
|
|
453
|
+
}
|
|
454
|
+
agentIdRef.id = held.run.agentId;
|
|
455
|
+
heldByAgent.set(held.run.agentId, held);
|
|
456
|
+
void wireRunCompletion(held.run.agentId, held);
|
|
457
|
+
return reply();
|
|
458
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Cursor } from "@cursor/sdk";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { handleChatCompletion } from "./bridge.js";
|
|
6
|
+
const BASE_URL = "http://cursor.local/v1";
|
|
7
|
+
const CACHE_DIR = join(homedir(), ".cache", "opencode-cursor-auth");
|
|
8
|
+
const MODELS_CACHE_PATH = join(CACHE_DIR, "models.json");
|
|
9
|
+
const MODELS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
function loadCachedModels() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = JSON.parse(readFileSync(MODELS_CACHE_PATH, "utf8"));
|
|
13
|
+
if (raw?.version === 1 && raw.models && Object.keys(raw.models).length > 0)
|
|
14
|
+
return raw;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// no cache yet, or unreadable — fall back
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
// Live model list from Cursor, cached 24h to keep opencode startup fast.
|
|
22
|
+
// Offline/logged-out: stale cache if present, else just "auto" (Cursor's universal alias).
|
|
23
|
+
async function currentModels() {
|
|
24
|
+
const cached = loadCachedModels();
|
|
25
|
+
if (cached && Date.now() - cached.fetchedAt <= MODELS_CACHE_TTL_MS)
|
|
26
|
+
return cached.models;
|
|
27
|
+
try {
|
|
28
|
+
const list = await Cursor.models.list();
|
|
29
|
+
const models = {};
|
|
30
|
+
for (const m of list)
|
|
31
|
+
models[m.id === "default" ? "auto" : m.id] = m.displayName ?? m.id;
|
|
32
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
33
|
+
writeFileSync(MODELS_CACHE_PATH, JSON.stringify({ version: 1, fetchedAt: Date.now(), models }));
|
|
34
|
+
return models;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return cached?.models ?? { auto: "Auto" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function bodyText(input, init) {
|
|
41
|
+
const b = init?.body;
|
|
42
|
+
if (typeof b === "string")
|
|
43
|
+
return b;
|
|
44
|
+
if (b instanceof Uint8Array)
|
|
45
|
+
return new TextDecoder().decode(b);
|
|
46
|
+
if (input instanceof Request)
|
|
47
|
+
return input.text();
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
let pastedApiKey;
|
|
51
|
+
async function cursorFetch(input, init) {
|
|
52
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
53
|
+
if (url.includes("/chat/completions")) {
|
|
54
|
+
const body = JSON.parse(await bodyText(input, init));
|
|
55
|
+
return handleChatCompletion(body, pastedApiKey);
|
|
56
|
+
}
|
|
57
|
+
if (url.includes("/models")) {
|
|
58
|
+
return Response.json({
|
|
59
|
+
object: "list",
|
|
60
|
+
data: Object.keys(await currentModels()).map((id) => ({ id, object: "model" })),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return Response.json({ error: { message: `unsupported endpoint: ${url}` } }, { status: 404 });
|
|
64
|
+
}
|
|
65
|
+
export const CursorAuthPlugin = async () => {
|
|
66
|
+
return {
|
|
67
|
+
config: async (config) => {
|
|
68
|
+
const providers = (config.provider ??= {});
|
|
69
|
+
const cursor = (providers["cursor"] ??= { models: {} });
|
|
70
|
+
cursor.npm ??= "@ai-sdk/openai-compatible";
|
|
71
|
+
cursor.name ??= "Cursor";
|
|
72
|
+
cursor.options = { ...cursor.options, baseURL: BASE_URL };
|
|
73
|
+
cursor.models ??= {};
|
|
74
|
+
for (const [id, displayName] of Object.entries(await currentModels())) {
|
|
75
|
+
cursor.models[id] ??= { name: displayName };
|
|
76
|
+
const model = cursor.models[id];
|
|
77
|
+
model.cost = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
auth: {
|
|
81
|
+
provider: "cursor",
|
|
82
|
+
loader: async (getAuth) => {
|
|
83
|
+
const auth = await getAuth().catch(() => undefined);
|
|
84
|
+
if (auth && auth.type === "api" && auth.key !== "cursor-sdk-store")
|
|
85
|
+
pastedApiKey = auth.key;
|
|
86
|
+
return {
|
|
87
|
+
apiKey: "cursor-managed",
|
|
88
|
+
baseURL: BASE_URL,
|
|
89
|
+
fetch: cursorFetch,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
methods: [
|
|
93
|
+
{
|
|
94
|
+
type: "oauth",
|
|
95
|
+
label: "Log in with Cursor (browser)",
|
|
96
|
+
authorize: async () => {
|
|
97
|
+
let resolveUrl;
|
|
98
|
+
const urlReady = new Promise((r) => (resolveUrl = r));
|
|
99
|
+
const login = Cursor.auth
|
|
100
|
+
.login({
|
|
101
|
+
apiKeyName: "opencode-cursor-auth",
|
|
102
|
+
openBrowser: false,
|
|
103
|
+
onLoginUrl: (url) => resolveUrl(url),
|
|
104
|
+
})
|
|
105
|
+
.catch((err) => err);
|
|
106
|
+
const url = await Promise.race([urlReady, login.then(() => "")]);
|
|
107
|
+
return {
|
|
108
|
+
url: typeof url === "string" && url ? url : "https://cursor.com/dashboard",
|
|
109
|
+
instructions: "Complete the login in your browser. The key is stored in ~/.cursor/sdk/auth.json.",
|
|
110
|
+
method: "auto",
|
|
111
|
+
callback: async () => {
|
|
112
|
+
const result = await login;
|
|
113
|
+
if (result instanceof Error)
|
|
114
|
+
return { type: "failed" };
|
|
115
|
+
return { type: "success", key: "cursor-sdk-store" };
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: "api",
|
|
122
|
+
label: "Paste a Cursor API key (cursor.com/dashboard)",
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
export default CursorAuthPlugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vidit19sharma/opencode-cursor-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk \u2014 no proxy server, no separate login.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"author": "Vidit Sharma <vidit19sharma@gmail.com> (https://github.com/vidit19sharma)",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/vidit19sharma/opencode-cursor-auth.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/vidit19sharma/opencode-cursor-auth/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/vidit19sharma/opencode-cursor-auth#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"opencode",
|
|
24
|
+
"opencode-plugin",
|
|
25
|
+
"cursor",
|
|
26
|
+
"cursor-sdk",
|
|
27
|
+
"ai",
|
|
28
|
+
"llm",
|
|
29
|
+
"auth"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"test": "bun spike/e2e-bridge.ts",
|
|
34
|
+
"spike": "bun spike/validate-sdk.ts",
|
|
35
|
+
"prepublishOnly": "tsc"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@cursor/sdk": "^1.0.36"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@opencode-ai/plugin": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@opencode-ai/plugin": "latest",
|
|
45
|
+
"@types/node": "^26.6.5",
|
|
46
|
+
"typescript": "^5.7.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.13"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|