cursor-opencode-provider 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +139 -10
  2. package/dist/agent-url.js +2 -0
  3. package/dist/auth.d.ts +7 -0
  4. package/dist/auth.js +9 -0
  5. package/dist/compaction-marker.d.ts +14 -0
  6. package/dist/compaction-marker.js +36 -0
  7. package/dist/context/env.js +6 -2
  8. package/dist/context/paths.js +3 -2
  9. package/dist/index.d.ts +1 -3
  10. package/dist/index.js +2 -8
  11. package/dist/language-model.js +51 -35
  12. package/dist/model-config.d.ts +15 -0
  13. package/dist/model-config.js +206 -0
  14. package/dist/opencode2/catalog.d.ts +33 -0
  15. package/dist/opencode2/catalog.js +85 -0
  16. package/dist/opencode2/integration.d.ts +23 -0
  17. package/dist/opencode2/integration.js +124 -0
  18. package/dist/opencode2/types.d.ts +316 -0
  19. package/dist/opencode2/types.js +18 -0
  20. package/dist/plugin-core.d.ts +27 -0
  21. package/dist/plugin-core.js +31 -0
  22. package/dist/plugin-opencode2.d.ts +3 -0
  23. package/dist/plugin-opencode2.js +293 -0
  24. package/dist/plugin-v2.d.ts +7 -0
  25. package/dist/plugin-v2.js +1 -15
  26. package/dist/plugin.d.ts +1 -15
  27. package/dist/plugin.js +11 -191
  28. package/dist/protocol/apply-patch.d.ts +109 -0
  29. package/dist/protocol/apply-patch.js +163 -0
  30. package/dist/protocol/messages.d.ts +10 -0
  31. package/dist/protocol/messages.js +83 -1
  32. package/dist/protocol/tools.d.ts +24 -1
  33. package/dist/protocol/tools.js +262 -19
  34. package/dist/session-directory.d.ts +17 -0
  35. package/dist/session-directory.js +35 -0
  36. package/dist/session.d.ts +13 -1
  37. package/dist/session.js +70 -1
  38. package/dist/shell-timeout.d.ts +3 -1
  39. package/dist/shell-timeout.js +12 -4
  40. package/dist/usage.d.ts +30 -0
  41. package/dist/usage.js +77 -0
  42. package/dist/web-search-tool.d.ts +33 -0
  43. package/dist/web-search-tool.js +22 -0
  44. package/dist/web-tools.d.ts +9 -25
  45. package/dist/web-tools.js +34 -36
  46. package/package.json +14 -9
