@magpie-community/opencode-cursor-auth 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.
Files changed (3) hide show
  1. package/README.md +70 -0
  2. package/index.mjs +1417 -0
  3. package/package.json +11 -0
package/index.mjs ADDED
@@ -0,0 +1,1417 @@
1
+ // Cursor subscriptions as an OpenCode provider plugin, served through the
2
+ // API cursor-agent talks to. Ported from magpie's built-in Cursor account
3
+ // (internal/provider/cursor*.go, internal/gateway/cursor.go).
4
+ //
5
+ // An account is a Cursor access token, got one of three ways:
6
+ // - the sign-in cursor-agent does (cursor.com/loginDeepControl with a PKCE
7
+ // challenge, then /auth/poll until the browser has signed in);
8
+ // - the token cursor-agent itself keeps (the Mac keychain, else its
9
+ // auth.json), read each time and never written;
10
+ // - a Cursor API key, exchanged for a token as the CLI does with
11
+ // CURSOR_API_KEY.
12
+ //
13
+ // Requests go to AgentService/Run, a Connect stream both ways over HTTP/2.
14
+ // Cursor keeps a conversation on its client: each message of the prompt is
15
+ // an AI SDK message in JSON, a blob named by its sha256, which the server
16
+ // asks the client for as it reads them. So each request goes whole, as such
17
+ // a conversation, the caller's system prompt at its head; the Run only
18
+ // resumes it. The caller's tools are MCP tools, which the model calls
19
+ // through Cursor's CallDynamicTool: the server hands each call to the
20
+ // client to run, and once it has said how many a step made, the stream is
21
+ // closed and the calls go back to OpenCode as a chat completion's tool
22
+ // calls. Nothing else the server asks the client to run (a shell, a file
23
+ // read) is run.
24
+ //
25
+ // OpenCode speaks chat completions to it (@ai-sdk/openai-compatible); the
26
+ // fetch here answers them.
27
+ import http2 from "node:http2"
28
+ import { gunzipSync } from "node:zlib"
29
+ import { createHash, randomBytes, randomUUID } from "node:crypto"
30
+ import { execFile } from "node:child_process"
31
+ import { readFile, stat, readdir, realpath } from "node:fs/promises"
32
+ import { homedir, platform, tmpdir } from "node:os"
33
+ import { basename, dirname, join } from "node:path"
34
+
35
+ const ID = "cursor"
36
+ const API = "https://api2.cursor.sh"
37
+ const WEBSITE = "https://cursor.com"
38
+ const AGENT = "https://agentn.global.api5.cursor.sh" // when the server config can't say
39
+ const CHAT = "@ai-sdk/openai-compatible"
40
+ const VERSION_FALLBACK = "2026.09.23-86fc751" // the CLI version said when no install names one
41
+ const DEFAULT_CONTEXT = 200_000 // what Cursor gives a model it doesn't name as a 1M one
42
+ const MODELS_KEEP = 10 * 60 * 1000
43
+ const CALL = "CallDynamicTool" // how the model calls an MCP tool
44
+ const NO_RESULT = "Tool use was interrupted and did not produce a result."
45
+ const CLI_MARK = "cursor-agent" // an oauth sign-in's refresh when the token is cursor-agent's own
46
+
47
+ const GOOS = { win32: "windows" }[platform()] ?? platform()
48
+ const EMPTY = Buffer.alloc(0)
49
+ const enc = new TextEncoder()
50
+
51
+ // ---- cursor-agent ---------------------------------------------------------------
52
+
53
+ const exists = (p) => stat(p).then((s) => !s.isDirectory(), () => false)
54
+
55
+ function run(file, args, { timeout = 10_000, env } = {}) {
56
+ return new Promise((resolve) => {
57
+ execFile(file, args, { timeout, env: env ?? process.env, maxBuffer: 8 << 20 }, (err, stdout) =>
58
+ resolve({ ok: !err, out: String(stdout ?? "") }))
59
+ })
60
+ }
61
+
62
+ // cursorExecutable finds the cursor-agent CLI, or Cursor's `agent` by that
63
+ // name.
64
+ async function cursorExecutable() {
65
+ const dirs = (process.env.PATH ?? "").split(GOOS === "windows" ? ";" : ":").filter(Boolean)
66
+ for (const name of ["cursor-agent", "agent"]) {
67
+ for (const d of dirs) {
68
+ const p = join(d, GOOS === "windows" ? name + ".exe" : name)
69
+ if (!(await exists(p))) continue
70
+ if (name === "cursor-agent") return p
71
+ if ((await realpath(p).catch(() => "")).includes("cursor-agent")) return p
72
+ }
73
+ }
74
+ const home = homedir()
75
+ for (const p of [join(home, ".local", "bin", "cursor-agent"), "/usr/local/bin/cursor-agent", "/opt/homebrew/bin/cursor-agent"])
76
+ if (await exists(p)) return p
77
+ return ""
78
+ }
79
+
80
+ const VERSION_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/
81
+
82
+ // clientVersion is the cursor-agent the API is told it is talking to,
83
+ // "cli-<version>": the installed one, as the API turns away a version it no
84
+ // longer supports.
85
+ let versionSaid = ""
86
+ async function clientVersion() {
87
+ if (versionSaid) return versionSaid
88
+ let v = ""
89
+ const exe = await cursorExecutable()
90
+ if (exe) {
91
+ const d = basename(dirname(await realpath(exe).catch(() => exe)))
92
+ if (VERSION_RE.test(d)) v = d
93
+ }
94
+ if (!v) {
95
+ const dirs = [join(homedir(), ".local", "share", "cursor-agent", "versions")]
96
+ if (GOOS === "windows") dirs.push(join(process.env.LOCALAPPDATA ?? "", "cursor-agent", "versions"))
97
+ for (const dir of dirs)
98
+ for (const n of await readdir(dir).catch(() => [])) if (VERSION_RE.test(n) && n > v) v = n
99
+ }
100
+ versionSaid = "cli-" + (v || VERSION_FALLBACK)
101
+ return versionSaid
102
+ }
103
+
104
+ // authFile is where cursor-agent keeps its sign-in off a Mac's keychain.
105
+ function authFile() {
106
+ const home = homedir()
107
+ if (GOOS === "windows") return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "Cursor", "auth.json")
108
+ if (GOOS === "darwin") return join(home, ".cursor", "auth.json")
109
+ return join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "cursor", "auth.json")
110
+ }
111
+
112
+ // cliToken is the access token cursor-agent signed in with; read, never
113
+ // written.
114
+ async function cliToken() {
115
+ if (GOOS === "darwin") {
116
+ const r = await run("security", ["find-generic-password", "-s", "cursor-access-token", "-a", "cursor-user", "-w"])
117
+ if (r.ok && r.out.trim()) return r.out.trim()
118
+ }
119
+ try {
120
+ return JSON.parse(await readFile(authFile(), "utf8"))?.accessToken ?? ""
121
+ } catch {
122
+ return ""
123
+ }
124
+ }
125
+
126
+ // expiry is when a JWT runs out, in ms; 0 when it doesn't say.
127
+ function expiry(tok) {
128
+ const parts = String(tok ?? "").split(".")
129
+ if (parts.length !== 3) return 0
130
+ try {
131
+ const exp = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"))?.exp
132
+ return typeof exp === "number" && exp > 0 ? exp * 1000 : 0
133
+ } catch {
134
+ return 0
135
+ }
136
+ }
137
+
138
+ const SOON = 5 * 60 * 1000
139
+
140
+ class AuthError extends Error {
141
+ constructor(msg) {
142
+ super(msg)
143
+ this.status = 401
144
+ }
145
+ }
146
+
147
+ // cliTokenNow is cursor-agent's token; one about to run out is renewed by
148
+ // cursor-agent, which does that whenever it runs.
149
+ let cliRefresh = null
150
+ async function cliTokenNow() {
151
+ let tok = await cliToken()
152
+ const exp = expiry(tok)
153
+ if (tok && (!exp || exp - Date.now() > SOON)) return tok
154
+ const exe = await cursorExecutable()
155
+ if (exe) {
156
+ cliRefresh ??= run(exe, ["status"], { timeout: 30_000 }).finally(() => (cliRefresh = null))
157
+ await cliRefresh
158
+ tok = await cliToken()
159
+ }
160
+ if (!tok) throw new AuthError("Cursor isn't signed in: run `cursor-agent login`, or sign in again")
161
+ const e = expiry(tok)
162
+ if (e && e <= Date.now()) throw new AuthError("Cursor's sign-in has run out: run `cursor-agent login`, or sign in again")
163
+ return tok
164
+ }
165
+
166
+ // ---- the account -----------------------------------------------------------------
167
+
168
+ async function headersFor(tok) {
169
+ return {
170
+ "Content-Type": "application/json",
171
+ "Connect-Protocol-Version": "1",
172
+ ...(tok ? { Authorization: `Bearer ${tok}` } : {}),
173
+ "x-cursor-client-version": await clientVersion(),
174
+ "x-cursor-client-type": "cli",
175
+ "x-ghost-mode": "true",
176
+ }
177
+ }
178
+
179
+ // unary is a Connect call in JSON.
180
+ async function unary(base, path, tok, body = {}, timeout = 15_000) {
181
+ const res = await fetch(base + "/" + path, {
182
+ method: "POST",
183
+ headers: await headersFor(tok),
184
+ body: JSON.stringify(body),
185
+ signal: AbortSignal.timeout(timeout),
186
+ })
187
+ const text = await res.text()
188
+ if (!res.ok) {
189
+ const f = failure(res.status, text)
190
+ const e = new Error(f.message)
191
+ e.status = f.status
192
+ throw e
193
+ }
194
+ return JSON.parse(text || "{}")
195
+ }
196
+
197
+ // whoIs is the account's email and plan, as `cursor-agent about` says them.
198
+ async function whoIs(tok) {
199
+ const [me, plan] = await Promise.allSettled([
200
+ unary(API, "aiserver.v1.DashboardService/GetMe", tok),
201
+ unary(API, "aiserver.v1.DashboardService/GetPlanInfo", tok),
202
+ ])
203
+ return {
204
+ email: me.status === "fulfilled" ? String(me.value?.email ?? "").trim() : "",
205
+ plan: plan.status === "fulfilled" ? String(plan.value?.planInfo?.planName ?? "").trim() : "",
206
+ }
207
+ }
208
+
209
+ // exchanged are API keys' tokens, by the key's hash.
210
+ const exchanged = new Map()
211
+
212
+ // keyToken is the access token for a Cursor API key, as the CLI gets it
213
+ // for CURSOR_API_KEY: /auth/exchange_user_api_key, again when it runs out.
214
+ async function keyToken(key) {
215
+ const h = createHash("sha256").update(key).digest("hex")
216
+ const had = exchanged.get(h)
217
+ if (had && (!had.exp || had.exp - Date.now() > SOON)) return had.tok
218
+ const res = await fetch(API + "/auth/exchange_user_api_key", {
219
+ method: "POST",
220
+ headers: { ...(await headersFor(key)), "Connect-Protocol-Version": undefined },
221
+ body: "{}",
222
+ signal: AbortSignal.timeout(15_000),
223
+ }).catch((e) => {
224
+ throw Object.assign(new Error("Cursor: couldn't reach the API: " + e.message), { status: 502 })
225
+ })
226
+ if (res.status >= 500) throw Object.assign(new Error(`Cursor: exchange_user_api_key: HTTP ${res.status}`), { status: 502 })
227
+ const j = res.ok ? await res.json().catch(() => null) : null
228
+ if (!j?.accessToken) throw new AuthError("Cursor didn't take this API key")
229
+ exchanged.set(h, { tok: j.accessToken, exp: expiry(j.accessToken) })
230
+ return j.accessToken
231
+ }
232
+
233
+ // tokenOf is the token to call Cursor's API with, for a sign-in.
234
+ async function tokenOf(auth) {
235
+ if (auth?.type === "api" && auth.key) return keyToken(auth.key)
236
+ if (auth?.type !== "oauth") throw new AuthError("Cursor isn't signed in")
237
+ if (auth.refresh === CLI_MARK) return cliTokenNow()
238
+ const exp = auth.expires || expiry(auth.access)
239
+ if (!auth.access) throw new AuthError("Cursor isn't signed in")
240
+ // Cursor's sign-in has nothing to renew it with: cursor-agent itself
241
+ // signs in again when it runs out (about two months)
242
+ if (exp && exp <= Date.now()) throw new AuthError("Cursor's sign-in has run out; sign in to Cursor again")
243
+ return auth.access
244
+ }
245
+
246
+ // browserSignIn is cursor-agent's `login`: the page signs in, and
247
+ // /auth/poll hands the tokens over once it has.
248
+ async function browserSignIn() {
249
+ const verifier = randomBytes(32).toString("base64url")
250
+ const challenge = createHash("sha256").update(verifier).digest("base64url")
251
+ const uuid = randomUUID()
252
+ const url = new URL("/loginDeepControl", WEBSITE)
253
+ url.searchParams.set("challenge", challenge)
254
+ url.searchParams.set("uuid", uuid)
255
+ url.searchParams.set("mode", "login")
256
+ url.searchParams.set("redirectTarget", "cli")
257
+ return {
258
+ url: url.toString(),
259
+ instructions: "Sign in to Cursor in the browser; this finishes by itself.",
260
+ method: "auto",
261
+ async callback() {
262
+ const headers = { "Content-Type": "application/json", "x-cursor-client-version": await clientVersion(), "x-cursor-client-type": "cli" }
263
+ let errors = 0
264
+ for (let r = 0; r < 150; r++) {
265
+ const wait = Math.min(1000 * 1.2 ** r, 10_000)
266
+ let res
267
+ try {
268
+ res = await fetch(`${API}/auth/poll?uuid=${uuid}&verifier=${verifier}`, { headers, signal: AbortSignal.timeout(15_000) })
269
+ } catch {
270
+ if (++errors >= 3) return { type: "failed" }
271
+ await sleep(wait)
272
+ continue
273
+ }
274
+ if (res.status === 404) {
275
+ errors = 0
276
+ await sleep(wait)
277
+ continue
278
+ }
279
+ if (!res.ok) {
280
+ if (res.status === 403 || ++errors >= 3) return { type: "failed" }
281
+ await sleep(wait)
282
+ continue
283
+ }
284
+ const j = await res.json().catch(() => null)
285
+ if (!j?.accessToken || !("refreshToken" in j)) return { type: "failed" }
286
+ const who = await whoIs(j.accessToken)
287
+ return {
288
+ type: "success",
289
+ access: j.accessToken,
290
+ refresh: j.refreshToken || "none",
291
+ expires: expiry(j.accessToken) || Date.now() + 30 * 24 * 3600 * 1000,
292
+ ...(who.email ? { accountId: who.email } : {}),
293
+ ...(who.plan ? { plan: who.plan } : {}),
294
+ }
295
+ }
296
+ return { type: "failed" }
297
+ },
298
+ }
299
+ }
300
+
301
+ // cliSignIn takes the account cursor-agent is signed in to; its token is
302
+ // read each time from where cursor-agent keeps it.
303
+ async function cliSignIn() {
304
+ return {
305
+ url: "",
306
+ instructions: "Uses the account `cursor-agent login` signed in to.",
307
+ method: "auto",
308
+ async callback() {
309
+ const tok = await cliToken()
310
+ const exp = expiry(tok)
311
+ if (!tok || (exp && exp <= Date.now())) return { type: "failed" }
312
+ const who = await whoIs(tok)
313
+ if (!who.email) return { type: "failed" }
314
+ return { type: "success", access: "", refresh: CLI_MARK, expires: 0, accountId: who.email, ...(who.plan ? { plan: who.plan } : {}) }
315
+ },
316
+ }
317
+ }
318
+
319
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
320
+
321
+ // ---- models ----------------------------------------------------------------------
322
+ //
323
+ // Cursor lists a model once for each effort and speed it serves it at
324
+ // ("grok-4.7-low", "grok-4.7-low-fast", … "grok-4.7-xhigh-fast"), and a
325
+ // Claude with thinking apart from one without. One model a family is
326
+ // offered, with the efforts there are, and the request asks for the id the
327
+ // effort picks. Fast stays a model of its own.
328
+
329
+ // the effort words of Cursor's ids, the levels they are and the words
330
+ // Cursor's names say them in; extra-high before high
331
+ const EFFORTS = [
332
+ ["extra-high", "xhigh", "Extra High"], ["xhigh", "xhigh", "Extra High"], ["minimal", "minimal", "Minimal"],
333
+ ["none", "none", "None"], ["low", "low", "Low"], ["medium", "medium", "Medium"], ["high", "high", "High"], ["max", "max", "Max"],
334
+ ]
335
+ const LEVEL_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
336
+ const EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
337
+
338
+ const cut = (s, suffix) => (s.endsWith(suffix) && s.length > suffix.length ? s.slice(0, -suffix.length) : null)
339
+
340
+ // splitID is a Cursor id taken apart: its family, and the effort ("" for
341
+ // none named) it is at. Thinking comes before the effort or after it; in
342
+ // the family it goes after, as does fast.
343
+ function splitID(id) {
344
+ let s = id
345
+ let fast = false
346
+ let thinking = false
347
+ let effort = ""
348
+ let b
349
+ if ((b = cut(s, "-fast")) !== null) [s, fast] = [b, true]
350
+ if ((b = cut(s, "-thinking")) !== null) [s, thinking] = [b, true]
351
+ for (const [word, level] of EFFORTS) {
352
+ if ((b = cut(s, "-" + word)) !== null) {
353
+ ;[s, effort] = [b, level]
354
+ break
355
+ }
356
+ }
357
+ if (!thinking && (b = cut(s, "-thinking")) !== null) [s, thinking] = [b, true]
358
+ if (thinking) s += "-thinking"
359
+ if (fast) s += "-fast"
360
+ return [s, effort]
361
+ }
362
+
363
+ const labelOf = (level) => EFFORTS.find((e) => e[1] === level)?.[2] ?? ""
364
+
365
+ // withoutWords is name with the first run of these words taken out, and
366
+ // whether it had them.
367
+ function withoutWords(name, words) {
368
+ const ns = name.split(/\s+/).filter(Boolean)
369
+ const ws = words.split(/\s+/).filter(Boolean)
370
+ if (!ws.length) return [name, false]
371
+ for (let i = 0; i + ws.length <= ns.length; i++)
372
+ if (ws.every((w, j) => ns[i + j] === w)) return [[...ns.slice(0, i), ...ns.slice(i + ws.length)].join(" "), true]
373
+ return [name, false]
374
+ }
375
+
376
+ function families(raw) {
377
+ const out = []
378
+ const by = new Map()
379
+ for (const m of raw) {
380
+ const [id, effort] = splitID(m.id)
381
+ let f = by.get(id)
382
+ if (!f) {
383
+ f = { id, variants: [], efforts: [] }
384
+ by.set(id, f)
385
+ out.push(f)
386
+ }
387
+ f.variants.push(m)
388
+ f.efforts.push(effort)
389
+ }
390
+ return out
391
+ }
392
+
393
+ // byEffort is the id for each effort, "" the one Cursor picks by default:
394
+ // the one without an effort in its id, else the one whose name doesn't say
395
+ // its effort, else medium, else the first.
396
+ function byEffort(f) {
397
+ const out = {}
398
+ let def = f.efforts.indexOf("")
399
+ f.variants.forEach((v, i) => {
400
+ const e = f.efforts[i]
401
+ if (!(e in out)) out[e] = v.id
402
+ if (def < 0 && e !== "" && !withoutWords(v.name, labelOf(e))[1]) def = i
403
+ })
404
+ if (def < 0) def = Math.max(0, f.efforts.indexOf("medium"))
405
+ out[""] = f.variants[def].id
406
+ return out
407
+ }
408
+
409
+ function familyModel(f) {
410
+ const def = byEffort(f)[""]
411
+ const m = { id: f.id, name: f.id, context: 0, efforts: [] }
412
+ f.variants.forEach((v, i) => {
413
+ if (v.id === def) m.name = withoutWords(v.name, labelOf(f.efforts[i]))[0]
414
+ if (v.context > 0 && (!m.context || v.context < m.context)) m.context = v.context
415
+ })
416
+ for (const l of LEVEL_RANK) if (f.efforts.includes(l)) m.efforts.push(l)
417
+ return m
418
+ }
419
+
420
+ // contextOf is how much of a conversation Cursor lets a model hold: what
421
+ // the name says ("Claude Opus 5.5 1M"), else Cursor's default.
422
+ function contextOf(name) {
423
+ const m = /\b(\d+)M\b/.exec(name)
424
+ return m ? Number(m[1]) * 1_000_000 : DEFAULT_CONTEXT
425
+ }
426
+
427
+ const CAPACITY = /\(\s*\d+M\s*\)|\b\d+M\b/g
428
+
429
+ // withoutCapacity takes the context out of each name, which is shown apart;
430
+ // a name that would then be another model's keeps it.
431
+ function withoutCapacity(ms) {
432
+ const strip = (n) => n.replace(CAPACITY, " ").split(/\s+/).filter(Boolean).join(" ")
433
+ const names = new Map()
434
+ const add = (n) => names.set(n.toLowerCase(), (names.get(n.toLowerCase()) ?? 0) + 1)
435
+ for (const m of ms) {
436
+ add(m.name)
437
+ const s = strip(m.name)
438
+ if (s !== m.name) add(s)
439
+ }
440
+ return ms.map((m) => {
441
+ const s = strip(m.name)
442
+ return s === m.name || !s || names.get(s.toLowerCase()) > 1 ? m : { ...m, name: s }
443
+ })
444
+ }
445
+
446
+ // offered is Cursor's list as it is offered: each family one model.
447
+ function offered(raw) {
448
+ return withoutCapacity(families(raw).map((f) => (f.variants.length === 1 ? { ...f.variants[0], efforts: [] } : familyModel(f))))
449
+ }
450
+
451
+ function variantsIn(raw, model) {
452
+ const f = families(raw).find((f) => f.id === model && f.variants.length > 1)
453
+ return f ? byEffort(f) : null
454
+ }
455
+
456
+ function baseIn(raw, id) {
457
+ const [family, effort] = splitID(id)
458
+ if (family === id) return null
459
+ const f = families(raw).find((f) => f.id === family && f.variants.length > 1 && f.variants.some((m) => m.id === id))
460
+ return f ? [family, effort] : null
461
+ }
462
+
463
+ function fitEffort(want, levels) {
464
+ if (want === "ultra" && !levels.includes(want)) want = "max"
465
+ if (!levels.length || levels.includes(want)) return want
466
+ const at = EFFORT_RANK.indexOf(want)
467
+ if (at < 0) return want
468
+ let best = want
469
+ let dist = EFFORT_RANK.length
470
+ for (const l of levels) {
471
+ const i = EFFORT_RANK.indexOf(l)
472
+ if (i < 0 || l === "none") continue
473
+ const d = Math.abs(i - at)
474
+ if (d < dist || (d === dist && i > at)) [best, dist] = [l, d]
475
+ }
476
+ return best
477
+ }
478
+
479
+ // modelID is Cursor's id for a model offered, at the effort asked for: the
480
+ // family's variant at it; else, for an effort between the ones it has, the
481
+ // one Cursor picks by default; else the nearest. Fast, when asked, is from
482
+ // the family's fast one. An id of Cursor's own at an effort is at the
483
+ // effort asked for where Cursor has that id; else it goes as it is.
484
+ function modelID(raw, model, effort, fast) {
485
+ const base = baseIn(raw, model)
486
+ if (base) {
487
+ let [b, at] = base
488
+ effort ||= at
489
+ if (fast && !b.endsWith("-fast") && variantsIn(raw, b + "-fast")) b += "-fast"
490
+ const vs = variantsIn(raw, b)
491
+ return vs && effort && vs[effort] ? vs[effort] : model
492
+ }
493
+ if (fast && !model.endsWith("-fast") && variantsIn(raw, model + "-fast")) model += "-fast"
494
+ const vs = variantsIn(raw, model)
495
+ if (!vs) return model
496
+ if (!effort) return vs[""]
497
+ if (vs[effort]) return vs[effort]
498
+ const levels = []
499
+ let unnamed = true // the default has no effort of its own
500
+ for (const l of EFFORT_RANK) {
501
+ if (vs[l]) {
502
+ levels.push(l)
503
+ unnamed &&= vs[l] !== vs[""]
504
+ }
505
+ }
506
+ if (!levels.length) return vs[""]
507
+ const at = EFFORT_RANK.indexOf(effort)
508
+ if (unnamed && at > EFFORT_RANK.indexOf(levels[0]) && at < EFFORT_RANK.indexOf(levels.at(-1))) return vs[""]
509
+ return vs[fitEffort(effort, levels)]
510
+ }
511
+
512
+ // usable is the account's list, as `cursor-agent models` has it: its ids,
513
+ // names and the id each is run by; kept a while, by token.
514
+ const lists = new Map()
515
+ async function usable(tok) {
516
+ const h = createHash("sha256").update(tok).digest("hex")
517
+ const had = lists.get(h)
518
+ if (had && Date.now() - had.at < MODELS_KEEP) return had.raw
519
+ const j = await unary(API, "agent.v1.AgentService/GetUsableModels", tok, {}, 30_000)
520
+ const raw = []
521
+ for (const m of j?.models ?? []) {
522
+ const id = m.displayModelId || m.modelId
523
+ if (!id) continue
524
+ // some names come with zero-width spaces and doubled ones
525
+ let name = String(m.displayName || id).replaceAll("​", "").split(/\s+/).filter(Boolean).join(" ")
526
+ name = name.replace(/\(default\)$/, "").trim().replace(/\(current\)$/, "").trim()
527
+ raw.push({ id, name, context: contextOf(name), run: m.modelId || id })
528
+ }
529
+ if (!raw.length) throw new Error("Cursor listed no models")
530
+ lists.set(h, { at: Date.now(), raw })
531
+ return raw
532
+ }
533
+
534
+ function runtimeModel(m) {
535
+ return {
536
+ id: m.id,
537
+ providerID: ID,
538
+ name: m.name,
539
+ api: { id: m.id, url: AGENT, npm: CHAT },
540
+ status: "active",
541
+ headers: {},
542
+ options: {},
543
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
544
+ limit: { context: m.context || DEFAULT_CONTEXT, output: 0 },
545
+ capabilities: {
546
+ temperature: false,
547
+ reasoning: m.efforts.length > 0,
548
+ attachment: true,
549
+ toolcall: true,
550
+ input: { text: true, image: true, audio: false, video: false, pdf: false },
551
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
552
+ interleaved: false,
553
+ },
554
+ release_date: "",
555
+ variants: Object.fromEntries(m.efforts.map((e) => [e, { reasoningEffort: e }])),
556
+ }
557
+ }
558
+
559
+ // ---- protobuf, by hand ------------------------------------------------------------
560
+
561
+ class PB {
562
+ parts = []
563
+ push(b) {
564
+ this.parts.push(b)
565
+ return this
566
+ }
567
+ uv(v) {
568
+ const out = []
569
+ while (v >= 0x80) {
570
+ out.push((v % 128) | 0x80)
571
+ v = Math.floor(v / 128)
572
+ }
573
+ out.push(v)
574
+ return this.push(Uint8Array.from(out))
575
+ }
576
+ tag(num, wire) {
577
+ return this.uv(num * 8 + wire)
578
+ }
579
+ varint(num, v) {
580
+ return this.tag(num, 0).uv(v)
581
+ }
582
+ bytes(num, v) {
583
+ const b = v instanceof PB ? v.done() : v ?? EMPTY
584
+ return this.tag(num, 2).uv(b.length).push(b)
585
+ }
586
+ str(num, s) {
587
+ return this.bytes(num, Buffer.from(s, "utf8"))
588
+ }
589
+ double(num, v) {
590
+ const b = Buffer.alloc(8)
591
+ b.writeDoubleLE(v)
592
+ return this.tag(num, 1).push(b)
593
+ }
594
+ done() {
595
+ return Buffer.concat(this.parts)
596
+ }
597
+ }
598
+ const pb = () => new PB()
599
+
600
+ // fields reads a message's fields; a malformed tail is dropped.
601
+ function fields(b) {
602
+ b = Buffer.isBuffer(b) ? b : Buffer.from(b ?? EMPTY)
603
+ const out = []
604
+ let i = 0
605
+ const uv = () => {
606
+ let v = 0
607
+ let m = 1
608
+ while (i < b.length) {
609
+ const c = b[i++]
610
+ v += (c & 0x7f) * m
611
+ if (c < 0x80) return v
612
+ m *= 128
613
+ }
614
+ return -1
615
+ }
616
+ while (i < b.length) {
617
+ const key = uv()
618
+ if (key < 0) break
619
+ const f = { num: Math.floor(key / 8), wire: key % 8, n: 0, data: EMPTY }
620
+ switch (f.wire) {
621
+ case 0: {
622
+ const v = uv()
623
+ if (v < 0) return out
624
+ f.n = v
625
+ break
626
+ }
627
+ case 1:
628
+ if (i + 8 > b.length) return out
629
+ f.data = b.subarray(i, i + 8)
630
+ f.n = Number(b.readBigUInt64LE(i))
631
+ i += 8
632
+ break
633
+ case 2: {
634
+ const l = uv()
635
+ if (l < 0 || i + l > b.length) return out
636
+ f.data = b.subarray(i, i + l)
637
+ i += l
638
+ break
639
+ }
640
+ case 5:
641
+ if (i + 4 > b.length) return out
642
+ f.n = b.readUInt32LE(i)
643
+ i += 4
644
+ break
645
+ default:
646
+ return out
647
+ }
648
+ out.push(f)
649
+ }
650
+ return out
651
+ }
652
+
653
+ const field = (fs, num) => fs.find((f) => f.num === num)
654
+ const pbStr = (fs, num) => field(fs, num)?.data.toString("utf8") ?? ""
655
+ const pbNum = (fs, num) => field(fs, num)?.n ?? 0
656
+
657
+ // pbValue is a google.protobuf.Value.
658
+ function pbValue(v) {
659
+ if (v === null || v === undefined) return pb().varint(1, 0)
660
+ if (typeof v === "number") return pb().double(2, v)
661
+ if (typeof v === "string") return pb().str(3, v)
662
+ if (typeof v === "boolean") return pb().varint(4, v ? 1 : 0)
663
+ if (Array.isArray(v)) {
664
+ const l = pb()
665
+ for (const e of v) l.bytes(1, pbValue(e))
666
+ return pb().bytes(6, l)
667
+ }
668
+ if (typeof v === "object") {
669
+ const st = pb()
670
+ for (const k of Object.keys(v).sort()) st.bytes(1, pb().str(1, k).bytes(2, pbValue(v[k])))
671
+ return pb().bytes(5, st)
672
+ }
673
+ return pb().varint(1, 0)
674
+ }
675
+
676
+ // pbAny reads a google.protobuf.Value.
677
+ function pbAny(b) {
678
+ for (const f of fields(b)) {
679
+ switch (f.num) {
680
+ case 1:
681
+ return null
682
+ case 2:
683
+ return f.data.length === 8 ? f.data.readDoubleLE(0) : 0
684
+ case 3:
685
+ return f.data.toString("utf8")
686
+ case 4:
687
+ return f.n !== 0
688
+ case 5: {
689
+ const m = {}
690
+ for (const e of fields(f.data)) {
691
+ if (e.num !== 1) continue
692
+ const kv = fields(e.data)
693
+ const v = field(kv, 2)
694
+ m[pbStr(kv, 1)] = v ? pbAny(v.data) : null
695
+ }
696
+ return m
697
+ }
698
+ case 6:
699
+ return fields(f.data).filter((e) => e.num === 1).map((e) => pbAny(e.data))
700
+ }
701
+ }
702
+ return null
703
+ }
704
+
705
+ // frame wraps a message as one frame of a Connect stream.
706
+ function frame(msg) {
707
+ const head = Buffer.alloc(5)
708
+ head.writeUInt32BE(msg.length, 1)
709
+ return Buffer.concat([head, msg])
710
+ }
711
+
712
+ // frames reads a Connect stream's frames: a message, or the stream's end
713
+ // with its JSON.
714
+ async function* frames(body) {
715
+ let buf = EMPTY
716
+ for await (const chunk of body) {
717
+ buf = buf.length ? Buffer.concat([buf, chunk]) : Buffer.from(chunk)
718
+ while (buf.length >= 5) {
719
+ const n = buf.readUInt32BE(1)
720
+ if (n > 64 << 20) throw new Error("a malformed stream")
721
+ if (buf.length < 5 + n) break
722
+ const flags = buf[0]
723
+ let data = buf.subarray(5, 5 + n)
724
+ buf = buf.subarray(5 + n)
725
+ if (flags & 1) data = gunzipSync(data)
726
+ yield { end: (flags & 2) !== 0, data }
727
+ }
728
+ }
729
+ if (buf.length) throw new Error("unexpected EOF")
730
+ }
731
+
732
+ // ---- failures -----------------------------------------------------------------------
733
+
734
+ const regional = (msg) => msg.toLowerCase().includes("region")
735
+
736
+ // failure is the status and message for Cursor's Connect error, whose
737
+ // details say it best: {"code", "message", "details": [{"debug": {"error",
738
+ // "details": {"title", "detail"}}}]}, or the same under "error" at a
739
+ // stream's end.
740
+ function failure(status, text) {
741
+ let e
742
+ try {
743
+ e = JSON.parse(typeof text === "string" ? text : Buffer.from(text).toString("utf8"))
744
+ } catch {
745
+ return statusOf(status, "", String(text ?? "").trim())
746
+ }
747
+ const f = e?.error && typeof e.error === "object" ? e.error : e ?? {}
748
+ let msg = f.message ?? ""
749
+ for (const d of f.details ?? []) {
750
+ const t = `${d?.debug?.details?.title ?? ""}: ${d?.debug?.details?.detail ?? ""}`.trim()
751
+ if (t !== ":") msg = t.replace(/^[:\s]+|[:\s]+$/g, "")
752
+ }
753
+ if (!msg || msg === "Error") msg = f.code ?? ""
754
+ if (!f.code && status >= 200 && status < 300) return { status, message: msg } // a stream that ended well
755
+ return statusOf(status, f.code ?? "", msg)
756
+ }
757
+
758
+ // statusOf is the status and words for Cursor's error of this code and
759
+ // message. A region the team isn't served in says so, not to sign in.
760
+ function statusOf(status, code, msg) {
761
+ msg ||= `HTTP ${status}`
762
+ const low = msg.toLowerCase()
763
+ const out = (status, message) => ({ status, message: "Cursor: " + message })
764
+ if (regional(msg)) return out(403, msg + " — Cursor serves your team only in some regions and turned this request away; signing in again won't change that")
765
+ if (code === "permission_denied") return out(403, msg)
766
+ if (code === "unauthenticated" || status === 401 || low.includes("expired")) return out(401, msg + " — sign in to Cursor again")
767
+ if (code === "resource_exhausted" || low.includes("quota") || low.includes("rate limit") || low.includes("usage limit"))
768
+ return out(429, "usage limit reached: " + msg)
769
+ if (low.includes("too long") || low.includes("context length") || low.includes("too many tokens"))
770
+ return out(400, "input is too long for the model's context: " + msg)
771
+ if (code === "invalid_argument") return out(400, msg)
772
+ if (code === "unavailable") return out(503, msg)
773
+ if (code) return out(502, msg)
774
+ return out(status, msg)
775
+ }
776
+
777
+ // ---- the conversation -------------------------------------------------------------
778
+
779
+ const textOf = (c) =>
780
+ typeof c === "string" ? c : Array.isArray(c) ? c.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : ""
781
+
782
+ function parseArgs(s) {
783
+ if (s && typeof s === "object") return s
784
+ try {
785
+ const v = JSON.parse(s || "{}")
786
+ return v && typeof v === "object" ? v : {}
787
+ } catch {
788
+ return {}
789
+ }
790
+ }
791
+
792
+ // tools are the caller's tools the model may call: none for tool_choice
793
+ // "none", the one named when one is.
794
+ function toolsOf(chat) {
795
+ const choice = chat.tool_choice
796
+ const only = typeof choice === "object" ? choice?.function?.name : ""
797
+ if (choice === "none") return []
798
+ return (chat.tools ?? [])
799
+ .filter((t) => t?.type === "function" && t.function?.name && (!only || t.function.name === only))
800
+ .map((t) => {
801
+ const schema = t.function.parameters ?? { type: "object", properties: {} }
802
+ return { name: t.function.name, description: t.function.description ?? "", schema, schemaText: JSON.stringify(schema) }
803
+ })
804
+ }
805
+
806
+ // catalog lists the caller's tools for the model. It sees MCP tools only
807
+ // through Cursor's GetDynamicTools and CallDynamicTool, and Cursor names
808
+ // them in a prompt of its own, which this conversation goes without:
809
+ // without the list the model says it has no such tool.
810
+ function catalog(tools) {
811
+ if (!tools.length) return ""
812
+ let s = `\n\n<dynamic_tool_catalog>\nThe tools below are available in the MCP namespace "magpie". Call one with \`${CALL}\` (namespace "magpie", toolName, arguments). Their schemas are given here, so there is no need to call \`GetDynamicTools\` first.\n`
813
+ for (const t of tools) s += `<tool name="${t.name}">\n${t.description}\ninput schema: ${t.schemaText}\n</tool>\n`
814
+ return s + "</dynamic_tool_catalog>"
815
+ }
816
+
817
+ // callID is a call's id as Cursor gave it, before the line in it was sent
818
+ // to the caller as __.
819
+ const callID = (id) => (id.startsWith("call_") ? id.replace("__fc_", "\nfc_") : id)
820
+
821
+ function result(id, text, isError) {
822
+ let res = text
823
+ try {
824
+ res = JSON.parse(text)
825
+ } catch {}
826
+ return {
827
+ type: "tool-result",
828
+ toolCallId: callID(id),
829
+ toolName: CALL,
830
+ result: res,
831
+ experimental_content: [{ type: "text", text }],
832
+ ...(isError ? { isError: true } : {}),
833
+ }
834
+ }
835
+
836
+ // messages is the conversation as AI SDK messages, in JSON: the system
837
+ // prompt with the caller's tools listed, then each turn. A tool result
838
+ // answers a call of the reply before it; a call left unanswered is
839
+ // answered for, as the API wants every call to have its result. With it,
840
+ // the user's last words and a guess at the prompt's size.
841
+ function conversation(chat, tools) {
842
+ const out = []
843
+ const add = (m) => out.push(Buffer.from(JSON.stringify(m)))
844
+ let size = 0
845
+ const system = []
846
+ let last = "."
847
+ for (const m of chat.messages ?? [])
848
+ if (m.role === "system" || m.role === "developer") system.push(textOf(m.content))
849
+ const sys = system.filter(Boolean).join("\n\n") + catalog(tools)
850
+ size += sys.length
851
+ if (sys) add({ role: "system", content: sys })
852
+ let pending = []
853
+ let results = []
854
+ const answer = () => {
855
+ for (const id of pending) results.push(result(id, NO_RESULT, true))
856
+ pending = []
857
+ if (results.length) add({ role: "tool", content: results })
858
+ results = []
859
+ }
860
+ for (const m of chat.messages ?? []) {
861
+ if (m.role === "system" || m.role === "developer") continue
862
+ if (m.role === "tool") {
863
+ const id = m.tool_call_id ?? ""
864
+ const i = pending.indexOf(id)
865
+ if (i >= 0) {
866
+ pending.splice(i, 1)
867
+ const text = textOf(m.content)
868
+ size += text.length
869
+ results.push(result(id, text, false))
870
+ }
871
+ continue
872
+ }
873
+ if (m.role === "assistant") {
874
+ answer()
875
+ const content = []
876
+ const text = textOf(m.content)
877
+ if (text) content.push({ type: "text", text })
878
+ size += text.length
879
+ for (const c of m.tool_calls ?? []) {
880
+ if (c?.type && c.type !== "function") continue
881
+ const args = parseArgs(c.function?.arguments)
882
+ size += (c.function?.arguments ?? "").length + (c.function?.name ?? "").length
883
+ content.push({ type: "tool-call", toolCallId: callID(c.id), toolName: CALL, args: { namespace: "magpie", toolName: c.function?.name, arguments: args } })
884
+ pending.push(c.id)
885
+ }
886
+ if (content.length) add({ role: "assistant", content })
887
+ continue
888
+ }
889
+ // the user's
890
+ const content = []
891
+ const parts = typeof m.content === "string" ? [{ type: "text", text: m.content }] : m.content ?? []
892
+ let first = ""
893
+ for (const p of parts) {
894
+ if (p?.type === "text" && p.text) {
895
+ content.push({ type: "text", text: p.text })
896
+ size += p.text.length
897
+ first ||= p.text
898
+ } else if (p?.type === "image_url") {
899
+ const u = typeof p.image_url === "string" ? p.image_url : p.image_url?.url ?? ""
900
+ const d = /^data:([^;,]+);base64,(.*)$/s.exec(u)
901
+ if (d) content.push({ type: "image", mimeType: d[1], image: { __type: "Uint8Array", hex: Buffer.from(d[2], "base64").toString("hex") } })
902
+ else content.push({ type: "text", text: `[attachment image: ${u}]` })
903
+ } else if (p?.type === "file") {
904
+ content.push({ type: "text", text: `[attachment ${p.file?.filename ?? "file"}]` })
905
+ }
906
+ }
907
+ answer()
908
+ if (first) last = first
909
+ if (content.length) add({ role: "user", content })
910
+ }
911
+ answer()
912
+ for (const t of tools) size += t.name.length + t.description.length + t.schemaText.length
913
+ return { msgs: out, last, estimate: Math.floor(size / 4) }
914
+ }
915
+
916
+ // ---- the request ---------------------------------------------------------------------
917
+
918
+ // toolDef is an McpToolDefinition.
919
+ function toolDef(t) {
920
+ const d = pb().str(1, t.name)
921
+ if (t.description) d.str(2, t.description)
922
+ d.bytes(3, pbValue(t.schema))
923
+ return d.str(4, "magpie").str(5, t.name).str(6, t.schemaText).done()
924
+ }
925
+
926
+ const env = () => pb().str(1, GOOS).str(2, tmpdir()).str(10, "UTC")
927
+
928
+ // buildRun is the Run's first message, an AgentClientMessage with its
929
+ // run_request, and the blobs it names. The conversation state is the
930
+ // messages and one turn, which the server wants there to sample at all.
931
+ function buildRun(msgs, lastUser, tools, model) {
932
+ const blobs = new Map()
933
+ const put = (b) => {
934
+ const id = createHash("sha256").update(b).digest()
935
+ blobs.set(id.toString("hex"), b)
936
+ return id
937
+ }
938
+ const state = pb()
939
+ for (const m of msgs) state.bytes(1, put(m))
940
+ const mid = randomUUID()
941
+ const user = pb().str(1, lastUser).str(2, mid).varint(4, 1).done() // mode: agent
942
+ const turn = pb().bytes(1, pb().bytes(1, put(user)).str(10, mid)).done()
943
+ state.bytes(8, put(turn)).varint(10, 1).str(22, "cli")
944
+ const defs = tools.map(toolDef)
945
+ const rc = pb().bytes(4, env())
946
+ const mcp = pb()
947
+ for (const d of defs) {
948
+ rc.bytes(7, d)
949
+ mcp.bytes(1, d)
950
+ }
951
+ const action = pb().bytes(2, pb().bytes(2, rc)) // resume_action
952
+ const rr = pb()
953
+ .bytes(1, state)
954
+ .bytes(2, action)
955
+ .bytes(3, pb().str(1, model).str(3, model).str(4, model))
956
+ .bytes(4, mcp)
957
+ .str(5, randomUUID())
958
+ .bytes(9, pb().str(1, model))
959
+ .varint(19, 1) // inline images
960
+ return { first: pb().bytes(1, rr).done(), blobs }
961
+ }
962
+
963
+ // ---- the agent API ---------------------------------------------------------------------
964
+
965
+ // endpoints are the agent API picked for each token (by its hash). As
966
+ // cursor-agent does, it is what ServerConfigService/GetServerConfig names —
967
+ // a team may be served in one region only, and the global API turns it
968
+ // away — asked once a token, and again when fresh is set; the global one
969
+ // while it can't be had.
970
+ const endpoints = new Map()
971
+ async function agentURL(tok, fresh) {
972
+ const key = createHash("sha256").update(tok).digest("hex")
973
+ const had = endpoints.get(key)
974
+ if (!fresh && had && (had.listed || Date.now() - had.at < 60_000)) return had.url
975
+ let url = AGENT
976
+ let listed = false
977
+ try {
978
+ const cfg = await unary(API, "aiserver.v1.ServerConfigService/GetServerConfig", tok, {}, 10_000)
979
+ for (const raw of [cfg?.agentUrlConfig?.agentUrl, cfg?.agentUrlConfig?.agentnUrl]) {
980
+ try {
981
+ const u = new URL(raw)
982
+ if ((u.protocol === "https:" || u.protocol === "http:") && u.host) {
983
+ url = raw.replace(/\/+$/, "")
984
+ listed = true
985
+ break
986
+ }
987
+ } catch {}
988
+ }
989
+ } catch {}
990
+ endpoints.set(key, { url, at: Date.now(), listed })
991
+ return url
992
+ }
993
+
994
+ // open starts a Run at base: the session, the stream, and the response's
995
+ // status, or an error the request never got past.
996
+ function open(base, headers, signal) {
997
+ return new Promise((resolve) => {
998
+ const u = new URL(base)
999
+ const session = http2.connect(u.origin)
1000
+ let done = false
1001
+ const fail = (e) => {
1002
+ if (done) return
1003
+ done = true
1004
+ session.destroy()
1005
+ resolve({ error: { status: 502, message: "Cursor: " + (e?.message ?? String(e)) } })
1006
+ }
1007
+ session.on("error", fail)
1008
+ const req = session.request({
1009
+ ":method": "POST",
1010
+ ":path": u.pathname.replace(/\/+$/, "") + "/agent.v1.AgentService/Run",
1011
+ ...headers,
1012
+ })
1013
+ req.on("error", fail)
1014
+ req.once("response", (h) => {
1015
+ if (done) return
1016
+ done = true
1017
+ resolve({ session, req, status: Number(h[":status"]) })
1018
+ })
1019
+ signal?.addEventListener("abort", () => fail(new Error("the request was cancelled")), { once: true })
1020
+ })
1021
+ }
1022
+
1023
+ // runOnce is one Run of the chat on the agent API at base: an async
1024
+ // iterator of the answer's pieces, or the error it failed with before any.
1025
+ async function runOnce({ tok, base, id, tools, conv, signal }) {
1026
+ const { first, blobs } = buildRun(conv.msgs, conv.last, tools, id)
1027
+ const o = await open(
1028
+ base,
1029
+ {
1030
+ "content-type": "application/connect+proto",
1031
+ "connect-protocol-version": "1",
1032
+ authorization: `Bearer ${tok}`,
1033
+ "x-cursor-client-version": await clientVersion(),
1034
+ "x-cursor-client-type": "cli", // else Cursor adds a prompt of its own
1035
+ "x-ghost-mode": "true", // privacy mode: nothing kept for training
1036
+ "x-request-id": randomUUID(),
1037
+ // none of Cursor's own tools, only the caller's
1038
+ "x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
1039
+ },
1040
+ signal,
1041
+ )
1042
+ if (o.error) return o
1043
+ const { session, req } = o
1044
+ let closed = false
1045
+ const send = (msg) => {
1046
+ if (!closed && !req.destroyed && req.writable) req.write(frame(msg))
1047
+ }
1048
+ const heartbeat = setInterval(() => send(pb().bytes(7, EMPTY).done()), 5000) // the client is still there, as the CLI says
1049
+ const close = () => {
1050
+ if (closed) return
1051
+ closed = true
1052
+ clearInterval(heartbeat)
1053
+ try {
1054
+ req.close(http2.constants.NGHTTP2_CANCEL)
1055
+ } catch {}
1056
+ session.close()
1057
+ setTimeout(() => session.destroy(), 1000).unref?.()
1058
+ }
1059
+ signal?.addEventListener("abort", close, { once: true })
1060
+ send(first)
1061
+ if (o.status < 200 || o.status > 299) {
1062
+ const chunks = []
1063
+ try {
1064
+ for await (const c of req) chunks.push(c)
1065
+ } catch {}
1066
+ close()
1067
+ return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
1068
+ }
1069
+ const it = decode(frames(req), { send, blobs, tools, estimate: conv.estimate })
1070
+ const wrapped = (async function* () {
1071
+ try {
1072
+ yield* it
1073
+ } finally {
1074
+ close()
1075
+ }
1076
+ })()
1077
+ // an error comes before anything of the answer: out of quota, a model the
1078
+ // plan doesn't have — answered with its own status
1079
+ const head = await wrapped.next()
1080
+ if (head.done) return { error: { status: 502, message: "Cursor: an empty reply" } }
1081
+ if (head.value.error) {
1082
+ await wrapped.return()
1083
+ return { error: head.value.error }
1084
+ }
1085
+ return {
1086
+ events: (async function* () {
1087
+ yield head.value
1088
+ yield* wrapped
1089
+ })(),
1090
+ }
1091
+ }
1092
+
1093
+ // decode follows the server's messages into the answer's pieces until the
1094
+ // turn ends, or the model has made its tool calls.
1095
+ async function* decode(stream, { send, blobs, tools, estimate }) {
1096
+ let calls = 0
1097
+ let listed = 0
1098
+ let said = 0
1099
+ let usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
1100
+ const finish = () => {
1101
+ if (!usage.output) usage.output = Math.floor((said + 3) / 4)
1102
+ if (!usage.input) usage.input = estimate
1103
+ const prompt = usage.input + usage.cacheRead + usage.cacheWrite
1104
+ return {
1105
+ stop: calls > 0 ? "tool_calls" : "stop",
1106
+ usage: {
1107
+ prompt_tokens: prompt,
1108
+ completion_tokens: usage.output,
1109
+ total_tokens: prompt + usage.output,
1110
+ prompt_tokens_details: { cached_tokens: usage.cacheRead, cache_write_tokens: usage.cacheWrite },
1111
+ },
1112
+ }
1113
+ }
1114
+ const closeExec = (id) => send(pb().bytes(5, pb().bytes(1, pb().varint(1, id))).done())
1115
+ try {
1116
+ for await (const f of stream) {
1117
+ if (f.end) {
1118
+ const e = failure(200, f.data)
1119
+ if (e.status < 200 || e.status > 299) yield { error: e }
1120
+ else if (said === 0 && calls === 0) yield { error: { status: 502, message: "Cursor: an empty reply" } }
1121
+ else yield finish()
1122
+ return
1123
+ }
1124
+ for (const m of fields(f.data)) {
1125
+ switch (m.num) {
1126
+ case 1: // interaction_update
1127
+ for (const u of fields(m.data)) {
1128
+ const uf = fields(u.data)
1129
+ switch (u.num) {
1130
+ case 1: {
1131
+ // text
1132
+ const t = pbStr(uf, 1)
1133
+ if (t) {
1134
+ said += t.length
1135
+ yield { text: t }
1136
+ }
1137
+ break
1138
+ }
1139
+ case 4: {
1140
+ // thinking
1141
+ const t = pbStr(uf, 1)
1142
+ if (t) {
1143
+ said += t.length
1144
+ yield { reasoning: t }
1145
+ }
1146
+ break
1147
+ }
1148
+ case 14: // turn ended, with what it used
1149
+ usage = { input: pbNum(uf, 1), output: pbNum(uf, 2), cacheRead: pbNum(uf, 3), cacheWrite: pbNum(uf, 4) }
1150
+ if (calls === 0) {
1151
+ yield finish()
1152
+ return
1153
+ }
1154
+ break
1155
+ case 27: // how many tool calls the step makes, sent before them
1156
+ listed = pbNum(uf, 1)
1157
+ if (listed > 0 && calls >= listed) {
1158
+ yield finish()
1159
+ return
1160
+ }
1161
+ break
1162
+ }
1163
+ }
1164
+ break
1165
+ case 2: {
1166
+ // exec_server_message: the server asks the client to run something
1167
+ const call = exec(m.data, tools, send, closeExec)
1168
+ if (call) {
1169
+ said += call.args.length
1170
+ yield { tool: { index: calls++, ...call } }
1171
+ }
1172
+ if (listed > 0 && calls >= listed) {
1173
+ // every call made: the caller runs them
1174
+ yield finish()
1175
+ return
1176
+ }
1177
+ break
1178
+ }
1179
+ case 4: {
1180
+ // kv_server_message: a blob wanted, or one to keep
1181
+ const kv = fields(m.data)
1182
+ const id = pbNum(kv, 1)
1183
+ for (const k of kv) {
1184
+ if (k.num === 2) {
1185
+ const want = field(fields(k.data), 1)?.data.toString("hex") ?? ""
1186
+ const b = blobs.get(want)
1187
+ const res = b ? pb().bytes(1, b) : pb().bytes(2, pb().str(1, "blob not found"))
1188
+ send(pb().bytes(3, pb().varint(1, id).bytes(2, res)).done())
1189
+ } else if (k.num === 3) {
1190
+ // the server's own record of the turn, not needed
1191
+ send(pb().bytes(3, pb().varint(1, id).bytes(3, EMPTY)).done())
1192
+ }
1193
+ }
1194
+ break
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1199
+ if (calls > 0) {
1200
+ yield finish()
1201
+ return
1202
+ }
1203
+ yield { error: { status: 502, message: "Cursor: the reply broke off: EOF" } }
1204
+ } catch (e) {
1205
+ if (calls > 0 && /EOF/.test(e?.message ?? "")) {
1206
+ yield finish()
1207
+ return
1208
+ }
1209
+ yield { error: { status: 502, message: "Cursor: the reply broke off: " + (e?.message ?? e) } }
1210
+ }
1211
+ }
1212
+
1213
+ // exec answers what the server asks the client to run: a call of the
1214
+ // caller's tools is returned, the list of them is given, and anything else
1215
+ // is refused.
1216
+ function exec(msg, tools, send, closeExec) {
1217
+ const es = fields(msg)
1218
+ const id = pbNum(es, 1)
1219
+ const execID = pbStr(es, 15)
1220
+ const answer = (num, res) => {
1221
+ send(pb().bytes(2, pb().varint(1, id).str(15, execID).bytes(num, res)).done())
1222
+ closeExec(id)
1223
+ }
1224
+ for (const e of es) {
1225
+ switch (e.num) {
1226
+ case 11: {
1227
+ // mcp_args: a call of the caller's tools
1228
+ const a = fields(e.data)
1229
+ const args = {}
1230
+ for (const kv of a) {
1231
+ if (kv.num !== 2) continue
1232
+ const ef = fields(kv.data)
1233
+ const v = field(ef, 2)
1234
+ args[pbStr(ef, 1)] = v ? pbAny(v.data) : null
1235
+ }
1236
+ const name = pbStr(a, 5) || pbStr(a, 1).replace(/^magpie-/, "")
1237
+ // an OpenAI model's id is its call's and its item's, a line apart,
1238
+ // which no caller would take as an id: the line goes as __
1239
+ const cid = pbStr(a, 3).replaceAll("\n", "__") || "call_" + randomBytes(12).toString("hex")
1240
+ return { id: cid, name, args: JSON.stringify(args) }
1241
+ }
1242
+ case 36: {
1243
+ // mcp_state_exec_args: the tools there are
1244
+ const srv = pb().str(1, "magpie").str(2, "magpie").str(7, "connected")
1245
+ for (const t of tools) srv.bytes(5, toolDef(t))
1246
+ answer(36, pb().bytes(1, pb().bytes(1, srv)))
1247
+ return null
1248
+ }
1249
+ case 10: // request_context_args
1250
+ answer(10, pb().bytes(1, pb().bytes(1, pb().bytes(4, env()))))
1251
+ return null
1252
+ }
1253
+ }
1254
+ // a shell, a file read or an edit of Cursor's own: never run
1255
+ send(pb().bytes(5, pb().bytes(2, pb().varint(1, id).str(2, "not available"))).done())
1256
+ closeExec(id)
1257
+ return null
1258
+ }
1259
+
1260
+ // ---- chat completions ------------------------------------------------------------------
1261
+
1262
+ const errorResponse = ({ status, message }) =>
1263
+ new Response(JSON.stringify({ error: { message, type: "cursor_error", code: status } }), {
1264
+ status,
1265
+ headers: { "Content-Type": "application/json" },
1266
+ })
1267
+
1268
+ // answer runs a chat completion on Cursor and answers it as one, streamed
1269
+ // or not.
1270
+ async function answer(auth, chat, signal) {
1271
+ let tok
1272
+ try {
1273
+ tok = await tokenOf(auth)
1274
+ } catch (e) {
1275
+ return errorResponse({ status: e.status ?? 401, message: e.message.startsWith("Cursor") ? e.message : "Cursor: " + e.message })
1276
+ }
1277
+ const model = String(chat.model ?? "")
1278
+ let raw = []
1279
+ try {
1280
+ raw = await usable(tok)
1281
+ } catch {}
1282
+ const fast = chat.service_tier === "priority" || chat.service_tier === "fast"
1283
+ let id = modelID(raw, model, chat.reasoning_effort ?? "", fast)
1284
+ id = raw.find((m) => m.id === id)?.run ?? id
1285
+ if (id === "auto") id = "default" // Cursor's pick, which its API calls default
1286
+ const tools = toolsOf(chat)
1287
+ const conv = conversation(chat, tools)
1288
+ const base = await agentURL(tok, false)
1289
+ let r = await runOnce({ tok, base, id, tools, conv, signal })
1290
+ if (r.error && regional(r.error.message)) {
1291
+ // the team moved, or the config was kept from before: once more with
1292
+ // what the config says now
1293
+ const fresh = await agentURL(tok, true)
1294
+ if (fresh !== base) r = await runOnce({ tok, base: fresh, id, tools, conv, signal })
1295
+ }
1296
+ if (r.error) return errorResponse(r.error)
1297
+ const it = r.events
1298
+ const cid = "chatcmpl-" + randomBytes(12).toString("hex")
1299
+ const created = Math.floor(Date.now() / 1000)
1300
+
1301
+ if (!chat.stream) {
1302
+ const msg = { role: "assistant", content: "" }
1303
+ let reasoning = ""
1304
+ const calls = []
1305
+ let stop = "stop"
1306
+ let usage
1307
+ for await (const e of it) {
1308
+ if (e.error) return errorResponse(e.error)
1309
+ if (e.text) msg.content += e.text
1310
+ if (e.reasoning) reasoning += e.reasoning
1311
+ if (e.tool) calls.push({ id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } })
1312
+ if (e.stop) [stop, usage] = [e.stop, e.usage]
1313
+ }
1314
+ if (reasoning) msg.reasoning_content = reasoning
1315
+ if (calls.length) msg.tool_calls = calls
1316
+ return Response.json({ id: cid, object: "chat.completion", created, model, choices: [{ index: 0, message: msg, finish_reason: stop }], ...(usage ? { usage } : {}) })
1317
+ }
1318
+
1319
+ const chunk = (delta, finish_reason = null, extra = {}) =>
1320
+ enc.encode(`data: ${JSON.stringify({ id: cid, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason }], ...extra })}\n\n`)
1321
+ let began = false
1322
+ const stream = new ReadableStream({
1323
+ async pull(ctl) {
1324
+ const r = await it.next()
1325
+ if (!began) {
1326
+ began = true
1327
+ ctl.enqueue(chunk({ role: "assistant", content: "" }))
1328
+ }
1329
+ if (r.done) {
1330
+ ctl.enqueue(enc.encode("data: [DONE]\n\n"))
1331
+ return ctl.close()
1332
+ }
1333
+ const e = r.value
1334
+ if (e.text) ctl.enqueue(chunk({ content: e.text }))
1335
+ else if (e.reasoning) ctl.enqueue(chunk({ reasoning_content: e.reasoning }))
1336
+ else if (e.tool) ctl.enqueue(chunk({ tool_calls: [{ index: e.tool.index, id: e.tool.id, type: "function", function: { name: e.tool.name, arguments: e.tool.args } }] }))
1337
+ else if (e.stop) ctl.enqueue(chunk({}, e.stop, e.usage ? { usage: e.usage } : {}))
1338
+ else if (e.error) {
1339
+ ctl.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: e.error.message, code: e.error.status } })}\n\n`))
1340
+ ctl.close()
1341
+ }
1342
+ },
1343
+ cancel() {
1344
+ it.return?.()
1345
+ },
1346
+ })
1347
+ return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
1348
+ }
1349
+
1350
+ // ---- the plugin ---------------------------------------------------------------------------
1351
+
1352
+ export async function CursorAuthPlugin() {
1353
+ return {
1354
+ auth: {
1355
+ provider: ID,
1356
+ async loader(getAuth) {
1357
+ const auth = await getAuth()
1358
+ if (!auth || (auth.type !== "oauth" && auth.type !== "api")) return {}
1359
+ return {
1360
+ baseURL: AGENT,
1361
+ apiKey: "cursor",
1362
+ // every chat completion run on Cursor's agent API, with the
1363
+ // sign-in as it is now
1364
+ async fetch(input, init = {}) {
1365
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
1366
+ if (!/\/chat\/completions$/.test(new URL(url).pathname))
1367
+ return errorResponse({ status: 404, message: "Cursor: only chat completions are served" })
1368
+ let chat
1369
+ try {
1370
+ const b = init.body ?? (input instanceof Request ? await input.clone().text() : undefined)
1371
+ chat = JSON.parse(typeof b === "string" ? b : new TextDecoder().decode(b))
1372
+ } catch {
1373
+ return errorResponse({ status: 400, message: "Cursor: a request that isn't JSON" })
1374
+ }
1375
+ return answer((await getAuth()) ?? auth, chat, init.signal ?? (input instanceof Request ? input.signal : undefined))
1376
+ },
1377
+ }
1378
+ },
1379
+ methods: [
1380
+ { type: "oauth", label: "Cursor (browser)", authorize: browserSignIn },
1381
+ { type: "oauth", label: "cursor-agent's sign-in", authorize: cliSignIn },
1382
+ { type: "api", label: "Cursor API key (cursor.com/dashboard → Integrations)" },
1383
+ ],
1384
+ },
1385
+ async config(config) {
1386
+ config.provider ??= {}
1387
+ const was = config.provider[ID] ?? {}
1388
+ config.provider[ID] = {
1389
+ name: "Cursor",
1390
+ npm: CHAT,
1391
+ api: AGENT,
1392
+ ...was,
1393
+ models: {
1394
+ auto: { name: "Auto", limit: { context: DEFAULT_CONTEXT, output: 0 }, attachment: true, tool_call: true },
1395
+ ...(was.models ?? {}),
1396
+ },
1397
+ }
1398
+ },
1399
+ // the account's list, each family of Cursor's ids one model with the
1400
+ // efforts there are
1401
+ provider: {
1402
+ id: ID,
1403
+ async models(provider, { auth } = {}) {
1404
+ if (!auth) return provider.models
1405
+ try {
1406
+ const tok = await tokenOf(auth)
1407
+ return Object.fromEntries(offered(await usable(tok)).map((m) => [m.id, runtimeModel(m)]))
1408
+ } catch {
1409
+ return provider.models
1410
+ }
1411
+ },
1412
+ },
1413
+ }
1414
+ }
1415
+
1416
+ // for tests
1417
+ export const _internal = { splitID, families, byEffort, offered, modelID, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }