cursor-opencode-provider 0.2.8 → 0.3.0
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 +143 -11
- 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/agents.d.ts +5 -1
- package/dist/context/agents.js +90 -30
- package/dist/context/build.js +57 -8
- package/dist/context/env.js +6 -2
- package/dist/context/paths.d.ts +10 -0
- package/dist/context/paths.js +24 -0
- package/dist/context/plugins.js +16 -12
- package/dist/context/rules.js +26 -23
- package/dist/context/skills.js +8 -3
- package/dist/index.d.ts +1 -3
- package/dist/index.js +2 -8
- package/dist/language-model.js +121 -35
- package/dist/model-config.d.ts +15 -0
- package/dist/model-config.js +188 -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/messages.d.ts +10 -0
- package/dist/protocol/messages.js +76 -1
- package/dist/protocol/tool-call-bridge.js +22 -2
- package/dist/protocol/tools.d.ts +57 -1
- package/dist/protocol/tools.js +277 -17
- package/dist/session-directory.d.ts +17 -0
- package/dist/session-directory.js +35 -0
- package/dist/session.d.ts +16 -2
- package/dist/session.js +70 -1
- package/dist/shell-timeout.d.ts +3 -1
- package/dist/shell-timeout.js +6 -2
- 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 +7 -2
|
@@ -0,0 +1,188 @@
|
|
|
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 safeLabel(value) {
|
|
23
|
+
return (value
|
|
24
|
+
.replace(/<[^>]+>/g, "")
|
|
25
|
+
.replace(/[()<>&"'`]/g, "")
|
|
26
|
+
.replace(/\s+/g, " ")
|
|
27
|
+
.trim() || "default");
|
|
28
|
+
}
|
|
29
|
+
function baseName(mi) {
|
|
30
|
+
return safeLabel(mi.displayName ?? mi.id);
|
|
31
|
+
}
|
|
32
|
+
function modelInfoVariants(mi, variants) {
|
|
33
|
+
if (variants.length === 0)
|
|
34
|
+
return undefined;
|
|
35
|
+
const entries = {};
|
|
36
|
+
const usedKeys = new Set();
|
|
37
|
+
const baseName = safeLabel(mi.displayName ?? mi.id);
|
|
38
|
+
// Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
|
|
39
|
+
// "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
|
|
40
|
+
// sees in Cursor. Two variants can sanitize to the same name when the IDE
|
|
41
|
+
// wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
|
|
42
|
+
// suffix collapses to the bare model name after stripping). To guarantee
|
|
43
|
+
// every variant stays pickable:
|
|
44
|
+
// 1. If the sanitized displayName matches the model name itself, suffix
|
|
45
|
+
// it with distinguishing params (or "default") so it never collides
|
|
46
|
+
// with the model entry in the variant panel.
|
|
47
|
+
// 2. If two variants still collide, tag the later one with its params.
|
|
48
|
+
// Suffixes must stay free of `()` / markup chars — same constraint as
|
|
49
|
+
// safeLabel (issue #2); use spaced tokens, not parenthetical tags.
|
|
50
|
+
const tagDims = (p) => {
|
|
51
|
+
const labels = [];
|
|
52
|
+
for (const d of p) {
|
|
53
|
+
if (d.id === "fast" && d.value === "true")
|
|
54
|
+
labels.push("Fast");
|
|
55
|
+
else if (d.id === "thinking" && d.value === "true")
|
|
56
|
+
labels.push("Thinking");
|
|
57
|
+
else if (d.id === "context")
|
|
58
|
+
labels.push(d.value);
|
|
59
|
+
}
|
|
60
|
+
if (labels.length > 0)
|
|
61
|
+
return ` ${labels.join(" ")}`;
|
|
62
|
+
// No params at all — still disambiguate from the model name itself.
|
|
63
|
+
if (p.length === 0)
|
|
64
|
+
return "";
|
|
65
|
+
return " default";
|
|
66
|
+
};
|
|
67
|
+
for (const v of variants) {
|
|
68
|
+
const sanitized = safeLabel(v.displayName || v.key || "default");
|
|
69
|
+
let key = sanitized;
|
|
70
|
+
// Never let a variant key equal the model name — that would make the
|
|
71
|
+
// variant entry indistinguishable from the model entry in pickers that
|
|
72
|
+
// collapse them.
|
|
73
|
+
if (key === baseName && !usedKeys.has(key)) {
|
|
74
|
+
key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
|
|
75
|
+
}
|
|
76
|
+
else if (usedKeys.has(key)) {
|
|
77
|
+
key = `${sanitized}${tagDims(v.parameterValues)}`;
|
|
78
|
+
}
|
|
79
|
+
let n = 2;
|
|
80
|
+
while (usedKeys.has(key))
|
|
81
|
+
key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
|
|
82
|
+
usedKeys.add(key);
|
|
83
|
+
entries[key] = {
|
|
84
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return entries;
|
|
88
|
+
}
|
|
89
|
+
function isLongContextVariant(v) {
|
|
90
|
+
return v.parameterValues.some((p) => p.id === "context" && parseCursorContextLimit(p.value) === 1_000_000);
|
|
91
|
+
}
|
|
92
|
+
function variantsForTier(mi, tier) {
|
|
93
|
+
return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
97
|
+
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
98
|
+
* non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
|
|
99
|
+
* are already unique — including Cursor's GPT family, where the reasoning tier
|
|
100
|
+
* ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
|
|
101
|
+
* tagged redundantly.
|
|
102
|
+
*/
|
|
103
|
+
export function thinkingSuffixBaseNames(models) {
|
|
104
|
+
const flags = new Map();
|
|
105
|
+
for (const m of models) {
|
|
106
|
+
const base = baseName(m);
|
|
107
|
+
const entry = flags.get(base) ?? { hasThinking: false, hasNonThinking: false };
|
|
108
|
+
if (m.supportsThinking)
|
|
109
|
+
entry.hasThinking = true;
|
|
110
|
+
else
|
|
111
|
+
entry.hasNonThinking = true;
|
|
112
|
+
flags.set(base, entry);
|
|
113
|
+
}
|
|
114
|
+
const ambiguous = new Set();
|
|
115
|
+
for (const [base, f] of flags)
|
|
116
|
+
if (f.hasThinking && f.hasNonThinking)
|
|
117
|
+
ambiguous.add(base);
|
|
118
|
+
return ambiguous;
|
|
119
|
+
}
|
|
120
|
+
export function modelInfoToConfig(mi, options = {}) {
|
|
121
|
+
const contextTier = options.contextTier ?? "base";
|
|
122
|
+
const variants = variantsForTier(mi, contextTier);
|
|
123
|
+
let name = baseName(mi);
|
|
124
|
+
if (options.thinkingSuffix)
|
|
125
|
+
name += " Thinking";
|
|
126
|
+
if (contextTier === "long")
|
|
127
|
+
name += " 1M";
|
|
128
|
+
// OpenCode's context limit is static per model entry, while Cursor's context
|
|
129
|
+
// tier is a variant parameter. Long-context choices are therefore emitted as
|
|
130
|
+
// separate OpenCode entries by modelsToConfig.
|
|
131
|
+
const context = contextTier === "long"
|
|
132
|
+
? (mi.maxContextForMaxMode ?? 1_000_000)
|
|
133
|
+
: (mi.maxContext ?? 200_000);
|
|
134
|
+
// OpenCode's overflow/compaction/UI use limit.context; generation and
|
|
135
|
+
// thinking budgets use limit.output. models.dev 1M peers advertise
|
|
136
|
+
// 64k–128k output — a tiny cap makes long-context sessions feel broken
|
|
137
|
+
// even when the 1M input window is correct.
|
|
138
|
+
const output = contextTier === "long" ? 128_000 : 32_000;
|
|
139
|
+
const config = {
|
|
140
|
+
name,
|
|
141
|
+
reasoning: mi.supportsThinking ?? false,
|
|
142
|
+
tool_call: mi.supportsAgent ?? true,
|
|
143
|
+
temperature: false,
|
|
144
|
+
limit: {
|
|
145
|
+
context,
|
|
146
|
+
output,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const variantConfig = modelInfoVariants(mi, variants);
|
|
150
|
+
if (variantConfig)
|
|
151
|
+
config.variants = variantConfig;
|
|
152
|
+
if (contextTier === "long") {
|
|
153
|
+
const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
|
|
154
|
+
if (defaultVariant) {
|
|
155
|
+
config.options = {
|
|
156
|
+
[CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
|
|
157
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return config;
|
|
162
|
+
}
|
|
163
|
+
export function modelsToConfig(models) {
|
|
164
|
+
const ambiguous = thinkingSuffixBaseNames(models);
|
|
165
|
+
const out = {};
|
|
166
|
+
const usedIds = new Set(models.map((m) => m.id));
|
|
167
|
+
for (const m of models) {
|
|
168
|
+
const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
|
|
169
|
+
const baseVariants = variantsForTier(m, "base");
|
|
170
|
+
const longVariants = variantsForTier(m, "long");
|
|
171
|
+
if (baseVariants.length > 0 || longVariants.length === 0) {
|
|
172
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
|
|
173
|
+
}
|
|
174
|
+
if (longVariants.length === 0)
|
|
175
|
+
continue;
|
|
176
|
+
if (baseVariants.length === 0) {
|
|
177
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
let longId = `${m.id}-1m`;
|
|
181
|
+
let suffix = 2;
|
|
182
|
+
while (usedIds.has(longId))
|
|
183
|
+
longId = `${m.id}-1m-${suffix++}`;
|
|
184
|
+
usedIds.add(longId);
|
|
185
|
+
out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CURSOR_WEBSITE_HOST } from "../shared.js";
|
|
2
|
+
import { cursorApiBaseURL } from "../plugin-core.js";
|
|
3
|
+
import { buildLoginUrl, decodeJwtExpiryMs, exchangeApiKey, generatePkceChallenge, generatePkceParams, isExpiringSoon, pollForTokens, refreshAccessToken, } from "../auth.js";
|
|
4
|
+
import { CURSOR_INTEGRATION_ID } from "./catalog.js";
|
|
5
|
+
/**
|
|
6
|
+
* Integration registration for the OpenCode 2.0 plugin — the replacement for the
|
|
7
|
+
* classic plugin's `auth` hook (`methods` + `loader`).
|
|
8
|
+
*
|
|
9
|
+
* Unlike the 1.18 v2 API (where OAuth registration is Effect-valued), 2.0 takes
|
|
10
|
+
* plain Promises, so the PKCE flow from `src/auth.ts` ports across unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export const CURSOR_OAUTH_METHOD_ID = "oauth";
|
|
13
|
+
/** Env vars that can supply a Cursor API key without running /connect. */
|
|
14
|
+
export const CURSOR_ENV_NAMES = ["CURSOR_API_KEY"];
|
|
15
|
+
function websiteURL() {
|
|
16
|
+
return process.env.CURSOR_WEBSITE_URL ?? `https://${CURSOR_WEBSITE_HOST}`;
|
|
17
|
+
}
|
|
18
|
+
/** Browser (PKCE) login: open URL, then poll until Cursor hands back tokens. */
|
|
19
|
+
async function authorizeOAuth() {
|
|
20
|
+
const params = generatePkceParams();
|
|
21
|
+
const challenge = await generatePkceChallenge(params.verifier);
|
|
22
|
+
const apiBaseURL = cursorApiBaseURL();
|
|
23
|
+
const url = buildLoginUrl(challenge, params.uuid, websiteURL());
|
|
24
|
+
return {
|
|
25
|
+
url,
|
|
26
|
+
instructions: "Open this URL in a browser to sign in to Cursor",
|
|
27
|
+
mode: "auto",
|
|
28
|
+
callback: pollForTokens(params.uuid, params.verifier, apiBaseURL).then((result) => ({
|
|
29
|
+
type: "oauth",
|
|
30
|
+
methodID: CURSOR_OAUTH_METHOD_ID,
|
|
31
|
+
access: result.accessToken,
|
|
32
|
+
refresh: result.refreshToken,
|
|
33
|
+
expires: decodeJwtExpiryMs(result.accessToken) ?? Date.now(),
|
|
34
|
+
})),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Renew an expiring Cursor JWT. The host calls this lazily when the stored
|
|
39
|
+
* credential is close to expiry, so it must not assume it runs on every request.
|
|
40
|
+
*/
|
|
41
|
+
async function refreshOAuth(credential) {
|
|
42
|
+
const tokens = await refreshAccessToken(credential.refresh, cursorApiBaseURL());
|
|
43
|
+
return {
|
|
44
|
+
type: "oauth",
|
|
45
|
+
methodID: credential.methodID || CURSOR_OAUTH_METHOD_ID,
|
|
46
|
+
access: tokens.accessToken,
|
|
47
|
+
refresh: tokens.refreshToken,
|
|
48
|
+
expires: decodeJwtExpiryMs(tokens.accessToken) ?? Date.now(),
|
|
49
|
+
...(credential.metadata === undefined ? {} : { metadata: credential.metadata }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Register the Cursor integration and its three connection methods. */
|
|
53
|
+
export function applyCursorIntegration(draft) {
|
|
54
|
+
draft.update(CURSOR_INTEGRATION_ID, (integration) => {
|
|
55
|
+
integration.id = CURSOR_INTEGRATION_ID;
|
|
56
|
+
integration.name = "Cursor";
|
|
57
|
+
});
|
|
58
|
+
draft.method.update({
|
|
59
|
+
integrationID: CURSOR_INTEGRATION_ID,
|
|
60
|
+
method: {
|
|
61
|
+
id: CURSOR_OAUTH_METHOD_ID,
|
|
62
|
+
type: "oauth",
|
|
63
|
+
label: "Cursor account (browser login)",
|
|
64
|
+
},
|
|
65
|
+
authorize: authorizeOAuth,
|
|
66
|
+
refresh: refreshOAuth,
|
|
67
|
+
});
|
|
68
|
+
draft.method.update({
|
|
69
|
+
integrationID: CURSOR_INTEGRATION_ID,
|
|
70
|
+
method: { type: "key", label: "API key (cursor.com/settings)" },
|
|
71
|
+
});
|
|
72
|
+
draft.method.update({
|
|
73
|
+
integrationID: CURSOR_INTEGRATION_ID,
|
|
74
|
+
method: { type: "env", names: CURSOR_ENV_NAMES },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Turn a stored credential into a Cursor access token.
|
|
79
|
+
*
|
|
80
|
+
* OAuth credentials already hold a JWT (the host refreshes them via `refresh`
|
|
81
|
+
* above). A `key` credential is the raw `crsr_…` API key, which Cursor requires us
|
|
82
|
+
* to exchange for a short-lived JWT — mirroring the classic plugin's behavior.
|
|
83
|
+
*/
|
|
84
|
+
export async function accessTokenFromCredential(credential) {
|
|
85
|
+
if (!credential)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (credential.type === "oauth") {
|
|
88
|
+
if (credential.access && !isExpiringSoon(credential.access))
|
|
89
|
+
return credential.access;
|
|
90
|
+
if (!credential.refresh)
|
|
91
|
+
return credential.access || undefined;
|
|
92
|
+
try {
|
|
93
|
+
return (await refreshOAuth(credential)).access;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Fall back to the existing token; the call may still succeed.
|
|
97
|
+
return credential.access || undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (credential.type === "key") {
|
|
101
|
+
// Already a JWT (e.g. exchanged by an earlier run) — use it directly.
|
|
102
|
+
if (!credential.key.startsWith("crsr_"))
|
|
103
|
+
return credential.key;
|
|
104
|
+
try {
|
|
105
|
+
return (await exchangeApiKey(credential.key, cursorApiBaseURL())).accessToken;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/** Resolve the active Cursor connection into an access token, if any. */
|
|
114
|
+
export async function resolveCursorAccessToken(integration) {
|
|
115
|
+
try {
|
|
116
|
+
const connection = await integration.connection.active(CURSOR_INTEGRATION_ID);
|
|
117
|
+
if (!connection)
|
|
118
|
+
return undefined;
|
|
119
|
+
return await accessTokenFromCredential(await integration.connection.resolve(connection));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|