Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- **Fix: opencode v2 loads the provider package at the plugin's own version.**
v2 registered the provider with the bare package name, which opencode
resolves to `latest` — so a plugin installed from `@next` (or any other
channel) silently loaded the `latest` provider instead of the one it
shipped with. The v2 registration now pins the specifier to
`@stablekernel/opencode-cursor@<plugin version>` (falling back to the
bare name only when the version can't be resolved to a valid semver).
`OPENCODE_CURSOR_PROVIDER_NPM` still overrides everything, so local
`file://` builds keep working. v1 is unaffected (its provider is loaded
in-process, not via the package specifier).

## [0.10.0-next.0] — 2026-09-29 (pre-release)

Adds opencode v2 support alongside v1 (#127). Not on `latest`; install with
Expand Down
4 changes: 2 additions & 2 deletions src/plugin/model-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export const NPM_PACKAGE = "@stablekernel/opencode-cursor";
* `OPENCODE_CURSOR_PROVIDER_NPM` — useful for local development and CI before
* the package is published.
*/
export function providerNpm(): string {
return process.env.OPENCODE_CURSOR_PROVIDER_NPM?.trim() || NPM_PACKAGE;
export function providerNpm(fallback = NPM_PACKAGE): string {
return process.env.OPENCODE_CURSOR_PROVIDER_NPM?.trim() || fallback;
}

/**
Expand Down
23 changes: 20 additions & 3 deletions src/plugin/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
import { tool } from "@opencode-ai/plugin";
import { discoverModels } from "../model-discovery.js";
import { defaultModelParams } from "../model-variants.js";
import { PROVIDER_ID, providerNpm } from "./model-v2.js";
import { NPM_PACKAGE, PROVIDER_ID, providerNpm } from "./model-v2.js";
import { getLocalVersion } from "../version-check.js";
import semver from "semver";
import {
resolveContextLimit,
resolveOutputLimit,
Expand Down Expand Up @@ -241,6 +243,21 @@ export type CredentialValueV2 =

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

/**
* The npm specifier v2 registers for the provider package, pinned to this
* plugin's own version: without the pin opencode resolves the bare name to
* `latest`, decoupling the provider from the plugin that shipped it (a
* `@next` plugin would silently load the `latest` provider). Falls back to
* the bare name when the local version is missing or not valid semver.
* `OPENCODE_CURSOR_PROVIDER_NPM` still overrides everything (via
* `providerNpm`).
*/
const pinnedProviderNpm = (() => {
const version = getLocalVersion();
return version && semver.valid(version)
? `${NPM_PACKAGE}@${version}`
: NPM_PACKAGE;
})();
/**
* Shape one discovered Cursor model as a v2 `Model.Info`. Carries the v1
* variant/param channels on the settings rest so the picker and per-request
Expand Down Expand Up @@ -288,7 +305,7 @@ function toModelInfo(
variants: variantList,
time: { released: 0 },
...(Object.keys(params).length > 0 ? { settings: { params } } : {}),
package: `aisdk:${providerNpm()}`,
package: `aisdk:${providerNpm(pinnedProviderNpm)}`,
};
}

Expand Down Expand Up @@ -344,7 +361,7 @@ export async function cursorV2Setup(
id: PROVIDER_ID,
name: "Cursor",
activation: "auto",
package: `aisdk:${providerNpm()}`,
package: `aisdk:${providerNpm(pinnedProviderNpm)}`,
settings: { cwd: resolvedCwd },
},
models: modelInfos,
Expand Down
43 changes: 42 additions & 1 deletion test/plugin-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
Expand Down Expand Up @@ -94,16 +95,26 @@ function makeCtx(directory = "/work") {
return ctx;
}

// package.json version, for pin assertions (single source of truth).
const pkgVersion = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
).version as string;

let savedEnvKey: string | undefined;
let savedProviderNpm: string | undefined;

beforeEach(() => {
savedEnvKey = process.env.CURSOR_API_KEY;
delete process.env.CURSOR_API_KEY;
savedProviderNpm = process.env.OPENCODE_CURSOR_PROVIDER_NPM;
delete process.env.OPENCODE_CURSOR_PROVIDER_NPM;
});

afterEach(() => {
if (savedEnvKey === undefined) delete process.env.CURSOR_API_KEY;
else process.env.CURSOR_API_KEY = savedEnvKey;
if (savedProviderNpm === undefined) delete process.env.OPENCODE_CURSOR_PROVIDER_NPM;
else process.env.OPENCODE_CURSOR_PROVIDER_NPM = savedProviderNpm;
});

describe("dual default export", () => {
Expand Down Expand Up @@ -178,14 +189,44 @@ describe("cursorV2Setup", () => {
};
expect(info["id"]).toBe("cursor");
expect(info["name"]).toBe("Cursor");
expect(String(info["package"])).toMatch(/^aisdk:/);
expect(String(info["package"])).toBe(
`aisdk:@stablekernel/opencode-cursor@${pkgVersion}`,
);
expect(info["activation"]).toBe("auto");
// The provider settings carry the session directory so the v2 runner
// hands createCursor the right cwd (it may run from anywhere).
expect(info.settings).toEqual({ cwd: "/work" });
const model = models.find((m) => m.id === "test-model");
expect(model).toBeDefined();
expect(model?.providerID).toBe("cursor");
// Every model carries the same pinned specifier (opencode loads the
// provider package per model, so an unpinned one would resolve
// `latest` and decouple the provider from this plugin's version).
for (const m of models) {
expect(m["package"]).toBe(
`aisdk:@stablekernel/opencode-cursor@${pkgVersion}`,
);
}
});

it("honors OPENCODE_CURSOR_PROVIDER_NPM over the pinned spec", async () => {
process.env.OPENCODE_CURSOR_PROVIDER_NPM =
"file:///tmp/opencode-cursor-test-build";
const ctx = makeCtx();
await cursorV2Setup(ctx as never);
const cb = ctx.provider.transform.mock.calls[0]![0] as (editor: {
add: (input: unknown) => void;
}) => void;
const add = vi.fn();
cb({ add });
const { info, models } = add.mock.calls[0]![0] as {
info: Record<string, unknown>;
models: Array<Record<string, unknown>>;
};
expect(info["package"]).toBe("aisdk:file:///tmp/opencode-cursor-test-build");
for (const m of models) {
expect(m["package"]).toBe("aisdk:file:///tmp/opencode-cursor-test-build");
}
});

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