@vidit19sharma/opencode-cursor-auth 0.2.0 → 0.3.2

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,49 @@
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.2 — 2026-10-16
6
+
7
+ ### Added
8
+
9
+ - `npx @vidit19sharma/opencode-cursor-auth setup` — adds the plugin to opencode config idempotently (`--dry-run`, `--config`).
10
+
11
+ ### Fixed
12
+
13
+ - The first request before `opencode auth login` hung in opencode's connect loop: without a `cursor` auth entry opencode skips the plugin's auth loader and dials the dummy `http://cursor.local/v1`. The plugin now seeds its auth entry (only when none exists), so the loader runs and returns a clear 401 telling you to run `opencode auth login`.
14
+
15
+ ### Changed
16
+
17
+ - Docs no longer claim the Cursor CLI (`cursor-agent`) is required; `@cursor/sdk` talks to Cursor's backend directly.
18
+
19
+ ## 0.3.1 — 2026-10-16
20
+
21
+ ### Fixed
22
+
23
+ - 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.
24
+
25
+ ## 0.3.0 — 2026-10-16
26
+
27
+ ### Breaking
28
+
29
+ - 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).
30
+
31
+ ### Added
32
+
33
+ - 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`).
34
+ - Variants are selectable in config with `variant: "<name>"` next to `model` in an agent entry.
35
+ - `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.
36
+ - `spike/probe-models.ts`: probes every base model once and reports AVAILABLE / BLOCKED / OTHER for the logged-in account.
37
+
38
+ ### Changed
39
+
40
+ - 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).
41
+ - Models cache bumped to v4 `{name, context, params, variants, allowed}`; older cache files are refetched once.
42
+
43
+ ### Fixed
44
+
45
+ - 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.
46
+ - 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.
47
+
5
48
  ## 0.2.0 — 2026-10-16
6
49
 
7
50
  ### 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
@@ -23,11 +23,23 @@ The plugin bridges opencode's OpenAI-style chat requests to the official [`@curs
23
23
  ## Requirements
24
24
 
25
25
  - Node >= 22.13 (opencode runs plugins on Bun, which also works)
26
- - [`cursor-agent` CLI](https://cursor.com/docs/cli) installed (`curl https://cursor.com/install -fsS | bash`) — the SDK's local executor uses it
27
26
  - A Cursor subscription
28
27
 
29
28
  ## Install
30
29
 
30
+ All three methods end with `opencode auth login` (next section).
31
+
32
+ ### 1. One command (npx)
33
+
34
+ ```sh
35
+ npx @vidit19sharma/opencode-cursor-auth setup
36
+ ```
37
+
38
+ Adds the plugin to the `plugin` array of your opencode config (`~/.config/opencode/opencode.json[c]`, or creates `opencode.json`); running it again is a no-op.
39
+ `--dry-run` shows the change without writing; `--config <path>` targets a specific file. A `.jsonc` file with comments is never rewritten — you get the one-line manual edit instead.
40
+
41
+ ### 2. Manual
42
+
31
43
  Add the plugin to `~/.config/opencode/opencode.json`:
32
44
 
33
45
  ```json
@@ -36,7 +48,17 @@ Add the plugin to `~/.config/opencode/opencode.json`:
36
48
  }
37
49
  ```
38
50
 
39
- The plugin auto-registers a `cursor` provider — no manual provider config needed.
51
+ The plugin auto-registers the `cursor` provider — no manual provider config needed.
52
+
53
+ ### 3. Let an AI agent do it
54
+
55
+ Paste this into any LLM agent:
56
+
57
+ ```
58
+ Install the @vidit19sharma/opencode-cursor-auth plugin and configure it by following: https://raw.githubusercontent.com/vidit19sharma/opencode-cursor-auth/main/installation.md
59
+ ```
60
+
61
+ See [installation.md](installation.md) for the step-by-step agent instructions.
40
62
 
41
63
  ## Authenticate
42
64
 
@@ -69,15 +91,27 @@ Or pick any `cursor/*` model from the opencode model selector.
69
91
  opencode models | grep cursor/
70
92
  ```
71
93
 
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.
94
+ - **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`.
95
+ - **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".
96
+ - **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`.
97
+ - **In config**, set `variant` next to `model`:
98
+
99
+ ```json
100
+ { "agent": { "build": { "model": "cursor/gpt-5.5", "variant": "low" } } }
101
+ ```
102
+
103
+ - **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.
104
+ - **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
105
  - **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
106
  - **Offline / logged out**: the stale cache is used if present, otherwise only `cursor/auto`.
79
107
 
80
- The catalog is cached at `~/.cache/opencode-cursor-auth/models.json`; delete it to force a refresh.
108
+ The catalog is cached for 24h at `~/.cache/opencode-cursor-auth/models.json`; delete it to force a refresh.
109
+
110
+ ### Migrating from 0.2.0
111
+
112
+ 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".
113
+ Replace each with the base model plus a variant: `cursor/gpt-5.5-low` → `"model": "cursor/gpt-5.5", "variant": "low"`.
114
+ The models cache is refetched once on upgrade.
81
115
 
82
116
  ## Configuration
83
117
 
@@ -95,7 +129,7 @@ The catalog is cached at `~/.cache/opencode-cursor-auth/models.json`; delete it
95
129
  ## How it works
96
130
 
97
131
  ```
98
- opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agent ──> cursor-agent ──> Cursor backend
132
+ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agent ──> Cursor backend
99
133
  <─(SSE stream)──────── bridge <──(typed deltas: text / thinking / tool calls)
100
134
  ```
101
135
 
@@ -103,7 +137,7 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
103
137
  - 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
138
  - 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
139
  - 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 }`).
