Skip to content
Open
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
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,21 @@ 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 "%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 plugin injects the `provider` block automatically. If you need explicit control:

Expand Down Expand Up @@ -267,9 +280,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 @@ -56,7 +56,8 @@
},
"dependencies": {
"@cursor/sdk": "^1.0.18",
"@opencode-ai/plugin": "^1.17.7"
"@opencode-ai/plugin": "^1.17.7",
"semver": "^7.8.4"
},
"overrides": {
"undici": "^6.24.0",
Expand All @@ -70,6 +71,7 @@
"@ai-sdk/provider": "^3.0.10",
"@opencode-ai/sdk": "^1.17.1",
"@types/node": "^25.9.2",
"@types/semver": "^7.7.1",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
Expand Down
6 changes: 6 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,11 @@ function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
* - `tool.cursor_refresh_models`: force-refresh the model catalog.
*/
export const CursorPlugin: Plugin = async (input) => {
// Warn once per day if the installed plugin is behind the npm `latest` tag.
// opencode freezes `@latest` plugin installs on first use, so this keeps
// users from silently running stale releases.
void warnIfStale();

// 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
130 changes: 130 additions & 0 deletions src/version-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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";

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;
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 {
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) => {
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 && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) {
return cached.latest;
}
const latest = await fetchLatestVersion();
writeCache(latest);
return latest;
}

/**
* Print a one-time 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 check is throttled to once per 24h so plugin startup stays fast and
* doesn't hit the registry repeatedly.
*/
export async function warnIfStale(): Promise<void> {
const local = getLocalVersion();
if (!local) return;
const latest = await getLatestVersion();
if (!latest) return;
if (!semver.gt(latest, local)) return;

const cacheSpec = process.platform === "win32"
? `%LocalAppData%\\opencode\\cache\\packages\\${PACKAGE_NAME}@latest`
: `~/.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` +
` rm -rf ${cacheSpec}\n\n` +
` then restart opencode.\n`,
);
}
114 changes: 114 additions & 0 deletions test/version-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { join } from "node:path";

const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});

const fsState: Record<string, string> = {};
let lastRequestUrl: string | undefined;
let requestHandlers: {
onResponse?: (res: MockResponse) => void;
onError?: () => void;
onTimeout?: () => void;
} = {};

class MockResponse extends EventEmitter {
setEncoding = vi.fn();
statusCode = 200;
}

class MockRequest extends EventEmitter {
setTimeout = vi.fn((_ms: number, cb: () => void) => {
requestHandlers.onTimeout = cb;
});
destroy = vi.fn();
}

const get = vi.fn((_url: string, _opts: object, cb: (res: MockResponse) => void) => {
const req = new MockRequest();
requestHandlers.onResponse = (res) => cb(res);
return req;
});

vi.mock("node:https", () => ({ get }));

vi.mock("node:fs", () => ({
mkdirSync: vi.fn((_path: string, _opts?: object) => {}),
readFileSync: vi.fn((path: string, _encoding: string) => {
if (fsState[path]) return fsState[path];
throw new Error("ENOENT");
}),
writeFileSync: vi.fn((path: string, data: string, _encoding: string) => {
fsState[path] = data;
}),
rmSync: vi.fn(),
}));

const { warnIfStale } = await import("../src/version-check.js");

function respondWith(version: string | undefined) {
const res = new MockResponse();
process.nextTick(() => {
if (version === undefined) {
res.emit("end");
} else {
res.emit("data", JSON.stringify({ version }));
res.emit("end");
}
});
requestHandlers.onResponse?.(res);
}

describe("warnIfStale", () => {
beforeEach(() => {
consoleWarn.mockClear();
Object.keys(fsState).forEach((k) => delete fsState[k]);
lastRequestUrl = undefined;
requestHandlers = {};
get.mockClear();
});

afterEach(() => {
consoleWarn.mockReset();
});

it("warns when registry latest is newer than local version", async () => {
const promise = warnIfStale();
respondWith("0.5.0");
await promise;
expect(consoleWarn).toHaveBeenCalledOnce();
expect(String(consoleWarn.mock.calls[0]?.[0])).toContain("0.5.0");
});

it("does not warn when local version equals latest", async () => {
const promise = warnIfStale();
respondWith("0.4.1");
await promise;
expect(consoleWarn).not.toHaveBeenCalled();
});

it("does not warn when local version is newer", async () => {
const promise = warnIfStale();
respondWith("0.3.0");
await promise;
expect(consoleWarn).not.toHaveBeenCalled();
});

it("does not warn when registry fetch fails", async () => {
const promise = warnIfStale();
const res = new MockResponse();
requestHandlers.onResponse?.(res);
process.nextTick(() => res.emit("error", new Error("network")));
await promise;
expect(consoleWarn).not.toHaveBeenCalled();
});

it("uses cached result within 24h", async () => {
// Seed cache with latest=9.9.9
fsState[join(process.env.HOME || "/tmp", ".cache/opencode-cursor/version-check.json")] =
JSON.stringify({ checkedAt: Date.now(), latest: "9.9.9" });
await warnIfStale();
expect(get).not.toHaveBeenCalled();
expect(consoleWarn).toHaveBeenCalledOnce();
});
});