@@ -0,0 +1,293 @@
1
+ import { CURSOR_PROVIDER_ID } from "./shared.js";
2
+ import { createSdk, cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled, isCursorPackage } from "./plugin-core.js";
3
+ import { adoptCompatHostCacheDir, opencodeGlobalCacheDir } from "./context/paths.js";
4
+ import { discoverModels, isCacheFresh, readCache } from "./models.js";
5
+ import { resolveAgentUrl } from "./agent-url.js";
6
+ import { sessionActivity } from "./activity.js";
7
+ import { fetchOpenCodeWebSearchText } from "./web-tools.js";
8
+ import { captureCursorShellResult, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
9
+ import { applyCursorModels, applyCursorProvider } from "./opencode2/catalog.js";
10
+ import { applyCursorIntegration, resolveCursorAccessToken } from "./opencode2/integration.js";
11
+ import { markCompactionSession } from "./compaction-marker.js";
12
+ import { markSessionDirectory } from "./session-directory.js";
13
+ /**
14
+ * OpenCode 2.0 beta plugin.
15
+ *
16
+ * Separate from `plugin-v2.ts` on purpose: the OpenCode 1.18 `/v2/promise` API
17
+ * and the 2.0 API are source-incompatible (hook signatures, OAuth value type,
18
+ * provider schema), so they cannot share an entrypoint. Shared behavior lives in
19
+ * `plugin-core.ts`, `model-config.ts`, and `opencode2/*`.
20
+ *
21
+ * Load with: { "plugins": ["cursor-opencode-provider/plugin/opencode2"] }
22
+ */
23
+ async function loadModels(cacheDir, accessToken) {
24
+ const cached = await readCache(cacheDir);
25
+ if (cached?.models.length && isCacheFresh(cached))
26
+ return cached.models;
27
+ if (accessToken) {
28
+ try {
29
+ return await discoverModels(accessToken, cacheDir, { baseURL: cursorApiBaseURL() });
30
+ }
31
+ catch {
32
+ // Fall through to stale-on-failure below.
33
+ }
34
+ }
35
+ // Preserve offline / stale-cache behavior rather than emptying the picker.
36
+ return cached?.models ?? [];
37
+ }
38
+ const plugin = {
39
+ id: "cursor.provider",
40
+ setup: async (ctx) => {
41
+ // Prefer OCP HostProfile.cacheDir when present; else XDG host heuristic.
42
+ await adoptCompatHostCacheDir();
43
+ const cacheDir = opencodeGlobalCacheDir();
44
+ const registrations = [];
45
+ const track = async (p) => {
46
+ registrations.push(await p);
47
+ };
48
+ let models = [];
49
+ // ── Credentials ──────────────────────────────────────────────────────────
50
+ await track(ctx.integration.transform(applyCursorIntegration));
51
+ let cachedToken;
52
+ let tokenInflight;
53
+ /**
54
+ * Cache only a *successful* resolution.
55
+ *
56
+ * On a fresh install `setup()` runs before the user has connected, so the
57
+ * first attempt necessarily returns nothing. Memoizing that would pin the
58
+ * plugin to "no credentials" for the whole process and models would never
59
+ * load, even after a successful /connect.
60
+ */
61
+ const accessToken = async () => {
62
+ if (cachedToken)
63
+ return cachedToken;
64
+ tokenInflight ??= resolveCursorAccessToken(ctx.integration).finally(() => {
65
+ tokenInflight = undefined;
66
+ });
67
+ const token = await tokenInflight;
68
+ if (token)
69
+ cachedToken = token;
70
+ return token;
71
+ };
72
+ // ── Catalog ──────────────────────────────────────────────────────────────
73
+ // Runs now with whatever models we have (likely none) and is replayed by the
74
+ // host on every `catalog.reload()`, so the async fill below just re-triggers it.
75
+ await track(ctx.catalog.transform((draft) => {
76
+ applyCursorProvider(draft);
77
+ applyCursorModels(draft, models);
78
+ }));
79
+ // ── AI SDK wiring ────────────────────────────────────────────────────────
80
+ await track(ctx.aisdk.hook("sdk", async (event) => {
81
+ if (event.sdk)
82
+ return;
83
+ if (!isCursorPackage(event.package, event.model.providerID))
84
+ return;
85
+ const token = await accessToken();
86
+ event.sdk = createSdk({
87
+ name: event.model.providerID || CURSOR_PROVIDER_ID,
88
+ ...(token ? { accessToken: token } : {}),
89
+ // Static fallback only. This hook fires once per model/package, not
90
+ // per session, and 2.0 runs one daemon across many projects — the
91
+ // real per-request directory comes from the session.context hook
92
+ // below via `getSessionDirectory`, which `language-model.ts` prefers.
93
+ workspaceRoot: process.cwd(),
94
+ cacheDir,
95
+ ...event.options,
96
+ });
97
+ }));
98
+ await track(ctx.aisdk.hook("language", (event) => {
99
+ if (event.language)
100
+ return;
101
+ if (event.model.providerID !== CURSOR_PROVIDER_ID)
102
+ return;
103
+ if (typeof event.sdk?.languageModel !== "function")
104
+ return;
105
+ // `modelID` is the Cursor wire id; `id` may be a synthetic long-context entry.
106
+ event.language = event.sdk.languageModel(event.model.modelID || event.model.id);
107
+ }));
108
+ // ── Web search ───────────────────────────────────────────────────────────
109
+ // Registered as a tool, matching the classic plugin. 2.0 also has a
110
+ // first-class `websearch` domain, but its Result shape is structured
111
+ // ({url, title?, content?}) while the Exa backend here returns an opaque
112
+ // text blob — mapping one to the other would mean guessing Exa's payload
113
+ // shape, so the tool form stays until that can be verified against a live
114
+ // response.
115
+ await track(ctx.tool.transform((draft) => {
116
+ draft.add({
117
+ name: "custom_websearch",
118
+ description: "Search the web for current information using OpenCode's web search backend.",
119
+ input: {
120
+ type: "object",
121
+ properties: {
122
+ query: { type: "string", description: "Web search query" },
123
+ numResults: { type: "integer", minimum: 1, maximum: 20 },
124
+ livecrawl: { type: "string", enum: ["fallback", "preferred"] },
125
+ type: { type: "string", enum: ["auto", "fast", "deep"] },
126
+ contextMaxCharacters: { type: "integer", exclusiveMinimum: 0 },
127
+ },
128
+ required: ["query"],
129
+ additionalProperties: false,
130
+ },
131
+ execute: async (input, context) => {
132
+ const output = await fetchOpenCodeWebSearchText(input, context?.abort);
133
+ return {
134
+ title: `Exa Web Search: ${input.query}`,
135
+ output,
136
+ metadata: { provider: "exa" },
137
+ };
138
+ },
139
+ });
140
+ }));
141
+ // ── Shell timeout wrapper ────────────────────────────────────────────────
142
+ await track(ctx.tool.hook("execute.before", (event) => {
143
+ if (event.tool !== "bash")
144
+ return;
145
+ // No `shell.env` in 2.0 → always use the wrapper-file command form.
146
+ prepareCursorShellArgs(event.callID, event.input, {
147
+ preferWrapperCommand: true,
148
+ });
149
+ }));
150
+ await track(ctx.tool.hook("execute.after", (event) => {
151
+ if (event.tool !== "bash")
152
+ return;
153
+ try {
154
+ if (event.status !== "completed")
155
+ return;
156
+ const result = event.result;
157
+ result.title = cursorShellOriginalCommand(event.callID) ?? result.title;
158
+ result.output = captureCursorShellResult(event.callID, result.output, result.metadata);
159
+ if (result.metadata && typeof result.metadata === "object") {
160
+ const metadata = result.metadata;
161
+ if (typeof metadata.output === "string") {
162
+ metadata.output = sanitizeRegisteredCursorShellOutput(event.callID, metadata.output);
163
+ }
164
+ }
165
+ }
166
+ finally {
167
+ releaseCursorShellEnv(event.callID);
168
+ }
169
+ }));
170
+ // ── Compaction marker + session directory ───────────────────────────────
171
+ // 2.0 removed `chat.params`, and `session.hook("context")` exposes no
172
+ // provider-options channel — only `system`/`messages`/`tools` are mutable.
173
+ // But it does name the owning agent and the session id, and the provider
174
+ // already derives the same session id from request headers, so record
175
+ // both facts here and let doStream read them back via
176
+ // `isCompactionSession` / `getSessionDirectory`.
177
+ await track(ctx.session.hook("context", async (event) => {
178
+ markCompactionSession(event.sessionID, event.agent === "compaction");
179
+ try {
180
+ const info = await ctx.session.get({ sessionID: event.sessionID });
181
+ markSessionDirectory(event.sessionID, info.location?.directory);
182
+ }
183
+ catch {
184
+ // Best effort — falls back to the static workspaceRoot above.
185
+ }
186
+ }));
187
+ // ── Model discovery ──────────────────────────────────────────────────────
188
+ // Idempotent and cheap once satisfied, so it is safe to call from anywhere
189
+ // that might mark the moment credentials became available.
190
+ let modelsLoaded = false;
191
+ let ensureInflight;
192
+ const ensureModels = () => {
193
+ if (modelsLoaded)
194
+ return Promise.resolve();
195
+ ensureInflight ??= (async () => {
196
+ try {
197
+ const token = await accessToken();
198
+ const discovered = await loadModels(cacheDir, token);
199
+ if (!discovered.length)
200
+ return;
201
+ models = discovered;
202
+ modelsLoaded = true;
203
+ // Replays the catalog transform above, this time with models.
204
+ await ctx.catalog.reload().catch(() => { });
205
+ if (token) {
206
+ // Resolve the region-specific Run origin so the first turn doesn't
207
+ // pay for GetServerConfig. Best effort: startSession surfaces real
208
+ // failures.
209
+ await resolveAgentUrl(token, {
210
+ apiBaseURL: cursorApiBaseURL(),
211
+ telemetryEnabled: cursorGetServerConfigTelemetryEnabled(),
212
+ }).catch(() => { });
213
+ }
214
+ }
215
+ finally {
216
+ ensureInflight = undefined;
217
+ }
218
+ })();
219
+ return ensureInflight;
220
+ };
221
+ // On a fresh install the user connects *after* startup, and the host has no
222
+ // "connection established" hook we can key on. Poll briefly so models show
223
+ // up within seconds of /connect instead of requiring a restart. The window
224
+ // is bounded; after it, any session/message event still triggers a retry.
225
+ const RETRY_INTERVAL_MS = 3_000;
226
+ const RETRY_WINDOW_MS = 300_000;
227
+ const startedAt = Date.now();
228
+ const retry = setInterval(() => {
229
+ if (modelsLoaded || Date.now() - startedAt > RETRY_WINDOW_MS) {
230
+ clearInterval(retry);
231
+ return;
232
+ }
233
+ void ensureModels();
234
+ }, RETRY_INTERVAL_MS);
235
+ retry.unref?.();
236
+ void ensureModels();
237
+ // ── Session activity ─────────────────────────────────────────────────────
238
+ // Doubles as a retry trigger: any host activity after the polling window has
239
+ // closed still gets one more chance to pick up newly-added credentials.
240
+ const unsubscribe = subscribeSessionActivity(ctx, ensureModels);
241
+ return async () => {
242
+ clearInterval(retry);
243
+ unsubscribe?.();
244
+ for (const registration of registrations.reverse()) {
245
+ await registration.dispose().catch(() => { });
246
+ }
247
+ };
248
+ },
249
+ };
250
+ /** Feed OpenCode session/message events into the shared activity tracker. */
251
+ function subscribeSessionActivity(ctx, onEvent) {
252
+ try {
253
+ const stream = ctx.event.subscribe();
254
+ if (!stream || typeof stream[Symbol.asyncIterator] !== "function")
255
+ return undefined;
256
+ let stopped = false;
257
+ void (async () => {
258
+ for await (const event of stream) {
259
+ if (stopped)
260
+ break;
261
+ applySessionActivity(event);
262
+ onEvent?.();
263
+ }
264
+ })().catch(() => { });
265
+ return () => {
266
+ stopped = true;
267
+ };
268
+ }
269
+ catch {
270
+ return undefined;
271
+ }
272
+ }
273
+ function applySessionActivity(event) {
274
+ switch (event?.type) {
275
+ case "session.created":
276
+ sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
277
+ sessionActivity.recordActivity(event.properties.info.id);
278
+ break;
279
+ case "session.updated":
280
+ sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
281
+ break;
282
+ case "session.deleted":
283
+ sessionActivity.removeSession(event.properties.info.id);
284
+ break;
285
+ case "message.updated":
286
+ sessionActivity.recordActivity(event.properties.info.sessionID);
287
+ break;
288
+ case "message.part.updated":
289
+ sessionActivity.recordActivity(event.properties.part.sessionID);
290
+ break;
291
+ }
292
+ }
293
+ export default plugin;
@@ -1,2 +1,9 @@
1
+ /**
2
+ * OpenCode Effect / Promise v2 plugin.
3
+ *
4
+ * Wires the Cursor provider through `ctx.aisdk.sdk` / `ctx.aisdk.language`
5
+ * (the V2 extension points). Auth still lives in the classic Hooks plugin
6
+ * (`plugin.ts`) until OpenCode integrations fully replace provider OAuth.
7
+ */
1
8
  declare const _default: import("@opencode-ai/plugin/v2/promise").Plugin;
2
9
  export default _default;
package/dist/plugin-v2.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { define } from "@opencode-ai/plugin/v2/promise";
2
2
  import { CURSOR_PROVIDER_ID } from "./shared.js";
3
- import { createCursorLanguageModel } from "./language-model.js";
3
+ import { createSdk, isCursorPackage } from "./plugin-core.js";
4
4
  import { adoptCompatHostCacheDir } from "./context/paths.js";
5
5
  /**
6
6
  * OpenCode Effect / Promise v2 plugin.
@@ -9,20 +9,6 @@ import { adoptCompatHostCacheDir } from "./context/paths.js";
9
9
  * (the V2 extension points). Auth still lives in the classic Hooks plugin
10
10
  * (`plugin.ts`) until OpenCode integrations fully replace provider OAuth.
11
11
  */
12
- function isCursorPackage(pkg, providerID) {
13
- if (providerID === CURSOR_PROVIDER_ID)
14
- return true;
15
- return (pkg.includes("cursor-opencode-provider") ||
16
- /cursor-opencode-provider[/\\]dist[/\\]index\.js/.test(pkg));
17
- }
18
- function createSdk(options) {
19
- const providerId = options.name || CURSOR_PROVIDER_ID;
20
- return {
21
- languageModel(modelId) {
22
- return createCursorLanguageModel(modelId, providerId, options);
23
- },
24
- };
25
- }
26
12
  export default define({
27
13
  id: "cursor.provider",
28
14
  setup: async (ctx) => {
package/dist/plugin.d.ts CHANGED
@@ -1,17 +1,3 @@
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
- contextTier?: "base" | "long";
15
- }): Record<string, any>;
16
- export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
2
+ export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
17
3
  export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
package/dist/plugin.js CHANGED
@@ -1,199 +1,19 @@
1
1
  import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
+ import { cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled } from "./plugin-core.js";
2
3
  import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
3
- import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh, parseCursorContextLimit } from "./models.js";
4
+ import { readCache, discoverModels, isCacheFresh } from "./models.js";
5
+ // Re-exported for API compatibility: tests and downstream code import these
6
+ // from "./plugin.js". Canonical home is ./model-config.ts, which stays free of
7
+ // host plugin imports so the OpenCode 2.0 entrypoint can share it.
8
+ import { modelsToConfig } from "./model-config.js";
9
+ export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
4
10
  import { adoptCompatHostCacheDir, opencodeGlobalCacheDir } from "./context/paths.js";
5
11
  import { readStoredAuth } from "./context/auth-store.js";
6
12
  import { resolveAgentUrl } from "./agent-url.js";
7
13
  import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, setCursorShellPath, } from "./shell-timeout.js";