140
+ - 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
141
  - 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
142
  - The HTTP reply opens on the first text or thinking delta, so failures before any content become real HTTP errors.
109
143
  - The request's abort signal and stream cancel cancel the Cursor run, close the agent, and release its slot.
@@ -112,7 +146,7 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
112
146
 
113
147
  | Status | When |
114
148
  | --- | --- |
115
- | 400 | Unknown model or bad argument (Cursor's message); also an expired tool-call continuation |
149
+ | 400 | Unknown model or bad argument (Cursor's message), unknown variant param; also an expired tool-call continuation |
116
150
  | 401 | SDK key missing/expired, or Cursor rejects the key |
117
151
  | 403 | Model blocked for your account (`permission_error`) |
118
152
  | 429 | Cursor rate limit |
@@ -127,6 +161,8 @@ bun install
127
161
  bun run build # tsc → dist/
128
162
  bun run test # end-to-end bridge tests (requires Cursor login, uses real Cursor usage)
129
163
  bun run spike # raw @cursor/sdk validation
164
+ bun run test:cli # exercises the npx setup command against temp config dirs (7 cases)
165
+ bun run smoke # packs the plugin and loads it in a pristine HOME (startup, auth login, first request before login)
130
166
  ```
131
167
 
132
168
  The test suite runs 13 scenarios: plain streaming, a full tool loop on one held run, in-memory and disk-persisted session resume, AGENTS.md system-prompt delivery, mid-stream abort, error statuses, session scoping, context limits and key checks, model variants, the concurrency cap, retry semantics, and blocked models.
@@ -134,8 +170,8 @@ The test suite runs 13 scenarios: plain streaming, a full tool loop on one held
134
170
  ## Known limitations
135
171
 
136
172
  - `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
- - 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.
173
+ - Cursor's catalog has no entitlement info, so models your plan or team blocks only disappear after the first 403.
174
+ - `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
175
  - 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
176
  - Usage/rate limits are whatever your Cursor plan enforces — the plugin adds none beyond the concurrency cap.
141
177
 
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/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import process from "node:process";
6
+ const PKG = "@vidit19sharma/opencode-cursor-auth";
7
+ const ENTRY = `${PKG}@latest`;
8
+ const HELP = `Usage: npx ${PKG} [setup] [--dry-run] [--config <path>]
9
+
10
+ Adds "${ENTRY}" to the "plugin" array of your opencode config.
11
+ Idempotent: does nothing if any ${PKG} entry is already present.
12
+
13
+ --dry-run print what would change, write nothing
14
+ --config <path> edit this file instead of the auto-detected one
15
+ --help show this help
16
+
17
+ Config lookup: --config, $OPENCODE_CONFIG, then opencode.jsonc / opencode.json in
18
+ $OPENCODE_CONFIG_DIR, $XDG_CONFIG_HOME/opencode or ~/.config/opencode.
19
+
20
+ Exit codes: 0 ok or already installed, 2 cannot edit safely (manual edit printed), 1 error.`;
21
+ const NEXT = `Next steps:
22
+ 1. Restart opencode
23
+ 2. opencode auth login -> Cursor -> Log in with Cursor (browser)
24
+ 3. opencode models | grep cursor/`;
25
+ const MANUAL = `Add this to the top-level "plugin" array of your opencode config (create it if missing):
26
+ "plugin": ["${ENTRY}"]`;
27
+ // Strips // and /* */ comments and trailing commas outside double-quoted strings; does not handle single-quoted strings or other JSON5 syntax.
28
+ const stripJsonc = (s) => s
29
+ .replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_m, str) => str ?? "")
30
+ .replace(/("(?:\\.|[^"\\])*")|,(\s*[}\]])/g, (_m, str, close) => str ?? close);
31
+ function tryParse(s) {
32
+ try {
33
+ return JSON.parse(s);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ function main(argv) {
40
+ let dryRun = false;
41
+ let explicit;
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a === "--help" || a === "-h")
45
+ return console.log(HELP), 0;
46
+ else if (a === "--dry-run")
47
+ dryRun = true;
48
+ else if (a === "--config")
49
+ explicit = argv[++i];
50
+ else if (a !== "setup")
51
+ return console.error(`Unknown argument: ${a}\n\n${HELP}`), 1;
52
+ }
53
+ if (argv.includes("--config") && !explicit)
54
+ return console.error("--config needs a path"), 1;
55
+ const env = process.env;
56
+ const dir = env.OPENCODE_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
57
+ const file = resolve(explicit ||
58
+ env.OPENCODE_CONFIG ||
59
+ ["opencode.jsonc", "opencode.json"].map((f) => join(dir, f)).find((f) => existsSync(f)) ||
60
+ join(dir, "opencode.json"));
61
+ const exists = existsSync(file);
62
+ const original = exists ? readFileSync(file, "utf8") : "";
63
+ const strict = exists ? tryParse(original) : { $schema: "https://opencode.ai/config.json" };
64
+ const config = (strict ?? tryParse(stripJsonc(original)));
65
+ console.log(`Config: ${file}${exists ? "" : " (will be created)"}`);
66
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
67
+ console.error(`Cannot parse ${file}; not editing it.\n${MANUAL}`);
68
+ return 2;
69
+ }
70
+ const leftovers = [];
71
+ const provider = config.provider;
72
+ if (provider && typeof provider === "object" && "cursor-acp" in provider)
73
+ leftovers.push(` - remove the "cursor-acp" block under "provider" in ${file}`);
74
+ const acp = join(dir, "plugin", "cursor-acp.js");
75
+ if (existsSync(acp))
76
+ leftovers.push(` - rm ${JSON.stringify(acp)}`);
77
+ if (leftovers.length)
78
+ console.log(`Notice: old open-cursor setup found (nothing was deleted). To remove it:\n${leftovers.join("\n")}\n - npm uninstall -g @rama_nigg/open-cursor`);
79
+ const plugins = config.plugin ?? [];
80
+ if (!Array.isArray(plugins)) {
81
+ console.error(`"plugin" in ${file} is not an array; not editing it.\n${MANUAL}`);
82
+ return 2;
83
+ }
84
+ if (plugins.some((p) => typeof p === "string" && p.startsWith(PKG))) {
85
+ console.log(`Already installed: ${PKG} is in "plugin". File left untouched.\n\n${NEXT}`);
86
+ return 0;
87
+ }
88
+ if (strict === undefined) {
89
+ console.error(`${file} has comments or trailing commas; not rewriting it (that would drop them).\n${MANUAL}`);
90
+ return 2;
91
+ }
92
+ config.plugin = [...plugins, ENTRY];
93
+ const next = JSON.stringify(config, null, 2) + "\n";
94
+ if (dryRun) {
95
+ console.log(`Dry run: would ${exists ? "update" : "create"} ${file} with:\n${next}`);
96
+ return 0;
97
+ }
98
+ mkdirSync(dirname(file), { recursive: true });
99
+ if (exists)
100
+ writeFileSync(`${file}.bak-cursor-auth`, original);
101
+ writeFileSync(`${file}.tmp`, next);
102
+ renameSync(`${file}.tmp`, file);
103
+ console.log(`Added "${ENTRY}" to "plugin"${exists ? ` (backup: ${file}.bak-cursor-auth)` : ""}.\n\n${NEXT}`);
104
+ return 0;
105
+ }
106
+ try {
107
+ process.exitCode = main(process.argv.slice(2));
108
+ }
109
+ catch (e) {
110
+ console.error(`Unexpected error: ${e instanceof Error ? e.message : e}`);
111
+ process.exitCode = 1;
112
+ }
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,152 @@
1
+ import { Cursor } from "@cursor/sdk";
2
+ import { errorResponse, handleChatCompletion } from "./bridge.js";
3
+ import { currentModels } from "./models.js";
4
+ import { readFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ export { parseContext } from "./models.js";
8
+ const BASE_URL = "http://cursor.local/v1";
9
+ // Cursor publishes no output limit and the bridge never passes max_tokens to Cursor, so output is only an accounting reserve for opencode.
10
+ const DEFAULT_CONTEXT = 200_000;
11
+ const DEFAULT_OUTPUT = 32_000;
12
+ export function keyProblem(status, nowMs) {
13
+ if (status.status !== "logged-in")
14
+ return "Cursor SDK key missing — run `opencode auth login` and pick Cursor";
15
+ if (typeof status.apiKeyExpiresAtMs === "number" && status.apiKeyExpiresAtMs <= nowMs)
16
+ return `Cursor SDK key expired on ${new Date(status.apiKeyExpiresAtMs).toISOString()} — run \`opencode auth login\` and pick Cursor`;
17
+ return undefined;
18
+ }
19
+ const KEY_CHECK_TTL_MS = 60_000;
20
+ const EXPIRY_WARN_MS = 7 * 24 * 60 * 60 * 1000;
21
+ let keyStatus;
22
+ let warnedExpiry = false;
23
+ async function checkKey() {
24
+ if (pastedApiKey || process.env.CURSOR_API_KEY)
25
+ return undefined;
26
+ const now = Date.now();
27
+ if (!keyStatus || now - keyStatus.at > KEY_CHECK_TTL_MS) {
28
+ keyStatus = { at: now, value: Cursor.auth.status().catch(() => undefined) };
29
+ }
30
+ const status = await keyStatus.value;
31
+ if (!status)
32
+ return undefined;
33
+ const exp = status.status === "logged-in" ? status.apiKeyExpiresAtMs : undefined;
34
+ if (!warnedExpiry && typeof exp === "number" && exp > now && exp - now <= EXPIRY_WARN_MS) {
35
+ warnedExpiry = true;
36
+ const days = Math.ceil((exp - now) / (24 * 60 * 60 * 1000));
37
+ console.error(`[opencode-cursor-auth] Cursor SDK key expires in ${days} days — run \`opencode auth login\` to renew`);
38
+ }
39
+ return keyProblem(status, now);
40
+ }
41
+ async function bodyText(input, init) {
42
+ const b = init?.body;
43
+ if (typeof b === "string")
44
+ return b;
45
+ if (b instanceof Uint8Array)
46
+ return new TextDecoder().decode(b);
47
+ if (input instanceof Request)
48
+ return input.text();
49
+ return "";
50
+ }
51
+ let pastedApiKey;
52
+ async function cursorFetch(input, init) {
53
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
54
+ if (url.includes("/chat/completions")) {
55
+ const problem = await checkKey();
56
+ if (problem)
57
+ return errorResponse(problem, 401);
58
+ const body = JSON.parse(await bodyText(input, init));
59
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
60
+ const scope = new Headers(init?.headers).get("x-opencode-session") ??
61
+ (input instanceof Request ? input.headers.get("x-opencode-session") : null) ??
62
+ undefined;
63
+ return handleChatCompletion(body, pastedApiKey, signal, scope);
64
+ }
65
+ if (url.includes("/models")) {
66
+ return Response.json({
67
+ object: "list",
68
+ data: Object.keys(await currentModels()).map((id) => ({ id, object: "model" })),
69
+ });
70
+ }
71
+ return Response.json({ error: { message: `unsupported endpoint: ${url}` } }, { status: 404 });
72
+ }
73
+ async function seedAuthEntry(client) {
74
+ if (!client?.auth?.set)
75
+ return;
76
+ const file = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "auth.json");
77
+ const entries = await readFile(file, "utf8").then(JSON.parse, (err) => (err.code === "ENOENT" ? {} : undefined));
78
+ if (!entries || typeof entries !== "object" || "cursor" in entries)
79
+ return;
80
+ await client.auth.set({ path: { id: "cursor" }, body: { type: "api", key: "cursor-sdk-store" } });
81
+ }
82
+ export const CursorAuthPlugin = async (input) => {
83
+ seedAuthEntry(input?.client).catch(() => { });
84
+ return {
85
+ config: async (config) => {
86
+ const providers = (config.provider ??= {});
87
+ const cursor = (providers["cursor"] ??= { models: {} });
88
+ cursor.npm ??= "@ai-sdk/openai-compatible";
89
+ cursor.name ??= "Cursor";
90
+ cursor.options = { ...cursor.options, baseURL: BASE_URL };
91
+ cursor.models ??= {};
92
+ for (const [id, { name, context, variants }] of Object.entries(await currentModels())) {
93
+ cursor.models[id] ??= { name };
94
+ const model = cursor.models[id];
95
+ model.cost = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
96
+ model.limit ??= { context: context ?? DEFAULT_CONTEXT, output: DEFAULT_OUTPUT };
97
+ if (variants)
98
+ model.variants ??= Object.fromEntries(Object.entries(variants).map(([n, v]) => [n, { cursor_params: v.params }]));
99
+ }
100
+ },
101
+ "chat.headers": async (input, output) => {
102
+ if (input.model.providerID === "cursor")
103
+ output.headers["x-opencode-session"] = input.sessionID;
104
+ },
105
+ auth: {
106
+ provider: "cursor",
107
+ loader: async (getAuth) => {
108
+ const auth = await getAuth().catch(() => undefined);
109
+ if (auth && auth.type === "api" && auth.key !== "cursor-sdk-store")
110
+ pastedApiKey = auth.key;
111
+ return {
112
+ apiKey: "cursor-managed",
113
+ baseURL: BASE_URL,
114
+ fetch: cursorFetch,
115
+ };
116
+ },
117
+ methods: [
118
+ {
119
+ type: "oauth",
120
+ label: "Log in with Cursor (browser)",
121
+ authorize: async () => {
122
+ let resolveUrl;
123
+ const urlReady = new Promise((r) => (resolveUrl = r));
124
+ const login = Cursor.auth
125
+ .login({
126
+ apiKeyName: "opencode-cursor-auth",
127
+ openBrowser: false,
128
+ onLoginUrl: (url) => resolveUrl(url),
129
+ })
130
+ .catch((err) => err);
131
+ const url = await Promise.race([urlReady, login.then(() => "")]);
132
+ return {
133
+ url: typeof url === "string" && url ? url : "https://cursor.com/dashboard",
134
+ instructions: "Complete the login in your browser. The key is stored in ~/.cursor/sdk/auth.json.",
135
+ method: "auto",
136
+ callback: async () => {
137
+ const result = await login;
138
+ if (result instanceof Error)
139
+ return { type: "failed" };
140
+ return { type: "success", key: "cursor-sdk-store" };
141
+ },
142
+ };
143
+ },
144
+ },
145
+ {
146
+ type: "api",
147
+ label: "Paste a Cursor API key (cursor.com/dashboard)",
148
+ },
149
+ ],
150
+ },
151
+ };
152
+ };
@@ -0,0 +1,132 @@
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. `@cursor/sdk` requires Node >= 22.13. If older, ask the user to upgrade Node.
24
+
25
+ A Cursor subscription (Pro, Business, or team) is required.
26
+
27
+ ## Installation
28
+
29
+ ### Step 1: Remove the old open-cursor setup (only if present)
30
+
31
+ 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:
32
+
33
+ 1. Delete the `cursor-acp` block under `provider` in the opencode config.
34
+ 2. Delete the file: `rm ~/.config/opencode/plugin/cursor-acp.js`
35
+ 3. Uninstall it: `npm uninstall -g @rama_nigg/open-cursor`
36
+
37
+ Skip this step if none of these exist.
38
+
39
+ ### Step 2: Add the plugin to the opencode config
40
+
41
+ **Preferred, if you can run shell commands:**
42
+
43
+ ```bash
44
+ npx @vidit19sharma/opencode-cursor-auth setup
45
+ ```
46
+
47
+ It adds the plugin idempotently (exit 0 = added or already present) and lists any open-cursor leftovers from Step 1 without deleting them. Exit 2 means it could not edit the file safely (e.g. a `.jsonc` with comments); it prints the exact edit, which you then make by hand as described below. Use `--dry-run` to preview.
48
+
49
+ **Fallback: edit the file by hand.** The config file is `~/.config/opencode/opencode.json`. Some users have `~/.config/opencode/opencode.jsonc` instead; edit whichever exists.
50
+
51
+ If neither exists, create `~/.config/opencode/opencode.json`:
52
+
53
+ ```json
54
+ {
55
+ "$schema": "https://opencode.ai/config.json",
56
+ "plugin": ["@vidit19sharma/opencode-cursor-auth@latest"]
57
+ }
58
+ ```
59
+
60
+ If the file exists and has no top-level `plugin` array, add one:
61
+
62
+ ```json
63
+ "plugin": ["@vidit19sharma/opencode-cursor-auth@latest"]
64
+ ```
65
+
66
+ If a `plugin` array already exists, append `"@vidit19sharma/opencode-cursor-auth@latest"` and keep every existing entry.
67
+
68
+ 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.
69
+
70
+ ### Step 3: Authenticate
71
+
72
+ Pick one method.
73
+
74
+ **Browser login.** This step needs the user. Ask them to run:
75
+
76
+ ```bash
77
+ opencode auth login
78
+ ```
79
+
80
+ 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.
81
+
82
+ **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.
83
+
84
+ ## Verification
85
+
86
+ ```bash
87
+ opencode models | grep cursor/
88
+ ```
89
+
90
+ 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`.
91
+
92
+ Variants are not separate ids. In the TUI, press Ctrl+T on a cursor model to pick its effort, thinking, fast, or context variant.
93
+
94
+ ```bash
95
+ opencode run -m cursor/gpt-5.5 "Reply with exactly: CURSOR-AUTH-OK"
96
+ ```
97
+
98
+ 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.
99
+
100
+ 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.
101
+
102
+ ## Upgrading
103
+
104
+ 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:
105
+
106
+ ```bash
107
+ rm -rf ~/.cache/opencode/packages/@vidit19sharma/opencode-cursor-auth@latest
108
+ ```
109
+
110
+ ## Done
111
+
112
+ The plugin is installed. Every model the user's Cursor plan includes is available as `cursor/*` in opencode.
113
+
114
+ ## Troubleshooting
115
+
116
+ - **`Cannot connect to API`, or nothing happens on cursor models**: the auth entry is missing. Run `opencode auth login` and pick Cursor.
117
+ - **401 `Cursor SDK key missing/expired`**: run `opencode auth login`.
118
+ - **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.
119
+ - **400 `Cannot use this model`**: the model id is not in Cursor's catalog for this account.
120
+ - **Request hangs with no error**: `opencode auth login` was never run for Cursor; run it (0.3.2+ seeds the entry automatically so you get a 401 message instead).
121
+ - **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.
122
+
123
+ ### Paths
124
+
125
+ | Path | Contents |
126
+ | --- | --- |
127
+ | `~/.cursor/sdk/auth.json` | SDK key from browser login |
128
+ | `~/.cache/opencode-cursor-auth/models.json` | Model catalog, 24 h |
129
+ | `~/.cache/opencode-cursor-auth/sessions.json` | Conversation to Cursor agent map, 7 days |
130
+ | `~/.local/share/opencode/auth.json` | opencode's auth registry; the Cursor entry is a sentinel, the real key lives in the SDK file |
131
+
132
+ See the [README](README.md) for more detail.
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@vidit19sharma/opencode-cursor-auth",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
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",
7
7
  "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "opencode-cursor-auth": "dist/cli.js"
10
+ },
8
11
  "files": [
9
12
  "dist",
10
13
  "README.md",
14
+ "installation.md",
11
15
  "CHANGELOG.md",
12
16
  "LICENSE"
13
17
  ],
@@ -30,10 +34,12 @@
30
34
  "auth"
31
35
  ],
32
36
  "scripts": {
33
- "build": "tsc",
37
+ "build": "tsc && chmod +x dist/cli.js",
34
38
  "test": "bun spike/e2e-bridge.ts",
35
39
  "spike": "bun spike/validate-sdk.ts",
36
- "prepublishOnly": "tsc"
40
+ "smoke": "bash scripts/fresh-home-smoke.sh",
41
+ "prepublishOnly": "npm run build",
42
+ "test:cli": "npm run build && bash spike/cli-setup.test.sh"
37
43
  },
38
44
  "dependencies": {
39
45
  "@cursor/sdk": "^1.0.36"