@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 +43 -0
- package/README.md +51 -15
- package/dist/bridge.d.ts +1 -0
- package/dist/bridge.js +6 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +112 -0
- package/dist/index.d.ts +1 -6
- package/dist/index.js +3 -138
- package/dist/models.d.ts +8 -1
- package/dist/models.js +49 -30
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.js +152 -0
- package/installation.md +132 -0
- package/package.json +9 -3
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 /
|
|
16
|
-
- **Context limits** — each model publishes
|
|
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
|
|
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`)
|
|
73
|
-
- **
|
|
74
|
-
-
|
|
75
|
-
- **
|
|
76
|
-
|
|
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 ──>
|
|
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
|
-
-
|
|
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
|
-
-
|
|
138
|
-
-
|
|
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,
|
|
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
|
|
608
|
+
model,
|
|
605
609
|
tools: ["mcp"],
|
|
606
610
|
...(apiKey ? { apiKey } : {}),
|
|
607
611
|
local: { cwd, settingSources: ["project"], enableAgentRetries: true },
|
package/dist/cli.d.ts
ADDED
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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:
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
42
|
-
|
|
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 ===
|
|
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.
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
124
|
+
const m = catalog[opencodeId];
|
|
125
|
+
if (!m || m.blocked)
|
|
126
|
+
return;
|
|
127
|
+
m.blocked = true;
|
|
128
|
+
try {
|
|
129
|
+
saveCatalog();
|
|
106
130
|
}
|
|
107
|
-
|
|
108
|
-
|
|
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
|
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -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
|
+
};
|
package/installation.md
ADDED
|
@@ -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
|
|
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
|
-
"
|
|
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"
|