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
@@ -5,8 +5,8 @@ import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } fr
5
5
  import { trace, traceRequestContextPaths } from "./debug.js";
6
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
7
  import { decodeFramePayload } from "./protocol/framing.js";
8
- import { decodeMessage } from "./protocol/messages.js";
9
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
8
+ import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
9
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
10
10
  import { describeCursorExecVariant } from "./protocol/exec-variants.js";
11
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
12
  import { handleKvServerMessage } from "./protocol/kv.js";
@@ -22,9 +22,12 @@ import { workspaceRootFromRequestContext } from "./context/env.js";
22
22
  import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
23
23
  import { resolveAgentUrl } from "./agent-url.js";
24
24
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
25
+ import { isCompactionSession } from "./compaction-marker.js";
26
+ import { getSessionDirectory } from "./session-directory.js";
25
27
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
26
28
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
27
29
  import { readAllFieldsStrict } from "./protocol/struct.js";
30
+ import { buildLanguageModelV3UsageFromEstimate, buildLanguageModelV3UsageFromTurnEnded, turnEndedCounter, } from "./usage.js";
28
31
  let _availableModels;
29
32
  // mtime of the cache file the last time we loaded it. Compared on each call
30
33
  // so discoverModels' background refresh is picked up without a process restart.
@@ -275,7 +278,7 @@ export function createCursorLanguageModel(modelId, providerId, options) {
275
278
  };
276
279
  }
277
280
  async function doStreamImpl(modelId, options, callOptions) {
278
- // A raw `sk-...` API key must be exchanged for a JWT before it can be used
281
+ // A raw `crsr_...` API key must be exchanged for a JWT before it can be used
279
282
  // as a Bearer token (the plugin path does this in auth.ts). The accessToken
280
283
  // path is already a JWT from OAuth/key-exchange, so we use it as-is.
281
284
  // resolveBearerToken caches apiKey exchanges so we don't hit /auth/exchange
@@ -445,8 +448,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
445
448
  const sessionKey = opencodeSessionKey(callOptions);
446
449
  const providerOptions = callOptions.providerOptions?.cursor;
447
450
  // The classic plugin marks OpenCode's agent="compaction" through chat.params.
451
+ // OpenCode 2.0 removed that hook, so its plugin records the same fact against
452
+ // the session id from session.hook("context") instead; consult both.
448
453
  // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
449
- const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true;
454
+ const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
450
455
  const toolState = resolveTurnToolState({
451
456
  sessionKey,
452
457
  incomingTools,
@@ -482,7 +487,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
482
487
  const userText = recovery?.kind === "rebase"
483
488
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
484
489
  : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
485
- const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
490
+ // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
491
+ // one plugin instance per project). OpenCode 2.0 runs one daemon across many
492
+ // projects, so its static `options.workspaceRoot` is only a last-resort
493
+ // fallback; `getSessionDirectory` carries the real per-session directory
494
+ // recorded from `session.hook("context")` in `plugin-opencode2.ts`.
495
+ const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
486
496
  const baseSystemPrompt = extractSystemPrompt(prompt);
487
497
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
488
498
  const systemPrompt = interactionGuidance
@@ -583,6 +593,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
583
593
  outputTokens: 0,
584
594
  cacheRead: 0,
585
595
  cacheWrite: 0,
596
+ reasoningTokens: 0,
586
597
  };
587
598
  trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
588
599
  `params=${JSON.stringify(parameterValues ?? [])} ` +
@@ -730,6 +741,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
730
741
  toolName: pending.toolName ?? r.toolName,
731
742
  resultMetadata: pending.resultMetadata,
732
743
  shellOutcome: shellResult?.outcome,
744
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
733
745
  });
734
746
  }
735
747
  catch (error) {
@@ -916,6 +928,7 @@ export async function pump(session, controller, ids, abortSignal) {
916
928
  error: reason,
917
929
  toolName: parsed.toolName,
918
930
  resultMetadata: parsed.resultMetadata,
931
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
919
932
  })) {
920
933
  session.stream.write(frame);
921
934
  }
