cursor-route 0.1.11 → 0.1.13
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 +17 -0
- package/CONTRIBUTING.md +3 -2
- package/README.md +33 -20
- package/dist/adapters/claude-ds.js +1 -1
- package/dist/adapters/openrouter.js +13 -7
- package/dist/cli.js +24 -7
- package/dist/config.js +13 -7
- package/dist/jobs.js +50 -14
- package/dist/openrouter-run.js +1 -1
- package/dist/or-free.js +216 -0
- package/docs/briefs/WORKING.md +11 -6
- package/docs/demo-notes.md +3 -3
- package/docs/fixtures/generate-hero-demo.sh +3 -0
- package/docs/fixtures/hero-demo.log +5 -3
- package/llms.txt +8 -6
- package/package.json +1 -1
- package/skills/route-orch/SKILL.md +12 -7
- package/src/adapters/claude-ds.ts +1 -1
- package/src/adapters/openrouter.test.ts +32 -3
- package/src/adapters/openrouter.ts +17 -7
- package/src/adapters/types.ts +2 -2
- package/src/cli.test.ts +266 -5
- package/src/cli.ts +22 -6
- package/src/config.ts +23 -9
- package/src/jobs.ts +47 -15
- package/src/openrouter-run.ts +1 -1
- package/src/or-free.test.ts +222 -0
- package/src/or-free.ts +243 -0
package/dist/or-free.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Live OpenRouter free-model pick — same shape as zen-free.ts.
|
|
7
|
+
* Probe GET {OPENROUTER_BASE_URL}/models, keep $0 text models, rank, cache
|
|
8
|
+
* ~15 min. Do not hardcode a third-party model id as the happy-path default.
|
|
9
|
+
* `openrouter/free` is the OpenRouter **router** fallback only.
|
|
10
|
+
*/
|
|
11
|
+
export const OPENROUTER_MODELS_URL_DEFAULT = "https://openrouter.ai/api/v1/models";
|
|
12
|
+
/** Fetch-fail / empty-rank fallback — OpenRouter live router, not a locked model. */
|
|
13
|
+
export const OPENROUTER_FALLBACK_MODEL = "openrouter/free";
|
|
14
|
+
const EXCLUDE_RE = /lyria|whisper|tts|embed|embedding|image|vision-only|audio|diffusion|flux|stable-diffusion|moderation/i;
|
|
15
|
+
const BOOST_RE = /coder|instruct|chat|nemotron|qwen|llama|gemma|gpt-oss|kimi|glm|deepseek/i;
|
|
16
|
+
function orModelsUrl() {
|
|
17
|
+
const base = (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1")
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/\/+$/, "");
|
|
20
|
+
return `${base}/models`;
|
|
21
|
+
}
|
|
22
|
+
function cacheMinutes() {
|
|
23
|
+
const n = Number(process.env.CURSOR_ROUTE_OR_CACHE_MINUTES);
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? n : 15;
|
|
25
|
+
}
|
|
26
|
+
function cachePath() {
|
|
27
|
+
return process.env.CURSOR_ROUTE_OR_CACHE_PATH?.trim() ||
|
|
28
|
+
join(tmpdir(), "cursor-route-or-best-free.json");
|
|
29
|
+
}
|
|
30
|
+
export function assertOpenRouterModel(id) {
|
|
31
|
+
if (!/^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._\-:]+)+$/i.test(id)) {
|
|
32
|
+
throw new Error(`Invalid OpenRouter model ${id}; expected provider/model (e.g. qwen/qwen3-coder:free) or free`);
|
|
33
|
+
}
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
function num(v) {
|
|
37
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
38
|
+
return v;
|
|
39
|
+
if (typeof v === "string" && v.trim()) {
|
|
40
|
+
const n = Number(v);
|
|
41
|
+
if (Number.isFinite(n))
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
44
|
+
return NaN;
|
|
45
|
+
}
|
|
46
|
+
export function isOrFreeModel(m) {
|
|
47
|
+
const id = (m.id || "").trim();
|
|
48
|
+
if (!id)
|
|
49
|
+
return false;
|
|
50
|
+
if (EXCLUDE_RE.test(id) || EXCLUDE_RE.test(m.name || ""))
|
|
51
|
+
return false;
|
|
52
|
+
const modality = m.architecture?.modality;
|
|
53
|
+
if (typeof modality === "string" && modality.trim() && !/text/i.test(modality)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const prompt = num(m.pricing?.prompt);
|
|
57
|
+
const completion = num(m.pricing?.completion);
|
|
58
|
+
const pricedFree = prompt === 0 && completion === 0;
|
|
59
|
+
const tagged = id.endsWith(":free");
|
|
60
|
+
return tagged || pricedFree;
|
|
61
|
+
}
|
|
62
|
+
/** Higher boost wins. Coding/chat families beat generic free. No hardcoded id. */
|
|
63
|
+
export function orFreeBoost(id) {
|
|
64
|
+
return BOOST_RE.test(id) ? 20 : 10;
|
|
65
|
+
}
|
|
66
|
+
export function rankOrFreeModels(models) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const m of models) {
|
|
69
|
+
if (!isOrFreeModel(m))
|
|
70
|
+
continue;
|
|
71
|
+
const raw = (m.id || "").trim();
|
|
72
|
+
let id;
|
|
73
|
+
try {
|
|
74
|
+
id = assertOpenRouterModel(raw);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const ctx = Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : 0;
|
|
80
|
+
out.push({
|
|
81
|
+
id,
|
|
82
|
+
name: (m.name || raw).trim(),
|
|
83
|
+
context_length: ctx,
|
|
84
|
+
boost: orFreeBoost(id),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
out.sort((a, b) => {
|
|
88
|
+
if (b.boost !== a.boost)
|
|
89
|
+
return b.boost - a.boost;
|
|
90
|
+
if (b.context_length !== a.context_length)
|
|
91
|
+
return b.context_length - a.context_length;
|
|
92
|
+
return a.id.localeCompare(b.id);
|
|
93
|
+
});
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
function readCache() {
|
|
97
|
+
const p = cachePath();
|
|
98
|
+
if (!existsSync(p))
|
|
99
|
+
return null;
|
|
100
|
+
try {
|
|
101
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
102
|
+
if (!j?.id)
|
|
103
|
+
return null;
|
|
104
|
+
const ageMs = Date.now() - Date.parse(j.picked_at);
|
|
105
|
+
if (!Number.isFinite(ageMs) || ageMs < 0)
|
|
106
|
+
return null;
|
|
107
|
+
if (ageMs > cacheMinutes() * 60_000)
|
|
108
|
+
return null;
|
|
109
|
+
assertOpenRouterModel(j.id);
|
|
110
|
+
return j;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function writeCache(pick) {
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(cachePath(), JSON.stringify(pick), { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* cache is optional */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function httpGetSync(url, timeoutMs = 4000) {
|
|
125
|
+
const sec = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
126
|
+
// Public GET /models — no Authorization header (key would land in `ps` argv).
|
|
127
|
+
const curl = spawnSync("curl", ["-fsS", "--max-time", String(sec), "-H", "Accept: application/json", url], {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
timeout: timeoutMs + 500,
|
|
130
|
+
env: { ...process.env },
|
|
131
|
+
});
|
|
132
|
+
if (curl.status === 0 && curl.stdout?.trim())
|
|
133
|
+
return curl.stdout;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function parseCatalog(raw) {
|
|
137
|
+
const j = JSON.parse(raw);
|
|
138
|
+
if (Array.isArray(j))
|
|
139
|
+
return j;
|
|
140
|
+
if (Array.isArray(j.data))
|
|
141
|
+
return j.data;
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
export function loadOrCatalog() {
|
|
145
|
+
const inline = process.env.CURSOR_ROUTE_OR_CATALOG_JSON?.trim();
|
|
146
|
+
if (inline)
|
|
147
|
+
return parseCatalog(inline);
|
|
148
|
+
if (process.env.CURSOR_ROUTE_OR_OFFLINE === "1")
|
|
149
|
+
return [];
|
|
150
|
+
const body = httpGetSync(orModelsUrl());
|
|
151
|
+
if (!body)
|
|
152
|
+
return [];
|
|
153
|
+
try {
|
|
154
|
+
return parseCatalog(body);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Last live pick if the cache is still fresh — health-safe (no network). */
|
|
161
|
+
export function cachedOrFreePick() {
|
|
162
|
+
try {
|
|
163
|
+
return readCache()?.id ?? null;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Best live OpenRouter free text model, or OPENROUTER_FALLBACK_MODEL if the
|
|
171
|
+
* catalog is empty/offline. `CURSOR_ROUTE_OR_REFRESH=1` skips cache.
|
|
172
|
+
* Tests: `CURSOR_ROUTE_OR_OFFLINE=1` or `CURSOR_ROUTE_OR_CATALOG_JSON`.
|
|
173
|
+
*/
|
|
174
|
+
export function pickOrFreeModel(opts) {
|
|
175
|
+
const refresh = opts?.refresh ||
|
|
176
|
+
process.env.CURSOR_ROUTE_OR_REFRESH === "1";
|
|
177
|
+
if (!refresh && opts?.catalog === undefined) {
|
|
178
|
+
const cached = readCache();
|
|
179
|
+
if (cached)
|
|
180
|
+
return cached.id;
|
|
181
|
+
}
|
|
182
|
+
const models = opts?.catalog ?? loadOrCatalog();
|
|
183
|
+
const ranked = rankOrFreeModels(models);
|
|
184
|
+
const winner = ranked[0];
|
|
185
|
+
if (!winner) {
|
|
186
|
+
// Do not cache fallback — a 15-min stale fallback would masquerade as a
|
|
187
|
+
// live pick in health (`now openrouter/free`). Retry the catalog next call.
|
|
188
|
+
return OPENROUTER_FALLBACK_MODEL;
|
|
189
|
+
}
|
|
190
|
+
const pick = {
|
|
191
|
+
id: winner.id,
|
|
192
|
+
name: winner.name,
|
|
193
|
+
context_length: winner.context_length,
|
|
194
|
+
boost: winner.boost,
|
|
195
|
+
picked_at: new Date().toISOString(),
|
|
196
|
+
candidates: ranked.length,
|
|
197
|
+
source: "ranked-free",
|
|
198
|
+
};
|
|
199
|
+
if (opts?.catalog === undefined)
|
|
200
|
+
writeCache(pick);
|
|
201
|
+
return pick.id;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Resolve OpenRouter `--model` / env to a concrete `provider/model` id.
|
|
205
|
+
* Empty or `free` → CURSOR_ROUTE_OPENROUTER_MODEL pin, else live catalog pick.
|
|
206
|
+
*/
|
|
207
|
+
export function openRouterModel(raw) {
|
|
208
|
+
const v = (raw ?? "").trim();
|
|
209
|
+
if (!v || v.toLowerCase() === "free") {
|
|
210
|
+
const env = (process.env.CURSOR_ROUTE_OPENROUTER_MODEL ?? "").trim();
|
|
211
|
+
if (env && env.toLowerCase() !== "free")
|
|
212
|
+
return assertOpenRouterModel(env);
|
|
213
|
+
return pickOrFreeModel();
|
|
214
|
+
}
|
|
215
|
+
return assertOpenRouterModel(v);
|
|
216
|
+
}
|
package/docs/briefs/WORKING.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: cursor-route workspace — working brief (edit in place)
|
|
3
3
|
repo: ~/Projects/cursor-route
|
|
4
|
-
npm: cursor-route@0.1.
|
|
4
|
+
npm: cursor-route@0.1.13 (publishing this slice)
|
|
5
5
|
created: 2026-08-12
|
|
6
|
-
updated: 2026-08-
|
|
6
|
+
updated: 2026-08-29
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# cursor-route — living brief
|
|
@@ -18,21 +18,21 @@ Cursor Agent plans. Workers run in tmux via `cursor-route`:
|
|
|
18
18
|
|
|
19
19
|
| Lane | Worker | Intent |
|
|
20
20
|
|------|--------|--------|
|
|
21
|
-
| `easy` | OpenRouter free | Wording / drafts — non-secret prompts only |
|
|
22
|
-
| `mid` | claude-ds (DeepSeek behind Claude Code) | Default implement (**Flash**; `--model pro`
|
|
21
|
+
| `easy` | OpenRouter free (live pick) | Wording / drafts — non-secret prompts only |
|
|
22
|
+
| `mid` | claude-ds (DeepSeek behind Claude Code) | Default implement (**Flash**; `--model vision` for screenshots; `--model pro` harder mid / hard backup only) |
|
|
23
23
|
| `hard` | Grok CLI | Hard implement |
|
|
24
24
|
| opt-in | OpenCode | `--worker opencode` coding agent; `--model free` ranks live Zen catalog (Ox Alpha first while listed) |
|
|
25
25
|
|
|
26
26
|
Always-approve on for coding worktrees (`--ask` / `CURSOR_ROUTE_ASK=1` to opt out) — not LIVE Discord/trading. Jobs live in `~/.local/share/cursor-route/jobs`, not in this clone.
|
|
27
27
|
|
|
28
|
-
Install: `npm i -g cursor-route` → **0.1.
|
|
28
|
+
Install: `npm i -g cursor-route` → **0.1.13**. Release notes: [CHANGELOG.md](../../CHANGELOG.md).
|
|
29
29
|
|
|
30
30
|
## Open (edit / check off)
|
|
31
31
|
|
|
32
32
|
- [x] **Flash vs Pro on the CLI** — public default **Flash**; `--model pro` → `deepseek-v4-pro` (LIVE 0.1.6+)
|
|
33
33
|
- [x] pass `claude-ds -Model deepseek-v4-flash|deepseek-v4-pro` from the adapter
|
|
34
34
|
- [x] add `--model flash|pro` on `start`
|
|
35
|
-
- [x] document Grok **auth** ≠ usage-out (`grok login`)
|
|
35
|
+
- [x] document Grok **auth** ≠ usage-out (`grok login`); Flash-first when Grok **usage** is out; Pro = harder mid / hard backup only (0.1.12)
|
|
36
36
|
- [x] **Skill `route-orch`** — Flash/Pro table in `skills/` + `.cursor/skills/`
|
|
37
37
|
- [x] **Official DeepSeek Harness** — `deepseek` adapter slot present; `@deepseek-ai/dsh` 0.1.0-rc.6 (2026-08-13, github.com/deepseek-ai/deepseek-harness, MIT) is a developer-preview plugin kernel (web UI + `dsh --profile headless "job"`), not a Claude Code replacement; mid does not swap
|
|
38
38
|
- [x] **0.1.7 debug fixes** — env default wired through `startJob`; Anthropic hatch omits DS ids; preserve `[1m]`
|
|
@@ -41,6 +41,8 @@ Install: `npm i -g cursor-route` → **0.1.11**. Release notes: [CHANGELOG.md](.
|
|
|
41
41
|
- [x] **Health proves mid DeepSeek + evidence tree** — 0.1.9: `lane:mid` ✓ only when DeepSeek is proven (shim or DeepSeek `ANTHROPIC_BASE_URL`; Anthropic hatch is not proof); health JSON `lanes.mid`; `status --json` evidence tree (`spawn` / `execute` / `verify.claim=unverified`); skill health-before-mid + evidence-tree closeout. Mid stays **claude-ds**.
|
|
42
42
|
- [x] **Opt-in `--worker opencode`** — 0.1.10: `opencode run --dir` + `--model`; always-approve → `--auto`; `--ask` omits it; never rewrites `~/.config/opencode/opencode.json`. Health ✓ needs `opencode` on PATH (`CURSOR_ROUTE_OPENCODE_BIN`). Mid stays **claude-ds**; easy stays OpenRouter chat. Free Zen may log/train — non-secret prompts.
|
|
43
43
|
- [x] **Live Zen free pick** — 0.1.11: `--model free` ranks `GET https://opencode.ai/zen/v1/models` (Ox Alpha first while listed; ~15 min cache; Ox Alpha fallback if fetch fails). Pin with `CURSOR_ROUTE_OPENCODE_MODEL`. Health stays offline (cache/fallback only).
|
|
44
|
+
- [x] **Flash-first + vision + live OpenRouter pick** — 0.1.12: `--model flash` stays the cheap Grok-out default; `--model vision` + screenshot auto-pick; easy lane live-picks free OpenRouter text models (`openrouter/free` is fetch-fail fallback only). Health never fetches `/models`. `route-orch` ProgRouter + MoRe one-liners. Hero fixture regenerated to 0.1.12. No npm publish in this slice.
|
|
45
|
+
- [x] **Kimi 0.1.13 follow-up** — do not cache OpenRouter fallback; health labels uncached fallback; no Authorization on GET /models; env-invalid model error names the env vars; `\bimage\b` vision trigger.
|
|
44
46
|
- [ ] **Hero GIF** — still outstanding; dry-run fixture ships as the substitute for now (`docs/fixtures/hero-demo.log` — see `docs/DEMO_GIF.md`)
|
|
45
47
|
- [x] **Do not** paste private `ROUTE_KIT`, SIP, prod paths, or hang-watchdog env into this public repo
|
|
46
48
|
|
|
@@ -55,6 +57,7 @@ Install: `npm i -g cursor-route` → **0.1.11**. Release notes: [CHANGELOG.md](.
|
|
|
55
57
|
| `src/adapters/openrouter.ts` | Easy worker |
|
|
56
58
|
| `src/adapters/opencode.ts` | Opt-in OpenCode worker (`--worker opencode`; mid stays claude-ds) |
|
|
57
59
|
| `src/zen-free.ts` | Live Zen catalog rank for `--model free` |
|
|
60
|
+
| `src/or-free.ts` | Live OpenRouter catalog rank for easy-lane `--model free` |
|
|
58
61
|
| `skills/route-orch/SKILL.md` | Cursor skill — spawn CLI, do not implement in-session |
|
|
59
62
|
| `CHANGELOG.md` | Release notes |
|
|
60
63
|
| `SECURITY.md` | Secret refuse gate |
|
|
@@ -75,3 +78,5 @@ Install: `npm i -g cursor-route` → **0.1.11**. Release notes: [CHANGELOG.md](.
|
|
|
75
78
|
| 2026-08-18 | Health `lane:mid` + `lanes.mid` prove DeepSeek; status evidence tree (`verify.claim` stays unverified); skill health-before-mid + closeout tree; mid stays claude-ds → 0.1.9 LIVE. |
|
|
76
79
|
| 2026-08-21 | Opt-in `--worker opencode` (Zen free `opencode/big-pickle`, `--auto`, no config rewrite); mid stays claude-ds → 0.1.10 LIVE. |
|
|
77
80
|
| 2026-08-21 | Live Zen free pick (Ox Alpha first while listed; OpenRouter-style catalog rank) → 0.1.11 LIVE. |
|
|
81
|
+
| 2026-08-29 | Flash-first docs, `--model vision` + auto-pick, live OpenRouter free pick, route-orch K318/K322, hero fixture 0.1.12. No npm publish. |
|
|
82
|
+
| 2026-08-29 | Kimi audit follow-up → 0.1.13: no fallback cache, health labels, unauth GET /models, env error wording. |
|
package/docs/demo-notes.md
CHANGED
|
@@ -6,10 +6,10 @@ Simulated capture output for README / tweet assets — replace with a real GIF o
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
$ cursor-route --version
|
|
9
|
-
0.1.
|
|
9
|
+
0.1.13
|
|
10
10
|
|
|
11
11
|
$ cursor-route health
|
|
12
|
-
cursor-route v0.1.
|
|
12
|
+
cursor-route v0.1.13
|
|
13
13
|
health: OK
|
|
14
14
|
✓ tmux
|
|
15
15
|
✓ runtime bun ok
|
|
@@ -32,7 +32,7 @@ $ cursor-route jobs --json
|
|
|
32
32
|
|
|
33
33
|
Verified locally (2026-08-10): headless `claude-ds` smoke returned `CURSOR_ROUTE_SMOKE_OK`.
|
|
34
34
|
Grok smoke hit 402 (Build usage balance exhausted) — auth/PATH wiring works; top up Grok Build for live demos.
|
|
35
|
-
Use `--model
|
|
35
|
+
Use `--model flash` when Grok **usage** is out (cheap default). `--model pro` is harder mid / hard backup only — not the default Grok-out stand-in. A missing `grok login` is auth, not the Pro case.
|
|
36
36
|
|
|
37
37
|
Current commands: `health`, `start`, `jobs`, `status`, `capture`, `send`, `attach`, `kill`, `sessions`, `clean`.
|
|
38
38
|
Headless demos (no tmux) use `--no-tmux` and `capture`/`status` instead of `attach`/`send`.
|
|
@@ -23,8 +23,11 @@ unset CURSOR_ROUTE_CLAUDE_DS_BIN CURSOR_ROUTE_GROK_BIN CURSOR_ROUTE_DSH_BIN \
|
|
|
23
23
|
CURSOR_ROUTE_ALLOW_ANTHROPIC \
|
|
24
24
|
CURSOR_ROUTE_ALLOW_STOCK_CLAUDE CURSOR_ROUTE_ANTHROPIC_BASE_URL CURSOR_ROUTE_DS_MODEL \
|
|
25
25
|
CURSOR_ROUTE_OPENROUTER_MODEL OPENROUTER_API_KEY OPENROUTER_BASE_URL \
|
|
26
|
+
CURSOR_ROUTE_OR_CATALOG_JSON CURSOR_ROUTE_OR_CACHE_PATH CURSOR_ROUTE_OR_REFRESH \
|
|
26
27
|
ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_MODEL \
|
|
27
28
|
DEEPSEEK_API_KEY XAI_API_KEY 2>/dev/null || true
|
|
29
|
+
# Stable easy-lane dry-run: do not fetch the live catalog while generating the fixture.
|
|
30
|
+
export CURSOR_ROUTE_OR_OFFLINE=1
|
|
28
31
|
|
|
29
32
|
TMP="$(mktemp -d)"
|
|
30
33
|
trap 'rm -rf "$TMP"' EXIT
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
$ cursor-route --version
|
|
2
|
-
0.1.
|
|
2
|
+
0.1.13
|
|
3
3
|
|
|
4
4
|
$ CURSOR_ROUTE_RELAXED=1 cursor-route health
|
|
5
|
-
cursor-route v0.1.
|
|
5
|
+
cursor-route v0.1.13
|
|
6
6
|
health: OK
|
|
7
7
|
|
|
8
8
|
✓ tmux ok
|
|
@@ -10,8 +10,9 @@ health: OK
|
|
|
10
10
|
✓ script(1) ok (tty log capture)
|
|
11
11
|
✓ worker:grok ok (auth checked at first start — run grok login if jobs fail) @ ~/.grok/bin/grok
|
|
12
12
|
✓ worker:claude-ds ok (claude-ds (DeepSeek shim); default model deepseek-v4-flash) @ ~/.local/bin/claude-ds
|
|
13
|
-
✗ worker:openrouter OPENROUTER_API_KEY not set — export your OpenRouter key (easy lane model
|
|
13
|
+
✗ worker:openrouter OPENROUTER_API_KEY not set — export your OpenRouter key (easy lane live-picks a free model at start; pin with CURSOR_ROUTE_OPENROUTER_MODEL) @ node '~/Projects/cursor-route/dist/openrouter-run.js'
|
|
14
14
|
✗ worker:deepseek dsh (@deepseek-ai/dsh) not found — install: npm i -g @deepseek-ai/dsh. Mid default remains claude-ds.
|
|
15
|
+
✓ worker:opencode ok (opencode run; --model free = live Zen pick, now opencode/x-preview-f-free; auth at first start — opencode auth login if jobs fail; mid default remains claude-ds) @ ~/.local/bin/opencode
|
|
15
16
|
✓ lane:mid DeepSeek proven (claude-ds (DeepSeek shim))
|
|
16
17
|
✓ cursor_cli optional ok (agent on PATH) — v0 supervisor is Cursor skill, not CLI
|
|
17
18
|
✓ relaxed CURSOR_ROUTE_RELAXED=1 — tmux optional (headless OK)
|
|
@@ -26,6 +27,7 @@ command: cd '~/Projects/cursor-route' && '~/.local/bin/claude-ds' -PromptFile '~
|
|
|
26
27
|
$ cursor-route start --lane easy --dry-run "Rewrite this FAQ answer in 3 sentences"
|
|
27
28
|
dry-run job b2c3d4e5
|
|
28
29
|
worker: openrouter
|
|
30
|
+
model: openrouter/free
|
|
29
31
|
command: node '~/Projects/cursor-route/dist/openrouter-run.js' --prompt-file '~/.local/share/cursor-route/jobs/b2c3d4e5.prompt'
|
|
30
32
|
|
|
31
33
|
$ cursor-route start --lane hard --dry-run "Refactor auth module; run tests; report verify evidence"
|
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Cursor stays the planner. DeepSeek (mid), Grok CLI (hard), and OpenRouter free models (easy) run parallel coding workers in tmux. `--worker opencode` is an opt-in coding agent on OpenCode Zen free models.
|
|
4
4
|
|
|
5
|
-
MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route (latest **0.1.
|
|
5
|
+
MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route (latest **0.1.13**)
|
|
6
6
|
GitHub: https://github.com/cemini23/cursor-route
|
|
7
7
|
|
|
8
8
|
## FAQ
|
|
@@ -14,13 +14,15 @@ cursor-route is a public MIT CLI and Cursor skill that runs parallel coding work
|
|
|
14
14
|
It uses the familiar strategist and worker-pane shape, but it is not a Codex clone. cursor-route uses Cursor as the planner and DeepSeek plus Grok CLI as workers. Codex is not required.
|
|
15
15
|
|
|
16
16
|
### Does mid lane use Anthropic Claude?
|
|
17
|
-
No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic` and a DeepSeek key in `ANTHROPIC_AUTH_TOKEN`. Default model is Flash (`--model flash`)
|
|
17
|
+
No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic` and a DeepSeek key in `ANTHROPIC_AUTH_TOKEN`. Default model is Flash (`--model flash`) — keep Flash when Grok **usage** is out. `--model vision` is for screenshots / ui mocks. `--model pro` is harder mid / **hard backup only**, not the default Grok-out stand-in. A missing `grok login` is auth, not the Pro case.
|
|
18
18
|
|
|
19
19
|
### Flash vs Pro?
|
|
20
20
|
| Flag | Model | When |
|
|
21
21
|
|------|-------|------|
|
|
22
|
-
| `--model flash` (default) | `deepseek-v4-flash` | Cheap mid execute |
|
|
23
|
-
| `--model
|
|
22
|
+
| `--model flash` (default) | `deepseek-v4-flash` | Cheap mid execute. Prefer this when Grok **usage** is out |
|
|
23
|
+
| `--model vision` | `deepseek-v4-flash-vision-exp` | Screenshots / ui mocks / image prompts (or auto-pick) |
|
|
24
|
+
| `--model pro` | `deepseek-v4-pro` | Harder mid / **hard backup** only — not the default Grok-out stand-in |
|
|
25
|
+
| `--model deepseek-v4-pro[1m]` | preserved SKU | Large-context Pro |
|
|
24
26
|
|
|
25
27
|
### How do I use OpenCode free models?
|
|
26
28
|
Install OpenCode (`npm i -g opencode-ai`), run `opencode auth login`, then `cursor-route start --worker opencode "…"`. `--model free` ranks live Zen free models (Ox Alpha first while listed). Mid stays `claude-ds`.
|
|
@@ -29,12 +31,12 @@ Install OpenCode (`npm i -g opencode-ai`), run `opencode auth login`, then `curs
|
|
|
29
31
|
Run `npm i -g cursor-route`, install tmux if needed, then run `cursor-route health`. Copy the Cursor skill from `$(npm root -g)/cursor-route/skills/route-orch`. Source: https://github.com/cemini23/cursor-route
|
|
30
32
|
|
|
31
33
|
### Is it free?
|
|
32
|
-
The cursor-route code is open source under MIT. It does not make the worker services free. Your costs depend on Cursor, DeepSeek API usage, and the Grok access or balance available to you. The easy lane
|
|
34
|
+
The cursor-route code is open source under MIT. It does not make the worker services free. Your costs depend on Cursor, DeepSeek API usage, and the Grok access or balance available to you. The easy lane live-picks a free OpenRouter text model at start (fallback router `openrouter/free` only if the catalog fetch fails). `--worker opencode` can use OpenCode Zen free models.
|
|
33
35
|
|
|
34
36
|
## Install
|
|
35
37
|
|
|
36
38
|
- npm: `npm i -g cursor-route`
|
|
37
39
|
- Health: `cursor-route health`
|
|
38
|
-
- Mid: `cursor-route start --lane mid "…"` (Flash) · `--model pro`
|
|
40
|
+
- Mid: `cursor-route start --lane mid "…"` (Flash; keep Flash when Grok usage is out) · `--model vision` for screenshots · `--model pro` harder mid / hard backup only
|
|
39
41
|
- OpenCode (opt-in): `cursor-route start --worker opencode "…"`
|
|
40
42
|
- Skill: `/route-orch` (Cursor)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-route",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter easy lane + OpenCode (opt-in free) are the parallel army \u2014 lane-aware /route orchestration in tmux.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,27 +24,30 @@ You are the **orchestrator**. Do **not** implement bulk code in this Cursor sess
|
|
|
24
24
|
|
|
25
25
|
| Lane | Worker | Use when |
|
|
26
26
|
|------|--------|----------|
|
|
27
|
-
| `easy` | `openrouter` (OpenRouter free
|
|
27
|
+
| `easy` | `openrouter` (live OpenRouter free pick) | Wording / drafts — non-secret prompts only |
|
|
28
28
|
| `mid` | `claude-ds` (DeepSeek via Claude Code harness) | Standard implement / refactor |
|
|
29
29
|
| `hard` | `grok` | Premium plan in Cursor → Grok implement |
|
|
30
30
|
|
|
31
|
-
Free OpenRouter models may log prompts — keep secrets off the easy lane (the CLI refuse gate still applies).
|
|
31
|
+
Free OpenRouter models may log prompts — keep secrets off the easy lane (the CLI refuse gate still applies). Easy lane **live-picks** the best free text model at request time (unset / `--model free`). Pin with `CURSOR_ROUTE_OPENROUTER_MODEL` or `--model provider/model`. Fetch-fail fallback is the OpenRouter router `openrouter/free` only.
|
|
32
32
|
|
|
33
33
|
## claude-ds models
|
|
34
34
|
|
|
35
35
|
One harness. Do not install a second coding loop.
|
|
36
36
|
|
|
37
|
-
| Flag
|
|
38
|
-
|
|
39
|
-
|
|
|
40
|
-
|
|
|
37
|
+
| Flag | Model id | When |
|
|
38
|
+
|------|----------|------|
|
|
39
|
+
| `--model flash` (default) | `deepseek-v4-flash` | Cheap mid execute. Prefer this when Grok **usage** is out |
|
|
40
|
+
| `--model vision` | `deepseek-v4-flash-vision-exp` | Screenshots / ui mocks / image prompts (or auto-pick) |
|
|
41
|
+
| `--model pro` | `deepseek-v4-pro` | Harder mid / **hard backup** only — not the default Grok-out stand-in |
|
|
42
|
+
| `--model deepseek-v4-pro[1m]` | preserved SKU | Large-context Pro |
|
|
41
43
|
|
|
42
44
|
```bash
|
|
43
45
|
cursor-route start --lane mid --dir "$PWD" "…"
|
|
46
|
+
cursor-route start --lane mid --model vision --dir "$PWD" "…"
|
|
44
47
|
cursor-route start --lane mid --model pro --dir "$PWD" "…"
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
If `worker:grok` is ✗ on health, that is usually **auth** (`grok login` / `XAI_API_KEY`) — not the Pro
|
|
50
|
+
If `worker:grok` is ✗ on health, that is usually **auth** (`grok login` / `XAI_API_KEY`) — not the Pro case. When Grok **usage** is out, stay on Flash.
|
|
48
51
|
|
|
49
52
|
## Experimental: --worker deepseek (dsh)
|
|
50
53
|
|
|
@@ -116,6 +119,8 @@ Verify criteria are an **external eval contract** (AutoDesign pattern), fixed by
|
|
|
116
119
|
- **External eval contract (AutoDesign):** do not rewrite Verify / Success criteria mid-run to make a failing job look green — capture + exit status are the contract (see Verify / claim closeout).
|
|
117
120
|
- **Skill misevolution:** do not auto-edit `route-orch` or promote skill variants from worker trajectories without operator HITL — write-time approval ≠ safe retrieval later.
|
|
118
121
|
- **On verify fail:** prefer reconsidering the plan/definition (wrong approach) over grinding the same tactic; attribute failure to stage when possible (spawn vs execute vs verify).
|
|
122
|
+
- **Step-wise re-route (ProgRouter):** `--lane` is the first pick. If progress stalls or verify fails, `send` a correction or spawn a follow-up on a different worker/model — do not lock the first worker for the whole job. Parent still closes on capture/exit evidence.
|
|
123
|
+
- **Do not auto-spawn N panes for multi-perspective:** spawn workers for **implementation parallelism** (independent files/jobs). Multi-perspective reasoning stays in the Cursor parent. Do not fan out “one agent per viewpoint.”
|
|
119
124
|
|
|
120
125
|
## Always-approve
|
|
121
126
|
|
|
@@ -212,7 +212,7 @@ export const claudeDsAdapter: Adapter = {
|
|
|
212
212
|
resolved.mode.startsWith("claude-ds") || resolved.mode.startsWith("deepseek-claude");
|
|
213
213
|
|
|
214
214
|
// Anthropic escape hatch: do not pass DeepSeek model ids (unknown to Anthropic).
|
|
215
|
-
// --model flash|pro is DeepSeek-only; stock Claude uses its own defaults / ANTHROPIC_MODEL.
|
|
215
|
+
// --model flash|pro|vision is DeepSeek-only; stock Claude uses its own defaults / ANTHROPIC_MODEL.
|
|
216
216
|
if (isAnthropicEscape) {
|
|
217
217
|
const parts = [
|
|
218
218
|
shellQuote(resolved.binary),
|
|
@@ -11,6 +11,10 @@ afterEach(() => {
|
|
|
11
11
|
delete process.env.OPENROUTER_API_KEY;
|
|
12
12
|
delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
13
13
|
delete process.env.OPENROUTER_BASE_URL;
|
|
14
|
+
delete process.env.CURSOR_ROUTE_OR_OFFLINE;
|
|
15
|
+
delete process.env.CURSOR_ROUTE_OR_CATALOG_JSON;
|
|
16
|
+
delete process.env.CURSOR_ROUTE_OR_CACHE_PATH;
|
|
17
|
+
delete process.env.CURSOR_ROUTE_OR_REFRESH;
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
describe("openrouter adapter", () => {
|
|
@@ -20,13 +24,38 @@ describe("openrouter adapter", () => {
|
|
|
20
24
|
expect(h.worker).toBe("openrouter");
|
|
21
25
|
expect(h.ok).toBe(false);
|
|
22
26
|
expect(h.detail).toContain("OPENROUTER_API_KEY");
|
|
27
|
+
expect(h.detail).not.toMatch(/defaults to openrouter\/free/);
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
test("health passes with a fake key (no network)", () => {
|
|
26
31
|
setKey();
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
const prev = {
|
|
33
|
+
model: process.env.CURSOR_ROUTE_OPENROUTER_MODEL,
|
|
34
|
+
offline: process.env.CURSOR_ROUTE_OR_OFFLINE,
|
|
35
|
+
json: process.env.CURSOR_ROUTE_OR_CATALOG_JSON,
|
|
36
|
+
cache: process.env.CURSOR_ROUTE_OR_CACHE_PATH,
|
|
37
|
+
};
|
|
38
|
+
delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
39
|
+
process.env.CURSOR_ROUTE_OR_OFFLINE = "1";
|
|
40
|
+
delete process.env.CURSOR_ROUTE_OR_CATALOG_JSON;
|
|
41
|
+
process.env.CURSOR_ROUTE_OR_CACHE_PATH = "/tmp/cursor-route-or-health-missing.json";
|
|
42
|
+
try {
|
|
43
|
+
const h = openRouterAdapter.health();
|
|
44
|
+
expect(h.ok).toBe(true);
|
|
45
|
+
expect(h.detail.toLowerCase()).toMatch(/live pick/);
|
|
46
|
+
expect(h.detail).toMatch(/fallback/);
|
|
47
|
+
expect(h.detail).not.toMatch(/now openrouter\/free/);
|
|
48
|
+
expect(h.detail).not.toMatch(/defaults to openrouter\/free/);
|
|
49
|
+
} finally {
|
|
50
|
+
if (prev.model === undefined) delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
51
|
+
else process.env.CURSOR_ROUTE_OPENROUTER_MODEL = prev.model;
|
|
52
|
+
if (prev.offline === undefined) delete process.env.CURSOR_ROUTE_OR_OFFLINE;
|
|
53
|
+
else process.env.CURSOR_ROUTE_OR_OFFLINE = prev.offline;
|
|
54
|
+
if (prev.json === undefined) delete process.env.CURSOR_ROUTE_OR_CATALOG_JSON;
|
|
55
|
+
else process.env.CURSOR_ROUTE_OR_CATALOG_JSON = prev.json;
|
|
56
|
+
if (prev.cache === undefined) delete process.env.CURSOR_ROUTE_OR_CACHE_PATH;
|
|
57
|
+
else process.env.CURSOR_ROUTE_OR_CACHE_PATH = prev.cache;
|
|
58
|
+
}
|
|
30
59
|
});
|
|
31
60
|
|
|
32
61
|
test("buildLaunch passes key via env and never echoes it in the command", () => {
|
|
@@ -3,7 +3,11 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { Adapter, WorkerHealth } from "./types.ts";
|
|
5
5
|
import { shellQuote } from "../util.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
cachedOrFreePick,
|
|
8
|
+
OPENROUTER_FALLBACK_MODEL,
|
|
9
|
+
openRouterBaseUrl,
|
|
10
|
+
} from "../config.ts";
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Resolve how to invoke the one-shot runner. Prefer the compiled dist via node
|
|
@@ -22,11 +26,11 @@ function resolveRunner(): { command: string } | null {
|
|
|
22
26
|
return null;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
function openRouterEnv(): Record<string, string> | undefined {
|
|
29
|
+
function openRouterEnv(modelId?: string): Record<string, string> | undefined {
|
|
26
30
|
const key = process.env.OPENROUTER_API_KEY;
|
|
27
31
|
if (!key) return undefined;
|
|
28
32
|
const env: Record<string, string> = { OPENROUTER_API_KEY: key };
|
|
29
|
-
const model = process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
33
|
+
const model = modelId || process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
30
34
|
if (model) env.CURSOR_ROUTE_OPENROUTER_MODEL = model;
|
|
31
35
|
const base = process.env.OPENROUTER_BASE_URL;
|
|
32
36
|
if (base) env.OPENROUTER_BASE_URL = base;
|
|
@@ -44,7 +48,7 @@ export const openRouterAdapter: Adapter = {
|
|
|
44
48
|
ok: false,
|
|
45
49
|
binary: runner?.command ?? null,
|
|
46
50
|
detail:
|
|
47
|
-
"OPENROUTER_API_KEY not set — export your OpenRouter key (easy lane model
|
|
51
|
+
"OPENROUTER_API_KEY not set — export your OpenRouter key (easy lane live-picks a free model at start; pin with CURSOR_ROUTE_OPENROUTER_MODEL)",
|
|
48
52
|
};
|
|
49
53
|
}
|
|
50
54
|
if (!runner) {
|
|
@@ -55,19 +59,25 @@ export const openRouterAdapter: Adapter = {
|
|
|
55
59
|
detail: "openrouter-run not found — run bun run build (or use Bun from a source clone)",
|
|
56
60
|
};
|
|
57
61
|
}
|
|
62
|
+
// Health stays offline: ranked cache hit, else label the router fallback
|
|
63
|
+
// (do not present openrouter/free as a fresh live pick).
|
|
64
|
+
const cached = cachedOrFreePick();
|
|
65
|
+
const detail = cached
|
|
66
|
+
? `ok (live pick at start; cached ${cached} @ ${openRouterBaseUrl()})`
|
|
67
|
+
: `ok (live pick at start; no catalog cache, fetch-fail fallback ${OPENROUTER_FALLBACK_MODEL} @ ${openRouterBaseUrl()})`;
|
|
58
68
|
return {
|
|
59
69
|
worker: "openrouter",
|
|
60
70
|
ok: true,
|
|
61
71
|
binary: runner.command,
|
|
62
|
-
detail
|
|
72
|
+
detail,
|
|
63
73
|
};
|
|
64
74
|
},
|
|
65
|
-
buildLaunch({ promptFile }) {
|
|
75
|
+
buildLaunch({ promptFile, modelId }) {
|
|
66
76
|
const runner = resolveRunner();
|
|
67
77
|
if (!runner) throw new Error("openrouter runner not available — run: bun run build");
|
|
68
78
|
// Missing key is tolerated here so `--dry-run` can still print the command;
|
|
69
79
|
// real starts are gated by the health preflight (which requires the key).
|
|
70
|
-
const env = openRouterEnv();
|
|
80
|
+
const env = openRouterEnv(modelId);
|
|
71
81
|
|
|
72
82
|
// No interactive approval concept for a pure HTTP call — nothing to auto-approve.
|
|
73
83
|
return {
|
package/src/adapters/types.ts
CHANGED
|
@@ -24,9 +24,9 @@ export interface Adapter {
|
|
|
24
24
|
promptFile: string;
|
|
25
25
|
cwd: string;
|
|
26
26
|
alwaysApprove: boolean;
|
|
27
|
-
/** Mid-lane DeepSeek flash|pro (claude-ds + deepseek; ignored by grok
|
|
27
|
+
/** Mid-lane DeepSeek flash|pro|vision (claude-ds + deepseek; ignored by grok / Anthropic escape hatch). */
|
|
28
28
|
model?: DsModelAlias;
|
|
29
|
-
/** Concrete DeepSeek id (preserves pro[1m]) or OpenCode provider/model. */
|
|
29
|
+
/** Concrete DeepSeek id (preserves pro[1m]) or OpenCode/OpenRouter 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;
|