@vidit19sharma/opencode-cursor-auth 0.2.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
4
 
5
+ ## 0.3.1 — 2026-10-16
6
+
7
+ ### Fixed
8
+
9
+ - opencode crashed at startup on a fresh machine (`undefined is not an object (evaluating 'C.provider')`, and `C.auth` on `opencode auth login`) as soon as the plugin was enabled. opencode invokes **every named export** of a plugin module as a plugin; the entry module also exported `CursorAuthPlugin`, `keyProblem` and `parseContext`, so opencode called the helpers as plugins and tried to read hooks off their return values. The entry now exposes only `default`; helpers moved to `src/plugin.ts`. Affects 0.1.0–0.3.0. A test guards the export shape.
10
+
11
+ ## 0.3.0 — 2026-10-16
12
+
13
+ ### Breaking
14
+
15
+ - The suffixed variant ids from 0.2.0 (`cursor/gpt-5.5-low`, `cursor/claude-opus-5-5-1m-max`, …) no longer exist; Cursor rejects them with 400 "Cannot use this model". Migrate `cursor/gpt-5.5-low` to model `cursor/gpt-5.5` + variant `low` (Ctrl+T in the TUI, or `variant:` in config).
16
+
17
+ ### Added
18
+
19
+ - Cursor variants on opencode's native variant picker (Ctrl+T): the model list is the 47 base ids only, and every other Cursor variant is attached as an opencode model variant, named by the params that differ from Cursor's default (e.g. `low`, `272k-none`, `no-thinking`, `xhigh-fast`).
20
+ - Variants are selectable in config with `variant: "<name>"` next to `model` in an agent entry.
21
+ - `installation.md`: step-by-step install guide for AI coding agents (prerequisites, config edit, auth, verification, troubleshooting), shipped in the npm tarball; the README has a one-line prompt to hand to any LLM agent.
22
+ - `spike/probe-models.ts`: probes every base model once and reports AVAILABLE / BLOCKED / OTHER for the logged-in account.
23
+
24
+ ### Changed
25
+
26
+ - A base id now means Cursor's own default variant (`isDefault`), sends its params explicitly, and gets that variant's context limit; previously it implied the first-listed param values and under-reported context (`gpt-5.5`: 272k → 1M).
27
+ - Models cache bumped to v4 `{name, context, params, variants, allowed}`; older cache files are refetched once.
28
+
29
+ ### Fixed
30
+
31
+ - Blocked-model flags learned from 403s persist across the 24h catalog refresh (carried over by base id), so admin-blocked models stay hidden instead of reappearing; delete `models.json` to re-check.
32
+ - An unknown variant param id or value (sent as `cursor_params`) returns 400 `invalid_request_error` before any Cursor agent is created or concurrency slot taken.
33
+
5
34
  ## 0.2.0 — 2026-10-16
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -12,8 +12,8 @@ The plugin bridges opencode's OpenAI-style chat requests to the official [`@curs
12
12
  - **Thinking blocks** — Cursor reasoning streams into opencode as proper thinking blocks (`reasoning_content`)
13
13
  - **Session resume** — conversations map to native Cursor agents (`Agent.resume`), in-memory and persisted to disk for 7 days, scoped per opencode session so parallel sub-agents never share an agent
14
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 with variants** — fetched live from `Cursor.models.list()`, cached 24h; every reasoning-effort / fast / 1M-context variant is its own model id
16
- - **Context limits** — each model publishes its Cursor context window, so opencode's auto-compaction and context indicator work
15
+ - **Dynamic model list with variants** — fetched live from `Cursor.models.list()`, cached 24h; one id per model, with every reasoning-effort / thinking / fast / context variant on opencode's Ctrl+T variant picker
16
+ - **Context limits** — each model publishes the context window of Cursor's default variant, so opencode's auto-compaction and context indicator work
17
17
  - **Real error statuses** — 400 / 401 / 403 / 429 / 502 instead of a 200 stream carrying an error
18
18
  - **Cancellation** — Esc or a sub-agent timeout cancels the Cursor run and closes the agent
19
19
  - **Concurrency cap and retry** — at most 4 Cursor runs at once (configurable); transient failures retried before the first byte
@@ -28,6 +28,14 @@ The plugin bridges opencode's OpenAI-style chat requests to the official [`@curs
28
28
 
29
29
  ## Install
30
30
 
31
+ For AI agents — paste this into any LLM agent:
32
+
33
+ ```
34
+ Install the @vidit19sharma/opencode-cursor-auth plugin and configure it by following: https://raw.githubusercontent.com/vidit19sharma/opencode-cursor-auth/main/installation.md
35
+ ```
36
+
37
+ See [installation.md](installation.md) for the step-by-step agent instructions.
38
+
31
39
  Add the plugin to `~/.config/opencode/opencode.json`:
32
40
 