@@ -941,7 +954,9 @@ export async function pump(session, controller, ids, abortSignal) {
941
954
  if (!displayCallId ||
942
955
  parsed.resultField !== "read_result" ||
943
956
  parsed.toolName !== "read" ||
944
- !advertisedToolNameSet.has("write"))
957
+ // `apply_patch` is the substitute this host offers when it withholds
958
+ // `write` — the follow-up write_args is remapped onto it either way.
959
+ !(advertisedToolNameSet.has("write") || advertisedToolNameSet.has("apply_patch")))
945
960
  return false;
946
961
  const stored = session.displayToolCalls.get(displayCallId);
947
962
  const display = parseDisplayToolCall(displayCallId, stored);
@@ -965,6 +980,7 @@ export async function pump(session, controller, ids, abortSignal) {
965
980
  output: "",
966
981
  toolName: parsed.toolName,
967
982
  resultMetadata: { path: requestedPath },
983
+ workspaceRoot,
968
984
  })) {
969
985
  session.stream.write(frame);
970
986
  }
@@ -1067,32 +1083,27 @@ export async function pump(session, controller, ids, abortSignal) {
1067
1083
  if (te) {
1068
1084
  // Authoritative TurnEnded counts — replace the running estimate.
1069
1085
  session.usageEstimate = {
1070
- inputTokens: Number(te.input_tokens ?? 0) || 0,
1071
- outputTokens: Number(te.output_tokens ?? 0) || 0,
1072
- cacheRead: Number(te.cache_read ?? 0) || 0,
1073
- cacheWrite: Number(te.cache_write ?? 0) || 0,
1086
+ inputTokens: turnEndedCounter(te, "input_tokens"),
1087
+ outputTokens: turnEndedCounter(te, "output_tokens"),
1088
+ cacheRead: turnEndedCounter(te, "cache_read"),
1089
+ cacheWrite: turnEndedCounter(te, "cache_write"),
1090
+ reasoningTokens: turnEndedCounter(te, "reasoning_tokens"),
1074
1091
  };
1075
1092
  }
1076
1093
  const est = session.usageEstimate;
1077
- const usage = {
1078
- inputTokens: {
1079
- total: promptTokens,
1080
- noCache: undefined,
1081
- cacheRead: 0,
1082
- cacheWrite: 0,
1083
- },
1084
- outputTokens: {
1085
- total: estimateTokens(requestUsage.outputChars),
1086
- text: undefined,
1087
- reasoning: undefined,
1088
- },
1089
- };
1094
+ const usage = te
1095
+ ? buildLanguageModelV3UsageFromTurnEnded(te)
1096
+ : buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
1090
1097
  const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
1091
1098
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1092
1099
  ? String(reason.unified ?? "unknown")
1093
1100
  : String(reason);
1101
+ const inTotal = usage.inputTokens?.total ?? 0;
1102
+ const outTotal = usage.outputTokens?.total ?? 0;
1094
1103
  trace(`finish: reason=${reasonLabel} ` +
1095
- `requestIn=${usage.inputTokens.total} requestOut=${usage.outputTokens.total} ` +
1104
+ `v3In=${inTotal} v3Out=${outTotal} ` +
1105
+ `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
1106
+ `v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
1096
1107
  `rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
1097
1108
  `rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
1098
1109
  `source=${te ? "turn_ended" : "estimate"}`);
@@ -1238,6 +1249,7 @@ export async function pump(session, controller, ids, abortSignal) {
1238
1249
  emitReasoning(iu.thinking_delta.text ?? "");
1239
1250
  }
1240
1251
  else if (iu?.turn_ended) {
1252
+ trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
1241
1253
  emitFinish(iu.turn_ended, { unified: "stop", raw: undefined });
1242
1254
  sessionManager.close(session);
1243
1255
  return;
@@ -1367,6 +1379,10 @@ export async function pump(session, controller, ids, abortSignal) {
1367
1379
  parsed.toolName = executableToolName;
1368
1380
  }
1369
1381
  remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
1382
+ // Hosts that advertise `apply_patch` in place of `edit`/`write` (see
1383
+ // protocol/apply-patch.ts) still receive Cursor's native write/edit
1384
+ // exec requests. Translate before the unavailable-tool check below.
1385
+ remapEditToolsForCatalog(parsed, advertisedToolNameSet, workspaceRootFromRequestContext(session.requestContext));
1370
1386
  }
1371
1387
  const displayCallId = extractExecDisplayCallId(esm);
1372
1388
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
@@ -1653,6 +1669,12 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1653
1669
  ? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
1654
1670
  : "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
1655
1671
  }
