cursor-opencode-provider 0.1.1 → 0.1.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/README.md CHANGED
@@ -6,6 +6,12 @@ This project is a custom **AI SDK provider** (`LanguageModelV3`) plus an **OpenC
6
6
 
7
7
  > **Status:** Usable end-to-end in OpenCode (auth, models, streaming, tools). See [Known limitations](#known-limitations).
8
8
 
9
+ ## Demo
10
+
11
+ OpenCode driving a Cursor-routed Grok model through this provider:
12
+
13
+ ![OpenCode running a Grok model via cursor-opencode-provider](https://raw.githubusercontent.com/oakimov/cursor-opencode-provider/main/assets/opencode-grok.png)
14
+
9
15
  ## Features
10
16
 
11
17
  - **OpenCode integration** — registers a `cursor` provider with auth hooks and cached model list
@@ -41,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
41
47
  }
42
48
  ```
43
49
 
44
- Pin a version if you want: `"cursor-opencode-provider@0.1.1"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.1.3"`.
45
51
 
46
52
  ### From a local clone
47
53
 
@@ -69,7 +75,7 @@ Point config at the built files with absolute `file://` URLs:
69
75
 
70
76
  ## OpenCode setup
71
77
 
72
- If the `cursor` provider block is omitted, the classic plugin auto-registers it on startup (as **Cursor Integration**) using this package's entry. Model entries are filled from the local cache after you authenticate.
78
+ If the `cursor` provider block is omitted, the classic plugin auto-registers it on startup (as **Cursor Integration**) using this package's entry. Model entries come from the local cache, which is filled after auth and again on startup when the cache is empty but credentials remain.
73
79
 
74
80
  For OpenCode builds that use the Effect/Promise **v2** plugin API (`plugins` field), also load:
75
81
 
@@ -96,7 +102,15 @@ Choose the **cursor** provider, then one of:
96
102
  | **Cursor account (browser login)** | PKCE OAuth — opens cursor.com to sign in |
97
103
  | **API key** | Paste a key from [cursor.com/settings](https://cursor.com/settings) (`sk-...`) |
98
104
 
99
- After login, the plugin fetches your available models and writes them to `cursor-models.json` (OpenCode config directory, or `CURSOR_CONFIG_DIR` if set).
105
+ After login, the plugin fetches your available models and writes them to `~/.cache/opencode/cursor-models.json` (or `$XDG_CACHE_HOME/opencode/` when set). On later startups, if that cache is missing or empty but Cursor auth is still present, the plugin fetches again during config load.
106
+
107
+ ### Paths (XDG)
108
+
109
+ | Kind | Default | Override |
110
+ |------|---------|----------|
111
+ | Model / version **cache** | `~/.cache/opencode/` | `$XDG_CACHE_HOME/opencode/` |
112
+ | OpenCode **auth** (`auth.json`) | `~/.local/share/opencode/` | `$XDG_DATA_HOME/opencode/` |
113
+ | OpenCode **config** (AGENTS, skills, …) | `~/.config/opencode/` | (config dir helper; not the model cache) |
100
114
 
101
115
  ### Select a model
102
116
 
@@ -126,11 +140,12 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-
126
140
 
127
141
  | Variable | Description |
128
142
  |----------|-------------|
129
- | `CURSOR_CONFIG_DIR` | Override directory for `cursor-models.json` cache (defaults to the OpenCode directory passed into the plugin) |
130
143
  | `CURSOR_WEBSITE_URL` | Override OAuth login base URL (default `https://cursor.com`) |
131
144
  | `CURSOR_API_BASE_URL` | Override API base for auth and model discovery (default `https://api2.cursor.sh`) |
132
145
  | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
133
146
  | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default `/tmp/cursor-provider-debug.log`) |
147
+ | `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
148
+ | `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
134
149
 
135
150
  `createCursor({ baseURL })` also overrides the agent Run host (default `https://agentn.global.api5.cursor.sh`).
136
151
 
@@ -181,10 +196,10 @@ OpenCode
181
196
 
182
197
  | Problem | What to try |
183
198
  |---------|-------------|
184
- | No Cursor models in the picker | Run `opencode auth login`, choose **cursor**, then restart OpenCode so the plugin reloads `cursor-models.json`. Confirm `provider.cursor.npm` is the package name (or a built `file://…/dist/index.js`). |
199
+ | No Cursor models in the picker | Confirm Cursor auth (`opencode auth login` **cursor**). Restart OpenCode if auth is present and the cache is empty, models are fetched on startup. Confirm `provider.cursor.npm` is the package name (or a built `file://…/dist/index.js`). |
185
200
  | Auth / 401 errors mid-session | Re-login. OAuth and exchanged API-key JWTs refresh automatically when near expiry; a revoked refresh token needs a fresh login. |
186
201
  | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
187
- | Empty or stale model list | Delete `cursor-models.json` under the OpenCode config dir (or the dir set by `CURSOR_CONFIG_DIR`) and re-auth / restart so models are fetched again. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
202
+ | Empty or stale model list | Delete `~/.cache/opencode/cursor-models.json` (or under `$XDG_CACHE_HOME/opencode/`) and restart OpenCode. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
188
203
  | Stream hangs or HTTP/2 errors | Abort the turn and retry. The agent Run uses a bidirectional HTTP/2 stream to `agentn.global.api5.cursor.sh`; a dropped connection leaves the in-flight session unusable. |
189
204
  | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`, default `/tmp/cursor-provider-debug.log`) and reproduce the issue. |
190
205
 
@@ -0,0 +1,21 @@
1
+ /** Mirrors OpenCode / SDK OAuth + API auth used for Cursor. `wellknown` is not wired yet. */
2
+ export type StoredAuth = {
3
+ type: "oauth";
4
+ access: string;
5
+ refresh: string;
6
+ expires: number;
7
+ accountId?: string;
8
+ enterpriseUrl?: string;
9
+ } | {
10
+ type: "api";
11
+ key: string;
12
+ metadata?: Record<string, string>;
13
+ };
14
+ /**
15
+ * Read a provider's credentials from OpenCode's `auth.json` (XDG data dir).
16
+ *
17
+ * Honors `OPENCODE_AUTH_CONTENT` when set — same injection hook OpenCode core
18
+ * uses in tests / embedded runs (not an SDK export).
19
+ */
20
+ export declare function readStoredAuth(providerId: string): Promise<StoredAuth | undefined>;
21
+ export declare function asStoredAuth(value: unknown): StoredAuth | undefined;
@@ -0,0 +1,64 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { opencodeGlobalDataDir } from "./paths.js";
4
+ function debugEnabled() {
5
+ return process.env.CURSOR_PROVIDER_DEBUG === "1" || process.env.CURSOR_PROVIDER_DEBUG === "true";
6
+ }
7
+ function debugAuthStore(message) {
8
+ if (!debugEnabled())
9
+ return;
10
+ console.error(`[cursor-opencode-provider] auth-store: ${message}`);
11
+ }
12
+ /**
13
+ * Read a provider's credentials from OpenCode's `auth.json` (XDG data dir).
14
+ *
15
+ * Honors `OPENCODE_AUTH_CONTENT` when set — same injection hook OpenCode core
16
+ * uses in tests / embedded runs (not an SDK export).
17
+ */
18
+ export async function readStoredAuth(providerId) {
19
+ if (process.env.OPENCODE_AUTH_CONTENT) {
20
+ try {
21
+ const data = JSON.parse(process.env.OPENCODE_AUTH_CONTENT);
22
+ return asStoredAuth(data[providerId]);
23
+ }
24
+ catch {
25
+ debugAuthStore("OPENCODE_AUTH_CONTENT is not valid JSON");
26
+ return undefined;
27
+ }
28
+ }
29
+ const filePath = path.join(opencodeGlobalDataDir(), "auth.json");
30
+ try {
31
+ const raw = await readFile(filePath, "utf-8");
32
+ try {
33
+ const data = JSON.parse(raw);
34
+ return asStoredAuth(data[providerId]);
35
+ }
36
+ catch {
37
+ debugAuthStore("auth.json is not valid JSON");
38
+ return undefined;
39
+ }
40
+ }
41
+ catch (err) {
42
+ const code = err?.code;
43
+ if (code !== "ENOENT") {
44
+ debugAuthStore(`failed to read auth.json (${code ?? "unknown"})`);
45
+ }
46
+ return undefined;
47
+ }
48
+ }
49
+ export function asStoredAuth(value) {
50
+ if (!value || typeof value !== "object")
51
+ return undefined;
52
+ const v = value;
53
+ if (v.type === "oauth" &&
54
+ typeof v.access === "string" &&
55
+ typeof v.refresh === "string" &&
56
+ typeof v.expires === "number") {
57
+ return value;
58
+ }
59
+ if (v.type === "api" && typeof v.key === "string") {
60
+ return value;
61
+ }
62
+ // wellknown is intentionally rejected until resolveAccessToken supports it
63
+ return undefined;
64
+ }
@@ -1,2 +1,2 @@
1
1
  export { buildRequestContext, type BuildRequestContextInput } from "./build.js";
2
- export { opencodeGlobalConfigDir } from "./paths.js";
2
+ export { opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir } from "./paths.js";
@@ -1,2 +1,2 @@
1
1
  export { buildRequestContext } from "./build.js";
2
- export { opencodeGlobalConfigDir } from "./paths.js";
2
+ export { opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir } from "./paths.js";
@@ -1,3 +1,14 @@
1
1
  /** OpenCode global config dir (`~/.config/opencode`). */
2
2
  export declare function opencodeGlobalConfigDir(): string;
3
+ /**
4
+ * OpenCode global cache dir (`~/.cache/opencode`).
5
+ * Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
6
+ */
7
+ export declare function opencodeGlobalCacheDir(): string;
8
+ /**
9
+ * OpenCode global data dir (`~/.local/share/opencode`).
10
+ * Uses `$XDG_DATA_HOME/opencode` when set, otherwise `$HOME/.local/share/opencode`.
11
+ * Auth credentials live here in `auth.json`.
12
+ */
13
+ export declare function opencodeGlobalDataDir(): string;
3
14
  export declare function resolveHomeRelative(p: string): string;
@@ -1,8 +1,32 @@
1
1
  import { homedir } from "node:os";
2
2
  import path from "node:path";
3
+ function resolveHome() {
4
+ return process.env.HOME || process.env.USERPROFILE || homedir();
5
+ }
3
6
  /** OpenCode global config dir (`~/.config/opencode`). */
4
7
  export function opencodeGlobalConfigDir() {
5
- return path.join(homedir(), ".config", "opencode");
8
+ return path.join(resolveHome(), ".config", "opencode");
9
+ }
10
+ /**
11
+ * OpenCode global cache dir (`~/.cache/opencode`).
12
+ * Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
13
+ */
14
+ export function opencodeGlobalCacheDir() {
15
+ if (process.env.XDG_CACHE_HOME) {
16
+ return path.join(process.env.XDG_CACHE_HOME, "opencode");
17
+ }
18
+ return path.join(resolveHome(), ".cache", "opencode");
19
+ }
20
+ /**
21
+ * OpenCode global data dir (`~/.local/share/opencode`).
22
+ * Uses `$XDG_DATA_HOME/opencode` when set, otherwise `$HOME/.local/share/opencode`.
23
+ * Auth credentials live here in `auth.json`.
24
+ */
25
+ export function opencodeGlobalDataDir() {
26
+ if (process.env.XDG_DATA_HOME) {
27
+ return path.join(process.env.XDG_DATA_HOME, "opencode");
28
+ }
29
+ return path.join(resolveHome(), ".local", "share", "opencode");
6
30
  }
7
31
  export function resolveHomeRelative(p) {
8
32
  if (p.startsWith("~/"))
@@ -1,4 +1,3 @@
1
- import path from "node:path";
2
1
  import fs from "node:fs";
3
2
  import { createHash } from "node:crypto";
4
3
  import { bidiRunStream, trace } from "./transport/connect.js";
@@ -12,10 +11,11 @@ import { conversationBlobCount } from "./protocol/blob-store.js";
12
11
  import { sessionManager } from "./session.js";
13
12
  import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
14
13
  import { buildRequestContext } from "./context/build.js";
14
+ import { opencodeGlobalCacheDir } from "./context/paths.js";
15
15
  let _availableModels;
16
16
  // mtime of the cache file the last time we loaded it. Compared on each call
17
17
  // so discoverModels' background refresh is picked up without a process restart.
18
- let _availableModelsMtimeMs = 0;
18
+ let _availableModelsMtimeMs = -1;
19
19
  export function createCursorLanguageModel(modelId, providerId, options) {
20
20
  return {
21
21
  specificationVersion: "v3",
@@ -301,11 +301,9 @@ export function findContinuationSession(toolResults) {
301
301
  return undefined;
302
302
  }
303
303
  async function loadAvailableModels() {
304
- const configDir = resolveModelCacheDir();
305
- if (!configDir)
306
- return;
304
+ const cacheDir = opencodeGlobalCacheDir();
307
305
  try {
308
- const filePath = cacheFilePath(configDir);
306
+ const filePath = cacheFilePath(cacheDir);
309
307
  let mtime = 0;
310
308
  try {
311
309
  const stat = await fs.promises.stat(filePath);
@@ -316,24 +314,13 @@ async function loadAvailableModels() {
316
314
  }
317
315
  // Re-read when the file changed (discoverModels background refresh).
318
316
  if (mtime !== _availableModelsMtimeMs) {
319
- const cached = await readCache(configDir);
317
+ const cached = await readCache(cacheDir);
320
318
  _availableModels = cached?.models;
321
319
  _availableModelsMtimeMs = mtime;
322
320
  }
323
321
  }
324
322
  catch { /* ignore */ }
325
323
  }
326
- /** Same directory the plugin writes to (CURSOR_CONFIG_DIR, else OpenCode config). */
327
- function resolveModelCacheDir() {
328
- if (process.env.CURSOR_CONFIG_DIR)
329
- return process.env.CURSOR_CONFIG_DIR;
330
- const home = process.env.HOME || process.env.USERPROFILE;
331
- if (!home)
332
- return undefined;
333
- return process.env.XDG_CONFIG_HOME
334
- ? path.join(process.env.XDG_CONFIG_HOME, "opencode")
335
- : path.join(home, ".config", "opencode");
336
- }
337
324
  /**
338
325
  * Read the held-open stream, emitting stream parts, until the turn boundary:
339
326
  * - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
package/dist/models.d.ts CHANGED
@@ -24,9 +24,9 @@ export type ModelCache = {
24
24
  fetchedAt: number;
25
25
  };
26
26
  export declare function isCacheFresh(cache: ModelCache, ttlMs?: number): boolean;
27
- export declare function cacheFilePath(configDir: string): string;
28
- export declare function readCache(configDir: string): Promise<ModelCache | null>;
29
- export declare function writeCache(configDir: string, cache: ModelCache): Promise<void>;
27
+ export declare function cacheFilePath(cacheDir: string): string;
28
+ export declare function readCache(cacheDir: string): Promise<ModelCache | null>;
29
+ export declare function writeCache(cacheDir: string, cache: ModelCache): Promise<void>;
30
30
  export declare function mapAvailableModelsResponse(raw: Record<string, unknown>): ModelInfo[];
31
31
  /**
32
32
  * Resolve the parameter values to send in `requested_model.parameters` for a
@@ -43,7 +43,7 @@ export declare function fetchModels(token: string, options?: {
43
43
  baseURL?: string;
44
44
  headers?: Record<string, string>;
45
45
  }): Promise<ModelInfo[]>;
46
- export declare function discoverModels(token: string, configDir: string, options?: {
46
+ export declare function discoverModels(token: string, cacheDir: string, options?: {
47
47
  baseURL?: string;
48
48
  headers?: Record<string, string>;
49
49
  }): Promise<ModelInfo[]>;
package/dist/models.js CHANGED
@@ -8,11 +8,11 @@ import path from "node:path";
8
8
  export function isCacheFresh(cache, ttlMs = MODEL_CACHE_TTL_MS) {
9
9
  return Date.now() - cache.fetchedAt < ttlMs;
10
10
  }
11
- export function cacheFilePath(configDir) {
12
- return path.join(configDir, MODEL_CACHE_FILE);
11
+ export function cacheFilePath(cacheDir) {
12
+ return path.join(cacheDir, MODEL_CACHE_FILE);
13
13
  }
14
- export async function readCache(configDir) {
15
- const filePath = cacheFilePath(configDir);
14
+ export async function readCache(cacheDir) {
15
+ const filePath = cacheFilePath(cacheDir);
16
16
  try {
17
17
  if (!existsSync(filePath))
18
18
  return null;
@@ -23,8 +23,8 @@ export async function readCache(configDir) {
23
23
  return null;
24
24
  }
25
25
  }
26
- export async function writeCache(configDir, cache) {
27
- const filePath = cacheFilePath(configDir);
26
+ export async function writeCache(cacheDir, cache) {
27
+ const filePath = cacheFilePath(cacheDir);
28
28
  await mkdir(path.dirname(filePath), { recursive: true });
29
29
  await writeFile(filePath, JSON.stringify(cache, null, 2), "utf-8");
30
30
  }
@@ -104,13 +104,13 @@ export async function fetchModels(token, options = {}) {
104
104
  const raw = await unaryAvailableModels(token, options);
105
105
  return mapAvailableModelsResponse(raw);
106
106
  }
107
- export async function discoverModels(token, configDir, options = {}) {
108
- const cached = await readCache(configDir);
107
+ export async function discoverModels(token, cacheDir, options = {}) {
108
+ const cached = await readCache(cacheDir);
109
109
  // Cache is fresh → return it; refresh in background
110
110
  if (cached && isCacheFresh(cached)) {
111
111
  // Background refresh (fire and forget)
112
112
  fetchModels(token, options)
113
- .then((models) => writeCache(configDir, { models, fetchedAt: Date.now() }))
113
+ .then((models) => writeCache(cacheDir, { models, fetchedAt: Date.now() }))
114
114
  .catch(() => {
115
115
  /* background refresh failure is non-fatal */
116
116
  });
@@ -121,7 +121,7 @@ export async function discoverModels(token, configDir, options = {}) {
121
121
  try {
122
122
  const models = await fetchModels(token, options);
123
123
  const newCache = { models, fetchedAt: Date.now() };
124
- await writeCache(configDir, newCache);
124
+ await writeCache(cacheDir, newCache);
125
125
  return models;
126
126
  }
127
127
  catch {
@@ -131,6 +131,6 @@ export async function discoverModels(token, configDir, options = {}) {
131
131
  // No cache → must fetch
132
132
  const models = await fetchModels(token, options);
133
133
  const newCache = { models, fetchedAt: Date.now() };
134
- await writeCache(configDir, newCache);
134
+ await writeCache(cacheDir, newCache);
135
135
  return models;
136
136
  }
package/dist/plugin.d.ts CHANGED
@@ -1,2 +1,15 @@
1
1
  import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
+ import { type ModelInfo } from "./models.js";
3
+ /**
4
+ * Display names shared by both a thinking and a non-thinking model. A thinking
5
+ * model with such a name needs a "Thinking" tag to disambiguate it from its
6
+ * non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
7
+ * are already unique — including Cursor's GPT family, where the reasoning tier
8
+ * ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
9
+ * tagged redundantly.
10
+ */
11
+ export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
12
+ export declare function modelInfoToConfig(mi: ModelInfo, options?: {
13
+ thinkingSuffix?: boolean;
14
+ }): Record<string, any>;
2
15
  export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
package/dist/plugin.js CHANGED
@@ -1,13 +1,36 @@
1
1
  import { CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
2
  import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
3
- import { readCache, discoverModels } from "./models.js";
3
+ import { readCache, discoverModels, isCacheFresh } from "./models.js";
4
+ import { opencodeGlobalCacheDir } from "./context/paths.js";
5
+ import { readStoredAuth } from "./context/auth-store.js";
4
6
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
7
+ /**
8
+ * Strip characters that break rendering in the OpenCode Desktop webview from a
9
+ * model or variant display name: HTML/markup chars (`< > & " ' \``), parentheses,
10
+ * Tabs/newlines collapse to single spaces; dots and unicode letters are preserved so names
11
+ * stay readable. Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
12
+ */
13
+ function safeLabel(value) {
14
+ return (value
15
+ .replace(/[()<>&"'`]/g, "")
16
+ .replace(/\s+/g, " ")
17
+ .trim() || "default");
18
+ }
19
+ function baseName(mi) {
20
+ return safeLabel(mi.displayName ?? mi.id);
21
+ }
5
22
  function modelInfoVariants(mi) {
6
23
  if (mi.variants.length === 0)
7
24
  return undefined;
8
25
  const entries = {};
26
+ const usedKeys = new Set();
9
27
  for (const v of mi.variants) {
10
- const key = v.displayName || v.key || "default";
28
+ const base = safeLabel(v.displayName || v.key || "default");
29
+ let key = base;
30
+ let suffix = 2;
31
+ while (usedKeys.has(key))
32
+ key = `${base}--${suffix++}`;
33
+ usedKeys.add(key);
11
34
  const params = {};
12
35
  for (const p of v.parameterValues) {
13
36
  params[p.id] = p.value;
@@ -16,9 +39,37 @@ function modelInfoVariants(mi) {
16
39
  }
17
40
  return entries;
18
41
  }
19
- function modelInfoToConfig(mi) {
42
+ /**
43
+ * Display names shared by both a thinking and a non-thinking model. A thinking
44
+ * model with such a name needs a "Thinking" tag to disambiguate it from its
45
+ * non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
46
+ * are already unique — including Cursor's GPT family, where the reasoning tier
47
+ * ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
48
+ * tagged redundantly.
49
+ */
50
+ export function thinkingSuffixBaseNames(models) {
51
+ const flags = new Map();
52
+ for (const m of models) {
53
+ const base = baseName(m);
54
+ const entry = flags.get(base) ?? { hasThinking: false, hasNonThinking: false };
55
+ if (m.supportsThinking)
56
+ entry.hasThinking = true;
57
+ else
58
+ entry.hasNonThinking = true;
59
+ flags.set(base, entry);
60
+ }
61
+ const ambiguous = new Set();
62
+ for (const [base, f] of flags)
63
+ if (f.hasThinking && f.hasNonThinking)
64
+ ambiguous.add(base);
65
+ return ambiguous;
66
+ }
67
+ export function modelInfoToConfig(mi, options = {}) {
68
+ let name = baseName(mi);
69
+ if (options.thinkingSuffix)
70
+ name += " Thinking";
20
71
  const config = {
21
- name: mi.displayName ?? mi.id,
72
+ name,
22
73
  reasoning: mi.supportsThinking ?? false,
23
74
  tool_call: mi.supportsAgent ?? true,
24
75
  temperature: false,
@@ -32,17 +83,131 @@ function modelInfoToConfig(mi) {
32
83
  config.variants = variants;
33
84
  return config;
34
85
  }
86
+ function modelsToConfig(models) {
87
+ const ambiguous = thinkingSuffixBaseNames(models);
88
+ const out = {};
89
+ for (const m of models) {
90
+ out[m.id] = modelInfoToConfig(m, {
91
+ thinkingSuffix: !!m.supportsThinking && ambiguous.has(baseName(m)),
92
+ });
93
+ }
94
+ return out;
95
+ }
35
96
  export async function CursorPlugin(input) {
36
- const configDir = process.env.CURSOR_CONFIG_DIR || input.directory;
97
+ const cacheDir = opencodeGlobalCacheDir();
98
+ // Last access token successfully resolved in this plugin instance. Config's
99
+ // loadModels can only read OpenCode's durable store (auth.json /
100
+ // OPENCODE_AUTH_CONTENT); auth.loader gets live credentials via getAuth().
101
+ // Those usually match, but after a refresh where persistAuthBestEffort fails,
102
+ // getAuth() may still see the old credentials while we already hold a usable
103
+ // token here — keep it so the loader can still discover models.
104
+ let sessionAccessToken;
105
+ async function persistAuth(body) {
106
+ await input.client.auth.set({
107
+ path: { id: CURSOR_PROVIDER_ID },
108
+ body,
109
+ });
110
+ }
111
+ /** Persist refreshed credentials without failing the caller that already holds a live token. */
112
+ async function persistAuthBestEffort(body) {
113
+ try {
114
+ await persistAuth(body);
115
+ }
116
+ catch {
117
+ // ignore — token is still usable for this process
118
+ }
119
+ }
120
+ /**
121
+ * Durable credentials OpenCode stores on disk (same file getAuth() reads in
122
+ * the normal path). Used from `config`, which has no getAuth() callback.
123
+ */
124
+ async function authFromStore() {
125
+ return readStoredAuth(CURSOR_PROVIDER_ID);
126
+ }
127
+ /**
128
+ * Prefer OpenCode's live getAuth(); fall back to the durable store so loader
129
+ * and config share the same underlying credentials when possible.
130
+ */
131
+ async function authForLoader(getAuth) {
132
+ return (await getAuth()) ?? (await authFromStore());
133
+ }
134
+ async function resolveAccessToken(auth) {
135
+ if (auth.type === "api") {
136
+ let accessToken = auth.key;
137
+ const refreshToken = auth.metadata?.refreshToken;
138
+ // API-key exchange returns a short-lived JWT stored as `key`. Refresh
139
+ // it the same way as OAuth when it is expiring / already expired.
140
+ if (refreshToken && isExpiringSoon(auth.key)) {
141
+ try {
142
+ const newTokens = await refreshAccessToken(refreshToken);
143
+ accessToken = newTokens.accessToken;
144
+ await persistAuthBestEffort({
145
+ type: "api",
146
+ key: newTokens.accessToken,
147
+ metadata: {
148
+ ...auth.metadata,
149
+ refreshToken: newTokens.refreshToken,
150
+ },
151
+ });
152
+ }
153
+ catch {
154
+ // refresh failed — keep the existing key; the next call may still work
155
+ }
156
+ }
157
+ if (accessToken)
158
+ sessionAccessToken = accessToken;
159
+ return accessToken;
160
+ }
161
+ if (auth.type === "oauth") {
162
+ if (!isExpiringSoon(auth.access)) {
163
+ sessionAccessToken = auth.access;
164
+ return auth.access;
165
+ }
166
+ if (!auth.refresh)
167
+ return undefined;
168
+ try {
169
+ const newTokens = await refreshAccessToken(auth.refresh);
170
+ // Preserve optional OAuth fields (v2 Auth / plugin may carry these).
171
+ const extras = auth;
172
+ // Use the new token even if persisting back to OpenCode fails.
173
+ await persistAuthBestEffort({
174
+ type: "oauth",
175
+ access: newTokens.accessToken,
176
+ refresh: newTokens.refreshToken,
177
+ expires: decodeExpFromJwt(newTokens.accessToken),
178
+ ...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
179
+ ...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
180
+ });
181
+ sessionAccessToken = newTokens.accessToken;
182
+ return newTokens.accessToken;
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
188
+ return undefined;
189
+ }
37
190
  async function loadModels() {
38
- const cached = await readCache(configDir);
39
- if (!cached || cached.models.length === 0)
191
+ const cached = await readCache(cacheDir);
192
+ if (!cached || cached.models.length === 0) {
193
+ // Config runs before auth.loader and has no getAuth(); read the durable
194
+ // store (normally the same source getAuth() uses).
195
+ const auth = await authFromStore();
196
+ if (auth) {
197
+ const accessToken = await resolveAccessToken(auth);
198
+ if (accessToken) {
199
+ try {
200
+ const models = await discoverModels(accessToken, cacheDir);
201
+ return modelsToConfig(models);
202
+ }
203
+ catch {
204
+ // discovery failed — leave the list empty
205
+ }
206
+ }
207
+ }
40
208
  return {};
41
- const models = {};
42
- for (const m of cached.models) {
43
- models[m.id] = modelInfoToConfig(m);
44
209
  }
45
- return models;
210
+ return modelsToConfig(cached.models);
46
211
  }
47
212
  return {
48
213
  async config(cfg) {
@@ -130,60 +295,19 @@ export async function CursorPlugin(input) {
130
295
  },
131
296
  ],
132
297
  async loader(getAuth) {
133
- const auth = await getAuth();
134
- if (!auth)
135
- return {};
136
- let accessToken;
137
- if (auth.type === "api") {
138
- accessToken = auth.key;
139
- const refreshToken = auth.metadata?.refreshToken;
140
- // API-key exchange returns a short-lived JWT stored as `key`. Refresh
141
- // it the same way as OAuth when it is expiring / already expired.
142
- if (refreshToken && isExpiringSoon(auth.key)) {
143
- try {
144
- const newTokens = await refreshAccessToken(refreshToken);
145
- await input.client.auth.set({
146
- path: { id: CURSOR_PROVIDER_ID },
147
- body: {
148
- type: "api",
149
- key: newTokens.accessToken,
150
- metadata: { refreshToken: newTokens.refreshToken },
151
- },
152
- });
153
- accessToken = newTokens.accessToken;
154
- }
155
- catch {
156
- // refresh failed — keep the existing key; the next call may still work
157
- }
158
- }
159
- }
160
- else if (auth.type === "oauth") {
161
- if (!isExpiringSoon(auth.access)) {
162
- accessToken = auth.access;
163
- }
164
- else if (auth.refresh) {
165
- try {
166
- const newTokens = await refreshAccessToken(auth.refresh);
167
- await input.client.auth.set({
168
- path: { id: CURSOR_PROVIDER_ID },
169
- body: {
170
- type: "oauth",
171
- access: newTokens.accessToken,
172
- refresh: newTokens.refreshToken,
173
- expires: decodeExpFromJwt(newTokens.accessToken),
174
- },
175
- });
176
- accessToken = newTokens.accessToken;
177
- }
178
- catch {
179
- // refresh failed
180
- }
181
- }
182
- }
298
+ const auth = await authForLoader(getAuth);
299
+ // Prefer credentials from getAuth/store; if refresh already succeeded in
300
+ // loadModels but persist failed, fall back to the in-memory session token.
301
+ const accessToken = (auth ? await resolveAccessToken(auth) : undefined) ?? sessionAccessToken;
183
302
  if (accessToken) {
184
- // Use discoverModels so we respect TTL / serve-stale semantics and
185
- // write through the same cache path language-model reads.
186
- discoverModels(accessToken, configDir).catch(() => { });
303
+ // Skip when config already filled a fresh cache (avoids a second
304
+ // AvailableModels round-trip + background refresh on cold start).
305
+ const cached = await readCache(cacheDir);
306
+ if (!cached || cached.models.length === 0 || !isCacheFresh(cached)) {
307
+ // Await so an empty/missing cache is written before the loader returns
308
+ // (fire-and-forget often loses the race on short-lived CLI commands).
309
+ await discoverModels(accessToken, cacheDir).catch(() => { });
310
+ }
187
311
  }
188
312
  return {
189
313
  ...(accessToken ? { accessToken } : {}),
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { FALLBACK_CLIENT_VERSION, MODEL_CACHE_TTL_MS, VERSION_CACHE_FILE, } from "../shared.js";
4
+ import { opencodeGlobalCacheDir } from "../context/paths.js";
4
5
  const INSTALL_URL = "https://cursor.com/install";
5
6
  const REMOTE_TIMEOUT_MS = 5_000;
6
7
  const BUILD_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9A-Za-z][0-9A-Za-z.-]*$/;
@@ -69,24 +70,11 @@ export function extractVersionFromInstaller(script) {
69
70
  const match = script.match(/downloads\.cursor\.com\/lab\/([^/"'\s]+)\//);
70
71
  return match && BUILD_RE.test(match[1]) ? match[1] : undefined;
71
72
  }
72
- function resolveCacheDir() {
73
- if (process.env.CURSOR_CONFIG_DIR)
74
- return process.env.CURSOR_CONFIG_DIR;
75
- const home = process.env.HOME || process.env.USERPROFILE;
76
- if (!home)
77
- return undefined;
78
- return process.env.XDG_CONFIG_HOME
79
- ? path.join(process.env.XDG_CONFIG_HOME, "opencode")
80
- : path.join(home, ".config", "opencode");
81
- }
82
73
  function versionCachePath() {
83
- const dir = resolveCacheDir();
84
- return dir ? path.join(dir, VERSION_CACHE_FILE) : undefined;
74
+ return path.join(opencodeGlobalCacheDir(), VERSION_CACHE_FILE);
85
75
  }
86
76
  function readVersionCache() {
87
77
  const file = versionCachePath();
88
- if (!file)
89
- return undefined;
90
78
  try {
91
79
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
92
80
  if (!isClientVersion(value.version) ||
@@ -102,8 +90,6 @@ function readVersionCache() {
102
90
  }
103
91
  function writeVersionCache(cache) {
104
92
  const file = versionCachePath();
105
- if (!file)
106
- return;
107
93
  try {
108
94
  fs.mkdirSync(path.dirname(file), { recursive: true });
109
95
  fs.writeFileSync(file, JSON.stringify(cache, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",