33
41
  ```json
@@ -69,15 +77,27 @@ Or pick any `cursor/*` model from the opencode model selector.
69
77
  opencode models | grep cursor/
70
78
  ```
71
79
 
72
- - **Base ids** (`cursor/gpt-5.5`) send no parameters, so Cursor applies its own defaults. Cursor's `default` model is exposed as `cursor/auto`.
73
- - **Variant ids** are the base id plus every non-default parameter value, in Cursor's parameter order. Boolean parameters use the parameter name. Examples: `cursor/gpt-5.5-low`, `cursor/gpt-5.5-high-fast`, `cursor/gpt-5.5-1m-extra-high`, `cursor/claude-opus-5-5-1m-max`.
74
- - The full catalog is currently about 485 ids from 47 models.
75
- - **Limits**: `limit.context` is the variant's context value, else the model's first listed context value, else 200k. `limit.output` is a fixed 32k accounting reserve (Cursor publishes no output cap and the bridge sends no `max_tokens`). Limits you set yourself in opencode config are kept.
76
- - **Unknown ids** are passed through to Cursor, which answers with its own 400.
80
+ - **Base ids only** — one id per Cursor model (47 today: `cursor/auto`, `cursor/gpt-5.5`, `cursor/claude-opus-5-5`, …). A base id is Cursor's own default variant (`isDefault`) and sends its params explicitly. Cursor's `default` model is exposed as `cursor/auto`.
81
+ - **Variants** — every other Cursor variant (reasoning effort, thinking, fast, context size) is an opencode model variant. In the TUI, select the model and press **Ctrl+T** to cycle them. Models with no variants (e.g. `gemini-3.1-pro`) show "No variants available".
82
+ - **Variant names** are the params that differ from Cursor's default, joined by `-`: values as-is (`low`, `xhigh`, `272k`), a boolean switched on is the param name (`fast`, `thinking`), switched off is `no-<param>` (`no-fast`, `no-thinking`). Examples: gpt-5.5 → `low`, `extra-high`, `272k-high-fast`; claude-opus-5 → `no-thinking`, `max-fast`; grok-4.7 → `256k-low-no-fast`.
83
+ - **In config**, set `variant` next to `model`:
84
+
85
+ ```json
86
+ { "agent": { "build": { "model": "cursor/gpt-5.5", "variant": "low" } } }
87
+ ```
88
+
89
+ - **Limits**: `limit.context` is the default variant's context value, else the model's first listed context value, else 200k (gpt-5.5 and claude-opus-5-5 → 1M). `limit.output` is a fixed 32k accounting reserve (Cursor publishes no output cap and the bridge sends no `max_tokens`). Limits you set yourself in opencode config are kept.
90
+ - **Unknown ids** are passed through to Cursor, which answers with its own 400. An unknown variant param returns 400 `invalid_request_error` before any Cursor agent is created.
77
91
  - **Blocked models**: Cursor's catalog has no per-account entitlement info. If Cursor rejects a model as "Model Blocked" (e.g. a team admin disabled it), the request fails with 403 `permission_error` carrying Cursor's message, and that model and all its variants are hidden from the list. Using a hidden id explicitly still sends it to Cursor. Blocked flags persist across catalog refreshes; delete `~/.cache/opencode-cursor-auth/models.json` to re-check.
78
92
  - **Offline / logged out**: the stale cache is used if present, otherwise only `cursor/auto`.
79
93
 
80
- The catalog is cached at `~/.cache/opencode-cursor-auth/models.json`; delete it to force a refresh.
94
+ The catalog is cached for 24h at `~/.cache/opencode-cursor-auth/models.json`; delete it to force a refresh.
95
+
96
+ ### Migrating from 0.2.0
97
+
98
+ The suffixed ids (`cursor/gpt-5.5-low`, `cursor/claude-opus-5-5-1m-max`, …) no longer exist; Cursor rejects them with 400 "Cannot use this model".
99
+ Replace each with the base model plus a variant: `cursor/gpt-5.5-low` → `"model": "cursor/gpt-5.5", "variant": "low"`.
100
+ The models cache is refetched once on upgrade.
81
101
 
82
102
  ## Configuration
83
103
 
@@ -103,7 +123,7 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
103
123
  - 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.
104
124
  - Conversation prefixes, plus the opencode session id (sent via the `chat.headers` hook), 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`.
105
125
  - System messages are written to `AGENTS.md` in a per-session temp workspace and loaded via Cursor's project rules (`settingSources: ["project"]`). Workspaces are removed once no session references them.
106
- - Variant ids are mapped back to Cursor `ModelSelection` params (`{ id, params }`).
126
+ - The selected variant reaches the plugin as `providerOptions.cursor`; `@ai-sdk/openai-compatible` puts it in the request body as top-level `cursor_params: [{ id, value }, …]`, which the bridge validates and maps to Cursor `ModelSelection.params`.
107
127
  - New turns take a slot from a FIFO semaphore (`OPENCODE_CURSOR_MAX_ACTIVE`); tool-result continuations reuse their run's slot. `Agent.create` / `Agent.resume` / `send` are attempted up to 3 times with jittered exponential backoff on 429, 5xx, and network errors, never after the first byte.
108
128
  - The HTTP reply opens on the first text or thinking delta, so failures before any content become real HTTP errors.
109
129
  - The request's abort signal and stream cancel cancel the Cursor run, close the agent, and release its slot.
@@ -112,7 +132,7 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
112
132
 
113
133
  | Status | When |
114
134
  | --- | --- |
115
- | 400 | Unknown model or bad argument (Cursor's message); also an expired tool-call continuation |
135
+ | 400 | Unknown model or bad argument (Cursor's message), unknown variant param; also an expired tool-call continuation |
116
136
  | 401 | SDK key missing/expired, or Cursor rejects the key |
117
137
  | 403 | Model blocked for your account (`permission_error`) |
118
138
  | 429 | Cursor rate limit |
@@ -135,7 +155,8 @@ The test suite runs 13 scenarios: plain streaming, a full tool loop on one held
135
155
 
136
156
  - `systemPrompt` as a true harness replacement is feature-gated server-side by Cursor; system text is applied via `AGENTS.md` as a project rule (merged under Cursor's harness prompt).
137
157
  - The SDK spawns a `cursor-agent` worker per turn (~1–2 s startup on resume).
138
- - The model list is Cursor's full catalog (~485 ids). It has no entitlement info, so models your plan or team blocks only disappear after the first 403.
158
+ - Cursor's catalog has no entitlement info, so models your plan or team blocks only disappear after the first 403.
159
+ - `limit.context` is per model, not per variant: picking a smaller-context variant (e.g. gpt-5.5 `272k`) keeps the base model's 1M limit in opencode's context indicator and auto-compaction.
139
160
  - A run parked waiting for a tool result keeps its slot. Cursor-model sessions that spawn Cursor-model sub-agents nested deeper than `OPENCODE_CURSOR_MAX_ACTIVE` will stall; raise the limit if you fan out.
140
161
  - Usage/rate limits are whatever your Cursor plan enforces — the plugin adds none beyond the concurrency cap.
141
162
 
package/dist/bridge.d.ts CHANGED
@@ -27,6 +27,7 @@ type OaBody = {
27
27
  messages: OaMessage[];
28
28
  tools?: OaTool[];
29
29
  stream?: boolean;
30
+ cursor_params?: unknown;
30
31
  };
31
32
  export declare function isTransient(err: unknown): boolean;
32
33
  export declare function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, baseDelayMs?: number, retryable?: typeof isTransient): Promise<T>;
package/dist/bridge.js CHANGED
@@ -14,7 +14,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
14
14
  import { mkdtemp, readFile, writeFile } from "node:fs/promises";
15
15
  import { homedir, tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
- import { markBlocked, resolveModel } from "./models.js";
17
+ import { markBlocked, resolveVariant } from "./models.js";
18
18
  // @cursor/sdk's internal logger writes "HH:MM:SS.mmm INFO ..." lines via
19
19
  // console.log unconditionally (no config/env to silence it, verified against
20
20
  // the esm bundle). Filter exactly those lines, pass everything else through.
@@ -592,6 +592,10 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
592
592
  }
593
593
  return reply.promise;
594
594
  }
595
+ // opencode wraps variant options as providerOptions.cursor; @ai-sdk/openai-compatible (name "cursor") spreads them into the body top level
596
+ const model = resolveVariant(body.model, body.cursor_params) ?? { id: body.model };
597
+ if (typeof model === "string")
598
+ return errorResponse(model, 400);
595
599
  // new turn: reuse the agent whose last-seen conversation matches our prefix
596
600
  const prefix = body.messages.slice(0, -1);
597
601
  const key = hashMessages(prefix, scope);
@@ -601,7 +605,7 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
601
605
  let cwd;
602
606
  let newSuffix = body.messages;
603
607
  const agentOptions = (cwd) => ({
604
- model: resolveModel(body.model) ?? { id: body.model },
608
+ model,
605
609
  tools: ["mcp"],
606
610
  ...(apiKey ? { apiKey } : {}),
607
611
  local: { cwd, settingSources: ["project"], enableAgentRetries: true },
package/dist/index.d.ts CHANGED
@@ -1,6 +1 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
2
- import { type SdkAuthStatus } from "@cursor/sdk";
3
- export { parseContext } from "./models.js";
4
- export declare function keyProblem(status: SdkAuthStatus, nowMs: number): string | undefined;
5
- export declare const CursorAuthPlugin: Plugin;
6
- export default CursorAuthPlugin;
1
+ export { CursorAuthPlugin as default } from "./plugin.js";
package/dist/index.js CHANGED
@@ -1,138 +1,3 @@
1
- import { Cursor } from "@cursor/sdk";
2
- import { errorResponse, handleChatCompletion } from "./bridge.js";
3
- import { currentModels } from "./models.js";
4
- export { parseContext } from "./models.js";
5
- const BASE_URL = "http://cursor.local/v1";
6
- // Cursor publishes no output limit and the bridge never passes max_tokens to Cursor, so output is only an accounting reserve for opencode.
7
- const DEFAULT_CONTEXT = 200_000;
8
- const DEFAULT_OUTPUT = 32_000;
9
- export function keyProblem(status, nowMs) {
10
- if (status.status !== "logged-in")
11
- return "Cursor SDK key missing — run `opencode auth login` and pick Cursor";
12
- if (typeof status.apiKeyExpiresAtMs === "number" && status.apiKeyExpiresAtMs <= nowMs)
13
- return `Cursor SDK key expired on ${new Date(status.apiKeyExpiresAtMs).toISOString()} — run \`opencode auth login\` and pick Cursor`;
14
- return undefined;
15
- }
16
- const KEY_CHECK_TTL_MS = 60_000;
17
- const EXPIRY_WARN_MS = 7 * 24 * 60 * 60 * 1000;
18
- let keyStatus;
19
- let warnedExpiry = false;
20
- async function checkKey() {
21
- if (pastedApiKey || process.env.CURSOR_API_KEY)
22
- return undefined;
23
- const now = Date.now();
24
- if (!keyStatus || now - keyStatus.at > KEY_CHECK_TTL_MS) {
25
- keyStatus = { at: now, value: Cursor.auth.status().catch(() => undefined) };
26
- }
27
- const status = await keyStatus.value;
28
- if (!status)
29
- return undefined;
30
- const exp = status.status === "logged-in" ? status.apiKeyExpiresAtMs : undefined;
31
- if (!warnedExpiry && typeof exp === "number" && exp > now && exp - now <= EXPIRY_WARN_MS) {
32
- warnedExpiry = true;
33
- const days = Math.ceil((exp - now) / (24 * 60 * 60 * 1000));
34
- console.error(`[opencode-cursor-auth] Cursor SDK key expires in ${days} days — run \`opencode auth login\` to renew`);
35
- }
36
- return keyProblem(status, now);
37
- }
38
- async function bodyText(input, init) {
39
- const b = init?.body;
40
- if (typeof b === "string")
41
- return b;
42
- if (b instanceof Uint8Array)
43
- return new TextDecoder().decode(b);
44
- if (input instanceof Request)
45
- return input.text();
46
- return "";
47
- }
48
- let pastedApiKey;
49
- async function cursorFetch(input, init) {
50
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
51
- if (url.includes("/chat/completions")) {
52
- const problem = await checkKey();
53
- if (problem)
54
- return errorResponse(problem, 401);
55
- const body = JSON.parse(await bodyText(input, init));
56
- const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
57
- const scope = new Headers(init?.headers).get("x-opencode-session") ??
58
- (input instanceof Request ? input.headers.get("x-opencode-session") : null) ??
59
- undefined;
60
- return handleChatCompletion(body, pastedApiKey, signal, scope);
61
- }
62
- if (url.includes("/models")) {
63
- return Response.json({
64
- object: "list",
65
- data: Object.keys(await currentModels()).map((id) => ({ id, object: "model" })),
66
- });
67
- }
68
- return Response.json({ error: { message: `unsupported endpoint: ${url}` } }, { status: 404 });
69
- }
70
- export const CursorAuthPlugin = async () => {
71
- return {
72
- config: async (config) => {
73
- const providers = (config.provider ??= {});
74
- const cursor = (providers["cursor"] ??= { models: {} });
75
- cursor.npm ??= "@ai-sdk/openai-compatible";
76
- cursor.name ??= "Cursor";
77
- cursor.options = { ...cursor.options, baseURL: BASE_URL };
78
- cursor.models ??= {};
79
- for (const [id, { name, context }] of Object.entries(await currentModels())) {
80
- cursor.models[id] ??= { name };
81
- const model = cursor.models[id];
82
- model.cost = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
83
- model.limit ??= { context: context ?? DEFAULT_CONTEXT, output: DEFAULT_OUTPUT };
84
- }
85
- },
86
- "chat.headers": async (input, output) => {
87
- if (input.model.providerID === "cursor")
88
- output.headers["x-opencode-session"] = input.sessionID;
89
- },
90
- auth: {
91
- provider: "cursor",
92
- loader: async (getAuth) => {
93
- const auth = await getAuth().catch(() => undefined);
94
- if (auth && auth.type === "api" && auth.key !== "cursor-sdk-store")
95
- pastedApiKey = auth.key;
96
- return {
97
- apiKey: "cursor-managed",
98
- baseURL: BASE_URL,
99
- fetch: cursorFetch,
100
- };
101
- },
102
- methods: [
103
- {
104
- type: "oauth",
105
- label: "Log in with Cursor (browser)",
106
- authorize: async () => {
107
- let resolveUrl;
108
- const urlReady = new Promise((r) => (resolveUrl = r));
109
- const login = Cursor.auth
110
- .login({
111
- apiKeyName: "opencode-cursor-auth",
112
- openBrowser: false,
113
- onLoginUrl: (url) => resolveUrl(url),
114
- })
115
- .catch((err) => err);
116
- const url = await Promise.race([urlReady, login.then(() => "")]);
117
- return {
118
- url: typeof url === "string" && url ? url : "https://cursor.com/dashboard",
119
- instructions: "Complete the login in your browser. The key is stored in ~/.cursor/sdk/auth.json.",
120
- method: "auto",
121
- callback: async () => {
122
- const result = await login;
123
- if (result instanceof Error)
124
- return { type: "failed" };
125
- return { type: "success", key: "cursor-sdk-store" };
126
- },
127
- };
128
- },
129
- },
130
- {
131
- type: "api",
132
- label: "Paste a Cursor API key (cursor.com/dashboard)",
133
- },
134
- ],
135
- },
136
- };
137
- };
138
- export default CursorAuthPlugin;
1
+ // opencode calls EVERY named export of a plugin module as a plugin, so this
2
+ // entry must expose exactly one export; helpers live in plugin.ts.
3
+ export { CursorAuthPlugin as default } from "./plugin.js";
package/dist/models.d.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  import { type ModelListItem, type ModelParameterValue, type ModelSelection } from "@cursor/sdk";
2
+ type Variant = {
3
+ params: ModelParameterValue[];
4
+ context?: number;
5
+ };
2
6
  export type ModelInfo = {
3
7
  name: string;
4
8
  context?: number;
5
- base?: string;
6
9
  params?: ModelParameterValue[];
10
+ variants?: Record<string, Variant>;
11
+ allowed?: Record<string, string[]>;
7
12
  blocked?: true;
8
13
  };
9
14
  export declare function parseContext(m: ModelListItem): number | undefined;
10
15
  export declare function buildCatalog(list: ModelListItem[]): Record<string, ModelInfo>;
11
16
  export declare function currentModels(): Promise<Record<string, ModelInfo>>;
12
17
  export declare function resolveModel(opencodeId: string): ModelSelection | undefined;
18
+ export declare function resolveVariant(opencodeId: string, params?: unknown): ModelSelection | string | undefined;
13
19
  export declare function markBlocked(opencodeId: string): void;
20
+ export {};
package/dist/models.js CHANGED
@@ -9,7 +9,7 @@ let catalog = {};
9
9
  let fetchedAt = 0;
10
10
  function saveCatalog() {
11
11
  mkdirSync(CACHE_DIR, { recursive: true });
12
- writeFileSync(MODELS_CACHE_PATH, JSON.stringify({ version: 3, fetchedAt, models: catalog }));
12
+ writeFileSync(MODELS_CACHE_PATH, JSON.stringify({ version: 4, fetchedAt, models: catalog }));
13
13
  }
14
14
  function contextValue(raw) {
15
15
  const match = raw?.trim().match(/^(\d+)([km]?)$/i);
@@ -23,32 +23,44 @@ export function parseContext(m) {
23
23
  }
24
24
  export function buildCatalog(list) {
25
25
  const models = {};
26
- const rename = (id) => (id === "default" ? "auto" : id);
27
26
  for (const m of list) {
28
- const context = parseContext(m);
29
- models[rename(m.id)] = { name: m.displayName ?? m.id, ...(context ? { context } : {}) };
30
- }
31
- for (const m of list) {
32
- const base = rename(m.id);
27
+ const def = m.variants?.find((v) => v.isDefault) ?? m.variants?.[0];
28
+ const ctxOf = (ps) => contextValue(ps?.find((p) => p.id === "context")?.value);
29
+ const context = ctxOf(def?.params) ?? parseContext(m);
30
+ const allowed = {};
31
+ const allow = (id, value) => {
32
+ if (!(allowed[id] ??= []).includes(value))
33
+ allowed[id].push(value);
34
+ };
35
+ for (const p of m.parameters ?? [])
36
+ for (const v of p.values)
37
+ allow(p.id, v.value);
38
+ const variants = {};
33
39
  for (const v of m.variants ?? []) {
34
- // id = base + non-default param values (first listed value is the default); "true" flags use the param id, e.g. gpt-5.5-low-fast
35
- const parts = v.params
36
- .filter((p) => m.parameters?.find((d) => d.id === p.id)?.values[0]?.value !== p.value)
37
- .map((p) => (p.value === "true" ? p.id : p.value));
38
- const id = [base, ...parts].join("-");
39
- if (models[id])
40
+ for (const p of v.params)
41
+ allow(p.id, p.value);
42
+ const name = v.params
43
+ .filter((p) => def?.params.find((d) => d.id === p.id)?.value !== p.value)
44
+ .map((p) => (p.value === "true" ? p.id : p.value === "false" ? `no-${p.id}` : p.value))
45
+ .join("-");
46
+ if (!name || variants[name])
40
47
  continue;
41
- const context = contextValue(v.params.find((p) => p.id === "context")?.value) ?? models[base].context;
42
- const label = v.displayName && v.displayName !== m.displayName ? v.displayName : parts.join(" ");
43
- models[id] = { name: `${models[base].name} (${label})`, ...(context ? { context } : {}), base, params: v.params };
48
+ const vctx = ctxOf(v.params) ?? context;
49
+ variants[name] = { params: v.params, ...(vctx ? { context: vctx } : {}) };
44
50
  }
51
+ models[m.id === "default" ? "auto" : m.id] = {
52
+ name: m.displayName ?? m.id,
53
+ ...(context ? { context } : {}),
54
+ ...(def ? { params: def.params, allowed } : {}),
55
+ ...(Object.keys(variants).length ? { variants } : {}),
56
+ };
45
57
  }
46
58
  return models;
47
59
  }
48
60
  function loadCachedModels() {
49
61
  try {
50
62
  const raw = JSON.parse(readFileSync(MODELS_CACHE_PATH, "utf8"));
51
- if (raw?.version === 3 && raw.models && Object.keys(raw.models).length > 0)
63
+ if (raw?.version === 4 && raw.models && Object.keys(raw.models).length > 0)
52
64
  return raw;
53
65
  }
54
66
  catch {
@@ -94,22 +106,29 @@ export function resolveModel(opencodeId) {
94
106
  const m = catalog[opencodeId];
95
107
  if (!m)
96
108
  return undefined;
97
- return m.base && m.params ? { id: m.base, params: m.params } : { id: opencodeId };
109
+ return m.params ? { id: opencodeId, params: m.params } : { id: opencodeId };
110
+ }
111
+ export function resolveVariant(opencodeId, params) {
112
+ if (!Array.isArray(params))
113
+ return resolveModel(opencodeId);
114
+ loadCatalog();
115
+ const allowed = catalog[opencodeId]?.allowed ?? {};
116
+ for (const p of params) {
117
+ if (!allowed[p?.id]?.includes(p?.value))
118
+ return `unknown variant param ${p?.id}=${p?.value} for model ${opencodeId}`;
119
+ }
120
+ return { id: opencodeId, params: params.map((p) => ({ id: p.id, value: p.value })) };
98
121
  }
99
122
  export function markBlocked(opencodeId) {
100
123
  loadCatalog();
101
- const base = catalog[opencodeId]?.base ?? opencodeId;
102
- let changed = false;
103
- for (const [id, m] of Object.entries(catalog)) {
104
- if ((id === base || m.base === base) && !m.blocked)
105
- m.blocked = changed = true;
124
+ const m = catalog[opencodeId];
125
+ if (!m || m.blocked)
126
+ return;
127
+ m.blocked = true;
128
+ try {
129
+ saveCatalog();
106
130
  }
107
- if (changed) {
108
- try {
109
- saveCatalog();
110
- }
111
- catch {
112
- // cache dir unwritable — the in-memory flag still hides it for this process
113
- }
131
+ catch {
132
+ // cache dir unwritable — the in-memory flag still hides it for this process
114
133
  }
115
134
  }
@@ -0,0 +1,5 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { type SdkAuthStatus } from "@cursor/sdk";
3
+ export { parseContext } from "./models.js";
4
+ export declare function keyProblem(status: SdkAuthStatus, nowMs: number): string | undefined;
5
+ export declare const CursorAuthPlugin: Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,139 @@
1
+ import { Cursor } from "@cursor/sdk";
2
+ import { errorResponse, handleChatCompletion } from "./bridge.js";
3
+ import { currentModels } from "./models.js";
4
+ export { parseContext } from "./models.js";
5
+ const BASE_URL = "http://cursor.local/v1";
6
+ // Cursor publishes no output limit and the bridge never passes max_tokens to Cursor, so output is only an accounting reserve for opencode.
7
+ const DEFAULT_CONTEXT = 200_000;
8
+ const DEFAULT_OUTPUT = 32_000;
9
+ export function keyProblem(status, nowMs) {
10
+ if (status.status !== "logged-in")
11
+ return "Cursor SDK key missing — run `opencode auth login` and pick Cursor";
12
+ if (typeof status.apiKeyExpiresAtMs === "number" && status.apiKeyExpiresAtMs <= nowMs)
13
+ return `Cursor SDK key expired on ${new Date(status.apiKeyExpiresAtMs).toISOString()} — run \`opencode auth login\` and pick Cursor`;
14
+ return undefined;
15
+ }
16
+ const KEY_CHECK_TTL_MS = 60_000;
17
+ const EXPIRY_WARN_MS = 7 * 24 * 60 * 60 * 1000;
18
+ let keyStatus;
19
+ let warnedExpiry = false;
20
+ async function checkKey() {
21
+ if (pastedApiKey || process.env.CURSOR_API_KEY)
22
+ return undefined;
23
+ const now = Date.now();
24
+ if (!keyStatus || now - keyStatus.at > KEY_CHECK_TTL_MS) {
25
+ keyStatus = { at: now, value: Cursor.auth.status().catch(() => undefined) };
26
+ }
27
+ const status = await keyStatus.value;
28
+ if (!status)
29
+ return undefined;
30
+ const exp = status.status === "logged-in" ? status.apiKeyExpiresAtMs : undefined;
31
+ if (!warnedExpiry && typeof exp === "number" && exp > now && exp - now <= EXPIRY_WARN_MS) {
32
+ warnedExpiry = true;
33
+ const days = Math.ceil((exp - now) / (24 * 60 * 60 * 1000));
34
+ console.error(`[opencode-cursor-auth] Cursor SDK key expires in ${days} days — run \`opencode auth login\` to renew`);
35
+ }
36
+ return keyProblem(status, now);
37
+ }
38
+ async function bodyText(input, init) {
39
+ const b = init?.body;
40
+ if (typeof b === "string")
41
+ return b;
42
+ if (b instanceof Uint8Array)
43
+ return new TextDecoder().decode(b);
44
+ if (input instanceof Request)
45
+ return input.text();
46
+ return "";
47
+ }
48
+ let pastedApiKey;
49
+ async function cursorFetch(input, init) {
50
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
51
+ if (url.includes("/chat/completions")) {
52
+ const problem = await checkKey();
53
+ if (problem)
54
+ return errorResponse(problem, 401);
55
+ const body = JSON.parse(await bodyText(input, init));
56
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
57
+ const scope = new Headers(init?.headers).get("x-opencode-session") ??
58
+ (input instanceof Request ? input.headers.get("x-opencode-session") : null) ??
59
+ undefined;
60
+ return handleChatCompletion(body, pastedApiKey, signal, scope);
61
+ }
62
+ if (url.includes("/models")) {
63
+ return Response.json({
64
+ object: "list",
65
+ data: Object.keys(await currentModels()).map((id) => ({ id, object: "model" })),
66
+ });
67
+ }
68
+ return Response.json({ error: { message: `unsupported endpoint: ${url}` } }, { status: 404 });
69
+ }
70
+ export const CursorAuthPlugin = async () => {
71
+ return {
72
+ config: async (config) => {
73
+ const providers = (config.provider ??= {});
74
+ const cursor = (providers["cursor"] ??= { models: {} });
75
+ cursor.npm ??= "@ai-sdk/openai-compatible";
76
+ cursor.name ??= "Cursor";
77
+ cursor.options = { ...cursor.options, baseURL: BASE_URL };
78
+ cursor.models ??= {};
79
+ for (const [id, { name, context, variants }] of Object.entries(await currentModels())) {
80
+ cursor.models[id] ??= { name };
81
+ const model = cursor.models[id];
82
+ model.cost = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
83
+ model.limit ??= { context: context ?? DEFAULT_CONTEXT, output: DEFAULT_OUTPUT };
84
+ if (variants)
85
+ model.variants ??= Object.fromEntries(Object.entries(variants).map(([n, v]) => [n, { cursor_params: v.params }]));
86
+ }
87
+ },
88
+ "chat.headers": async (input, output) => {
89
+ if (input.model.providerID === "cursor")
90
+ output.headers["x-opencode-session"] = input.sessionID;
91
+ },
92
+ auth: {
93
+ provider: "cursor",
94
+ loader: async (getAuth) => {
95
+ const auth = await getAuth().catch(() => undefined);
96
+ if (auth && auth.type === "api" && auth.key !== "cursor-sdk-store")
97
+ pastedApiKey = auth.key;
98
+ return {
99
+ apiKey: "cursor-managed",
100
+ baseURL: BASE_URL,
101
+ fetch: cursorFetch,
102
+ };
103
+ },
104
+ methods: [
105
+ {
106
+ type: "oauth",
107
+ label: "Log in with Cursor (browser)",
108
+ authorize: async () => {
109
+ let resolveUrl;
110
+ const urlReady = new Promise((r) => (resolveUrl = r));
111
+ const login = Cursor.auth
112
+ .login({
113
+ apiKeyName: "opencode-cursor-auth",
114
+ openBrowser: false,
115
+ onLoginUrl: (url) => resolveUrl(url),
116
+ })
117
+ .catch((err) => err);
118
+ const url = await Promise.race([urlReady, login.then(() => "")]);
119
+ return {
120
+ url: typeof url === "string" && url ? url : "https://cursor.com/dashboard",
121
+ instructions: "Complete the login in your browser. The key is stored in ~/.cursor/sdk/auth.json.",
122
+ method: "auto",
123
+ callback: async () => {
124
+ const result = await login;
125
+ if (result instanceof Error)
126
+ return { type: "failed" };
127
+ return { type: "success", key: "cursor-sdk-store" };
128
+ },
129
+ };
130
+ },
131
+ },
132
+ {
133
+ type: "api",
134
+ label: "Paste a Cursor API key (cursor.com/dashboard)",
135
+ },
136
+ ],
137
+ },
138
+ };
139
+ };
@@ -0,0 +1,139 @@
1
+ # Install @vidit19sharma/opencode-cursor-auth
2
+
3
+ These instructions are designed for AI coding agents.
4
+
5
+ The plugin lets opencode use a Cursor subscription through the official `@cursor/sdk`, in-process. Supported on macOS/Linux; Windows untested.
6
+
7
+ ## Prerequisites
8
+
9
+ ### Check opencode version
10
+
11
+ ```bash
12
+ opencode --version
13
+ ```
14
+
15
+ You should see `1.19` or newer. If older or missing, ask the user to install or upgrade opencode before continuing.
16
+
17
+ ### Check Node version
18
+
19
+ ```bash
20
+ node --version
21
+ ```
22
+
23
+ You should see `v22.13` or newer. opencode installs plugins with Bun, but `@cursor/sdk` spawns the `cursor-agent` binary and requires Node >= 22.13. If older, ask the user to upgrade Node.
24
+
25
+ ### Check the Cursor CLI
26
+
27
+ ```bash
28
+ cursor-agent --version
29
+ ```
30
+
31
+ If this prints a version, continue. If it fails with `command not found`, install it:
32
+
33
+ ```bash
34
+ curl https://cursor.com/install -fsS | bash
35
+ ```
36
+
37
+ Then open a new shell, or add `~/.local/bin` to `PATH`, and re-run `cursor-agent --version`.
38
+
39
+ A Cursor subscription (Pro, Business, or team) is required. `cursor-agent login` is not required (the plugin has its own login) but is harmless.
40
+
41
+ ## Installation
42
+
43
+ ### Step 1: Remove the old open-cursor setup (only if present)
44
+
45
+ If `~/.config/opencode/plugin/cursor-acp.js` exists or the opencode config has a `cursor-acp` provider, the user has the old `@rama_nigg/open-cursor` proxy setup. Remove it:
46
+
47
+ 1. Delete the `cursor-acp` block under `provider` in the opencode config.
48
+ 2. Delete the file: `rm ~/.config/opencode/plugin/cursor-acp.js`
49
+ 3. Uninstall it: `npm uninstall -g @rama_nigg/open-cursor`
50
+
51
+ Skip this step if none of these exist.
52
+
53
+ ### Step 2: Add the plugin to the opencode config
54
+
55
+ The config file is `~/.config/opencode/opencode.json`. Some users have `~/.config/opencode/opencode.jsonc` instead; edit whichever exists.
56
+
57
+ If neither exists, create `~/.config/opencode/opencode.json`:
58
+
59
+ ```json
60
+ {
61
+ "$schema": "https://opencode.ai/config.json",
62
+ "plugin": ["@vidit19sharma/opencode-cursor-auth@latest"]
63
+ }
64
+ ```
65
+
66
+ If the file exists and has no top-level `plugin` array, add one:
67
+
68
+ ```json
69
+ "plugin": ["@vidit19sharma/opencode-cursor-auth@latest"]
70
+ ```
71
+
72
+ If a `plugin` array already exists, append `"@vidit19sharma/opencode-cursor-auth@latest"` and keep every existing entry.
73
+
74
+ Do not add a `provider.cursor` block by hand. On startup the plugin registers the `cursor` provider and its model list automatically. No `npm install` is needed; opencode installs the package at startup.
75
+
76
+ ### Step 3: Authenticate
77
+
78
+ Pick one method.
79
+
80
+ **Browser login.** This step needs the user. Ask them to run:
81
+
82
+ ```bash
83
+ opencode auth login
84
+ ```
85
+
86
+ and choose provider **Cursor**, then **Log in with Cursor (browser)**. A browser opens; they sign in to Cursor. This mints a 90-day SDK key stored at `~/.cursor/sdk/auth.json` (written by `@cursor/sdk`, not by the plugin). When the key expires, requests return a 401 telling the user to run `opencode auth login` again; a warning is printed to stderr during the last 7 days.
87
+
88
+ **API key.** In `opencode auth login`, choose **Cursor**, then **Paste a Cursor API key**. Keys come from cursor.com/dashboard (Integrations / API keys). Alternatively, set `CURSOR_API_KEY` in the environment before starting opencode.
89
+
90
+ ## Verification
91
+
92
+ ```bash
93
+ opencode models | grep cursor/
94
+ ```
95
+
96
+ You should see `cursor/auto`, `cursor/gpt-5.5`, `cursor/claude-opus-5-5`, and more. The first run fetches the catalog from Cursor (about 3 s); it is cached for 24 h at `~/.cache/opencode-cursor-auth/models.json`.
97
+
98
+ Variants are not separate ids. In the TUI, press Ctrl+T on a cursor model to pick its effort, thinking, fast, or context variant.
99
+
100
+ ```bash
101
+ opencode run -m cursor/gpt-5.5 "Reply with exactly: CURSOR-AUTH-OK"
102
+ ```
103
+
104
+ You should see `CURSOR-AUTH-OK`. In some setups `opencode run` does not exit on its own because of other plugins; Ctrl-C after the reply is fine.
105
+
106
+ Optional: to see which models the account is actually allowed to use, clone the repo, run `bun install`, then `bun spike/probe-models.ts`. It sends one tiny request per model.
107
+
108
+ ## Upgrading
109
+
110
+ With `@latest` in the `plugin` array, restarting opencode picks up new versions. If it stays on an old version, clear the cached package and restart opencode:
111
+
112
+ ```bash
113
+ rm -rf ~/.cache/opencode/packages/@vidit19sharma/opencode-cursor-auth@latest
114
+ ```
115
+
116
+ ## Done
117
+
118
+ The plugin is installed. Every model the user's Cursor plan includes is available as `cursor/*` in opencode.
119
+
120
+ ## Troubleshooting
121
+
122
+ - **`Cannot connect to API`, or nothing happens on cursor models**: the auth entry is missing. Run `opencode auth login` and pick Cursor.
123
+ - **401 `Cursor SDK key missing/expired`**: run `opencode auth login`.
124
+ - **403 `permission_error` "Model Blocked"**: a team admin disabled that model for the account. It is hidden from `opencode models` from then on; delete `~/.cache/opencode-cursor-auth/models.json` to re-check.
125
+ - **400 `Cannot use this model`**: the model id is not in Cursor's catalog for this account.
126
+ - **`cursor-agent: command not found` at request time**: install the Cursor CLI (see Prerequisites) and make sure it is on `PATH` for the shell that launches opencode.
127
+ - **Model list empty or only `cursor/auto`**: the catalog fetch failed (offline or not logged in). Fix auth, delete `~/.cache/opencode-cursor-auth/models.json`, restart opencode.
128
+ - **Stray `cursor-agent` processes**: check with `pgrep -fl cursor-agent`. A single `worker-server` is the SDK's idle singleton and is normal.
129
+
130
+ ### Paths
131
+
132
+ | Path | Contents |
133
+ | --- | --- |
134
+ | `~/.cursor/sdk/auth.json` | SDK key from browser login |
135
+ | `~/.cache/opencode-cursor-auth/models.json` | Model catalog, 24 h |
136
+ | `~/.cache/opencode-cursor-auth/sessions.json` | Conversation to Cursor agent map, 7 days |
137
+ | `~/.local/share/opencode/auth.json` | opencode's auth registry; the Cursor entry is a sentinel, the real key lives in the SDK file |
138
+
139
+ See the [README](README.md) for more detail.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vidit19sharma/opencode-cursor-auth",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk \u2014 no proxy server, no separate login.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "README.md",
11
+ "installation.md",
11
12
  "CHANGELOG.md",
12
13
  "LICENSE"
13
14
  ],