Skip to content

Commit 2e98307

Browse files
Merge pull request #8 from stablekernel/fix/opencode-integration
fix: opencode plugin loading, native binding self-heal, variants, plan mode
2 parents f759fd4 + ed794fb commit 2e98307

16 files changed

Lines changed: 580 additions & 60 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ opencode loads two things from this one package:
5252

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

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

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
"types": "./dist/provider/index.d.ts",
3838
"import": "./dist/provider/index.js"
3939
},
40+
"./server": {
41+
"types": "./dist/plugin/index.d.ts",
42+
"import": "./dist/plugin/index.js"
43+
},
4044
"./plugin": {
4145
"types": "./dist/plugin/index.d.ts",
4246
"import": "./dist/plugin/index.js"

src/cursor-runtime.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,27 @@
66
* dependency is missing) degrades gracefully into a clear error instead of
77
* crashing opencode at startup.
88
*/
9+
import { ensureSqliteBinding } from "./native-binding.js";
10+
911
export type CursorSdkModule = typeof import("@cursor/sdk");
1012

1113
let cached: Promise<CursorSdkModule> | undefined;
1214

1315
export async function loadCursorSdk(): Promise<CursorSdkModule> {
1416
if (!cached) {
15-
cached = import("@cursor/sdk").catch((err: unknown) => {
16-
// Allow a later retry if the failure was transient.
17-
cached = undefined;
18-
const detail = err instanceof Error ? err.message : String(err);
19-
throw new Error(
20-
`[opencode-cursor] Failed to load "@cursor/sdk". Make sure it is installed ` +
21-
`(\`npm install @cursor/sdk\`). Original error: ${detail}`,
22-
);
23-
});
17+
// @cursor/sdk eagerly requires sqlite3 (native addon); opencode's Bun
18+
// install skips its build script, so repair the binding first if missing.
19+
cached = ensureSqliteBinding()
20+
.then(() => import("@cursor/sdk"))
21+
.catch((err: unknown) => {
22+
// Allow a later retry if the failure was transient.
23+
cached = undefined;
24+
const detail = err instanceof Error ? err.message : String(err);
25+
throw new Error(
26+
`[opencode-cursor] Failed to load "@cursor/sdk". Make sure it is installed ` +
27+
`(\`npm install @cursor/sdk\`). Original error: ${detail}`,
28+
);
29+
});
2430
}
2531
return cached;
2632
}

src/model-discovery.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js";
33
import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js";
44
import { FALLBACK_MODELS } from "./fallback-models.js";
55
import { loadCursorSdk } from "./cursor-runtime.js";
6+
import { buildModelVariants, type CursorVariant } from "./model-variants.js";
67

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

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

