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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ opencode loads two things from this one package:

| opencode concept | What it loads | Export |
| --- | --- | --- |
| Plugin (`plugin` config) | auth + provider registration + dynamic model listing + a refresh tool | `@stablekernel/opencode-cursor/plugin` |
| Plugin (`plugin` config) | auth + provider registration + dynamic model listing + a refresh tool | `@stablekernel/opencode-cursor` (resolved via the package's `./server` export) |
| Provider (`provider.cursor.npm`) | a Vercel AI SDK `LanguageModelV3` that drives a local Cursor agent | `@stablekernel/opencode-cursor` (`createCursor`) |

The plugin's `config` hook registers `provider.cursor` (pointing `npm` at this package) and seeds
Expand All @@ -71,7 +71,7 @@ Add the plugin to your `opencode.json` (project or global):
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@stablekernel/opencode-cursor/plugin"]
"plugin": ["@stablekernel/opencode-cursor"]
}
```

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"types": "./dist/provider/index.d.ts",
"import": "./dist/provider/index.js"
},
"./server": {
"types": "./dist/plugin/index.d.ts",
"import": "./dist/plugin/index.js"
},
"./plugin": {
"types": "./dist/plugin/index.d.ts",
"import": "./dist/plugin/index.js"
Expand Down
24 changes: 15 additions & 9 deletions src/cursor-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,27 @@
* dependency is missing) degrades gracefully into a clear error instead of
* crashing opencode at startup.
*/
import { ensureSqliteBinding } from "./native-binding.js";

export type CursorSdkModule = typeof import("@cursor/sdk");

let cached: Promise<CursorSdkModule> | undefined;

export async function loadCursorSdk(): Promise<CursorSdkModule> {
if (!cached) {
cached = import("@cursor/sdk").catch((err: unknown) => {
// Allow a later retry if the failure was transient.
cached = undefined;
const detail = err instanceof Error ? err.message : String(err);
throw new Error(
`[opencode-cursor] Failed to load "@cursor/sdk". Make sure it is installed ` +
`(\`npm install @cursor/sdk\`). Original error: ${detail}`,
);
});
// @cursor/sdk eagerly requires sqlite3 (native addon); opencode's Bun
// install skips its build script, so repair the binding first if missing.
cached = ensureSqliteBinding()
.then(() => import("@cursor/sdk"))
.catch((err: unknown) => {
// Allow a later retry if the failure was transient.
cached = undefined;
const detail = err instanceof Error ? err.message : String(err);
throw new Error(
`[opencode-cursor] Failed to load "@cursor/sdk". Make sure it is installed ` +
`(\`npm install @cursor/sdk\`). Original error: ${detail}`,
);
});
}
return cached;
}
9 changes: 9 additions & 0 deletions src/model-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js";
import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js";
import { FALLBACK_MODELS } from "./fallback-models.js";
import { loadCursorSdk } from "./cursor-runtime.js";
import { buildModelVariants, type CursorVariant } from "./model-variants.js";

export type ModelSource = "live" | "cache" | "fallback";

Expand Down Expand Up @@ -90,6 +91,13 @@ export interface OpencodeModelConfigEntry {
reasoning: boolean;
temperature: boolean;
tool_call: boolean;
/**
* opencode model variants (thinking levels + plan mode). They MUST be seeded
* here: opencode discards the plugin `provider.models()` hook for providers
* absent from its models.dev catalog, so this config map is the only channel
* through which cursor model variants reach the picker.
*/
variants: Record<string, CursorVariant>;
}

