Skip to content

Commit 13a4cd7

Browse files
committed
fix(v2): load the provider package at the plugin's own version
v2 registers the provider as `aisdk:<npm spec>`. The bare `@stablekernel/opencode-cursor` spec resolved to `latest`, so a plugin installed from `@next` loaded the `latest` provider (verified with a clean cache: 0.9.0 was loaded). Pin the spec to the running plugin's version; OPENCODE_CURSOR_PROVIDER_NPM still overrides. v1 is unchanged.
1 parent ad14541 commit 13a4cd7

4 files changed

Lines changed: 75 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- **Fix: opencode v2 loads the provider package at the plugin's own version.**
8+
v2 registered the provider with the bare package name, which opencode
9+
resolves to `latest` — so a plugin installed from `@next` (or any other
10+
channel) silently loaded the `latest` provider instead of the one it
11+
shipped with. The v2 registration now pins the specifier to
12+
`@stablekernel/opencode-cursor@<plugin version>` (falling back to the
13+
bare name only when the version can't be resolved to a valid semver).
14+
`OPENCODE_CURSOR_PROVIDER_NPM` still overrides everything, so local
15+
`file://` builds keep working. v1 is unaffected (its provider is loaded
16+
in-process, not via the package specifier).
17+
718
## [0.10.0-next.0] — 2026-09-29 (pre-release)
819

920
Adds opencode v2 support alongside v1 (#127). Not on `latest`; install with

‎src/plugin/model-v2.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export const NPM_PACKAGE = "@stablekernel/opencode-cursor";
1919
* `OPENCODE_CURSOR_PROVIDER_NPM` — useful for local development and CI before
2020
* the package is published.
2121
*/
22-
export function providerNpm(): string {
23-
return process.env.OPENCODE_CURSOR_PROVIDER_NPM?.trim() || NPM_PACKAGE;
22+
export function providerNpm(fallback = NPM_PACKAGE): string {
23+
return process.env.OPENCODE_CURSOR_PROVIDER_NPM?.trim() || fallback;
2424
}
2525

2626
/**

‎src/plugin/v2.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
import { tool } from "@opencode-ai/plugin";
4949
import { discoverModels } from "../model-discovery.js";
5050
import { defaultModelParams } from "../model-variants.js";
51-
import { PROVIDER_ID, providerNpm } from "./model-v2.js";
51+
import { NPM_PACKAGE, PROVIDER_ID, providerNpm } from "./model-v2.js";
52+
import { getLocalVersion } from "../version-check.js";
53+
import semver from "semver";
5254
import {
5355
resolveContextLimit,
5456
resolveOutputLimit,
@@ -241,6 +243,21 @@ export type CredentialValueV2 =
241243

242244
type Registration = { dispose: () => Promise<void> };
243245

246+
/**
247+
* The npm specifier v2 registers for the provider package, pinned to this
248+
* plugin's own version: without the pin opencode resolves the bare name to
249+
* `latest`, decoupling the provider from the plugin that shipped it (a
250+
* `@next` plugin would silently load the `latest` provider). Falls back to
251+
* the bare name when the local version is missing or not valid semver.
252+
* `OPENCODE_CURSOR_PROVIDER_NPM` still overrides everything (via
253+
* `providerNpm`).
254+
*/
255+
const pinnedProviderNpm = (() => {
256+
const version = getLocalVersion();
257+
return version && semver.valid(version)
258+
? `${NPM_PACKAGE}@${version}`
259+
: NPM_PACKAGE;
260+
})();
244261
/**
245262
* Shape one discovered Cursor model as a v2 `Model.Info`. Carries the v1
246263
* variant/param channels on the settings rest so the picker and per-request
@@ -288,7 +305,7 @@ function toModelInfo(
288305
variants: variantList,
289306
time: { released: 0 },
290307
...(Object.keys(params).length > 0 ? { settings: { params } } : {}),
291-
package: `aisdk:${providerNpm()}`,
308+
package: `aisdk:${providerNpm(pinnedProviderNpm)}`,
292309
};
293310
}
294311

@@ -344,7 +361,7 @@ export async function cursorV2Setup(
344361
id: PROVIDER_ID,
345362
name: "Cursor",
346363
activation: "auto",
347-
package: `aisdk:${providerNpm()}`,
364+
package: `aisdk:${providerNpm(pinnedProviderNpm)}`,
348365
settings: { cwd: resolvedCwd },
349366
},
350367
models: modelInfos,

‎test/plugin-v2.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
existsSync,
44
mkdirSync,
55
mkdtempSync,
6+
readFileSync,
67
rmSync,
78
writeFileSync,
89
} from "node:fs";
@@ -94,16 +95,26 @@ function makeCtx(directory = "/work") {
9495
return ctx;
9596
}
9697

98+
// package.json version, for pin assertions (single source of truth).
99+
const pkgVersion = JSON.parse(
100+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
101+
).version as string;
102+
97103
let savedEnvKey: string | undefined;
104+
let savedProviderNpm: string | undefined;
98105

99106
beforeEach(() => {
100107
savedEnvKey = process.env.CURSOR_API_KEY;
101108
delete process.env.CURSOR_API_KEY;
109+
savedProviderNpm = process.env.OPENCODE_CURSOR_PROVIDER_NPM;
110+
delete process.env.OPENCODE_CURSOR_PROVIDER_NPM;
102111
});
103112

104113
afterEach(() => {
105114
if (savedEnvKey === undefined) delete process.env.CURSOR_API_KEY;
106115
else process.env.CURSOR_API_KEY = savedEnvKey;
116+
if (savedProviderNpm === undefined) delete process.env.OPENCODE_CURSOR_PROVIDER_NPM;
117+
else process.env.OPENCODE_CURSOR_PROVIDER_NPM = savedProviderNpm;
107118
});
108119

109120
describe("dual default export", () => {
@@ -178,14 +189,44 @@ describe("cursorV2Setup", () => {
178189
};
179190
expect(info["id"]).toBe("cursor");
180191
expect(info["name"]).toBe("Cursor");
181-
expect(String(info["package"])).toMatch(/^aisdk:/);
192+
expect(String(info["package"])).toBe(
193+
`aisdk:@stablekernel/opencode-cursor@${pkgVersion}`,
194+
);
182195
expect(info["activation"]).toBe("auto");
183196
// The provider settings carry the session directory so the v2 runner
184197
// hands createCursor the right cwd (it may run from anywhere).
185198
expect(info.settings).toEqual({ cwd: "/work" });
186199
const model = models.find((m) => m.id === "test-model");
187200
expect(model).toBeDefined();
188201
expect(model?.providerID).toBe("cursor");
202+
// Every model carries the same pinned specifier (opencode loads the
203+
// provider package per model, so an unpinned one would resolve
204+
// `latest` and decouple the provider from this plugin's version).
205+
for (const m of models) {
206+
expect(m["package"]).toBe(
207+
`aisdk:@stablekernel/opencode-cursor@${pkgVersion}`,
208+
);
209+
}
210+
});
211+
212+
it("honors OPENCODE_CURSOR_PROVIDER_NPM over the pinned spec", async () => {
213+
process.env.OPENCODE_CURSOR_PROVIDER_NPM =
214+
"file:///tmp/opencode-cursor-test-build";
215+
const ctx = makeCtx();
216+
await cursorV2Setup(ctx as never);
217+
const cb = ctx.provider.transform.mock.calls[0]![0] as (editor: {
218+
add: (input: unknown) => void;
219+
}) => void;
220+
const add = vi.fn();
221+
cb({ add });
222+
const { info, models } = add.mock.calls[0]![0] as {
223+
info: Record<string, unknown>;
224+
models: Array<Record<string, unknown>>;
225+
};
226+
expect(info["package"]).toBe("aisdk:file:///tmp/opencode-cursor-test-build");
227+
for (const m of models) {
228+
expect(m["package"]).toBe("aisdk:file:///tmp/opencode-cursor-test-build");
229+
}
189230
});
190231

191232
it("emits defaultModelParams as the model's settings.params", async () => {

0 commit comments

Comments
 (0)