@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 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
- # Temporary Holding Version
1
+ # opencode-cursor-auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const CursorAuthPlugin: Plugin;
3
+ export default CursorAuthPlugin;
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }