Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- OpenCode 2.0 loads the AI SDK provider at the installed plugin's version instead of the latest npm release ([#41](https://github.com/oakimov/cursor-opencode-provider/pull/41) by [@nkoynov](https://github.com/nkoynov))

## [0.8.0] - 2026-10-04

### Changed
Expand Down
2 changes: 1 addition & 1 deletion docs/opencode-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Synced models set `time.released` to `0` (Cursor AvailableModels does not expose

Pin a version if you want: `"cursor-opencode-provider@0.4.1/plugin/opencode2"`.

OpenCode 2.0 installs the published package into its host cache and loads the AI SDK entry from there (`aisdk:cursor-opencode-provider`). No extra env vars are required.
OpenCode 2.0 installs the published package into its host cache and loads the AI SDK entry from there (`aisdk:cursor-opencode-provider@<version>`, pinned to the version of the plugin that registered it, so a pinned plugin never runs a newer language model). No extra env vars are required.

## Config isolation

Expand Down
40 changes: 34 additions & 6 deletions src/opencode2/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs"
import { pathToFileURL } from "node:url"
import { CURSOR_PROVIDER_ID } from "../shared.js"
import { CURSOR_WIRE_MODEL_ID_KEY, type ModelInfo } from "../models.js"
Expand All @@ -17,14 +18,18 @@ import type { ConnectionInfo, ModelVariantInfo, ProviderEditor } from "./types.j
/** Integration id owning Cursor credentials. Matches the provider id. */
export const CURSOR_INTEGRATION_ID = CURSOR_PROVIDER_ID

const CURSOR_PACKAGE_NAME = "cursor-opencode-provider"

/**
* `aisdk:` selects OpenCode 2.0's AI SDK path, which is what surfaces the
* `aisdk.hook("sdk")` / `("language")` extension points we supply the provider
* through. The suffix is this package's npm name so the host's built-in
* fallback can still resolve it if our own hook is ever bypassed — that
* fallback runs `npm.add(pkg)` against the *published* registry into
* `<host-cache>/packages/<pkg>/node_modules/<pkg>`, ignoring any local
* `file://` plugin path this process was loaded from.
* through. The suffix is this package's npm name for the host's built-in
* fallback, which runs `npm.add(pkg)` against the *published* registry,
* ignoring any local `file://` plugin path this process was loaded from. On
* OpenCode 2.0.22 that fallback runs before plugin `sdk` hooks
* (anomalyco/opencode#42788), so the language model always comes from this
* spec. It is pinned to this package's own version: OpenCode resolves a bare
* name as `@latest`, which would load whatever was published last.
*
* `CURSOR_OPENCODE2_DEV_ENTRY` overrides the suffix with an `aisdk:file://…`
* spec instead, pointed at a local built entry file (e.g. `dist/index.js`,
Expand All @@ -35,7 +40,30 @@ export const CURSOR_INTEGRATION_ID = CURSOR_PROVIDER_ID
*/
export const CURSOR_AISDK_PACKAGE = process.env.CURSOR_OPENCODE2_DEV_ENTRY
? `aisdk:${pathToFileURL(process.env.CURSOR_OPENCODE2_DEV_ENTRY).href}`
: "aisdk:cursor-opencode-provider"
: `aisdk:${ownPackageSpec()}`

/**
* `cursor-opencode-provider@<version>` from this package's own package.json
* (two levels up from both `src/opencode2/` and `dist/opencode2/`), or the
* bare name when that file is missing or does not describe this package.
*/
export function ownPackageSpec(
packageJsonUrl: URL = new URL("../../package.json", import.meta.url),
): string {
try {
const pkg = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as { name?: unknown; version?: unknown }
if (
pkg.name === CURSOR_PACKAGE_NAME
&& typeof pkg.version === "string"
&& /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(pkg.version)
) {
return `${CURSOR_PACKAGE_NAME}@${pkg.version}`
}
} catch {
// Bundled or relocated copies have no package.json beside them.
}
return CURSOR_PACKAGE_NAME
}

/**
* Plain-object `Model.Info` equivalent used by `ctx.provider.transform`.
Expand Down
25 changes: 24 additions & 1 deletion test/plugin-opencode2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import plugin from "../src/plugin-opencode2.js"
import { CursorPlugin } from "../src/plugin.js"
import { applyCursorProviderInventory, CURSOR_AISDK_PACKAGE } from "../src/opencode2/catalog.js"
import { applyCursorProviderInventory, CURSOR_AISDK_PACKAGE, ownPackageSpec } from "../src/opencode2/catalog.js"
import {
accessTokenFromCredential,
applyCursorIntegration,
Expand Down Expand Up @@ -159,6 +159,29 @@ describe("opencode2 provider inventory", () => {
expect(provider!.activation).toBe("enabled")
})

test("pins the aisdk package to this package's own version", () => {
// A bare name is resolved as `@latest`, so the host would load a newer
// language model than the plugin the user pinned.
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
if (!process.env.CURSOR_OPENCODE2_DEV_ENTRY) {
expect(CURSOR_AISDK_PACKAGE).toBe(`aisdk:cursor-opencode-provider@${pkg.version}`)
}
expect(ownPackageSpec()).toBe(`cursor-opencode-provider@${pkg.version}`)
})

test("falls back to the bare package name without a matching package.json", () => {
const dir = mkdtempSync(join(tmpdir(), "cursor-pin-"))
try {
expect(ownPackageSpec(new URL(`file://${dir}/missing.json`))).toBe("cursor-opencode-provider")
writeFileSync(join(dir, "other.json"), JSON.stringify({ name: "some-bundle", version: "1.2.3" }))
expect(ownPackageSpec(new URL(`file://${dir}/other.json`))).toBe("cursor-opencode-provider")
writeFileSync(join(dir, "range.json"), JSON.stringify({ name: "cursor-opencode-provider", version: "^0.8.0" }))
expect(ownPackageSpec(new URL(`file://${dir}/range.json`))).toBe("cursor-opencode-provider")
} finally {
rmSync(dir, { recursive: true, force: true })
}
})

test("maps a model into the 2.0 shape", () => {
const { editor, models } = fakeProviderEditor()
applyCursorProviderInventory(editor, [baseModel])
Expand Down
Loading