8
14
  import { sessionActivity } from "./activity.js";
9
- import { openCodeWebSearchTool } from "./web-tools.js";
15
+ import { openCodeWebSearchTool } from "./web-search-tool.js";
10
16
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
11
- /**
12
- * Strip characters and markup that break rendering in the OpenCode TUI/GUI from
13
- * a model or variant display name:
14
- * • HTML tags (e.g. `<span style="…">Medium</span>`) — the IDE colours variant
15
- * suffixes with a CSS var that doesn't exist in OpenCode, so the raw markup
16
- * would show as literal text. We drop the tags and keep the inner text
17
- * ("Medium").
18
- * • HTML/markup chars (`< > & " ' \``), parentheses.
19
- * • Tabs/newlines collapse to single spaces.
20
- * Dots and unicode letters are preserved so names stay readable.
21
- * Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
22
- */
23
- function safeLabel(value) {
24
- return (value
25
- .replace(/<[^>]+>/g, "")
26
- .replace(/[()<>&"'`]/g, "")
27
- .replace(/\s+/g, " ")
28
- .trim() || "default");
29
- }
30
- function baseName(mi) {
31
- return safeLabel(mi.displayName ?? mi.id);
32
- }
33
- function modelInfoVariants(mi, variants) {
34
- if (variants.length === 0)
35
- return undefined;
36
- const entries = {};
37
- const usedKeys = new Set();
38
- const baseName = safeLabel(mi.displayName ?? mi.id);
39
- // Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
40
- // "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
41
- // sees in Cursor. Two variants can sanitize to the same name when the IDE
42
- // wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
43
- // suffix collapses to the bare model name after stripping). To guarantee
44
- // every variant stays pickable:
45
- // 1. If the sanitized displayName matches the model name itself, suffix
46
- // it with distinguishing params (or "default") so it never collides
47
- // with the model entry in the variant panel.
48
- // 2. If two variants still collide, tag the later one with its params.
49
- // Suffixes must stay free of `()` / markup chars — same constraint as
50
- // safeLabel (issue #2); use spaced tokens, not parenthetical tags.
51
- const tagDims = (p) => {
52
- const labels = [];
53
- for (const d of p) {
54
- if (d.id === "fast" && d.value === "true")
55
- labels.push("Fast");
56
- else if (d.id === "thinking" && d.value === "true")
57
- labels.push("Thinking");
58
- else if (d.id === "context")
59
- labels.push(d.value);
60
- }
61
- if (labels.length > 0)
62
- return ` ${labels.join(" ")}`;
63
- // No params at all — still disambiguate from the model name itself.
64
- if (p.length === 0)
65
- return "";
66
- return " default";
67
- };
68
- for (const v of variants) {
69
- const sanitized = safeLabel(v.displayName || v.key || "default");
70
- let key = sanitized;
71
- // Never let a variant key equal the model name — that would make the
72
- // variant entry indistinguishable from the model entry in pickers that
73
- // collapse them.
74
- if (key === baseName && !usedKeys.has(key)) {
75
- key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
76
- }
77
- else if (usedKeys.has(key)) {
78
- key = `${sanitized}${tagDims(v.parameterValues)}`;
79
- }
80
- let n = 2;
81
- while (usedKeys.has(key))
82
- key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
83
- usedKeys.add(key);
84
- entries[key] = {
85
- [CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
86
- };
87
- }
88
- return entries;
89
- }
90
- function isLongContextVariant(v) {
91
- return v.parameterValues.some((p) => p.id === "context" && parseCursorContextLimit(p.value) === 1_000_000);
92
- }
93
- function variantsForTier(mi, tier) {
94
- return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
95
- }
96
- /**
97
- * Display names shared by both a thinking and a non-thinking model. A thinking
98
- * model with such a name needs a "Thinking" tag to disambiguate it from its
99
- * non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
100
- * are already unique — including Cursor's GPT family, where the reasoning tier
101
- * ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
102
- * tagged redundantly.
103
- */
104
- export function thinkingSuffixBaseNames(models) {
105
- const flags = new Map();
106
- for (const m of models) {
107
- const base = baseName(m);
108
- const entry = flags.get(base) ?? { hasThinking: false, hasNonThinking: false };
109
- if (m.supportsThinking)
110
- entry.hasThinking = true;
111
- else
112
- entry.hasNonThinking = true;
113
- flags.set(base, entry);
114
- }
115
- const ambiguous = new Set();
116
- for (const [base, f] of flags)
117
- if (f.hasThinking && f.hasNonThinking)
118
- ambiguous.add(base);
119
- return ambiguous;
120
- }
121
- export function modelInfoToConfig(mi, options = {}) {
122
- const contextTier = options.contextTier ?? "base";
123
- const variants = variantsForTier(mi, contextTier);
124
- let name = baseName(mi);
125
- if (options.thinkingSuffix)
126
- name += " Thinking";
127
- if (contextTier === "long")
128
- name += " 1M";
129
- // OpenCode's context limit is static per model entry, while Cursor's context
130
- // tier is a variant parameter. Long-context choices are therefore emitted as
131
- // separate OpenCode entries by modelsToConfig.
132
- const context = contextTier === "long"
133
- ? (mi.maxContextForMaxMode ?? 1_000_000)
134
- : (mi.maxContext ?? 200_000);
135
- // OpenCode's overflow/compaction/UI use limit.context; generation and
136
- // thinking budgets use limit.output. models.dev 1M peers advertise
137
- // 64k–128k output — a tiny cap makes long-context sessions feel broken
138
- // even when the 1M input window is correct.
139
- const output = contextTier === "long" ? 128_000 : 32_000;
140
- const config = {
141
- name,
142
- reasoning: mi.supportsThinking ?? false,
143
- tool_call: mi.supportsAgent ?? true,
144
- temperature: false,
145
- limit: {
146
- context,
147
- output,
148
- },
149
- };
150
- const variantConfig = modelInfoVariants(mi, variants);
151
- if (variantConfig)
152
- config.variants = variantConfig;
153
- if (contextTier === "long") {
154
- const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
155
- if (defaultVariant) {
156
- config.options = {
157
- [CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
158
- [CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
159
- };
160
- }
161
- }
162
- return config;
163
- }
164
- export function modelsToConfig(models) {
165
- const ambiguous = thinkingSuffixBaseNames(models);
166
- const out = {};
167
- const usedIds = new Set(models.map((m) => m.id));
168
- for (const m of models) {
169
- const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
170
- const baseVariants = variantsForTier(m, "base");
171
- const longVariants = variantsForTier(m, "long");
172
- if (baseVariants.length > 0 || longVariants.length === 0) {
173
- out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
174
- }
175
- if (longVariants.length === 0)
176
- continue;
177
- if (baseVariants.length === 0) {
178
- out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
179
- continue;
180
- }
181
- let longId = `${m.id}-1m`;
182
- let suffix = 2;
183
- while (usedIds.has(longId))
184
- longId = `${m.id}-1m-${suffix++}`;
185
- usedIds.add(longId);
186
- out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
187
- }
188
- return out;
189
- }
190
- function cursorApiBaseURL() {
191
- return process.env.CURSOR_API_BASE_URL ?? `https://${CURSOR_API_HOST}`;
192
- }
193
- function cursorGetServerConfigTelemetryEnabled() {
194
- return (process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "1" ||
195
- process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "true");
196
- }
197
17
  export async function CursorPlugin(input) {
198
18
  // Prefer strong OCP host identity when available; otherwise resolve from explicit host signals/install path.
199
19
  await adoptCompatHostCacheDir();
@@ -443,10 +263,10 @@ export async function CursorPlugin(input) {
443
263
  type: "text",
444
264
  key: "apiKey",
445
265
  message: "Cursor API key",
446
- placeholder: "sk-...",
266
+ placeholder: "crsr_...",
447
267
  validate(value) {
448
- if (!value.startsWith("sk-"))
449
- return "API key should start with sk-";
268
+ if (!value.startsWith("crsr_"))
269
+ return "API key should start with crsr_";
450
270
  return undefined;
451
271
  },
452
272
  },