cursor-route 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +42 -4
- package/SECURITY.md +6 -5
- package/SUPPORT.md +1 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/opencode.js +78 -0
- package/dist/cli.js +27 -8
- package/dist/config.js +4 -3
- package/dist/health.js +1 -1
- package/dist/jobs.js +21 -2
- package/dist/zen-free.js +237 -0
- package/docs/briefs/WORKING.md +11 -4
- package/llms.txt +8 -4
- package/package.json +3 -2
- package/skills/route-orch/SKILL.md +22 -7
- package/src/adapters/index.ts +2 -0
- package/src/adapters/opencode.test.ts +297 -0
- package/src/adapters/opencode.ts +85 -0
- package/src/adapters/types.ts +1 -1
- package/src/cli.test.ts +28 -3
- package/src/cli.ts +25 -7
- package/src/config.ts +14 -6
- package/src/health.ts +1 -1
- package/src/jobs.ts +23 -6
- package/src/zen-free.test.ts +137 -0
- package/src/zen-free.ts +258 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { opencodeAdapter } from "./opencode.ts";
|
|
6
|
+
import { startJob } from "../jobs.ts";
|
|
7
|
+
import {
|
|
8
|
+
openCodeModel,
|
|
9
|
+
assertOpenCodeModel,
|
|
10
|
+
OPENCODE_DEFAULT_MODEL,
|
|
11
|
+
} from "../config.ts";
|
|
12
|
+
|
|
13
|
+
describe("openCodeModel", () => {
|
|
14
|
+
test("defaults to live Zen pick (Ox Alpha fallback when offline)", () => {
|
|
15
|
+
const prev = {
|
|
16
|
+
model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
|
|
17
|
+
offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
|
|
18
|
+
cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
|
|
19
|
+
json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
|
|
20
|
+
refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
|
|
21
|
+
};
|
|
22
|
+
const cache = join(tmpdir(), `cr-oc-model-${process.pid}.json`);
|
|
23
|
+
delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
24
|
+
delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
25
|
+
process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
|
|
26
|
+
process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = cache;
|
|
27
|
+
process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
|
|
28
|
+
try {
|
|
29
|
+
expect(openCodeModel()).toBe("opencode/x-preview-f-free");
|
|
30
|
+
expect(openCodeModel("")).toBe(OPENCODE_DEFAULT_MODEL);
|
|
31
|
+
expect(openCodeModel("free")).toBe("opencode/x-preview-f-free");
|
|
32
|
+
} finally {
|
|
33
|
+
if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
34
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
|
|
35
|
+
if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_ZEN_OFFLINE;
|
|
36
|
+
else process.env.CURSOR_ROUTE_ZEN_OFFLINE = prev.offline;
|
|
37
|
+
if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_ZEN_CACHE_PATH;
|
|
38
|
+
else process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = prev.cache;
|
|
39
|
+
if (prev.json === undefined) delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
40
|
+
else process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = prev.json;
|
|
41
|
+
if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_ZEN_REFRESH;
|
|
42
|
+
else process.env.CURSOR_ROUTE_ZEN_REFRESH = prev.refresh;
|
|
43
|
+
rmSync(cache, { force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("env override wins for empty/free", () => {
|
|
48
|
+
const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
49
|
+
process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
|
|
50
|
+
try {
|
|
51
|
+
expect(openCodeModel()).toBe("opencode/hy3-free");
|
|
52
|
+
expect(openCodeModel("free")).toBe("opencode/hy3-free");
|
|
53
|
+
} finally {
|
|
54
|
+
if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
55
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("explicit provider/model wins over env", () => {
|
|
60
|
+
const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
61
|
+
process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
|
|
62
|
+
try {
|
|
63
|
+
expect(openCodeModel("opencode/mimo-v2.5-free")).toBe("opencode/mimo-v2.5-free");
|
|
64
|
+
} finally {
|
|
65
|
+
if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
66
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("accepts OpenRouter-style extra slash and :free", () => {
|
|
71
|
+
expect(assertOpenCodeModel("openrouter/qwen/qwen3-coder:free")).toBe(
|
|
72
|
+
"openrouter/qwen/qwen3-coder:free",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rejects injection / flash|pro aliases", () => {
|
|
77
|
+
expect(() => openCodeModel("flash")).toThrow(/provider\/model/);
|
|
78
|
+
expect(() => openCodeModel("x\n--evil")).toThrow(/provider\/model/);
|
|
79
|
+
expect(() => openCodeModel("opencode/big pickle")).toThrow(/provider\/model/);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("opencode adapter", () => {
|
|
84
|
+
const makeFake = (suffix: string) => {
|
|
85
|
+
const dir = join(tmpdir(), `cr-oc-${process.pid}-${suffix}`);
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
const bin = join(dir, "opencode");
|
|
88
|
+
writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
|
|
89
|
+
chmodSync(bin, 0o755);
|
|
90
|
+
return { dir, bin };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const withEnv = (patch: Record<string, string | undefined>, fn: () => void) => {
|
|
94
|
+
const prev = new Map<string, string | undefined>();
|
|
95
|
+
for (const k of Object.keys(patch)) {
|
|
96
|
+
prev.set(k, process.env[k]);
|
|
97
|
+
const v = patch[k];
|
|
98
|
+
if (v === undefined) delete process.env[k];
|
|
99
|
+
else process.env[k] = v;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
fn();
|
|
103
|
+
} finally {
|
|
104
|
+
for (const [k, v] of prev) {
|
|
105
|
+
if (v === undefined) delete process.env[k];
|
|
106
|
+
else process.env[k] = v;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
test("health: no binary → not ok, install hint names npm package", () => {
|
|
112
|
+
withEnv(
|
|
113
|
+
{ CURSOR_ROUTE_OPENCODE_BIN: join(tmpdir(), `missing-oc-${process.pid}`) },
|
|
114
|
+
() => {
|
|
115
|
+
const h = opencodeAdapter.health();
|
|
116
|
+
expect(h.ok).toBe(false);
|
|
117
|
+
expect(h.detail).toMatch(/npm i -g opencode-ai/);
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("health: binary → ok (auth at first start)", () => {
|
|
123
|
+
const { dir, bin } = makeFake("ok");
|
|
124
|
+
withEnv(
|
|
125
|
+
{
|
|
126
|
+
CURSOR_ROUTE_OPENCODE_BIN: bin,
|
|
127
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "missing-cache.json"),
|
|
128
|
+
},
|
|
129
|
+
() => {
|
|
130
|
+
const h = opencodeAdapter.health();
|
|
131
|
+
expect(h.ok).toBe(true);
|
|
132
|
+
expect(h.binary).toBe(bin);
|
|
133
|
+
expect(h.detail).toContain("live Zen pick");
|
|
134
|
+
expect(h.detail).toContain("opencode/x-preview-f-free");
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
rmSync(dir, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("buildLaunch: default free model + --auto; prompt via cat", () => {
|
|
141
|
+
const { dir, bin } = makeFake("launch");
|
|
142
|
+
const promptFile = join(dir, "job.prompt");
|
|
143
|
+
writeFileSync(promptFile, "ping");
|
|
144
|
+
withEnv(
|
|
145
|
+
{
|
|
146
|
+
CURSOR_ROUTE_OPENCODE_BIN: bin,
|
|
147
|
+
CURSOR_ROUTE_OPENCODE_MODEL: undefined,
|
|
148
|
+
CURSOR_ROUTE_ASK: undefined,
|
|
149
|
+
CURSOR_ROUTE_ZEN_OFFLINE: "1",
|
|
150
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
|
|
151
|
+
CURSOR_ROUTE_ZEN_REFRESH: "1",
|
|
152
|
+
CURSOR_ROUTE_ZEN_CATALOG_JSON: undefined,
|
|
153
|
+
},
|
|
154
|
+
() => {
|
|
155
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
156
|
+
promptFile,
|
|
157
|
+
cwd: dir,
|
|
158
|
+
alwaysApprove: true,
|
|
159
|
+
});
|
|
160
|
+
expect(plan.command).toContain("run");
|
|
161
|
+
expect(plan.command).toContain("--auto");
|
|
162
|
+
expect(plan.command).toContain("opencode/x-preview-f-free");
|
|
163
|
+
expect(plan.command).toContain("$(cat");
|
|
164
|
+
expect(plan.command).toContain("--dir");
|
|
165
|
+
expect(plan.alwaysApprove).toBe(true);
|
|
166
|
+
expect(plan.command).not.toContain("npx");
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("buildLaunch: alwaysApprove false (--ask) omits --auto", () => {
|
|
173
|
+
const { dir, bin } = makeFake("ask");
|
|
174
|
+
const promptFile = join(dir, "job.prompt");
|
|
175
|
+
writeFileSync(promptFile, "ping");
|
|
176
|
+
withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin }, () => {
|
|
177
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
178
|
+
promptFile,
|
|
179
|
+
cwd: dir,
|
|
180
|
+
alwaysApprove: false,
|
|
181
|
+
modelId: "opencode/hy3-free",
|
|
182
|
+
});
|
|
183
|
+
expect(plan.command).toContain("opencode/hy3-free");
|
|
184
|
+
expect(plan.command).not.toContain("--auto");
|
|
185
|
+
expect(plan.alwaysApprove).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
rmSync(dir, { recursive: true, force: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("buildLaunch: CURSOR_ROUTE_ASK=1 opts out even when alwaysApprove true", () => {
|
|
191
|
+
const { dir, bin } = makeFake("ask-env");
|
|
192
|
+
const promptFile = join(dir, "job.prompt");
|
|
193
|
+
writeFileSync(promptFile, "ping");
|
|
194
|
+
withEnv(
|
|
195
|
+
{
|
|
196
|
+
CURSOR_ROUTE_OPENCODE_BIN: bin,
|
|
197
|
+
CURSOR_ROUTE_ASK: "1",
|
|
198
|
+
CURSOR_ROUTE_ZEN_OFFLINE: "1",
|
|
199
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
|
|
200
|
+
CURSOR_ROUTE_ZEN_REFRESH: "1",
|
|
201
|
+
},
|
|
202
|
+
() => {
|
|
203
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
204
|
+
promptFile,
|
|
205
|
+
cwd: dir,
|
|
206
|
+
alwaysApprove: true,
|
|
207
|
+
});
|
|
208
|
+
expect(plan.command).not.toContain("--auto");
|
|
209
|
+
expect(plan.alwaysApprove).toBe(false);
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
rmSync(dir, { recursive: true, force: true });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("buildLaunch: missing binary falls back to plain `opencode` for dry-run", () => {
|
|
216
|
+
const { dir } = makeFake("nobin");
|
|
217
|
+
const promptFile = join(dir, "job.prompt");
|
|
218
|
+
writeFileSync(promptFile, "ping");
|
|
219
|
+
const empty = join(dir, "empty");
|
|
220
|
+
mkdirSync(empty, { recursive: true });
|
|
221
|
+
withEnv(
|
|
222
|
+
{
|
|
223
|
+
CURSOR_ROUTE_OPENCODE_BIN: undefined,
|
|
224
|
+
PATH: empty,
|
|
225
|
+
CURSOR_ROUTE_OPENCODE_MODEL: undefined,
|
|
226
|
+
CURSOR_ROUTE_ZEN_OFFLINE: "1",
|
|
227
|
+
CURSOR_ROUTE_ZEN_CACHE_PATH: join(dir, "zen-cache.json"),
|
|
228
|
+
CURSOR_ROUTE_ZEN_REFRESH: "1",
|
|
229
|
+
},
|
|
230
|
+
() => {
|
|
231
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
232
|
+
promptFile,
|
|
233
|
+
cwd: dir,
|
|
234
|
+
alwaysApprove: true,
|
|
235
|
+
dryRun: true,
|
|
236
|
+
});
|
|
237
|
+
expect(plan.command).toContain("'opencode' run");
|
|
238
|
+
expect(plan.command).toContain("--auto");
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
rmSync(dir, { recursive: true, force: true });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("--worker opencode dry-run succeeds with fake binary", () => {
|
|
245
|
+
const dir = join(tmpdir(), `cr-oc-start-${process.pid}`);
|
|
246
|
+
mkdirSync(dir, { recursive: true });
|
|
247
|
+
const bin = join(dir, "opencode");
|
|
248
|
+
writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
|
|
249
|
+
chmodSync(bin, 0o755);
|
|
250
|
+
const prev = {
|
|
251
|
+
bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
|
|
252
|
+
model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
|
|
253
|
+
jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
|
|
254
|
+
offline: process.env.CURSOR_ROUTE_ZEN_OFFLINE,
|
|
255
|
+
cache: process.env.CURSOR_ROUTE_ZEN_CACHE_PATH,
|
|
256
|
+
refresh: process.env.CURSOR_ROUTE_ZEN_REFRESH,
|
|
257
|
+
json: process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON,
|
|
258
|
+
};
|
|
259
|
+
process.env.CURSOR_ROUTE_OPENCODE_BIN = bin;
|
|
260
|
+
delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
261
|
+
delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
262
|
+
process.env.CURSOR_ROUTE_JOBS_DIR = join(dir, "jobs");
|
|
263
|
+
process.env.CURSOR_ROUTE_ZEN_OFFLINE = "1";
|
|
264
|
+
process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = join(dir, "zen-cache.json");
|
|
265
|
+
process.env.CURSOR_ROUTE_ZEN_REFRESH = "1";
|
|
266
|
+
try {
|
|
267
|
+
const result = startJob({
|
|
268
|
+
prompt: "ping",
|
|
269
|
+
worker: "opencode",
|
|
270
|
+
dryRun: true,
|
|
271
|
+
});
|
|
272
|
+
expect(result.ok).toBe(true);
|
|
273
|
+
if (!result.ok) return;
|
|
274
|
+
expect(result.job.worker).toBe("opencode");
|
|
275
|
+
expect(result.job.model).toBe("opencode/x-preview-f-free");
|
|
276
|
+
expect(result.command).toContain("run");
|
|
277
|
+
expect(result.command).toContain("--auto");
|
|
278
|
+
expect(result.command).toContain("$(cat");
|
|
279
|
+
} finally {
|
|
280
|
+
if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_BIN;
|
|
281
|
+
else process.env.CURSOR_ROUTE_OPENCODE_BIN = prev.bin;
|
|
282
|
+
if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
283
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
|
|
284
|
+
if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
|
|
285
|
+
else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
|
|
286
|
+
if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_ZEN_OFFLINE;
|
|
287
|
+
else process.env.CURSOR_ROUTE_ZEN_OFFLINE = prev.offline;
|
|
288
|
+
if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_ZEN_CACHE_PATH;
|
|
289
|
+
else process.env.CURSOR_ROUTE_ZEN_CACHE_PATH = prev.cache;
|
|
290
|
+
if (prev.refresh === undefined) delete process.env.CURSOR_ROUTE_ZEN_REFRESH;
|
|
291
|
+
else process.env.CURSOR_ROUTE_ZEN_REFRESH = prev.refresh;
|
|
292
|
+
if (prev.json === undefined) delete process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON;
|
|
293
|
+
else process.env.CURSOR_ROUTE_ZEN_CATALOG_JSON = prev.json;
|
|
294
|
+
rmSync(dir, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import type { Adapter, WorkerHealth } from "./types.ts";
|
|
4
|
+
import { shellQuote } from "../util.ts";
|
|
5
|
+
import { openCodeModel, OPENCODE_DEFAULT_MODEL, cachedZenFreePick } from "../config.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Opt-in OpenCode worker (`opencode run`) — a coding agent on live OpenCode
|
|
9
|
+
* Zen free models (`--model free` ranks the catalog; Ox Alpha wins while
|
|
10
|
+
* listed). Not a lane default: mid stays claude-ds; easy stays OpenRouter
|
|
11
|
+
* chat (no tools).
|
|
12
|
+
*
|
|
13
|
+
* We never rewrite ~/.config/opencode/opencode.json (parallel jobs would
|
|
14
|
+
* race). Always-approve maps to `opencode run --auto` (still honors explicit
|
|
15
|
+
* deny rules). Prompt is inlined via cat — never interpolated.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function findOpencode(): string | null {
|
|
19
|
+
// Env override lets tests pin a fake opencode — but it must exist, so a
|
|
20
|
+
// stale override cannot pass health with a dangling path.
|
|
21
|
+
const override = process.env.CURSOR_ROUTE_OPENCODE_BIN;
|
|
22
|
+
if (override) return existsSync(override) ? override : null;
|
|
23
|
+
try {
|
|
24
|
+
return (
|
|
25
|
+
execSync("command -v opencode", {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
}).trim() || null
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const opencodeAdapter: Adapter = {
|
|
37
|
+
kind: "opencode",
|
|
38
|
+
label: "OpenCode (free Zen / other models)",
|
|
39
|
+
health(): WorkerHealth {
|
|
40
|
+
const binary = findOpencode();
|
|
41
|
+
if (!binary) {
|
|
42
|
+
return {
|
|
43
|
+
worker: "opencode",
|
|
44
|
+
ok: false,
|
|
45
|
+
binary: null,
|
|
46
|
+
detail:
|
|
47
|
+
"opencode not found — install: npm i -g opencode-ai (or brew install opencode). Then: opencode auth login. Mid default remains claude-ds.",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Health stays offline: show a fresh cache hit, else the Ox Alpha fallback.
|
|
51
|
+
const pick = cachedZenFreePick() || OPENCODE_DEFAULT_MODEL;
|
|
52
|
+
return {
|
|
53
|
+
worker: "opencode",
|
|
54
|
+
ok: true,
|
|
55
|
+
binary,
|
|
56
|
+
detail: `ok (opencode run; --model free = live Zen pick, now ${pick}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
|
|
60
|
+
// Missing binary is tolerated here so `--dry-run` can still print the
|
|
61
|
+
// command; real starts are gated by the health preflight.
|
|
62
|
+
const binary = findOpencode() || "opencode";
|
|
63
|
+
const id = openCodeModel(modelId);
|
|
64
|
+
|
|
65
|
+
const ask = process.env.CURSOR_ROUTE_ASK === "1";
|
|
66
|
+
const skip = alwaysApprove && !ask;
|
|
67
|
+
|
|
68
|
+
const parts = [
|
|
69
|
+
shellQuote(binary),
|
|
70
|
+
"run",
|
|
71
|
+
"--dir",
|
|
72
|
+
shellQuote(cwd),
|
|
73
|
+
"--model",
|
|
74
|
+
shellQuote(id),
|
|
75
|
+
];
|
|
76
|
+
if (skip) parts.push("--auto");
|
|
77
|
+
parts.push(`"$(cat ${shellQuote(promptFile)})"`);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
worker: "opencode",
|
|
81
|
+
command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
|
|
82
|
+
alwaysApprove: skip,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
package/src/adapters/types.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface Adapter {
|
|
|
26
26
|
alwaysApprove: boolean;
|
|
27
27
|
/** Mid-lane DeepSeek flash|pro (claude-ds + deepseek; ignored by grok/openrouter / Anthropic escape hatch). */
|
|
28
28
|
model?: DsModelAlias;
|
|
29
|
-
/** Concrete DeepSeek
|
|
29
|
+
/** Concrete DeepSeek id (preserves pro[1m]) or OpenCode provider/model. */
|
|
30
30
|
modelId?: string;
|
|
31
31
|
/** True on --dry-run: adapters may drop artifacts they just wrote (e.g. dsh patch). */
|
|
32
32
|
dryRun?: boolean;
|
package/src/cli.test.ts
CHANGED
|
@@ -41,6 +41,9 @@ describe("resolveWorker", () => {
|
|
|
41
41
|
expect(resolveWorker({ prompt: "x", lane: "mid", worker: "openrouter" })).toBe(
|
|
42
42
|
"openrouter",
|
|
43
43
|
);
|
|
44
|
+
expect(resolveWorker({ prompt: "x", lane: "mid", worker: "opencode" })).toBe(
|
|
45
|
+
"opencode",
|
|
46
|
+
);
|
|
44
47
|
});
|
|
45
48
|
test("default worker", () => {
|
|
46
49
|
expect(resolveWorker({ prompt: "x" })).toBe(config.defaultWorker);
|
|
@@ -615,7 +618,7 @@ describe("health", () => {
|
|
|
615
618
|
test("returns structured report", () => {
|
|
616
619
|
const r = runHealth();
|
|
617
620
|
expect(r.product).toBe("cursor-route");
|
|
618
|
-
expect(r.version).toBe("0.1.
|
|
621
|
+
expect(r.version).toBe("0.1.11");
|
|
619
622
|
expect(r.checks.length).toBeGreaterThan(3);
|
|
620
623
|
expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
|
|
621
624
|
expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
|
|
@@ -623,10 +626,32 @@ describe("health", () => {
|
|
|
623
626
|
expect(mid).toBeDefined();
|
|
624
627
|
expect(r.lanes.mid.worker).toBe("claude-ds");
|
|
625
628
|
expect(r.lanes.mid.deepseek).toBe(mid!.ok);
|
|
629
|
+
expect(r.checks.some((c) => c.name === "worker:opencode")).toBe(true);
|
|
630
|
+
expect(r.checks.some((c) => c.name === "worker:deepseek")).toBe(true);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("config version is 0.1.11", () => {
|
|
634
|
+
expect(config.version).toBe("0.1.11");
|
|
626
635
|
});
|
|
627
636
|
|
|
628
|
-
test("
|
|
629
|
-
|
|
637
|
+
test("OR-gate: ok can be true while worker:opencode is false", () => {
|
|
638
|
+
const prev = {
|
|
639
|
+
relaxed: process.env.CURSOR_ROUTE_RELAXED,
|
|
640
|
+
bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
|
|
641
|
+
};
|
|
642
|
+
process.env.CURSOR_ROUTE_RELAXED = "1";
|
|
643
|
+
process.env.CURSOR_ROUTE_OPENCODE_BIN = join(tmpdir(), `missing-oc-${process.pid}`);
|
|
644
|
+
try {
|
|
645
|
+
const r = runHealth();
|
|
646
|
+
const oc = r.checks.find((c) => c.name === "worker:opencode");
|
|
647
|
+
expect(oc?.ok).toBe(false);
|
|
648
|
+
expect(r.ok).toBe(true);
|
|
649
|
+
} finally {
|
|
650
|
+
if (prev.relaxed === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
|
|
651
|
+
else process.env.CURSOR_ROUTE_RELAXED = prev.relaxed;
|
|
652
|
+
if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_BIN;
|
|
653
|
+
else process.env.CURSOR_ROUTE_OPENCODE_BIN = prev.bin;
|
|
654
|
+
}
|
|
630
655
|
});
|
|
631
656
|
|
|
632
657
|
test("OR-gate: ok can be true while worker:deepseek is false", () => {
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* cursor-route CLI — Cursor brain, Grok + DeepSeek + OpenRouter (easy) workers in tmux.
|
|
3
|
+
* cursor-route CLI — Cursor brain, Grok + DeepSeek + OpenRouter (easy) + OpenCode (opt-in) workers in tmux.
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
|
|
6
6
|
import { resolve, basename } from "node:path";
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
WORKERS,
|
|
10
10
|
LANES,
|
|
11
11
|
resolveDsModel,
|
|
12
|
+
openCodeModel,
|
|
12
13
|
type WorkerKind,
|
|
13
14
|
type Lane,
|
|
14
15
|
type DsModelAlias,
|
|
@@ -36,7 +37,7 @@ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
|
|
|
36
37
|
function usage(exitCode = 0): never {
|
|
37
38
|
console.log(`cursor-route v${config.version}
|
|
38
39
|
|
|
39
|
-
Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) are the parallel army.
|
|
40
|
+
Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) + OpenCode (opt-in free) are the parallel army.
|
|
40
41
|
|
|
41
42
|
Usage:
|
|
42
43
|
cursor-route --version
|
|
@@ -53,9 +54,9 @@ Usage:
|
|
|
53
54
|
cursor-route clean [--days N]
|
|
54
55
|
|
|
55
56
|
Start options:
|
|
56
|
-
--worker <grok|claude-ds|openrouter|deepseek> Worker adapter (default: grok; deepseek =
|
|
57
|
+
--worker <grok|claude-ds|openrouter|deepseek|opencode> Worker adapter (default: grok; deepseek/opencode = opt-in)
|
|
57
58
|
--lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
|
|
58
|
-
--model <flash|pro>
|
|
59
|
+
--model <flash|pro|free|provider/model> claude-ds/deepseek: flash|pro. opencode: free (live Zen catalog pick) or provider/model
|
|
59
60
|
--dir <path> Working directory (default: cwd)
|
|
60
61
|
--ask Disable always-approve for this job
|
|
61
62
|
--dry-run Print launch command; do not start
|
|
@@ -72,10 +73,12 @@ Env:
|
|
|
72
73
|
CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
|
|
73
74
|
CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
|
|
74
75
|
CURSOR_ROUTE_DSH_BIN Override the dsh binary path (tests / power users)
|
|
76
|
+
CURSOR_ROUTE_OPENCODE_BIN Override the opencode binary path (tests / power users)
|
|
75
77
|
DEEPSEEK_API_KEY DeepSeek API key (required for --worker deepseek)
|
|
76
78
|
OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
|
|
77
79
|
CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
|
|
78
80
|
OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
|
|
81
|
+
CURSOR_ROUTE_OPENCODE_MODEL OpenCode model pin (unset or free = live Zen free pick); --model overrides
|
|
79
82
|
`);
|
|
80
83
|
process.exit(exitCode);
|
|
81
84
|
}
|
|
@@ -171,6 +174,14 @@ function asDsModelChoice(v: unknown): { alias: DsModelAlias; id: string } | unde
|
|
|
171
174
|
return resolveDsModel(v);
|
|
172
175
|
}
|
|
173
176
|
|
|
177
|
+
function asOpenCodeModel(v: unknown): string | undefined {
|
|
178
|
+
if (v === undefined || v === true) return undefined;
|
|
179
|
+
if (typeof v !== "string") {
|
|
180
|
+
throw new Error(`Invalid --model; expected provider/model (e.g. opencode/x-preview-f-free) or free`);
|
|
181
|
+
}
|
|
182
|
+
return openCodeModel(v);
|
|
183
|
+
}
|
|
184
|
+
|
|
174
185
|
function refuseSecrets(text: string, context: string): void {
|
|
175
186
|
if (looksLikeSecretMaterial(text)) {
|
|
176
187
|
console.error(
|
|
@@ -253,8 +264,8 @@ async function main() {
|
|
|
253
264
|
|
|
254
265
|
let model: DsModelAlias | undefined;
|
|
255
266
|
let modelId: string | undefined;
|
|
256
|
-
// --model
|
|
257
|
-
// ignore (do not validate) for
|
|
267
|
+
// --model: DeepSeek flash|pro for claude-ds/deepseek; provider/model (or free) for opencode;
|
|
268
|
+
// ignore (do not validate) for grok/openrouter
|
|
258
269
|
if (f.model !== undefined && (resolvedWorker === "claude-ds" || resolvedWorker === "deepseek")) {
|
|
259
270
|
try {
|
|
260
271
|
const choice = asDsModelChoice(f.model);
|
|
@@ -266,6 +277,13 @@ async function main() {
|
|
|
266
277
|
console.error((e as Error).message);
|
|
267
278
|
process.exit(2);
|
|
268
279
|
}
|
|
280
|
+
} else if (f.model !== undefined && resolvedWorker === "opencode") {
|
|
281
|
+
try {
|
|
282
|
+
modelId = asOpenCodeModel(f.model);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error((e as Error).message);
|
|
285
|
+
process.exit(2);
|
|
286
|
+
}
|
|
269
287
|
}
|
|
270
288
|
|
|
271
289
|
let prompt = "";
|
|
@@ -359,7 +377,7 @@ async function main() {
|
|
|
359
377
|
} else {
|
|
360
378
|
for (const j of jobs) {
|
|
361
379
|
const age = j.startedAt || j.createdAt;
|
|
362
|
-
const modelCol = j.model ? j.model.padEnd(
|
|
380
|
+
const modelCol = j.model ? j.model.padEnd(22) : "".padEnd(22);
|
|
363
381
|
console.log(
|
|
364
382
|
`${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${modelCol} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`,
|
|
365
383
|
);
|
package/src/config.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { join } from "node:path";
|
|
|
3
3
|
import { defaultJobsDir } from "./runtime.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Workers with a live adapter. `deepseek`
|
|
7
|
-
*
|
|
6
|
+
* Workers with a live adapter. `deepseek` (official dsh) and `opencode`
|
|
7
|
+
* (free Zen / other models) are opt-in workers, not lane defaults.
|
|
8
|
+
* Mid stays on claude-ds; easy stays on openrouter (chat-only).
|
|
8
9
|
*/
|
|
9
|
-
export type WorkerKind = "grok" | "claude-ds" | "openrouter" | "deepseek";
|
|
10
|
+
export type WorkerKind = "grok" | "claude-ds" | "openrouter" | "deepseek" | "opencode";
|
|
10
11
|
export type Lane = "easy" | "mid" | "hard";
|
|
11
12
|
|
|
12
13
|
/** Public CLI aliases for mid-lane DeepSeek models. */
|
|
@@ -18,7 +19,7 @@ export interface DsModelChoice {
|
|
|
18
19
|
id: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter", "deepseek"];
|
|
22
|
+
export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter", "deepseek", "opencode"];
|
|
22
23
|
export const LANES: Lane[] = ["easy", "mid", "hard"];
|
|
23
24
|
export const DS_MODELS: DsModelAlias[] = ["flash", "pro"];
|
|
24
25
|
|
|
@@ -73,6 +74,13 @@ export function openRouterBaseUrl(): string {
|
|
|
73
74
|
return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
export {
|
|
78
|
+
OPENCODE_DEFAULT_MODEL,
|
|
79
|
+
assertOpenCodeModel,
|
|
80
|
+
openCodeModel,
|
|
81
|
+
cachedZenFreePick,
|
|
82
|
+
} from "./zen-free.ts";
|
|
83
|
+
|
|
76
84
|
function maxConcurrentJobsFromEnv(): number {
|
|
77
85
|
const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
|
|
78
86
|
if (raw) {
|
|
@@ -89,7 +97,7 @@ function maxConcurrentJobsFromEnv(): number {
|
|
|
89
97
|
*/
|
|
90
98
|
export const config = {
|
|
91
99
|
product: "cursor-route",
|
|
92
|
-
version: "0.1.
|
|
100
|
+
version: "0.1.11",
|
|
93
101
|
get jobsDir(): string {
|
|
94
102
|
return defaultJobsDir();
|
|
95
103
|
},
|
|
@@ -97,7 +105,7 @@ export const config = {
|
|
|
97
105
|
defaultWorker: "grok" as WorkerKind,
|
|
98
106
|
/**
|
|
99
107
|
* Lane → default worker (Cemini /route public core).
|
|
100
|
-
* `deepseek`
|
|
108
|
+
* `deepseek` and `opencode` are opt-in only — mid stays on claude-ds.
|
|
101
109
|
*/
|
|
102
110
|
laneWorkers: {
|
|
103
111
|
easy: "openrouter" as WorkerKind,
|
package/src/health.ts
CHANGED
|
@@ -133,7 +133,7 @@ export function printHealth(report: HealthReport, asJson: boolean): void {
|
|
|
133
133
|
if (!report.ok) {
|
|
134
134
|
console.log("");
|
|
135
135
|
console.log("Fix the ✗ items, then re-run: cursor-route health");
|
|
136
|
-
console.log("Tip: start with one worker (grok OR claude-ds) before parallel demos.");
|
|
136
|
+
console.log("Tip: start with one worker (grok OR claude-ds OR opencode) before parallel demos.");
|
|
137
137
|
} else if (report.checks.some((c) => c.name === "lane:mid" && !c.ok)) {
|
|
138
138
|
console.log("");
|
|
139
139
|
console.log(
|