/**
Expand All @@ -107,6 +115,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
reasoning: modelSupportsReasoning(item),
temperature: false,
tool_call: true,
variants: buildModelVariants(item),
};
}
return out;
Expand Down
32 changes: 23 additions & 9 deletions src/model-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,41 @@ export interface CursorVariant {
}

const REASONING_PARAM = /think|reason|effort/i;
const BOOLEAN_VALUES = new Set(["true", "false"]);

/**
* Derive opencode model variants from a Cursor model's parameters so the
* variant picker can expose thinking/reasoning levels and a plan-mode option.
* Each variant's object is exactly what {@link resolveControls} consumes.
* variant picker can expose thinking/reasoning levels. Each variant's object is
* exactly what {@link resolveControls} consumes. Plan mode is NOT a variant:
* opencode's plan agent (Tab) is mapped to Cursor's plan mode by the plugin's
* `chat.params` hook.
*/
export function buildModelVariants(item: ModelListItem): Record<string, CursorVariant> {
const out: Record<string, CursorVariant> = {};

for (const param of item.parameters ?? []) {
if (!REASONING_PARAM.test(param.id)) continue;
for (const { value } of param.values ?? []) {
// Key is unique across params; value object carries the param id+value.
const key = param.id.toLowerCase() === "thinking" ? value : `${param.id}-${value}`;
const values = (param.values ?? []).map((v) => v.value);
if (values.length === 0) continue;

if (values.every((v) => BOOLEAN_VALUES.has(v))) {
// Boolean toggle (e.g. thinking=["false","true"]). Literal true/false
// variant names are meaningless in the picker — surface a single
// variant named after the param that switches it on. "Off" is the
// model's default (no variant selected).
if (values.includes("true")) {
out[param.id.toLowerCase()] = { params: { [param.id]: "true" } };
}
continue;
}

for (const value of values) {
// Key by the bare value (e.g. "high"); prefix with the param id only
// when two params share a value (e.g. reasoning-low vs effort-low).
const key = out[value] === undefined ? value : `${param.id}-${value}`;
out[key] = { params: { [param.id]: value } };
}
}

// Plan mode is orthogonal to model params and never auto-signaled by opencode,
// so always offer it as a selectable variant.
out["plan"] = { mode: "plan" };

return out;
}
172 changes: 172 additions & 0 deletions src/native-binding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Self-heal for sqlite3's native binding.
*
* `@cursor/sdk` depends on `sqlite3` (a native addon). opencode installs
* plugin packages with Bun, which does not run sqlite3's `install` lifecycle
* script (`prebuild-install -r napi || node-gyp rebuild`), so the installed
* tree has **no** `node_sqlite3.node` binary and the SDK crashes at import
* with "Could not locate the bindings file".
*
* Before loading the SDK (in-process or via the Node sidecar) we check for a
* binding and, when it is missing, run sqlite3's own `prebuild-install -r napi`
* to fetch the prebuilt NAPI binary (ABI-portable across Node versions, also
* loadable by Bun). Failures degrade to a clear warning; the SDK import then
* surfaces its own error.
*/
import { execSync, spawn } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";

export type EnsureResult = "present" | "repaired" | "failed" | "not-found";

export interface EnsureOptions {
/** Override the sqlite3 package directory (tests). */
sqliteDir?: string;
/** Override the repair runner (tests). Returns true when the command succeeded. */
run?: (sqliteDir: string) => Promise<boolean>;
/** Override the warning sink (tests). */
log?: (message: string) => void;
}

/** Directories (relative to the sqlite3 package root) that may hold the binding. */
const BINDING_ROOTS = ["build", "lib/binding", "compiled"];

function hasNodeFile(dir: string, depth: number): boolean {
if (depth < 0) return false;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return false;
}
for (const entry of entries) {
const path = join(dir, entry);
if (entry.endsWith(".node")) {
try {
if (statSync(path).isFile()) return true;
} catch {
// ignore unreadable entries
}
continue;
}
try {
if (statSync(path).isDirectory() && hasNodeFile(path, depth - 1)) return true;
} catch {
// ignore unreadable entries
}
}
return false;
}

/** True when the sqlite3 package dir contains a compiled `.node` binding. */
export function hasSqliteBinding(sqliteDir: string): boolean {
return BINDING_ROOTS.some((root) => hasNodeFile(join(sqliteDir, root), 3));
}

/**
* Locate the sqlite3 package directory that `@cursor/sdk` will load, walking
* the same resolution chain (our module -> @cursor/sdk -> sqlite3).
*/
export function resolveSqliteDir(): string | undefined {
const req = createRequire(import.meta.url);
try {
const sdkPkg = req.resolve("@cursor/sdk/package.json");
return dirname(createRequire(sdkPkg).resolve("sqlite3/package.json"));
} catch {
// fall through: try resolving sqlite3 directly (hoisted installs)
}
try {
return dirname(req.resolve("sqlite3/package.json"));
} catch {
return undefined;
}
}

