@vidit19sharma/opencode-cursor-auth 0.3.1 → 0.3.3

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,34 @@
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.3 — 2026-10-16
6
+
7
+ ### Added
8
+
9
+ - `npx @vidit19sharma/opencode-cursor-auth update` — removes opencode's cached copy of the plugin so the next opencode start installs the current release. opencode resolves `@latest` once at first install and never re-checks, so users were silently stuck on the version they first installed (`--dry-run` supported).
10
+ - Mocked plugin unit tests (`bun run test:plugin`) covering key handling, expiry, headless login URL passthrough and auth-entry seeding.
11
+
12
+ ### Fixed
13
+
14
+ - Pressing Esc before the first byte (during agent setup) returned HTTP 502 "aborted"; it now propagates as a proper abort.
15
+ - `@cursor/sdk` INFO/WARN log lines leaked into the opencode TUI: the lines are ANSI-coloured when stdout is a TTY, so the filter never matched there. Colours are stripped before matching and the filter is re-asserted per request.
16
+ - An empty or whitespace API key stored via the paste method silently fell back to the SDK key store; it now returns 401 `invalid_api_key`.
17
+ - A failure after content has already streamed emits a single SSE `error` frame and closes without `[DONE]` (covered by a test).
18
+
19
+ ## 0.3.2 — 2026-10-16
20
+
21
+ ### Added
22
+
23
+ - `npx @vidit19sharma/opencode-cursor-auth setup` — adds the plugin to opencode config idempotently (`--dry-run`, `--config`).
24
+
25
+ ### Fixed
26
+
27
+ - 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`.
28
+
29
+ ### Changed
30
+
31
+ - Docs no longer claim the Cursor CLI (`cursor-agent`) is required; `@cursor/sdk` talks to Cursor's backend directly.
32
+
5
33
  ## 0.3.1 — 2026-10-16
6
34
 
7
35
  ### Fixed
package/README.md CHANGED
@@ -23,18 +23,22 @@ 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
 
31
- For AI agents — paste this into any LLM agent:
30
+ All three methods end with `opencode auth login` (next section).
32
31
 
33
- ```
34
- Install the @vidit19sharma/opencode-cursor-auth plugin and configure it by following: https://raw.githubusercontent.com/vidit19sharma/opencode-cursor-auth/main/installation.md
32
+ ### 1. One command (npx)
33
+
34
+ ```sh
35
+ npx @vidit19sharma/opencode-cursor-auth setup
35
36
  ```
36
37
 
37
- See [installation.md](installation.md) for the step-by-step agent instructions.
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
38
42
 
39
43
  Add the plugin to `~/.config/opencode/opencode.json`:
40
44
 
@@ -44,7 +48,17 @@ Add the plugin to `~/.config/opencode/opencode.json`:
44
48
  }
