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
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,24 @@ Add to your `opencode.json` (or `opencode.jsonc` — both are supported):
```

The `@latest` suffix makes opencode re-resolve to the newest release on each
startup. Drop it (`"@stablekernel/opencode-cursor"`) or pin a version
(`"@stablekernel/opencode-cursor@1.2.3"`) if you prefer.
startup. In practice opencode caches the `@latest` plugin install and does **not**
auto-update it. If you see a stale-version warning from the plugin, or a version
mismatch, exit opencode and clear the cached package:

```bash
# macOS / Linux
rm -rf ~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest
# Windows
rmdir /s /q "%LocalAppData%\opencode\cache\packages\@stablekernel\opencode-cursor@latest"
```

Then restart opencode.

Drop `@latest` (`"@stablekernel/opencode-cursor"`) or pin a version
(`"@stablekernel/opencode-cursor@1.2.3"`) if you prefer deterministic installs.

The stale-version check is skipped when the `CI` or `NO_UPDATE_NOTIFIER`
environment variable is set.

The plugin injects the `provider` block automatically. If you need explicit control:

Expand Down Expand Up @@ -267,9 +283,10 @@ Override with `OPENCODE_CURSOR_SIDECAR=1` (always sidecar) or `OPENCODE_CURSOR_S
on your `PATH`. Install Node.js 22+, or force the sidecar with `OPENCODE_CURSOR_SIDECAR=1`.
- **"Running under Bun without a usable Node sidecar" warning.** Install Node.js 22+, or set
`OPENCODE_CURSOR_SIDECAR=0` to accept in-process behavior and silence the warning.
- **Plugin enabled but no `cursor` provider/models appear.** Stale opencode plugin cache. Pin an
exact version (`@stablekernel/opencode-cursor@<version>`) or delete
`~/.cache/opencode/packages/` and restart.
- **Plugin enabled but no `cursor` provider/models appear, or you see a stale-version warning.**
opencode caches the `@latest` plugin install on first use and never refreshes it.
Exit opencode, delete `~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest`
(or the pinned version directory), and restart.
- **Only the four fallback models appear.** The live catalog loads after the first authenticated
use. Restart opencode once after login, or run `cursor_refresh_models`.
- **Invalid or expired key.** Validated on first use — that's where the error surfaces.
Expand Down
23 changes: 22 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"dependencies": {
"@connectrpc/connect-node": "^2.1.2",
"@cursor/sdk": "^1.0.20",
"@opencode-ai/plugin": "^1.17.9"
"@opencode-ai/plugin": "^1.17.9",
"semver": "^7.8.4"
},
"overrides": {
"undici": "^6.24.0",
Expand All @@ -71,6 +72,7 @@
"@ai-sdk/provider": "^3.0.10",
"@opencode-ai/sdk": "^1.17.9",
"@types/node": "^26.0.0",
"@types/semver": "^7.7.1",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
Expand Down
9 changes: 9 additions & 0 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
translateMcpServers,
} from "./mcp-config.js";
import { buildCursorTools } from "./cursor-tools.js";
import { warnIfStale } from "../version-check.js";

