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.
- package/README.md +139 -10
- package/dist/agent-url.js +2 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +9 -0
- package/dist/compaction-marker.d.ts +14 -0
- package/dist/compaction-marker.js +36 -0
- package/dist/context/env.js +6 -2
- package/dist/context/paths.js +3 -2
- package/dist/index.d.ts +1 -3
- package/dist/index.js +2 -8
- package/dist/language-model.js +51 -35
- package/dist/model-config.d.ts +15 -0
- package/dist/model-config.js +206 -0
- package/dist/opencode2/catalog.d.ts +33 -0
- package/dist/opencode2/catalog.js +85 -0
- package/dist/opencode2/integration.d.ts +23 -0
- package/dist/opencode2/integration.js +124 -0
- package/dist/opencode2/types.d.ts +316 -0
- package/dist/opencode2/types.js +18 -0
- package/dist/plugin-core.d.ts +27 -0
- package/dist/plugin-core.js +31 -0
- package/dist/plugin-opencode2.d.ts +3 -0
- package/dist/plugin-opencode2.js +293 -0
- package/dist/plugin-v2.d.ts +7 -0
- package/dist/plugin-v2.js +1 -15
- package/dist/plugin.d.ts +1 -15
- package/dist/plugin.js +11 -191
- package/dist/protocol/apply-patch.d.ts +109 -0
- package/dist/protocol/apply-patch.js +163 -0
- package/dist/protocol/messages.d.ts +10 -0
- package/dist/protocol/messages.js +83 -1
- package/dist/protocol/tools.d.ts +24 -1
- package/dist/protocol/tools.js +262 -19
- package/dist/session-directory.d.ts +17 -0
- package/dist/session-directory.js +35 -0
- package/dist/session.d.ts +13 -1
- package/dist/session.js +70 -1
- package/dist/shell-timeout.d.ts +3 -1
- package/dist/shell-timeout.js +12 -4
- package/dist/usage.d.ts +30 -0
- package/dist/usage.js +77 -0
- package/dist/web-search-tool.d.ts +33 -0
- package/dist/web-search-tool.js +22 -0
- package/dist/web-tools.d.ts +9 -25
- package/dist/web-tools.js +34 -36
- 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;
|
package/dist/plugin-v2.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
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: "
|
|
266
|
+
placeholder: "crsr_...",
|
|
447
267
|
validate(value) {
|
|
448
|
-
if (!value.startsWith("
|
|
449
|
-
return "API key should start with
|
|
268
|
+
if (!value.startsWith("crsr_"))
|
|
269
|
+
return "API key should start with crsr_";
|
|
450
270
|
return undefined;
|
|
451
271
|
},
|
|
452
272
|
},
|