45
49
  ```
46
50
 
47
- 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.
48
62
 
49
63
  ## Authenticate
50
64
 
@@ -71,6 +85,18 @@ opencode run -m cursor/auto "hello"
71
85
 
72
86
  Or pick any `cursor/*` model from the opencode model selector.
73
87
 
88
+ ## Upgrading
89
+
90
+ ```sh
91
+ npx @vidit19sharma/opencode-cursor-auth update
92
+ ```
93
+
94
+ Then restart opencode. opencode pins `@latest` to the version it first installed and never re-checks npm, so this deletes that cached copy and opencode installs the current release on next start. Tell-tale sign you need it: Ctrl+T shows no variants, or features from the changelog are missing. Manual equivalent:
95
+
96
+ ```sh
97
+ rm -rf ~/.cache/opencode/packages/@vidit19sharma/opencode-cursor-auth@latest
98
+ ```
99
+
74
100
  ## Models
75
101
 
76
102
  ```sh
@@ -115,7 +141,7 @@ The models cache is refetched once on upgrade.
115
141
  ## How it works
116
142
 
117
143
  ```
118
- opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agent ──> cursor-agent ──> Cursor backend
144
+ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agent ──> Cursor backend
119
145
  <─(SSE stream)──────── bridge <──(typed deltas: text / thinking / tool calls)
120
146
  ```
121
147
 
@@ -147,6 +173,8 @@ bun install
147
173
  bun run build # tsc → dist/
148
174
  bun run test # end-to-end bridge tests (requires Cursor login, uses real Cursor usage)
149
175
  bun run spike # raw @cursor/sdk validation
176
+ bun run test:cli # exercises the npx setup command against temp config dirs (7 cases)
177
+ bun run smoke # packs the plugin and loads it in a pristine HOME (startup, auth login, first request before login)
150
178
  ```
151
179
 
152
180
  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.
@@ -154,7 +182,6 @@ The test suite runs 13 scenarios: plain streaming, a full tool loop on one held
154
182
  ## Known limitations
155
183
 
156
184
  - `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).
157
- - The SDK spawns a `cursor-agent` worker per turn (~1–2 s startup on resume).
158
185
  - Cursor's catalog has no entitlement info, so models your plan or team blocks only disappear after the first 403.
159
186
  - `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.
160
187
  - 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.
package/dist/bridge.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare function ensureQuietConsole(): void;
1
2
  type ContentPart = {
2
3
  type: string;
3
4
  text?: string;
@@ -34,6 +35,7 @@ export declare function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal,
34
35
  export declare function _setMaxActive(n: number): void;
35
36
  export declare function errorResponse(err: unknown, status?: number): Response;
36
37
  export declare function _resetForTest(): void;
38
+ export declare function _failAfterFirstDelta(message: string | null): void;
37
39
  export declare function _stats(): {
38
40
  held: number;
39
41
  sessions: number;
package/dist/bridge.js CHANGED
@@ -15,16 +15,28 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
15
15
  import { homedir, tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { markBlocked, resolveVariant } from "./models.js";
18
- // @cursor/sdk's internal logger writes "HH:MM:SS.mmm INFO ..." lines via
19
- // console.log unconditionally (no config/env to silence it, verified against
20
- // the esm bundle). Filter exactly those lines, pass everything else through.
21
- const SDK_LOG_LINE = /^\d{2}:\d{2}:\d{2}\.\d{3} (TRACE|DEBUG|INFO|WARN|ERROR)\s/;
22
- const originalConsoleLog = console.log.bind(console);
23
- console.log = (...args) => {
24
- if (typeof args[0] === "string" && SDK_LOG_LINE.test(args[0]))
25
- return;
26
- originalConsoleLog(...args);
27
- };
18
+ // @cursor/sdk has no log-level switch: it prints "HH:MM:SS.mmm INFO ..." through
19
+ // whatever console.* is installed at call time, ANSI-coloured when stdout is a
20
+ // TTY (the opencode TUI). Match after stripping colours; re-wrap per request so
21
+ // a host that replaces console.* after plugin load cannot bypass the filter.
22
+ const SDK_LOG_LINE = /^\d{2}:\d{2}:\d{2}\.\d{3} (TRACE|DEBUG|INFO|WARN)\s/;
23
+ const ANSI = /\x1b\[[0-9;]*m/g;
24
+ const QUIET = Symbol.for("opencode-cursor-auth.quiet");
25
+ export function ensureQuietConsole() {
26
+ for (const m of ["log", "info", "warn", "debug"]) {
27
+ const current = console[m];
28
+ if (current[QUIET])
29
+ continue;
30
+ const wrapped = (...args) => {
31
+ if (typeof args[0] === "string" && SDK_LOG_LINE.test(args[0].replace(ANSI, "")))
32
+ return;
33
+ current(...args);
34
+ };
35
+ wrapped[QUIET] = true;
36
+ console[m] = wrapped;
37
+ }
38
+ }
39
+ ensureQuietConsole();
28
40
  function textOf(content) {
29
41
  if (typeof content === "string")
30
42
  return content;
@@ -63,6 +75,7 @@ function errorBody(err, status = statusFor(err)) {
63
75
  };
64
76
  }
65
77
  const abortError = () => new DOMException("aborted", "AbortError");
78
+ const isAbort = (err) => err?.name === "AbortError";
66
79
  export function isTransient(err) {
67
80
  const e = (typeof err === "object" && err !== null ? err : {});
68
81
  if (e.name === "AbortError")
@@ -84,7 +97,9 @@ export async function withRetry(fn, signal, baseDelayMs = 1000, retryable = isTr
84
97
  return await fn();
85
98
  }
86
99
  catch (err) {
87
- if (attempt >= 2 || signal?.aborted || !retryable(err))
100
+ if (signal?.aborted || isAbort(err))
101
+ throw abortError();
102
+ if (attempt >= 2 || !retryable(err))
88
103
  throw err;
89
104
  const delay = Math.min(baseDelayMs * 2 ** attempt * (0.75 + Math.random() * 0.5), 30_000);
90
105
  await new Promise((r) => {
@@ -438,6 +453,9 @@ function attachSignal(held, signal) {
438
453
  held.detachSignal = null;
439
454
  if (!signal)
440
455
  return;
456
+ // listeners never fire on an already-aborted signal (abort during Agent.create/resume)
457
+ if (signal.aborted)
458
+ return abortHeld(held);
441
459
  const onAbort = () => abortHeld(held);
442
460
  signal.addEventListener("abort", onAbort, { once: true });
443
461
  held.detachSignal = () => signal.removeEventListener("abort", onAbort);
@@ -465,7 +483,10 @@ function teardownHeld(held) {
465
483
  heldByAgent.delete(held.agent.agentId);
466
484
  }
467
485
  function failHeld(held, err) {
486
+ if (isAbort(err))
487
+ return abortHeld(held);
468
488
  teardownHeld(held);
489
+ held.run?.cancel().catch(() => { });
469
490
  if (statusFor(err) === 403)
470
491
  markBlocked(held.model);
471
492
  if (held.response && !held.opened) {
@@ -544,6 +565,10 @@ export function _resetForTest() {
544
565
  agentByToolCallId.clear();
545
566
  sessionsLoaded = false;
546
567
  }
568
+ let failAfterFirstDelta = null;
569
+ export function _failAfterFirstDelta(message) {
570
+ failAfterFirstDelta = message;
571
+ }
547
572
  export function _stats() {
548
573
  return {
549
574
  held: heldByAgent.size,
@@ -557,6 +582,7 @@ export function _stats() {
557
582
  export async function handleChatCompletion(body, apiKey, signal, scope) {
558
583
  if (signal?.aborted)
559
584
  throw new DOMException("aborted", "AbortError");
585
+ ensureQuietConsole();
560
586
  await loadSessions();
561
587
  const streaming = body.stream !== false;
562
588
  const reply = deferred();
@@ -579,6 +605,8 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
579
605
  held.response = sse?.response ?? null;
580
606
  held.opened = false;
581
607
  attachSignal(held, signal);
608
+ if (held.done)
609
+ return reply.promise;
582
610
  attachSink(held, sink);
583
611
  for (const m of body.messages) {
584
612
  if (m.role !== "tool" || !m.tool_call_id)
@@ -630,7 +658,7 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
630
658
  catch (err) {
631
659
  releaseSlot();
632
660
  releaseCwd(cwd);
633
- if (signal?.aborted)
661
+ if (signal?.aborted || isAbort(err))
634
662
  throw abortError();
635
663
  return errorResponse(err);
636
664
  }
@@ -660,6 +688,8 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
660
688
  const self = held;
661
689
  heldByAgent.set(agent.agentId, held);
662
690
  attachSignal(held, signal);
691
+ if (held.done)
692
+ return reply.promise;
663
693
  attachSink(held, sink);
664
694
  const customTools = {};
665
695
  for (const t of body.tools ?? []) {
@@ -676,7 +706,12 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
676
706
  },
677
707
  };
678
708
  }
679
- await syncAgentsMd(cwd, body.messages);
709
+ await syncAgentsMd(cwd, body.messages).catch((err) => {
710
+ if (!self.done)
711
+ throw err;
712
+ });
713
+ if (self.done)
714
+ return reply.promise;
680
715
  const prompt = newSuffix
681
716
  .filter((m) => m.role !== "system")
682
717
  .map((m) => (m.role === "user" ? textOf(m.content) : `${m.role.toUpperCase()}: ${textOf(m.content)}`))
@@ -692,6 +727,11 @@ export async function handleChatCompletion(body, apiKey, signal, scope) {
692
727
  openReply(self);
693
728
  self.textAcc += update.text;
694
729
  emit(self, "text", update.text);
730
+ if (failAfterFirstDelta !== null) {
731
+ const message = failAfterFirstDelta;
732
+ failAfterFirstDelta = null;
733
+ failHeld(self, new Error(message));
734
+ }
695
735
  }
696
736
  else if (update.type === "thinking-delta" && update.text) {
697
737
  openReply(self);
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,157 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, 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
+ npx ${PKG} update [--dry-run]
10
+
11
+ setup (default): adds "${ENTRY}" to the "plugin" array of your opencode config.
12
+ Idempotent: does nothing if any ${PKG} entry is already present.
13
+
14
+ update: deletes opencode's cached copy of ${PKG} (opencode never re-resolves
15
+ "@latest" once installed), so the next opencode start installs the latest version.
16
+ Cache: $XDG_CACHE_HOME/opencode or ~/.cache/opencode.
17
+
18
+ --dry-run print what would change, write/delete nothing
19
+ --config <path> edit this file instead of the auto-detected one
20
+ --help show this help
21
+
22
+ Config lookup: --config, $OPENCODE_CONFIG, then opencode.jsonc / opencode.json in
23
+ $OPENCODE_CONFIG_DIR, $XDG_CONFIG_HOME/opencode or ~/.config/opencode.
24
+
25
+ Exit codes: 0 ok or already installed, 2 cannot edit safely (manual edit printed), 1 error.`;
26
+ const NEXT = `Next steps:
27
+ 1. Restart opencode
28
+ 2. opencode auth login -> Cursor -> Log in with Cursor (browser)
29
+ 3. opencode models | grep cursor/`;
30
+ const MANUAL = `Add this to the top-level "plugin" array of your opencode config (create it if missing):
31
+ "plugin": ["${ENTRY}"]`;
32
+ // Strips // and /* */ comments and trailing commas outside double-quoted strings; does not handle single-quoted strings or other JSON5 syntax.
33
+ const stripJsonc = (s) => s
34
+ .replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_m, str) => str ?? "")
35
+ .replace(/("(?:\\.|[^"\\])*")|,(\s*[}\]])/g, (_m, str, close) => str ?? close);
36
+ function tryParse(s) {
37
+ try {
38
+ return JSON.parse(s);
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ // opencode caches npm plugins in <cache>/packages/<name>@<spec>/ (older: <cache>/npm/) and never re-resolves @latest.
45
+ function update(dryRun) {
46
+ const cache = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "opencode");
47
+ const [scope, name] = PKG.split("/");
48
+ const dirs = ["packages", "npm"].flatMap((p) => {
49
+ const parent = join(cache, p, scope);
50
+ if (!existsSync(parent))
51
+ return [];
52
+ return readdirSync(parent)
53
+ .filter((d) => d.startsWith(`${name}@`))
54
+ .map((d) => join(parent, d));
55
+ });
56
+ if (!dirs.length) {
57
+ console.log(`nothing to update: opencode has no cached copy of ${PKG} (it will install the latest version on next start)`);
58
+ return 0;
59
+ }
60
+ for (const d of dirs) {
61
+ const pkg = tryParse(readFileSafe(join(d, "node_modules", PKG, "package.json")));
62
+ const version = typeof pkg?.version === "string" ? pkg.version : "unknown version";
63
+ if (!dryRun)
64
+ rmSync(d, { recursive: true, force: true });
65
+ console.log(`${dryRun ? "would remove" : "removed"} cached ${version} at ${d}`);
66
+ }
67
+ if (!dryRun)
68
+ console.log(`restart opencode — it will install the latest ${PKG} from npm on start`);
69
+ return 0;
70
+ }
71
+ function readFileSafe(f) {
72
+ try {
73
+ return readFileSync(f, "utf8");
74
+ }
75
+ catch {
76
+ return "";
77
+ }
78
+ }
79
+ function main(argv) {
80
+ let dryRun = false;
81
+ let explicit;
82
+ let cmd = "setup";
83
+ for (let i = 0; i < argv.length; i++) {
84
+ const a = argv[i];
85
+ if (a === "--help" || a === "-h")
86
+ return console.log(HELP), 0;
87
+ else if (a === "--dry-run")
88
+ dryRun = true;
89
+ else if (a === "--config")
90
+ explicit = argv[++i];
91
+ else if (a === "setup" || a === "update")
92
+ cmd = a;
93
+ else
94
+ return console.error(`Unknown argument: ${a}\n\n${HELP}`), 1;
95
+ }
96
+ if (argv.includes("--config") && !explicit)
97
+ return console.error("--config needs a path"), 1;
98
+ if (cmd === "update")
99
+ return update(dryRun);
100
+ const env = process.env;
101
+ const dir = env.OPENCODE_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
102
+ const file = resolve(explicit ||
103
+ env.OPENCODE_CONFIG ||
104
+ ["opencode.jsonc", "opencode.json"].map((f) => join(dir, f)).find((f) => existsSync(f)) ||
105
+ join(dir, "opencode.json"));
106
+ const exists = existsSync(file);
107
+ const original = exists ? readFileSync(file, "utf8") : "";
108
+ const strict = exists ? tryParse(original) : { $schema: "https://opencode.ai/config.json" };
109
+ const config = (strict ?? tryParse(stripJsonc(original)));
110
+ console.log(`Config: ${file}${exists ? "" : " (will be created)"}`);
111
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
112
+ console.error(`Cannot parse ${file}; not editing it.\n${MANUAL}`);
113
+ return 2;
114
+ }
115
+ const leftovers = [];
116
+ const provider = config.provider;
117
+ if (provider && typeof provider === "object" && "cursor-acp" in provider)
118
+ leftovers.push(` - remove the "cursor-acp" block under "provider" in ${file}`);
119
+ const acp = join(dir, "plugin", "cursor-acp.js");
120
+ if (existsSync(acp))
121
+ leftovers.push(` - rm ${JSON.stringify(acp)}`);
122
+ if (leftovers.length)
123
+ 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`);
124
+ const plugins = config.plugin ?? [];
125
+ if (!Array.isArray(plugins)) {
126
+ console.error(`"plugin" in ${file} is not an array; not editing it.\n${MANUAL}`);
127
+ return 2;
128
+ }
129
+ if (plugins.some((p) => typeof p === "string" && p.startsWith(PKG))) {
130
+ console.log(`Already installed: ${PKG} is in "plugin". File left untouched.\n\n${NEXT}`);
131
+ return 0;
132
+ }
133
+ if (strict === undefined) {
134
+ console.error(`${file} has comments or trailing commas; not rewriting it (that would drop them).\n${MANUAL}`);
135
+ return 2;
136
+ }
137
+ config.plugin = [...plugins, ENTRY];
138
+ const next = JSON.stringify(config, null, 2) + "\n";
139
+ if (dryRun) {
140
+ console.log(`Dry run: would ${exists ? "update" : "create"} ${file} with:\n${next}`);
141
+ return 0;
142
+ }
143
+ mkdirSync(dirname(file), { recursive: true });
144
+ if (exists)
145
+ writeFileSync(`${file}.bak-cursor-auth`, original);
146
+ writeFileSync(`${file}.tmp`, next);
147
+ renameSync(`${file}.tmp`, file);
148
+ console.log(`Added "${ENTRY}" to "plugin"${exists ? ` (backup: ${file}.bak-cursor-auth)` : ""}.\n\n${NEXT}`);
149
+ return 0;
150
+ }
151
+ try {
152
+ process.exitCode = main(process.argv.slice(2));
153
+ }
154
+ catch (e) {
155
+ console.error(`Unexpected error: ${e instanceof Error ? e.message : e}`);
156
+ process.exitCode = 1;
157
+ }
package/dist/plugin.d.ts CHANGED
@@ -2,4 +2,5 @@ import type { Plugin } from "@opencode-ai/plugin";
2
2
  import { type SdkAuthStatus } from "@cursor/sdk";
3
3
  export { parseContext } from "./models.js";
4
4
  export declare function keyProblem(status: SdkAuthStatus, nowMs: number): string | undefined;
5
+ export declare function _resetKeyCache(): void;
5
6
  export declare const CursorAuthPlugin: Plugin;
package/dist/plugin.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Cursor } from "@cursor/sdk";
2
2
  import { errorResponse, handleChatCompletion } from "./bridge.js";
3
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";
4
7
  export { parseContext } from "./models.js";
5
8
  const BASE_URL = "http://cursor.local/v1";
6
9
  // Cursor publishes no output limit and the bridge never passes max_tokens to Cursor, so output is only an accounting reserve for opencode.
@@ -45,10 +48,17 @@ async function bodyText(input, init) {
45
48
  return input.text();
46
49
  return "";
47
50
  }
51
+ export function _resetKeyCache() {
52
+ keyStatus = undefined;
53
+ warnedExpiry = false;
54
+ }
48
55
  let pastedApiKey;
56
+ const EMPTY_KEY = "Cursor API key is empty — run `opencode auth login` and pick Cursor, or paste a key from cursor.com/dashboard";
49
57
  async function cursorFetch(input, init) {
50
58
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
51
59
  if (url.includes("/chat/completions")) {
60
+ if (pastedApiKey === "")
61
+ return errorResponse({ message: EMPTY_KEY, code: "invalid_api_key" }, 401);
52
62
  const problem = await checkKey();
53
63
  if (problem)
54
64
  return errorResponse(problem, 401);
@@ -67,7 +77,17 @@ async function cursorFetch(input, init) {
67
77
  }
68
78
  return Response.json({ error: { message: `unsupported endpoint: ${url}` } }, { status: 404 });
69
79
  }
70
- export const CursorAuthPlugin = async () => {
80
+ async function seedAuthEntry(client) {
81
+ if (!client?.auth?.set)
82
+ return;
83
+ const file = join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "auth.json");
84
+ const entries = await readFile(file, "utf8").then(JSON.parse, (err) => (err.code === "ENOENT" ? {} : undefined));
85
+ if (!entries || typeof entries !== "object" || "cursor" in entries)
86
+ return;
87
+ await client.auth.set({ path: { id: "cursor" }, body: { type: "api", key: "cursor-sdk-store" } });
88
+ }
89
+ export const CursorAuthPlugin = async (input) => {
90
+ seedAuthEntry(input?.client).catch(() => { });
71
91
  return {
72
92
  config: async (config) => {
73
93
  const providers = (config.provider ??= {});
@@ -93,8 +113,7 @@ export const CursorAuthPlugin = async () => {
93
113
  provider: "cursor",
94
114
  loader: async (getAuth) => {
95
115
  const auth = await getAuth().catch(() => undefined);
96
- if (auth && auth.type === "api" && auth.key !== "cursor-sdk-store")
97
- pastedApiKey = auth.key;
116
+ pastedApiKey = auth?.type === "api" && auth.key !== "cursor-sdk-store" ? (auth.key ?? "").trim() : undefined;
98
117
  return {
99
118
  apiKey: "cursor-managed",
100
119
  baseURL: BASE_URL,
package/installation.md CHANGED
@@ -20,23 +20,9 @@ You should see `1.19` or newer. If older or missing, ask the user to install or
20
20
  node --version
21
21
  ```
22
22
 
23
- You should see `v22.13` or newer. opencode installs plugins with Bun, but `@cursor/sdk` spawns the `cursor-agent` binary and requires Node >= 22.13. If older, ask the user to upgrade Node.
23
+ You should see `v22.13` or newer. `@cursor/sdk` requires Node >= 22.13. If older, ask the user to upgrade Node.
24
24
 
25
- ### Check the Cursor CLI
26
-
27
- ```bash
28
- cursor-agent --version
29
- ```
30
-
31
- If this prints a version, continue. If it fails with `command not found`, install it:
32
-
33
- ```bash
34
- curl https://cursor.com/install -fsS | bash
35
- ```
36
-
37
- Then open a new shell, or add `~/.local/bin` to `PATH`, and re-run `cursor-agent --version`.
38
-
39
- A Cursor subscription (Pro, Business, or team) is required. `cursor-agent login` is not required (the plugin has its own login) but is harmless.
25
+ A Cursor subscription (Pro, Business, or team) is required.
40
26
 
41
27
  ## Installation
42
28
 
@@ -52,7 +38,15 @@ Skip this step if none of these exist.
52
38
 
53
39
  ### Step 2: Add the plugin to the opencode config
54
40
 
55
- The config file is `~/.config/opencode/opencode.json`. Some users have `~/.config/opencode/opencode.jsonc` instead; edit whichever exists.
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.
56
50
 
57
51
  If neither exists, create `~/.config/opencode/opencode.json`:
58
52
 
@@ -107,7 +101,13 @@ Optional: to see which models the account is actually allowed to use, clone the
107
101
 
108
102
  ## Upgrading
109
103
 
110
- With `@latest` in the `plugin` array, restarting opencode picks up new versions. If it stays on an old version, clear the cached package and restart opencode:
104
+ opencode resolves `@latest` once, at first install, and keeps that cached version forever; restarting does not upgrade it. To upgrade, run:
105
+
106
+ ```bash
107
+ npx @vidit19sharma/opencode-cursor-auth update
108
+ ```
109
+
110
+ Then tell the user to restart opencode; it installs the current release from npm on start. `update --dry-run` shows what would be removed. Manual equivalent (also delete any other `@vidit19sharma/opencode-cursor-auth@*` dir there, and under `~/.cache/opencode/npm/` on older opencode):
111
111
 
112
112
  ```bash
113
113
  rm -rf ~/.cache/opencode/packages/@vidit19sharma/opencode-cursor-auth@latest
@@ -123,9 +123,9 @@ The plugin is installed. Every model the user's Cursor plan includes is availabl
123
123
  - **401 `Cursor SDK key missing/expired`**: run `opencode auth login`.
124
124
  - **403 `permission_error` "Model Blocked"**: a team admin disabled that model for the account. It is hidden from `opencode models` from then on; delete `~/.cache/opencode-cursor-auth/models.json` to re-check.
125
125
  - **400 `Cannot use this model`**: the model id is not in Cursor's catalog for this account.
126
- - **`cursor-agent: command not found` at request time**: install the Cursor CLI (see Prerequisites) and make sure it is on `PATH` for the shell that launches opencode.
126
+ - **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).
127
+ - **Features missing / variants not showing on Ctrl+T**: opencode is running a stale cached version. Run `npx @vidit19sharma/opencode-cursor-auth update`, then restart opencode.
127
128
  - **Model list empty or only `cursor/auto`**: the catalog fetch failed (offline or not logged in). Fix auth, delete `~/.cache/opencode-cursor-auth/models.json`, restart opencode.
128
- - **Stray `cursor-agent` processes**: check with `pgrep -fl cursor-agent`. A single `worker-server` is the SDK's idle singleton and is normal.
129
129
 
130
130
  ### Paths
131
131
 
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@vidit19sharma/opencode-cursor-auth",
3
- "version": "0.3.1",
4
- "description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk \u2014 no proxy server, no separate login.",
3
+ "version": "0.3.3",
4
+ "description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk — 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",
@@ -31,10 +34,13 @@
31
34
  "auth"
32
35
  ],
33
36
  "scripts": {
34
- "build": "tsc",
37
+ "build": "tsc && chmod +x dist/cli.js",
35
38
  "test": "bun spike/e2e-bridge.ts",
36
39
  "spike": "bun spike/validate-sdk.ts",
37
- "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",
43
+ "test:plugin": "bun test spike/plugin.test.ts"
38
44
  },
39
45
  "dependencies": {
40
46
  "@cursor/sdk": "^1.0.36"