1672
+ else if (names.has("apply_patch")) {
1673
+ // This host withheld `edit`/`write` and offers `apply_patch` instead. Native
1674
+ // Cursor write/edit requests are translated into it, so say so rather than
1675
+ // leaving the turn with no file-editing guidance at all.
1676
+ instructions.push("- Use OpenCode `apply_patch` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available. Cursor-native write and edit requests are accepted and converted to `apply_patch` automatically.");
1677
+ }
1656
1678
  return [
1657
1679
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1658
1680
  `Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
@@ -1683,20 +1705,14 @@ export function estimatePromptTokens(prompt) {
1683
1705
  }
1684
1706
  /** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
1685
1707
  export function cursorTurnEndedProviderMetadata(te) {
1686
- const counter = (key) => {
1687
- const value = te[key];
1688
- return typeof value === "number" && Number.isFinite(value) && value >= 0
1689
- ? Math.trunc(value)
1690
- : 0;
1691
- };
1692
1708
  return {
1693
1709
  cursor: {
1694
1710
  usageVersion: 2,
1695
- inputTokensRaw: counter("input_tokens"),
1696
- outputTokensRaw: counter("output_tokens"),
1697
- cacheReadRaw: counter("cache_read"),
1698
- cacheWriteRaw: counter("cache_write"),
1699
- reasoningTokensRaw: counter("reasoning_tokens"),
1711
+ inputTokensRaw: turnEndedCounter(te, "input_tokens"),
1712
+ outputTokensRaw: turnEndedCounter(te, "output_tokens"),
1713
+ cacheReadRaw: turnEndedCounter(te, "cache_read"),
1714
+ cacheWriteRaw: turnEndedCounter(te, "cache_write"),
1715
+ reasoningTokensRaw: turnEndedCounter(te, "reasoning_tokens"),
1700
1716
  },
1701
1717
  };
1702
1718
  }
@@ -0,0 +1,15 @@
1
+ import { type ModelInfo } from "./models.js";
2
+ /**
3
+ * Display names shared by both a thinking and a non-thinking model. A thinking
4
+ * model with such a name needs a "Thinking" tag to disambiguate it from its
5
+ * non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
6
+ * are already unique — including Cursor's GPT family, where the reasoning tier
7
+ * ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
8
+ * tagged redundantly.
9
+ */
10
+ export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
11
+ export declare function modelInfoToConfig(mi: ModelInfo, options?: {
12
+ thinkingSuffix?: boolean;
13
+ contextTier?: "base" | "long";
14
+ }): Record<string, any>;
15
+ export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
@@ -0,0 +1,206 @@
1
+ import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, parseCursorContextLimit, } from "./models.js";
2
+ /**
3
+ * Cursor `ModelInfo` → OpenCode model-config mapping.
4
+ *
5
+ * Host-neutral on purpose: both the classic/1.18 plugins and the OpenCode 2.0
6
+ * plugin derive their catalogs from here, so model naming, thinking suffixes,
7
+ * and long-context tiering stay identical across every surface. Do not import
8
+ * a host plugin API into this module.
9
+ */
10
+ /**
11
+ * Strip characters and markup that break rendering in the OpenCode TUI/GUI from
12
+ * a model or variant display name:
13
+ * • HTML tags (e.g. `<span style="…">Medium</span>`) — the IDE colours variant
14
+ * suffixes with a CSS var that doesn't exist in OpenCode, so the raw markup
15
+ * would show as literal text. We drop the tags and keep the inner text
16
+ * ("Medium").
17
+ * • HTML/markup chars (`< > & " ' \``), parentheses.
18
+ * • Tabs/newlines collapse to single spaces.
19
+ * Dots and unicode letters are preserved so names stay readable.
20
+ * Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
21
+ */
22
+ function stripMarkupTags(value) {
23
+ const chunks = [];
24
+ let cursor = 0;
25
+ while (cursor < value.length) {
26
+ const start = value.indexOf("<", cursor);
27
+ if (start === -1) {
28
+ chunks.push(value.slice(cursor));
29
+ break;
30
+ }
31
+ chunks.push(value.slice(cursor, start));
32
+ const end = value.indexOf(">", start + 1);
33
+ if (end === -1) {
34
+ chunks.push(value.slice(start));
35
+ break;
36
+ }
37
+ cursor = end + 1;
38
+ }
39
+ return chunks.join("");
40
+ }
41
+ function safeLabel(value) {
42
+ return (stripMarkupTags(value)
43
+ .replace(/[()<>&"'`]/g, "")
44
+ .replace(/\s+/g, " ")
45
+ .trim() || "default");
46
+ }
47
+ function baseName(mi) {
48
+ return safeLabel(mi.displayName ?? mi.id);
49
+ }
50
+ function modelInfoVariants(mi, variants) {
51
+ if (variants.length === 0)
52
+ return undefined;
53
+ const entries = {};
54
+ const usedKeys = new Set();
55
+ const baseName = safeLabel(mi.displayName ?? mi.id);
56
+ // Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
57
+ // "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
58
+ // sees in Cursor. Two variants can sanitize to the same name when the IDE
59
+ // wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
60
+ // suffix collapses to the bare model name after stripping). To guarantee
61
+ // every variant stays pickable:
62
+ // 1. If the sanitized displayName matches the model name itself, suffix
63
+ // it with distinguishing params (or "default") so it never collides
64
+ // with the model entry in the variant panel.
65
+ // 2. If two variants still collide, tag the later one with its params.
66
+ // Suffixes must stay free of `()` / markup chars — same constraint as
67
+ // safeLabel (issue #2); use spaced tokens, not parenthetical tags.
68
+ const tagDims = (p) => {
69
+ const labels = [];
70
+ for (const d of p) {
71
+ if (d.id === "fast" && d.value === "true")
72
+ labels.push("Fast");
73
+ else if (d.id === "thinking" && d.value === "true")
74
+ labels.push("Thinking");
75
+ else if (d.id === "context")
76
+ labels.push(d.value);
77
+ }
78
+ if (labels.length > 0)
79
+ return ` ${labels.join(" ")}`;
80
+ // No params at all — still disambiguate from the model name itself.
81
+ if (p.length === 0)
82
+ return "";
83
+ return " default";
84
+ };
85
+ for (const v of variants) {
86
+ const sanitized = safeLabel(v.displayName || v.key || "default");
87
+ let key = sanitized;
88
+ // Never let a variant key equal the model name — that would make the
89
+ // variant entry indistinguishable from the model entry in pickers that
90
+ // collapse them.
91
+ if (key === baseName && !usedKeys.has(key)) {
92
+ key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
93
+ }
94
+ else if (usedKeys.has(key)) {
95
+ key = `${sanitized}${tagDims(v.parameterValues)}`;
96
+ }
97
+ let n = 2;
98
+ while (usedKeys.has(key))
99
+ key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
100
+ usedKeys.add(key);
101
+ entries[key] = {
102
+ [CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
103
+ };
104
+ }
105
+ return entries;
106
+ }
107
+ function isLongContextVariant(v) {
108
+ return v.parameterValues.some((p) => p.id === "context" && parseCursorContextLimit(p.value) === 1_000_000);
109
+ }
110
+ function variantsForTier(mi, tier) {
111
+ return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
112
+ }
113
+ /**
114
+ * Display names shared by both a thinking and a non-thinking model. A thinking
115
+ * model with such a name needs a "Thinking" tag to disambiguate it from its
116
+ * non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
117
+ * are already unique — including Cursor's GPT family, where the reasoning tier
118
+ * ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
119
+ * tagged redundantly.
120
+ */
121
+ export function thinkingSuffixBaseNames(models) {
122
+ const flags = new Map();
123
+ for (const m of models) {
124
+ const base = baseName(m);
125
+ const entry = flags.get(base) ?? { hasThinking: false, hasNonThinking: false };
126
+ if (m.supportsThinking)
127
+ entry.hasThinking = true;
128
+ else
129
+ entry.hasNonThinking = true;
130
+ flags.set(base, entry);
131
+ }
132
+ const ambiguous = new Set();
133
+ for (const [base, f] of flags)
134
+ if (f.hasThinking && f.hasNonThinking)
135
+ ambiguous.add(base);
136
+ return ambiguous;
137
+ }
138
+ export function modelInfoToConfig(mi, options = {}) {
139
+ const contextTier = options.contextTier ?? "base";
140
+ const variants = variantsForTier(mi, contextTier);
141
+ let name = baseName(mi);
142
+ if (options.thinkingSuffix)
143
+ name += " Thinking";
144
+ if (contextTier === "long")
145
+ name += " 1M";
146
+ // OpenCode's context limit is static per model entry, while Cursor's context
147
+ // tier is a variant parameter. Long-context choices are therefore emitted as
148
+ // separate OpenCode entries by modelsToConfig.
149
+ const context = contextTier === "long"
150
+ ? (mi.maxContextForMaxMode ?? 1_000_000)
151
+ : (mi.maxContext ?? 200_000);
152
+ // OpenCode's overflow/compaction/UI use limit.context; generation and
153
+ // thinking budgets use limit.output. models.dev 1M peers advertise
154
+ // 64k–128k output — a tiny cap makes long-context sessions feel broken
155
+ // even when the 1M input window is correct.
156
+ const output = contextTier === "long" ? 128_000 : 32_000;
157
+ const config = {
158
+ name,
159
+ reasoning: mi.supportsThinking ?? false,
160
+ tool_call: mi.supportsAgent ?? true,
161
+ temperature: false,
162
+ limit: {
163
+ context,
164
+ output,
165
+ },
166
+ };
167
+ const variantConfig = modelInfoVariants(mi, variants);
168
+ if (variantConfig)
169
+ config.variants = variantConfig;
170
+ if (contextTier === "long") {
171
+ const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
172
+ if (defaultVariant) {
173
+ config.options = {
174
+ [CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
175
+ [CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
176
+ };
177
+ }
178
+ }
179
+ return config;
180
+ }
181
+ export function modelsToConfig(models) {
182
+ const ambiguous = thinkingSuffixBaseNames(models);
183
+ const out = {};
184
+ const usedIds = new Set(models.map((m) => m.id));
185
+ for (const m of models) {
186
+ const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
187
+ const baseVariants = variantsForTier(m, "base");
188
+ const longVariants = variantsForTier(m, "long");
189
+ if (baseVariants.length > 0 || longVariants.length === 0) {
190
+ out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
191
+ }
192
+ if (longVariants.length === 0)
193
+ continue;
194
+ if (baseVariants.length === 0) {
195
+ out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
196
+ continue;
197
+ }
198
+ let longId = `${m.id}-1m`;
199
+ let suffix = 2;
200
+ while (usedIds.has(longId))
201
+ longId = `${m.id}-1m-${suffix++}`;
202
+ usedIds.add(longId);
203
+ out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
204
+ }
205
+ return out;
206
+ }
@@ -0,0 +1,33 @@
1
+ import { type ModelInfo } from "../models.js";
2
+ import type { CatalogDraft } from "./types.js";
3
+ /**
4
+ * Catalog registration for the OpenCode 2.0 plugin — the replacement for the
5
+ * classic plugin's `config` hook.
6
+ *
7
+ * Model naming, thinking suffixes, and long-context tiering are NOT reimplemented
8
+ * here: we run the shared `modelsToConfig` and translate its output into the 2.0
9
+ * `Model.Info` shape, so every surface exposes an identical model list.
10
+ */
11
+ /** Integration id owning Cursor credentials. Matches the provider id. */
12
+ export declare const CURSOR_INTEGRATION_ID = "cursor";
13
+ /**
14
+ * `aisdk:` selects OpenCode 2.0's AI SDK path, which is what surfaces the
15
+ * `aisdk.hook("sdk")` / `("language")` extension points we supply the provider
16
+ * through. The suffix is this package's npm name so the host's built-in
17
+ * `DynamicProviderPlugin` can still resolve it if our own hook is ever
18
+ * bypassed — that fallback runs `npm.add(pkg)` against the *published*
19
+ * registry into `<host-cache>/packages/<pkg>/node_modules/<pkg>`, ignoring
20
+ * any local `file://` plugin path this process was loaded from.
21
+ *
22
+ * `CURSOR_OPENCODE2_DEV_ENTRY` overrides the suffix with an `aisdk:file://…`
23
+ * spec instead, pointed at a local built entry file (e.g. `dist/index.js`,
24
+ * which exports `createCursor`). The host's fallback recognizes `file://`
25
+ * specs and imports them directly, skipping `npm.add` — the only way to
26
+ * exercise a local build through that fallback path short of publishing.
27
+ * Unset in production; only meant for local `opencode2 run` testing.
28
+ */
29
+ export declare const CURSOR_AISDK_PACKAGE: string;
30
+ /** Register (or update) the Cursor provider entry. `update` is an upsert. */
31
+ export declare function applyCursorProvider(draft: CatalogDraft): void;
32
+ /** Register every discovered Cursor model. Safe to re-run; `update` upserts. */
33
+ export declare function applyCursorModels(draft: CatalogDraft, models: ModelInfo[]): void;
@@ -0,0 +1,85 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { CURSOR_PROVIDER_ID } from "../shared.js";
3
+ import { CURSOR_WIRE_MODEL_ID_KEY } from "../models.js";
4
+ import { modelsToConfig } from "../model-config.js";
5
+ /**
6
+ * Catalog registration for the OpenCode 2.0 plugin — the replacement for the
7
+ * classic plugin's `config` hook.
8
+ *
9
+ * Model naming, thinking suffixes, and long-context tiering are NOT reimplemented
10
+ * here: we run the shared `modelsToConfig` and translate its output into the 2.0
11
+ * `Model.Info` shape, so every surface exposes an identical model list.
12
+ */
13
+ /** Integration id owning Cursor credentials. Matches the provider id. */
14
+ export const CURSOR_INTEGRATION_ID = CURSOR_PROVIDER_ID;
15
+ /**
16
+ * `aisdk:` selects OpenCode 2.0's AI SDK path, which is what surfaces the
17
+ * `aisdk.hook("sdk")` / `("language")` extension points we supply the provider
18
+ * through. The suffix is this package's npm name so the host's built-in
19
+ * `DynamicProviderPlugin` can still resolve it if our own hook is ever
20
+ * bypassed — that fallback runs `npm.add(pkg)` against the *published*
21
+ * registry into `<host-cache>/packages/<pkg>/node_modules/<pkg>`, ignoring
22
+ * any local `file://` plugin path this process was loaded from.
23
+ *
24
+ * `CURSOR_OPENCODE2_DEV_ENTRY` overrides the suffix with an `aisdk:file://…`
25
+ * spec instead, pointed at a local built entry file (e.g. `dist/index.js`,
26
+ * which exports `createCursor`). The host's fallback recognizes `file://`
27
+ * specs and imports them directly, skipping `npm.add` — the only way to
28
+ * exercise a local build through that fallback path short of publishing.
29
+ * Unset in production; only meant for local `opencode2 run` testing.
30
+ */
31
+ export const CURSOR_AISDK_PACKAGE = process.env.CURSOR_OPENCODE2_DEV_ENTRY
32
+ ? `aisdk:${pathToFileURL(process.env.CURSOR_OPENCODE2_DEV_ENTRY).href}`
33
+ : "aisdk:cursor-opencode-provider";
34
+ /** Register (or update) the Cursor provider entry. `update` is an upsert. */
35
+ export function applyCursorProvider(draft) {
36
+ draft.provider.update(CURSOR_PROVIDER_ID, (provider) => {
37
+ provider.id = CURSOR_PROVIDER_ID;
38
+ provider.name = "Cursor";
39
+ provider.package = CURSOR_AISDK_PACKAGE;
40
+ // Links the provider to the integration that stores its credentials, so
41
+ // `connection.active(...)` resolves the token the user set up via /connect.
42
+ provider.integrationID = CURSOR_INTEGRATION_ID;
43
+ });
44
+ }
45
+ /** Translate one `modelsToConfig` entry into the 2.0 `Model.Info` shape. */
46
+ function applyModelEntry(draft, id, entry) {
47
+ const options = entry.options;
48
+ // Long-context entries get a synthetic OpenCode id (`<id>-1m`) while still
49
+ // addressing the same Cursor model on the wire. V1 smuggled that through
50
+ // provider options; 2.0 has a first-class `modelID` for exactly this.
51
+ const wireId = typeof options?.[CURSOR_WIRE_MODEL_ID_KEY] === "string"
52
+ ? options[CURSOR_WIRE_MODEL_ID_KEY]
53
+ : id;
54
+ const variants = Object.entries((entry.variants ?? {})).map(([variantId, settings]) => ({ id: variantId, settings: { ...settings } }));
55
+ draft.model.update(CURSOR_PROVIDER_ID, id, (model) => {
56
+ model.id = id;
57
+ model.modelID = wireId;
58
+ model.providerID = CURSOR_PROVIDER_ID;
59
+ model.name = entry.name;
60
+ model.capabilities = {
61
+ tools: entry.tool_call !== false,
62
+ // The provider does not convert file/image prompt parts, so text-only is
63
+ // the honest declaration. Revisit alongside Cursor's `supports_images`.
64
+ input: ["text"],
65
+ output: ["text"],
66
+ };
67
+ model.limit = { context: entry.limit.context, output: entry.limit.output };
68
+ model.variants = variants;
69
+ model.status = "active";
70
+ model.enabled = true;
71
+ model.time = { released: 0 };
72
+ model.cost = [];
73
+ // Carries the default variant parameters (and the wire id) through to
74
+ // doStream as provider options, matching the classic plugin.
75
+ if (options)
76
+ model.settings = { ...options };
77
+ });
78
+ }
79
+ /** Register every discovered Cursor model. Safe to re-run; `update` upserts. */
80
+ export function applyCursorModels(draft, models) {
81
+ const config = modelsToConfig(models);
82
+ for (const [id, entry] of Object.entries(config)) {
83
+ applyModelEntry(draft, id, entry);
84
+ }
85
+ }
@@ -0,0 +1,23 @@
1
+ import type { CredentialValue, IntegrationDomain, IntegrationDraft } from "./types.js";
2
+ /**
3
+ * Integration registration for the OpenCode 2.0 plugin — the replacement for the
4
+ * classic plugin's `auth` hook (`methods` + `loader`).
5
+ *
6
+ * Unlike the 1.18 v2 API (where OAuth registration is Effect-valued), 2.0 takes
7
+ * plain Promises, so the PKCE flow from `src/auth.ts` ports across unchanged.
8
+ */
9
+ export declare const CURSOR_OAUTH_METHOD_ID = "oauth";
10
+ /** Env vars that can supply a Cursor API key without running /connect. */
11
+ export declare const CURSOR_ENV_NAMES: string[];
12
+ /** Register the Cursor integration and its three connection methods. */
13
+ export declare function applyCursorIntegration(draft: IntegrationDraft): void;
14
+ /**
15
+ * Turn a stored credential into a Cursor access token.
16
+ *
17
+ * OAuth credentials already hold a JWT (the host refreshes them via `refresh`
18
+ * above). A `key` credential is the raw `crsr_…` API key, which Cursor requires us
19
+ * to exchange for a short-lived JWT — mirroring the classic plugin's behavior.
20
+ */
21
+ export declare function accessTokenFromCredential(credential: CredentialValue | undefined): Promise<string | undefined>;
22
+ /** Resolve the active Cursor connection into an access token, if any. */
23
+ export declare function resolveCursorAccessToken(integration: IntegrationDomain): Promise<string | undefined>;