cursor-route 0.1.9 → 0.1.10
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 +9 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +39 -4
- package/SECURITY.md +6 -5
- package/SUPPORT.md +1 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/opencode.js +83 -0
- package/dist/cli.js +27 -8
- package/dist/config.js +30 -3
- package/dist/health.js +1 -1
- package/dist/jobs.js +21 -2
- package/docs/briefs/WORKING.md +8 -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 +238 -0
- package/src/adapters/opencode.ts +89 -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 +38 -6
- package/src/health.ts +1 -1
- package/src/jobs.ts +23 -6
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
name: route-orch
|
|
3
3
|
description: >-
|
|
4
4
|
Delegate coding work from Cursor to parallel Grok CLI / claude-ds (DeepSeek) /
|
|
5
|
-
OpenRouter (easy lane) workers via cursor-route. Use
|
|
6
|
-
/route-orch, spawn workers, parallel agents with
|
|
7
|
-
asks to outsource implementation to
|
|
8
|
-
Cemini /route.
|
|
5
|
+
OpenRouter (easy lane) / OpenCode (opt-in free) workers via cursor-route. Use
|
|
6
|
+
when the user says /route-orch, spawn workers, parallel agents with
|
|
7
|
+
cursor-route, or explicitly asks to outsource implementation to
|
|
8
|
+
Grok/DeepSeek/OpenCode panes — not for private Cemini /route.
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# route-orch (cursor-route)
|
|
@@ -14,8 +14,8 @@ You are the **orchestrator**. Do **not** implement bulk code in this Cursor sess
|
|
|
14
14
|
|
|
15
15
|
## When to activate
|
|
16
16
|
|
|
17
|
-
- User says `/route-orch`, `spawn workers`, or asks for parallel Grok/DeepSeek via **cursor-route**
|
|
18
|
-
- Mid/hard implementation that should run on a subscription worker (Grok CLI / claude-ds)
|
|
17
|
+
- User says `/route-orch`, `spawn workers`, or asks for parallel Grok/DeepSeek/OpenCode via **cursor-route**
|
|
18
|
+
- Mid/hard implementation that should run on a subscription worker (Grok CLI / claude-ds) or opt-in OpenCode free models
|
|
19
19
|
- Multi-file investigation that benefits from parallel panes
|
|
20
20
|
|
|
21
21
|
**Do not steal federation `/route`.** Private Cemini `/route` (route-task → verify → Grok/claude-ds chain) is a different skill. This public skill only drives the `cursor-route` CLI.
|
|
@@ -59,6 +59,20 @@ cursor-route start --worker deepseek --model pro --dir "$PWD" "…" # --model
|
|
|
59
59
|
|
|
60
60
|
Health ✓ needs `dsh` on PATH and `DEEPSEEK_API_KEY` set. The adapter pins `--model` via a per-job `--patch` (never touches `~/.dsh/settings.yaml`); always-approve → `DSH_PERMISSION_MODE=danger-full-access`, `--ask` → `workspace-write`. The key never enters the command or patch.
|
|
61
61
|
|
|
62
|
+
## Experimental: --worker opencode (free Zen)
|
|
63
|
+
|
|
64
|
+
OpenCode as an opt-in **coding agent** on OpenCode Zen free models — **not a lane default** (mid stays `claude-ds`; easy stays OpenRouter chat). Use this to cut Grok / DeepSeek usage on implement work.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm i -g opencode-ai
|
|
68
|
+
opencode auth login
|
|
69
|
+
cursor-route start --worker opencode --dir "$PWD" "…"
|
|
70
|
+
cursor-route start --worker opencode --model free --dir "$PWD" "…"
|
|
71
|
+
cursor-route start --worker opencode --model opencode/hy3-free --dir "$PWD" "…"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Health ✓ needs `opencode` on PATH (override `CURSOR_ROUTE_OPENCODE_BIN`). Default model is `opencode/big-pickle` (`CURSOR_ROUTE_OPENCODE_MODEL` / `--model free`). Always-approve → `opencode run --auto`; `--ask` omits `--auto`. Never rewrites `~/.config/opencode/opencode.json`. Free Zen models may log/train — keep secrets off this worker (same refuse gate as easy).
|
|
75
|
+
|
|
62
76
|
## Workflow
|
|
63
77
|
|
|
64
78
|
1. Run `cursor-route health` (or `CURSOR_ROUTE_RELAXED=1` for headless). If the **target worker** is unhealthy, fix before spawning. If targeting **mid**, require `lane:mid` ✓ (or health JSON `lanes.mid.deepseek`) before spawn — `CURSOR_ROUTE_ALLOW_ANTHROPIC=1` is not DeepSeek proof.
|
|
@@ -82,7 +96,7 @@ EOF
|
|
|
82
96
|
)"
|
|
83
97
|
```
|
|
84
98
|
|
|
85
|
-
Or `--worker grok` / `--worker claude-ds` / `--worker deepseek` (experimental) / `--worker openrouter` (or `--lane easy`). Use `--no-tmux` only when tmux is unavailable.
|
|
99
|
+
Or `--worker grok` / `--worker claude-ds` / `--worker deepseek` (experimental) / `--worker opencode` (opt-in free) / `--worker openrouter` (or `--lane easy`). Use `--no-tmux` only when tmux is unavailable.
|
|
86
100
|
|
|
87
101
|
4. Monitor: `cursor-route jobs --json` · `cursor-route capture <id>` · `cursor-route send <id> "…"` (tmux only).
|
|
88
102
|
5. Summarize worker results with **verify evidence** — no status-only “done” (see Verify / claim closeout). If verify fails, reconsider the plan/definition (not only retry) — `send` a correction or spawn a follow-up; do not invent success.
|
|
@@ -111,6 +125,7 @@ Defaults on for workers. Opt out: `cursor-route start … --ask` or `CURSOR_ROUT
|
|
|
111
125
|
|
|
112
126
|
- Do not paste API keys / private keys into prompts or `send`
|
|
113
127
|
- Do not claim the official DeepSeek harness (`@deepseek-ai/dsh`) is the mid default — `--worker deepseek` is an opt-in experiment (cheap to abandon), not a product fork; mid stays **claude-ds**
|
|
128
|
+
- Do not claim OpenCode is the mid default — `--worker opencode` is opt-in for free Zen (or other) models; mid stays **claude-ds**
|
|
114
129
|
- Do not fork a second mid harness
|
|
115
130
|
- Do not open-source or dump private cemini `agent-toolkit` paths into public handoffs
|
|
116
131
|
- Do not mark done without reading `capture` / exit status
|
package/src/adapters/index.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { grokAdapter } from "./grok.ts";
|
|
|
4
4
|
import { claudeDsAdapter } from "./claude-ds.ts";
|
|
5
5
|
import { openRouterAdapter } from "./openrouter.ts";
|
|
6
6
|
import { deepseekAdapter } from "./deepseek.ts";
|
|
7
|
+
import { opencodeAdapter } from "./opencode.ts";
|
|
7
8
|
|
|
8
9
|
const registry: Record<WorkerKind, Adapter> = {
|
|
9
10
|
grok: grokAdapter,
|
|
10
11
|
"claude-ds": claudeDsAdapter,
|
|
11
12
|
openrouter: openRouterAdapter,
|
|
12
13
|
deepseek: deepseekAdapter,
|
|
14
|
+
opencode: opencodeAdapter,
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export function getAdapter(worker: WorkerKind): Adapter {
|
|
@@ -0,0 +1,238 @@
|
|
|
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 OpenCode Zen Big Pickle", () => {
|
|
15
|
+
const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
16
|
+
delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
17
|
+
try {
|
|
18
|
+
expect(openCodeModel()).toBe("opencode/big-pickle");
|
|
19
|
+
expect(openCodeModel("")).toBe(OPENCODE_DEFAULT_MODEL);
|
|
20
|
+
expect(openCodeModel("free")).toBe("opencode/big-pickle");
|
|
21
|
+
} finally {
|
|
22
|
+
if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
23
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("env override wins for empty/free", () => {
|
|
28
|
+
const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
29
|
+
process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
|
|
30
|
+
try {
|
|
31
|
+
expect(openCodeModel()).toBe("opencode/hy3-free");
|
|
32
|
+
expect(openCodeModel("free")).toBe("opencode/hy3-free");
|
|
33
|
+
} finally {
|
|
34
|
+
if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
35
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("explicit provider/model wins over env", () => {
|
|
40
|
+
const prev = process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
41
|
+
process.env.CURSOR_ROUTE_OPENCODE_MODEL = "opencode/hy3-free";
|
|
42
|
+
try {
|
|
43
|
+
expect(openCodeModel("opencode/mimo-v2.5-free")).toBe("opencode/mimo-v2.5-free");
|
|
44
|
+
} finally {
|
|
45
|
+
if (prev === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
46
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("accepts OpenRouter-style extra slash and :free", () => {
|
|
51
|
+
expect(assertOpenCodeModel("openrouter/qwen/qwen3-coder:free")).toBe(
|
|
52
|
+
"openrouter/qwen/qwen3-coder:free",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("rejects injection / flash|pro aliases", () => {
|
|
57
|
+
expect(() => openCodeModel("flash")).toThrow(/provider\/model/);
|
|
58
|
+
expect(() => openCodeModel("x\n--evil")).toThrow(/provider\/model/);
|
|
59
|
+
expect(() => openCodeModel("opencode/big pickle")).toThrow(/provider\/model/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("opencode adapter", () => {
|
|
64
|
+
const makeFake = (suffix: string) => {
|
|
65
|
+
const dir = join(tmpdir(), `cr-oc-${process.pid}-${suffix}`);
|
|
66
|
+
mkdirSync(dir, { recursive: true });
|
|
67
|
+
const bin = join(dir, "opencode");
|
|
68
|
+
writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
|
|
69
|
+
chmodSync(bin, 0o755);
|
|
70
|
+
return { dir, bin };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const withEnv = (patch: Record<string, string | undefined>, fn: () => void) => {
|
|
74
|
+
const prev = new Map<string, string | undefined>();
|
|
75
|
+
for (const k of Object.keys(patch)) {
|
|
76
|
+
prev.set(k, process.env[k]);
|
|
77
|
+
const v = patch[k];
|
|
78
|
+
if (v === undefined) delete process.env[k];
|
|
79
|
+
else process.env[k] = v;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
fn();
|
|
83
|
+
} finally {
|
|
84
|
+
for (const [k, v] of prev) {
|
|
85
|
+
if (v === undefined) delete process.env[k];
|
|
86
|
+
else process.env[k] = v;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
test("health: no binary → not ok, install hint names npm package", () => {
|
|
92
|
+
withEnv(
|
|
93
|
+
{ CURSOR_ROUTE_OPENCODE_BIN: join(tmpdir(), `missing-oc-${process.pid}`) },
|
|
94
|
+
() => {
|
|
95
|
+
const h = opencodeAdapter.health();
|
|
96
|
+
expect(h.ok).toBe(false);
|
|
97
|
+
expect(h.detail).toMatch(/npm i -g opencode-ai/);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("health: binary → ok (auth at first start)", () => {
|
|
103
|
+
const { bin } = makeFake("ok");
|
|
104
|
+
withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin }, () => {
|
|
105
|
+
const h = opencodeAdapter.health();
|
|
106
|
+
expect(h.ok).toBe(true);
|
|
107
|
+
expect(h.binary).toBe(bin);
|
|
108
|
+
expect(h.detail).toContain("opencode/big-pickle");
|
|
109
|
+
});
|
|
110
|
+
rmSync(join(tmpdir(), `cr-oc-${process.pid}-ok`), { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("buildLaunch: default free model + --auto; prompt via cat", () => {
|
|
114
|
+
const { dir, bin } = makeFake("launch");
|
|
115
|
+
const promptFile = join(dir, "job.prompt");
|
|
116
|
+
writeFileSync(promptFile, "ping");
|
|
117
|
+
withEnv(
|
|
118
|
+
{
|
|
119
|
+
CURSOR_ROUTE_OPENCODE_BIN: bin,
|
|
120
|
+
CURSOR_ROUTE_OPENCODE_MODEL: undefined,
|
|
121
|
+
CURSOR_ROUTE_ASK: undefined,
|
|
122
|
+
},
|
|
123
|
+
() => {
|
|
124
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
125
|
+
promptFile,
|
|
126
|
+
cwd: dir,
|
|
127
|
+
alwaysApprove: true,
|
|
128
|
+
});
|
|
129
|
+
expect(plan.command).toContain("run");
|
|
130
|
+
expect(plan.command).toContain("--auto");
|
|
131
|
+
expect(plan.command).toContain("opencode/big-pickle");
|
|
132
|
+
expect(plan.command).toContain("$(cat");
|
|
133
|
+
expect(plan.command).toContain("--dir");
|
|
134
|
+
expect(plan.alwaysApprove).toBe(true);
|
|
135
|
+
expect(plan.command).not.toContain("npx");
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
rmSync(dir, { recursive: true, force: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("buildLaunch: alwaysApprove false (--ask) omits --auto", () => {
|
|
142
|
+
const { dir, bin } = makeFake("ask");
|
|
143
|
+
const promptFile = join(dir, "job.prompt");
|
|
144
|
+
writeFileSync(promptFile, "ping");
|
|
145
|
+
withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin }, () => {
|
|
146
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
147
|
+
promptFile,
|
|
148
|
+
cwd: dir,
|
|
149
|
+
alwaysApprove: false,
|
|
150
|
+
modelId: "opencode/hy3-free",
|
|
151
|
+
});
|
|
152
|
+
expect(plan.command).toContain("opencode/hy3-free");
|
|
153
|
+
expect(plan.command).not.toContain("--auto");
|
|
154
|
+
expect(plan.alwaysApprove).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("buildLaunch: CURSOR_ROUTE_ASK=1 opts out even when alwaysApprove true", () => {
|
|
160
|
+
const { dir, bin } = makeFake("ask-env");
|
|
161
|
+
const promptFile = join(dir, "job.prompt");
|
|
162
|
+
writeFileSync(promptFile, "ping");
|
|
163
|
+
withEnv({ CURSOR_ROUTE_OPENCODE_BIN: bin, CURSOR_ROUTE_ASK: "1" }, () => {
|
|
164
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
165
|
+
promptFile,
|
|
166
|
+
cwd: dir,
|
|
167
|
+
alwaysApprove: true,
|
|
168
|
+
});
|
|
169
|
+
expect(plan.command).not.toContain("--auto");
|
|
170
|
+
expect(plan.alwaysApprove).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
rmSync(dir, { recursive: true, force: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("buildLaunch: missing binary falls back to plain `opencode` for dry-run", () => {
|
|
176
|
+
const { dir } = makeFake("nobin");
|
|
177
|
+
const promptFile = join(dir, "job.prompt");
|
|
178
|
+
writeFileSync(promptFile, "ping");
|
|
179
|
+
const empty = join(dir, "empty");
|
|
180
|
+
mkdirSync(empty, { recursive: true });
|
|
181
|
+
withEnv(
|
|
182
|
+
{
|
|
183
|
+
CURSOR_ROUTE_OPENCODE_BIN: undefined,
|
|
184
|
+
PATH: empty,
|
|
185
|
+
CURSOR_ROUTE_OPENCODE_MODEL: undefined,
|
|
186
|
+
},
|
|
187
|
+
() => {
|
|
188
|
+
const plan = opencodeAdapter.buildLaunch({
|
|
189
|
+
promptFile,
|
|
190
|
+
cwd: dir,
|
|
191
|
+
alwaysApprove: true,
|
|
192
|
+
dryRun: true,
|
|
193
|
+
});
|
|
194
|
+
expect(plan.command).toContain("'opencode' run");
|
|
195
|
+
expect(plan.command).toContain("--auto");
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
rmSync(dir, { recursive: true, force: true });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("--worker opencode dry-run succeeds with fake binary", () => {
|
|
202
|
+
const dir = join(tmpdir(), `cr-oc-start-${process.pid}`);
|
|
203
|
+
mkdirSync(dir, { recursive: true });
|
|
204
|
+
const bin = join(dir, "opencode");
|
|
205
|
+
writeFileSync(bin, "#!/bin/sh\necho fake-opencode\n");
|
|
206
|
+
chmodSync(bin, 0o755);
|
|
207
|
+
const prev = {
|
|
208
|
+
bin: process.env.CURSOR_ROUTE_OPENCODE_BIN,
|
|
209
|
+
model: process.env.CURSOR_ROUTE_OPENCODE_MODEL,
|
|
210
|
+
jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
|
|
211
|
+
};
|
|
212
|
+
process.env.CURSOR_ROUTE_OPENCODE_BIN = bin;
|
|
213
|
+
delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
214
|
+
process.env.CURSOR_ROUTE_JOBS_DIR = join(dir, "jobs");
|
|
215
|
+
try {
|
|
216
|
+
const result = startJob({
|
|
217
|
+
prompt: "ping",
|
|
218
|
+
worker: "opencode",
|
|
219
|
+
dryRun: true,
|
|
220
|
+
});
|
|
221
|
+
expect(result.ok).toBe(true);
|
|
222
|
+
if (!result.ok) return;
|
|
223
|
+
expect(result.job.worker).toBe("opencode");
|
|
224
|
+
expect(result.job.model).toBe("opencode/big-pickle");
|
|
225
|
+
expect(result.command).toContain("run");
|
|
226
|
+
expect(result.command).toContain("--auto");
|
|
227
|
+
expect(result.command).toContain("$(cat");
|
|
228
|
+
} finally {
|
|
229
|
+
if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_BIN;
|
|
230
|
+
else process.env.CURSOR_ROUTE_OPENCODE_BIN = prev.bin;
|
|
231
|
+
if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENCODE_MODEL;
|
|
232
|
+
else process.env.CURSOR_ROUTE_OPENCODE_MODEL = prev.model;
|
|
233
|
+
if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
|
|
234
|
+
else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
|
|
235
|
+
rmSync(dir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
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 } from "../config.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Opt-in OpenCode worker (`opencode run`) — a coding agent that can use
|
|
9
|
+
* OpenCode Zen free models (default `opencode/big-pickle`) to cut Grok /
|
|
10
|
+
* DeepSeek usage. Not a lane default: mid stays claude-ds; easy stays
|
|
11
|
+
* OpenRouter 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
|
+
let defaultModel = OPENCODE_DEFAULT_MODEL;
|
|
51
|
+
try {
|
|
52
|
+
defaultModel = openCodeModel();
|
|
53
|
+
} catch {
|
|
54
|
+
/* invalid env — health still ok; startJob will fail loud */
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
worker: "opencode",
|
|
58
|
+
ok: true,
|
|
59
|
+
binary,
|
|
60
|
+
detail: `ok (opencode run; default model ${defaultModel}; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds)`,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
buildLaunch({ promptFile, cwd, alwaysApprove, modelId }) {
|
|
64
|
+
// Missing binary is tolerated here so `--dry-run` can still print the
|
|
65
|
+
// command; real starts are gated by the health preflight.
|
|
66
|
+
const binary = findOpencode() || "opencode";
|
|
67
|
+
const id = openCodeModel(modelId);
|
|
68
|
+
|
|
69
|
+
const ask = process.env.CURSOR_ROUTE_ASK === "1";
|
|
70
|
+
const skip = alwaysApprove && !ask;
|
|
71
|
+
|
|
72
|
+
const parts = [
|
|
73
|
+
shellQuote(binary),
|
|
74
|
+
"run",
|
|
75
|
+
"--dir",
|
|
76
|
+
shellQuote(cwd),
|
|
77
|
+
"--model",
|
|
78
|
+
shellQuote(id),
|
|
79
|
+
];
|
|
80
|
+
if (skip) parts.push("--auto");
|
|
81
|
+
parts.push(`"$(cat ${shellQuote(promptFile)})"`);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
worker: "opencode",
|
|
85
|
+
command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
|
|
86
|
+
alwaysApprove: skip,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
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.10");
|
|
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.10", () => {
|
|
634
|
+
expect(config.version).toBe("0.1.10");
|
|
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 (default opencode/big-pickle) 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 (default: opencode/big-pickle); --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/big-pickle) 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,37 @@ export function openRouterBaseUrl(): string {
|
|
|
73
74
|
return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Default OpenCode model: OpenCode Zen Big Pickle (free, limited-time).
|
|
79
|
+
* Override: CURSOR_ROUTE_OPENCODE_MODEL or `--model provider/model`.
|
|
80
|
+
* Alias `free` resolves to this default (or the env override).
|
|
81
|
+
*/
|
|
82
|
+
export const OPENCODE_DEFAULT_MODEL = "opencode/big-pickle";
|
|
83
|
+
|
|
84
|
+
/** Whitelist OpenCode `provider/model` ids (no spaces / injection). */
|
|
85
|
+
export function assertOpenCodeModel(id: string): string {
|
|
86
|
+
if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Invalid OpenCode model ${id}; expected provider/model (e.g. opencode/big-pickle) or free`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return id;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve OpenCode `--model` / env to a concrete `provider/model` id.
|
|
96
|
+
* Empty or `free` → CURSOR_ROUTE_OPENCODE_MODEL, else OPENCODE_DEFAULT_MODEL.
|
|
97
|
+
*/
|
|
98
|
+
export function openCodeModel(raw?: string | null): string {
|
|
99
|
+
const v = (raw ?? "").trim();
|
|
100
|
+
if (!v || v.toLowerCase() === "free") {
|
|
101
|
+
const env = (process.env.CURSOR_ROUTE_OPENCODE_MODEL ?? "").trim();
|
|
102
|
+
if (!env || env.toLowerCase() === "free") return OPENCODE_DEFAULT_MODEL;
|
|
103
|
+
return assertOpenCodeModel(env);
|
|
104
|
+
}
|
|
105
|
+
return assertOpenCodeModel(v);
|
|
106
|
+
}
|
|
107
|
+
|
|
76
108
|
function maxConcurrentJobsFromEnv(): number {
|
|
77
109
|
const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
|
|
78
110
|
if (raw) {
|
|
@@ -89,7 +121,7 @@ function maxConcurrentJobsFromEnv(): number {
|
|
|
89
121
|
*/
|
|
90
122
|
export const config = {
|
|
91
123
|
product: "cursor-route",
|
|
92
|
-
version: "0.1.
|
|
124
|
+
version: "0.1.10",
|
|
93
125
|
get jobsDir(): string {
|
|
94
126
|
return defaultJobsDir();
|
|
95
127
|
},
|
|
@@ -97,7 +129,7 @@ export const config = {
|
|
|
97
129
|
defaultWorker: "grok" as WorkerKind,
|
|
98
130
|
/**
|
|
99
131
|
* Lane → default worker (Cemini /route public core).
|
|
100
|
-
* `deepseek`
|
|
132
|
+
* `deepseek` and `opencode` are opt-in only — mid stays on claude-ds.
|
|
101
133
|
*/
|
|
102
134
|
laneWorkers: {
|
|
103
135
|
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(
|