95103
/**
@@ -107,6 +115,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
107115
reasoning: modelSupportsReasoning(item),
108116
temperature: false,
109117
tool_call: true,
118+
variants: buildModelVariants(item),
110119
};
111120
}
112121
return out;

src/model-variants.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,41 @@ export interface CursorVariant {
1111
}
1212

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

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

2326
for (const param of item.parameters ?? []) {
2427
if (!REASONING_PARAM.test(param.id)) continue;
25-
for (const { value } of param.values ?? []) {
26-
// Key is unique across params; value object carries the param id+value.
27-
const key = param.id.toLowerCase() === "thinking" ? value : `${param.id}-${value}`;
28+
const values = (param.values ?? []).map((v) => v.value);
29+
if (values.length === 0) continue;
30+
31+
if (values.every((v) => BOOLEAN_VALUES.has(v))) {
32+
// Boolean toggle (e.g. thinking=["false","true"]). Literal true/false
33+
// variant names are meaningless in the picker — surface a single
34+
// variant named after the param that switches it on. "Off" is the
35+
// model's default (no variant selected).
36+
if (values.includes("true")) {
37+
out[param.id.toLowerCase()] = { params: { [param.id]: "true" } };
38+
}
39+
continue;
40+
}
41+
42+
for (const value of values) {
43+
// Key by the bare value (e.g. "high"); prefix with the param id only
44+
// when two params share a value (e.g. reasoning-low vs effort-low).
45+
const key = out[value] === undefined ? value : `${param.id}-${value}`;
2846
out[key] = { params: { [param.id]: value } };
2947
}
3048
}
3149

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

src/native-binding.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Self-heal for sqlite3's native binding.
3+
*
4+
* `@cursor/sdk` depends on `sqlite3` (a native addon). opencode installs
5+
* plugin packages with Bun, which does not run sqlite3's `install` lifecycle
6+
* script (`prebuild-install -r napi || node-gyp rebuild`), so the installed
7+
* tree has **no** `node_sqlite3.node` binary and the SDK crashes at import
8+
* with "Could not locate the bindings file".
9+
*
10+
* Before loading the SDK (in-process or via the Node sidecar) we check for a
11+
* binding and, when it is missing, run sqlite3's own `prebuild-install -r napi`
12+
* to fetch the prebuilt NAPI binary (ABI-portable across Node versions, also
13+
* loadable by Bun). Failures degrade to a clear warning; the SDK import then
14+
* surfaces its own error.
15+
*/
16+
import { execSync, spawn } from "node:child_process";
17+
import { existsSync, readdirSync, statSync } from "node:fs";
18+
import { createRequire } from "node:module";
19+
import { dirname, join } from "node:path";
20+
21+
export type EnsureResult = "present" | "repaired" | "failed" | "not-found";
22+
23+
export interface EnsureOptions {
24+
/** Override the sqlite3 package directory (tests). */
25+
sqliteDir?: string;
26+
/** Override the repair runner (tests). Returns true when the command succeeded. */
27+
run?: (sqliteDir: string) => Promise<boolean>;
28+
/** Override the warning sink (tests). */
29+
log?: (message: string) => void;
30+
}
31+
32+
/** Directories (relative to the sqlite3 package root) that may hold the binding. */
33+
const BINDING_ROOTS = ["build", "lib/binding", "compiled"];
34+
35+
function hasNodeFile(dir: string, depth: number): boolean {
36+
if (depth < 0) return false;
37+
let entries: string[];
38+
try {
39+
entries = readdirSync(dir);
40+
} catch {
41+
return false;
42+
}
43+
for (const entry of entries) {
44+
const path = join(dir, entry);
45+
if (entry.endsWith(".node")) {
46+
try {
47+
if (statSync(path).isFile()) return true;
48+
} catch {
49+
// ignore unreadable entries
50+
}
51+
continue;
52+
}
53+
try {
54+
if (statSync(path).isDirectory() && hasNodeFile(path, depth - 1)) return true;
55+
} catch {
56+
// ignore unreadable entries
57+
}
58+
}
59+
return false;
60+
}
61+
62+
/** True when the sqlite3 package dir contains a compiled `.node` binding. */
63+
export function hasSqliteBinding(sqliteDir: string): boolean {
64+
return BINDING_ROOTS.some((root) => hasNodeFile(join(sqliteDir, root), 3));
65+
}
66+
67+
/**
68+
* Locate the sqlite3 package directory that `@cursor/sdk` will load, walking
69+
* the same resolution chain (our module -> @cursor/sdk -> sqlite3).
70+
*/
71+
export function resolveSqliteDir(): string | undefined {
72+
const req = createRequire(import.meta.url);
73+
try {
74+
const sdkPkg = req.resolve("@cursor/sdk/package.json");
75+
return dirname(createRequire(sdkPkg).resolve("sqlite3/package.json"));
76+
} catch {
77+
// fall through: try resolving sqlite3 directly (hoisted installs)
78+
}
79+
try {
80+
return dirname(req.resolve("sqlite3/package.json"));
81+
} catch {
82+
return undefined;
83+
}
84+
}
85+
86+
function detectNodeExecutable(): string {
87+
const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
88+
if (!isBun) return process.execPath;
89+
// Under Bun prefer a real Node (matches the sidecar runtime); prebuild-install
90+
// itself is plain JS, so Bun works as a last resort.
91+
try {
92+
const out = execSync(process.platform === "win32" ? "where node" : "command -v node", {
93+
encoding: "utf8",
94+
stdio: ["ignore", "pipe", "ignore"],
95+
}).trim();
96+
return out.split("\n")[0] || process.execPath;
97+
} catch {
98+
return process.execPath;
99+
}
100+
}
101+
102+
/** Default repair: run sqlite3's own `prebuild-install -r napi` in its package dir. */
103+
async function runPrebuildInstall(sqliteDir: string): Promise<boolean> {
104+
let bin: string;
105+
try {
106+
const req = createRequire(join(sqliteDir, "package.json"));
107+
const pkgPath = req.resolve("prebuild-install/package.json");
108+
const pkg = (await import(pkgPath, { with: { type: "json" } })) as {
109+
default: { bin?: string | Record<string, string> };
110+
};
111+
const binField = pkg.default.bin;
112+
const rel = typeof binField === "string" ? binField : binField?.["prebuild-install"];
113+
if (!rel) return false;
114+
bin = join(dirname(pkgPath), rel);
115+
} catch {
116+
return false;
117+
}
118+
if (!existsSync(bin)) return false;
119+
120+
return new Promise<boolean>((resolve) => {
121+
const child = spawn(detectNodeExecutable(), [bin, "-r", "napi"], {
122+
cwd: sqliteDir,
123+
stdio: ["ignore", "ignore", "pipe"],
124+
});
125+
let stderr = "";
126+
child.stderr?.on("data", (chunk: Buffer) => {
127+
stderr += chunk.toString();
128+
});
129+
child.on("error", () => resolve(false));
130+
child.on("exit", (code) => {
131+
if (code !== 0 && stderr && process.env["OPENCODE_CURSOR_DEBUG"]) {
132+
console.error(`[opencode-cursor] prebuild-install stderr: ${stderr.trim()}`);
133+
}
134+
resolve(code === 0);
135+
});
136+
});
137+
}
138+
139+
let cached: Promise<EnsureResult> | undefined;
140+
141+
/**
142+
* Ensure the sqlite3 native binding exists, repairing it once per process if
143+
* needed. Never throws; "failed"/"not-found" outcomes warn and let the SDK
144+
* import surface its own error.
145+
*/
146+
export function ensureSqliteBinding(options: EnsureOptions = {}): Promise<EnsureResult> {
147+
cached ??= (async () => {
148+
const log = options.log ?? ((message: string) => console.error(message));
149+
const sqliteDir = options.sqliteDir ?? resolveSqliteDir();
150+
if (!sqliteDir || !existsSync(join(sqliteDir, "package.json"))) {
151+
return "not-found";
152+
}
153+
if (hasSqliteBinding(sqliteDir)) return "present";
154+
155+
const run = options.run ?? runPrebuildInstall;
156+
const ok = await run(sqliteDir).catch(() => false);
157+
if (ok && hasSqliteBinding(sqliteDir)) return "repaired";
158+
159+
log(
160+
`[opencode-cursor] sqlite3 native binding is missing in ${sqliteDir} and automatic ` +
161+
`repair failed. @cursor/sdk will not load. Fix manually with: ` +
162+
`cd ${sqliteDir} && npx prebuild-install -r napi (or: npm rebuild sqlite3)`,
163+
);
164+
return "failed";
165+
})();
166+
return cached;
167+
}
168+
169+
/** Test hook. */
170+
export function resetNativeBinding(): void {
171+
cached = undefined;
172+
}