function detectNodeExecutable(): string {
const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
if (!isBun) return process.execPath;
// Under Bun prefer a real Node (matches the sidecar runtime); prebuild-install
// itself is plain JS, so Bun works as a last resort.
try {
const out = execSync(process.platform === "win32" ? "where node" : "command -v node", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out.split("\n")[0] || process.execPath;
} catch {
return process.execPath;
}
}

/** Default repair: run sqlite3's own `prebuild-install -r napi` in its package dir. */
async function runPrebuildInstall(sqliteDir: string): Promise<boolean> {
let bin: string;
try {
const req = createRequire(join(sqliteDir, "package.json"));
const pkgPath = req.resolve("prebuild-install/package.json");
const pkg = (await import(pkgPath, { with: { type: "json" } })) as {
default: { bin?: string | Record<string, string> };
};
const binField = pkg.default.bin;
const rel = typeof binField === "string" ? binField : binField?.["prebuild-install"];
if (!rel) return false;
bin = join(dirname(pkgPath), rel);
} catch {
return false;
}
if (!existsSync(bin)) return false;

return new Promise<boolean>((resolve) => {
const child = spawn(detectNodeExecutable(), [bin, "-r", "napi"], {
cwd: sqliteDir,
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", () => resolve(false));
child.on("exit", (code) => {
if (code !== 0 && stderr && process.env["OPENCODE_CURSOR_DEBUG"]) {
console.error(`[opencode-cursor] prebuild-install stderr: ${stderr.trim()}`);
}
resolve(code === 0);
});
});
}

let cached: Promise<EnsureResult> | undefined;

/**
* Ensure the sqlite3 native binding exists, repairing it once per process if
* needed. Never throws; "failed"/"not-found" outcomes warn and let the SDK
* import surface its own error.
*/
export function ensureSqliteBinding(options: EnsureOptions = {}): Promise<EnsureResult> {
cached ??= (async () => {
const log = options.log ?? ((message: string) => console.error(message));
const sqliteDir = options.sqliteDir ?? resolveSqliteDir();
if (!sqliteDir || !existsSync(join(sqliteDir, "package.json"))) {
return "not-found";
}
if (hasSqliteBinding(sqliteDir)) return "present";

const run = options.run ?? runPrebuildInstall;
const ok = await run(sqliteDir).catch(() => false);
if (ok && hasSqliteBinding(sqliteDir)) return "repaired";

log(
`[opencode-cursor] sqlite3 native binding is missing in ${sqliteDir} and automatic ` +
`repair failed. @cursor/sdk will not load. Fix manually with: ` +
`cd ${sqliteDir} && npx prebuild-install -r napi (or: npm rebuild sqlite3)`,
);
return "failed";
})();
return cached;
}

/** Test hook. */
export function resetNativeBinding(): void {
cached = undefined;
}
8 changes: 8 additions & 0 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,17 @@ export const CursorPlugin: Plugin = async (input) => {
// Bridge opencode's session id to the provider: it lands in
// providerOptions.cursor.sessionID, which the provider reads to pool/resume a
// Cursor agent per session (when the `session` option is enabled).
//
// Also map opencode's plan AGENT to Cursor's plan mode. This hook fires
// after opencode merges the selected variant into `output.options`, so an
// explicit mode from the `plan` variant (or model options) wins — the
// agent-based default only applies when no mode was set.
"chat.params": async (input, output) => {
if (input.model?.providerID !== PROVIDER_ID) return;
output.options = { ...(output.options ?? {}), sessionID: input.sessionID };
if (input.agent === "plan" && output.options["mode"] === undefined) {
output.options["mode"] = "plan";
}
},

tool: {
Expand Down
13 changes: 11 additions & 2 deletions src/provider/agent-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { loadCursorSdk } from "../cursor-runtime.js";
import { ensureSqliteBinding } from "../native-binding.js";
import { SidecarClient, type AgentLike } from "./sidecar-client.js";

export type { AgentLike, AgentRunLike, AgentSendOptions } from "./sidecar-client.js";
Expand Down Expand Up @@ -93,10 +94,18 @@ export function resolveSidecarScript(): string | undefined {

function sidecarBackend(nodePath: string, scriptPath: string): AgentBackend {
const client = new SidecarClient({ scriptPath, nodePath });
// The sidecar imports @cursor/sdk (which eagerly requires sqlite3's native
// binding) in the child process; repair the binding before first use.
return {
kind: "sidecar",
createAgent: (options) => client.createAgent(options),
resumeAgent: (agentId, options) => client.resumeAgent(agentId, options),
createAgent: async (options) => {
await ensureSqliteBinding();
return client.createAgent(options);
},
resumeAgent: async (agentId, options) => {
await ensureSqliteBinding();
return client.resumeAgent(agentId, options);
},
};
}

Expand Down
Loading