function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
return auth?.type === "api" ? auth.key : undefined;
Expand All @@ -28,6 +29,14 @@ function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
* - `tool.cursor_refresh_models`: force-refresh the model catalog.
*/
export const CursorPlugin: Plugin = async (input) => {
// Warn if the installed plugin is behind the npm `latest` tag. The registry
// fetch is throttled to once per 24h via an on-disk cache, but while the
// cached result says the install is stale the warning prints on each
// startup. opencode freezes `@latest` plugin installs on first use, so this
// keeps users from silently running stale releases. Fire-and-forget: never
// block or fail plugin init.
void warnIfStale().catch(() => {});

// The Cursor API key resolved by opencode's auth loader, captured so the
// delegation tools (which don't receive auth directly) can reuse it. Falls
// back to the CURSOR_API_KEY env var when the loader hasn't run.
Expand Down
158 changes: 158 additions & 0 deletions src/version-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { createRequire } from "node:module";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { get } from "node:https";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import semver from "semver";

/**
* Inlined by tsup's `define` option in the published bundle (see
* tsup.config.ts). In the bundle, a relative require of `../package.json`
* would resolve inside `dist/` where no package.json exists, so the version
* must be baked in at build time. When running un-bundled (tests against
* `src/`), this stays undefined and `getLocalVersion` falls back to reading
* package.json.
*/
declare const __PKG_VERSION__: string | undefined;

const PACKAGE_NAME = "@stablekernel/opencode-cursor";
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
// Failed fetches are retried sooner than successful ones so a transient
// network error doesn't suppress the check for a full day.
const FAILURE_TTL_MS = 60 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 5000;

interface VersionCheckCache {
checkedAt: number;
latest: string | undefined;
}

function cacheDir(): string {
const base =
process.env.XDG_CACHE_HOME?.trim() ||
(homedir() ? join(homedir(), ".cache") : tmpdir());
return join(base, "opencode-cursor");
}

function cacheFile(): string {
return join(cacheDir(), "version-check.json");
}

function readCache(): VersionCheckCache | undefined {
try {
const parsed = JSON.parse(readFileSync(cacheFile(), "utf8")) as VersionCheckCache;
if (typeof parsed.checkedAt === "number") return parsed;
} catch {
// ignore
}
return undefined;
}

function writeCache(latest: string | undefined): void {
try {
mkdirSync(cacheDir(), { recursive: true });
writeFileSync(
cacheFile(),
JSON.stringify({ checkedAt: Date.now(), latest }),
"utf8",
);
} catch {
// Best-effort; never block plugin init.
}
}

function getLocalVersion(): string | undefined {
// Build-time inlined version (published bundle path).
if (typeof __PKG_VERSION__ === "string") return __PKG_VERSION__;
// Un-bundled fallback: resolve package.json relative to this source file.
try {
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version: string };
return pkg.version;
} catch {
return undefined;
}
}

function fetchLatestVersion(): Promise<string | undefined> {
return new Promise((resolve) => {
const req = get(
REGISTRY_URL,
{ headers: { Accept: "application/json", Connection: "close" } },
(res) => {
if (res.statusCode !== 200) {
res.resume();
resolve(undefined);
return;
}
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk: string) => {
body += chunk;
});
res.on("end", () => {
try {
const parsed = JSON.parse(body) as { version?: string };
resolve(parsed.version);
} catch {
resolve(undefined);
}
});
res.on("error", () => resolve(undefined));
},
);
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
req.destroy();
resolve(undefined);
});
req.on("error", () => resolve(undefined));
});
}

/** Return the cached latest version if fresh, else fetch from npm. */
async function getLatestVersion(): Promise<string | undefined> {
const cached = readCache();
if (cached) {
// Successful lookups are trusted for 24h; failures only briefly.
const ttl = cached.latest ? CHECK_INTERVAL_MS : FAILURE_TTL_MS;
if (Date.now() - cached.checkedAt < ttl) return cached.latest;
}
const latest = await fetchLatestVersion();
writeCache(latest);
return latest;
}

/**
* Print a warning when this installed plugin is older than the registry's
* `latest` tag. opencode resolves `@latest` once and then never reinstalls
* the plugin, so users can silently stay on old versions. This surfaces the
* staleness with actionable instructions.
*
* The registry fetch is throttled to once per 24h via an on-disk cache;
* while the cached result says the install is stale, the warning prints on
* each startup until the user upgrades.
*
* Set CI or NO_UPDATE_NOTIFIER to skip the check entirely.
*/
export async function warnIfStale(): Promise<void> {
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return;

const local = getLocalVersion();
if (!local || !semver.valid(local)) return;
const latest = await getLatestVersion();
if (!latest || !semver.valid(latest)) return;
if (!semver.gt(latest, local)) return;

const removeCommand = process.platform === "win32"
? `rmdir /s /q "%LocalAppData%\\opencode\\cache\\packages\\@stablekernel\\opencode-cursor@latest"`
: `rm -rf ~/.cache/opencode/packages/${PACKAGE_NAME}@latest`;

console.warn(
`\n⚠️ @stablekernel/opencode-cursor update available: v${local} → v${latest}.\n` +
` opencode caches the @latest plugin on first install and never auto-updates it.\n` +
` To upgrade, exit opencode, run:\n\n` +
` ${removeCommand}\n\n` +
` then restart opencode.\n`,
);
}
Loading