src/plugin/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,17 @@ export const CursorPlugin: Plugin = async (input) => {
9393
// Bridge opencode's session id to the provider: it lands in
9494
// providerOptions.cursor.sessionID, which the provider reads to pool/resume a
9595
// Cursor agent per session (when the `session` option is enabled).
96+
//
97+
// Also map opencode's plan AGENT to Cursor's plan mode. This hook fires
98+
// after opencode merges the selected variant into `output.options`, so an
99+
// explicit mode from the `plan` variant (or model options) wins — the
100+
// agent-based default only applies when no mode was set.
96101
"chat.params": async (input, output) => {
97102
if (input.model?.providerID !== PROVIDER_ID) return;
98103
output.options = { ...(output.options ?? {}), sessionID: input.sessionID };
104+
if (input.agent === "plan" && output.options["mode"] === undefined) {
105+
output.options["mode"] = "plan";
106+
}
99107
},
100108

101109
tool: {

src/provider/agent-backend.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { execSync } from "node:child_process";
1414
import { existsSync } from "node:fs";
1515
import { fileURLToPath } from "node:url";
1616
import { loadCursorSdk } from "../cursor-runtime.js";
17+
import { ensureSqliteBinding } from "../native-binding.js";
1718
import { SidecarClient, type AgentLike } from "./sidecar-client.js";
1819

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

9495
function sidecarBackend(nodePath: string, scriptPath: string): AgentBackend {
9596
const client = new SidecarClient({ scriptPath, nodePath });
97+
// The sidecar imports @cursor/sdk (which eagerly requires sqlite3's native
98+
// binding) in the child process; repair the binding before first use.
9699
return {
97100
kind: "sidecar",
98-
createAgent: (options) => client.createAgent(options),
99-
resumeAgent: (agentId, options) => client.resumeAgent(agentId, options),
101+
createAgent: async (options) => {
102+
await ensureSqliteBinding();
103+
return client.createAgent(options);
104+
},
105+
resumeAgent: async (agentId, options) => {
106+
await ensureSqliteBinding();
107+
return client.resumeAgent(agentId, options);
108+
},
100109
};
101110
}
102111

0 commit comments

Comments
 (0)