cursor-route 0.1.1 → 0.1.5
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/README.md +79 -12
- package/SECURITY.md +8 -5
- package/SUPPORT.md +24 -0
- package/bin/cursor-route +6 -8
- package/bin/cursor-route.js +11 -8
- package/dist/adapters/claude-ds.js +189 -0
- package/dist/adapters/grok.js +57 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/openrouter.js +78 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +434 -0
- package/dist/config.js +54 -0
- package/dist/health.js +100 -0
- package/dist/jobs.js +410 -0
- package/dist/mark-complete.js +38 -0
- package/dist/openrouter-run.js +95 -0
- package/dist/runtime.js +27 -0
- package/dist/secrets.js +39 -0
- package/dist/tmux.js +117 -0
- package/dist/util.js +21 -0
- package/docs/DEMO_GIF.md +9 -2
- package/docs/demo-notes.md +9 -1
- package/docs/fixtures/claude-ds-smoke.log +1 -1
- package/llms.txt +29 -0
- package/package.json +13 -6
- package/skills/route-orch/SKILL.md +8 -6
- package/src/adapters/claude-ds.ts +68 -22
- package/src/adapters/grok.ts +10 -5
- package/src/adapters/index.ts +2 -0
- package/src/adapters/openrouter.test.ts +57 -0
- package/src/adapters/openrouter.ts +80 -0
- package/src/adapters/types.ts +2 -0
- package/src/cli.test.ts +30 -9
- package/src/cli.ts +117 -24
- package/src/config.ts +37 -6
- package/src/health.ts +8 -6
- package/src/integration.test.ts +174 -0
- package/src/jobs.ts +85 -11
- package/src/mark-complete.ts +2 -6
- package/src/openrouter-run.ts +102 -0
- package/src/runtime.ts +9 -4
- package/src/secrets.ts +21 -4
- package/src/tmux.ts +20 -8
- package/src/util.ts +3 -1
- package/docs/audit-2026-08-10-sol-grok-kimi.md +0 -64
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawnSync, execSync } from "node:child_process";
|
|
2
|
+
import { config, sessionName } from "./config.js";
|
|
3
|
+
import { shellQuote } from "./util.js";
|
|
4
|
+
import { markCompleteInvoker } from "./runtime.js";
|
|
5
|
+
export function isTmuxAvailable() {
|
|
6
|
+
try {
|
|
7
|
+
execSync("command -v tmux", { stdio: "ignore" });
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function sessionExists(name) {
|
|
15
|
+
try {
|
|
16
|
+
execSync(`tmux has-session -t ${shellQuote(name)} 2>/dev/null`, {
|
|
17
|
+
stdio: "ignore",
|
|
18
|
+
});
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function listManagedSessions() {
|
|
26
|
+
try {
|
|
27
|
+
const out = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
30
|
+
}).trim();
|
|
31
|
+
if (!out)
|
|
32
|
+
return [];
|
|
33
|
+
return out.split("\n").filter((n) => n.startsWith(`${config.tmuxPrefix}-`));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function capturePane(name, lines = 50) {
|
|
40
|
+
try {
|
|
41
|
+
return execSync(`tmux capture-pane -t ${shellQuote(name)} -p -S -${Math.max(1, lines)}`, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function sendKeys(name, message) {
|
|
48
|
+
if (!sessionExists(name))
|
|
49
|
+
return false;
|
|
50
|
+
// Reject embedded newlines — they would submit early even with -l
|
|
51
|
+
if (/[\r\n]/.test(message))
|
|
52
|
+
return false;
|
|
53
|
+
try {
|
|
54
|
+
// -l = literal keys (so "C-c" types text, does not SIGINT the worker)
|
|
55
|
+
execSync(`tmux send-keys -l -t ${shellQuote(name)} -- ${shellQuote(message)}`, { stdio: "ignore" });
|
|
56
|
+
spawnSync("sleep", ["0.25"]);
|
|
57
|
+
execSync(`tmux send-keys -t ${shellQuote(name)} Enter`, { stdio: "ignore" });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function killSession(name) {
|
|
65
|
+
try {
|
|
66
|
+
execSync(`tmux kill-session -t ${shellQuote(name)}`, { stdio: "ignore" });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function attachHint(jobId) {
|
|
74
|
+
return `tmux attach -t ${sessionName(jobId)}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create a detached tmux session that runs `workerCmd` via `sh -c` under `script`,
|
|
78
|
+
* then marks the job complete and exits the session.
|
|
79
|
+
* Always quote the shell expression so macOS BSD `script` does not split `cd && …`.
|
|
80
|
+
*/
|
|
81
|
+
export function createWorkerSession(options) {
|
|
82
|
+
const name = sessionName(options.jobId);
|
|
83
|
+
const isLinux = process.platform === "linux";
|
|
84
|
+
const invoker = markCompleteInvoker(options.markCompleteScript);
|
|
85
|
+
const completion = [
|
|
86
|
+
`exit_code=$?`,
|
|
87
|
+
`${invoker} ${shellQuote(options.jobFile)} "$exit_code" ${shellQuote(options.logFile)}`,
|
|
88
|
+
`echo ""`,
|
|
89
|
+
`echo "[cursor-route: session complete — closing in 5s]"`,
|
|
90
|
+
`sleep 5`,
|
|
91
|
+
`tmux kill-session -t ${shellQuote(name)} 2>/dev/null || true`,
|
|
92
|
+
].join("; ");
|
|
93
|
+
// Always run workerCmd under sh -c so `cd … && …` stays one expression.
|
|
94
|
+
// Linux script: script -q -e -c '<cmd>' <logfile>
|
|
95
|
+
// macOS script: script -q <logfile> <cmd> <args...>
|
|
96
|
+
const wrapped = isLinux
|
|
97
|
+
? `script -q -e -c ${shellQuote(`/bin/sh -c ${shellQuote(options.workerCmd)}`)} ${shellQuote(options.logFile)}; ${completion}`
|
|
98
|
+
: `script -q ${shellQuote(options.logFile)} /bin/sh -c ${shellQuote(options.workerCmd)}; ${completion}`;
|
|
99
|
+
const args = ["new-session", "-d", "-s", name, "-c", options.cwd];
|
|
100
|
+
if (options.env) {
|
|
101
|
+
for (const [k, v] of Object.entries(options.env)) {
|
|
102
|
+
args.push("-e", `${k}=${v}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
args.push(wrapped);
|
|
106
|
+
const r = spawnSync("tmux", args, {
|
|
107
|
+
encoding: "utf8",
|
|
108
|
+
cwd: options.cwd,
|
|
109
|
+
});
|
|
110
|
+
if (r.status !== 0) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: (r.stderr || r.stdout || "tmux new-session failed").toString().trim(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, session: name };
|
|
117
|
+
}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** Shell-escape a string for single-quoted POSIX use. */
|
|
3
|
+
export function shellQuote(value) {
|
|
4
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
5
|
+
}
|
|
6
|
+
/** Short job id (8 hex chars). */
|
|
7
|
+
export function newJobId() {
|
|
8
|
+
const bytes = crypto.getRandomValues(new Uint8Array(4));
|
|
9
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
}
|
|
11
|
+
export function commandExists(cmd) {
|
|
12
|
+
try {
|
|
13
|
+
const r = spawnSync("sh", ["-c", `command -v ${shellQuote(cmd)}`], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
});
|
|
16
|
+
return r.status === 0 && Boolean(r.stdout?.trim());
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/docs/DEMO_GIF.md
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
tmux is required for the viral attach/send demo. On this laptop brew needs sudo — record after:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# tmux may live at ~/.local/bin/tmux (already on PATH in this setup); otherwise:
|
|
7
|
+
# brew install tmux
|
|
8
|
+
command -v tmux
|
|
8
9
|
cursor-route health
|
|
9
10
|
|
|
11
|
+
# Jobs live under the XDG data dir, not a git clone:
|
|
12
|
+
# ~/.local/share/cursor-route/jobs (override: CURSOR_ROUTE_JOBS_DIR)
|
|
13
|
+
|
|
10
14
|
# Terminal A — three parallel jobs
|
|
11
15
|
cursor-route start --lane hard --dir "$PWD" "Add README section Demo"
|
|
12
16
|
cursor-route start --lane mid --dir "$PWD" "Add a unit test for shellQuote"
|
|
@@ -17,6 +21,9 @@ cursor-route jobs --json
|
|
|
17
21
|
# Attach one pane for the GIF: tmux attach -t cursor-route-<id>
|
|
18
22
|
```
|
|
19
23
|
|
|
24
|
+
If you previously exported `$HOME/.cursor-route/bin`, that dir is stale — remove it:
|
|
25
|
+
`rm -rf ~/.cursor-route/bin` (the launcher lives in the installed package, not there).
|
|
26
|
+
|
|
20
27
|
Record with [asciinema](https://asciinema.org/) or CleanShot → export GIF → `docs/fixtures/hero.gif`.
|
|
21
28
|
|
|
22
29
|
Until then, use `docs/fixtures/claude-ds-smoke.log` as the committed proof fixture.
|
package/docs/demo-notes.md
CHANGED
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
Simulated capture output for README / tweet assets — replace with a real GIF once tmux is available.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
+
$ cursor-route --version
|
|
7
|
+
0.1.5
|
|
8
|
+
|
|
6
9
|
$ cursor-route health
|
|
7
|
-
cursor-route v0.1.
|
|
10
|
+
cursor-route v0.1.5
|
|
8
11
|
health: OK
|
|
9
12
|
✓ tmux
|
|
10
13
|
✓ runtime bun ok
|
|
11
14
|
✓ script(1)
|
|
12
15
|
✓ worker:grok
|
|
13
16
|
✓ worker:claude-ds
|
|
17
|
+
✓ worker:openrouter
|
|
18
|
+
✓ jobs_dir ~/.local/share/cursor-route/jobs
|
|
14
19
|
|
|
15
20
|
$ cursor-route start --lane mid "Add a failing test then make it pass"
|
|
16
21
|
started a1b2c3d4 (claude-ds)
|
|
@@ -23,3 +28,6 @@ $ cursor-route jobs --json
|
|
|
23
28
|
|
|
24
29
|
Verified locally (2026-08-10): headless `claude-ds` smoke returned `CURSOR_ROUTE_SMOKE_OK`.
|
|
25
30
|
Grok smoke hit 402 (Build usage balance exhausted) — auth/PATH wiring works; top up Grok Build for live demos.
|
|
31
|
+
|
|
32
|
+
Current commands: `health`, `start`, `jobs`, `status`, `capture`, `send`, `attach`, `kill`, `sessions`, `clean`.
|
|
33
|
+
Headless demos (no tmux) use `--no-tmux` and `capture`/`status` instead of `attach`/`send`.
|
package/llms.txt
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# cursor-route
|
|
2
|
+
|
|
3
|
+
> Cursor stays the planner. DeepSeek (mid), Grok CLI (hard), and OpenRouter free models (easy) run parallel coding workers in tmux.
|
|
4
|
+
|
|
5
|
+
MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route
|
|
6
|
+
GitHub: https://github.com/cemini23/cursor-route
|
|
7
|
+
|
|
8
|
+
## FAQ
|
|
9
|
+
|
|
10
|
+
### What is cursor-route?
|
|
11
|
+
cursor-route is a public MIT CLI and Cursor skill that runs parallel coding workers in tmux while Cursor remains the planner. DeepSeek handles the mid lane, Grok CLI handles the hard lane, and OpenRouter free models handle the easy lane (wording/drafts, non-secret prompts only).
|
|
12
|
+
|
|
13
|
+
### How is this different from Codex orchestrator?
|
|
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
|
+
|
|
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`.
|
|
18
|
+
|
|
19
|
+
### How do I install?
|
|
20
|
+
Run `npm i -g cursor-route`, install tmux if needed, then run `cursor-route health`. The package is available at https://www.npmjs.com/package/cursor-route, and the source is at https://github.com/cemini23/cursor-route.
|
|
21
|
+
|
|
22
|
+
### Is it free?
|
|
23
|
+
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 can be free on OpenRouter's free-model route (`openrouter/free`).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
- npm: `npm i -g cursor-route`
|
|
28
|
+
- Health: `cursor-route health`
|
|
29
|
+
- Skill: `/route-orch` (Cursor)
|
package/package.json
CHANGED
|
@@ -1,37 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-route",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter easy lane are the parallel army \u2014 lane-aware /route orchestration in tmux.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
|
-
"cursor-route": "./bin/cursor-route
|
|
8
|
+
"cursor-route": "./bin/cursor-route"
|
|
9
9
|
},
|
|
10
10
|
"engines": {
|
|
11
11
|
"node": ">=20"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin",
|
|
15
|
+
"dist",
|
|
15
16
|
"src",
|
|
16
17
|
"skills",
|
|
17
|
-
"docs",
|
|
18
|
+
"docs/DEMO_GIF.md",
|
|
19
|
+
"docs/demo-notes.md",
|
|
20
|
+
"docs/fixtures",
|
|
18
21
|
"LICENSE",
|
|
19
22
|
"SECURITY.md",
|
|
23
|
+
"SUPPORT.md",
|
|
20
24
|
"CONTRIBUTING.md",
|
|
21
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"llms.txt"
|
|
22
27
|
],
|
|
23
28
|
"scripts": {
|
|
24
29
|
"start": "bun run src/cli.ts",
|
|
25
30
|
"health": "bun run src/cli.ts health",
|
|
26
31
|
"test": "bun test",
|
|
27
32
|
"typecheck": "tsc --noEmit",
|
|
28
|
-
"
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"prepublishOnly": "bun test && bun run typecheck && bun run build"
|
|
29
35
|
},
|
|
30
36
|
"keywords": [
|
|
31
37
|
"cursor",
|
|
32
38
|
"grok",
|
|
33
39
|
"deepseek",
|
|
34
40
|
"claude-ds",
|
|
41
|
+
"openrouter",
|
|
35
42
|
"tmux",
|
|
36
43
|
"orchestrator",
|
|
37
44
|
"agents",
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: route-orch
|
|
3
3
|
description: >-
|
|
4
|
-
Delegate coding work from Cursor to parallel Grok CLI / claude-ds (DeepSeek)
|
|
5
|
-
workers via cursor-route. Use when the user says
|
|
6
|
-
parallel agents with cursor-route, or explicitly
|
|
7
|
-
implementation to Grok/DeepSeek panes — not for private
|
|
4
|
+
Delegate coding work from Cursor to parallel Grok CLI / claude-ds (DeepSeek) /
|
|
5
|
+
OpenRouter (easy lane) workers via cursor-route. Use when the user says
|
|
6
|
+
/route-orch, spawn workers, parallel agents with cursor-route, or explicitly
|
|
7
|
+
asks to outsource implementation to Grok/DeepSeek panes — not for private
|
|
8
|
+
Cemini /route.
|
|
8
9
|
---
|
|
9
10
|
|
|
10
11
|
# route-orch (cursor-route)
|
|
@@ -23,10 +24,11 @@ You are the **orchestrator**. Do **not** implement bulk code in this Cursor sess
|
|
|
23
24
|
|
|
24
25
|
| Lane | Worker | Use when |
|
|
25
26
|
|------|--------|----------|
|
|
27
|
+
| `easy` | `openrouter` (OpenRouter free models) | Wording / drafts — non-secret prompts only |
|
|
26
28
|
| `mid` | `claude-ds` (DeepSeek via Claude Code harness) | Standard implement / refactor |
|
|
27
29
|
| `hard` | `grok` | Premium plan in Cursor → Grok implement |
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
Free OpenRouter models may log prompts — keep secrets off the easy lane (the CLI refuse gate still applies).
|
|
30
32
|
|
|
31
33
|
## Workflow
|
|
32
34
|
|
|
@@ -45,7 +47,7 @@ EOF
|
|
|
45
47
|
)"
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
Or `--worker grok` / `--worker claude-ds
|
|
50
|
+
Or `--worker grok` / `--worker claude-ds` / `--worker openrouter` (or `--lane easy`). Use `--no-tmux` only when tmux is unavailable.
|
|
49
51
|
|
|
50
52
|
4. Monitor: `cursor-route jobs --json` · `cursor-route capture <id>` · `cursor-route send <id> "…"` (tmux only).
|
|
51
53
|
5. Summarize worker results with **verify evidence** — no status-only “done”. If verify fails, `send` a correction or spawn a follow-up — do not invent success.
|
|
@@ -20,7 +20,7 @@ import { shellQuote } from "../util.ts";
|
|
|
20
20
|
function which(cmd: string): string | null {
|
|
21
21
|
try {
|
|
22
22
|
return (
|
|
23
|
-
execSync(`command -v ${cmd}`, {
|
|
23
|
+
execSync(`command -v ${shellQuote(cmd)}`, {
|
|
24
24
|
encoding: "utf8",
|
|
25
25
|
stdio: ["ignore", "pipe", "ignore"],
|
|
26
26
|
}).trim() || null
|
|
@@ -30,37 +30,58 @@ function which(cmd: string): string | null {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** True when URL hostname is deepseek.com (or a subdomain). */
|
|
34
|
+
export function isDeepSeekBaseUrl(url: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(url);
|
|
37
|
+
const host = u.hostname.toLowerCase();
|
|
38
|
+
return host === "deepseek.com" || host.endsWith(".deepseek.com");
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
function deepseekBaseFromSettings(): string | null {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
// Home settings only — do not trust cwd/.claude/settings.json (spoof / exfil risk)
|
|
46
|
+
const p = join(homedir(), ".claude", "settings.json");
|
|
47
|
+
if (!existsSync(p)) return null;
|
|
48
|
+
try {
|
|
49
|
+
const j = JSON.parse(readFileSync(p, "utf8")) as {
|
|
50
|
+
env?: Record<string, string>;
|
|
51
|
+
};
|
|
52
|
+
const url = j.env?.ANTHROPIC_BASE_URL;
|
|
53
|
+
if (url && isDeepSeekBaseUrl(url)) return url;
|
|
54
|
+
} catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolved DeepSeek base URL for the mid-lane harness, or null. */
|
|
61
|
+
export function resolvedDeepSeekBaseUrl(): string | null {
|
|
62
|
+
for (const candidate of [
|
|
63
|
+
process.env.ANTHROPIC_BASE_URL,
|
|
64
|
+
process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL,
|
|
65
|
+
deepseekBaseFromSettings(),
|
|
66
|
+
]) {
|
|
67
|
+
if (candidate && isDeepSeekBaseUrl(candidate)) return candidate;
|
|
49
68
|
}
|
|
50
69
|
return null;
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/** True when Claude Code harness is routed to DeepSeek (cheap path). */
|
|
54
73
|
export function isDeepSeekRouted(): boolean {
|
|
55
|
-
|
|
56
|
-
process.env.ANTHROPIC_BASE_URL ||
|
|
57
|
-
process.env.CURSOR_ROUTE_ANTHROPIC_BASE_URL ||
|
|
58
|
-
deepseekBaseFromSettings() ||
|
|
59
|
-
"";
|
|
60
|
-
return /deepseek\.com/i.test(url);
|
|
74
|
+
return Boolean(resolvedDeepSeekBaseUrl());
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
function resolveClaudeDs(): { binary: string; mode: string } | null {
|
|
78
|
+
// Env override lets tests pin a fake claude-ds (and power users pick a specific binary).
|
|
79
|
+
if (process.env.CURSOR_ROUTE_CLAUDE_DS_BIN) {
|
|
80
|
+
return {
|
|
81
|
+
binary: process.env.CURSOR_ROUTE_CLAUDE_DS_BIN,
|
|
82
|
+
mode: "claude-ds (CURSOR_ROUTE_CLAUDE_DS_BIN override)",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
64
85
|
for (const c of [
|
|
65
86
|
{ cmd: "claude-ds", mode: "claude-ds (DeepSeek shim)" },
|
|
66
87
|
{ cmd: "deepseek-claude", mode: "deepseek-claude" },
|
|
@@ -93,6 +114,25 @@ function resolveClaudeDs(): { binary: string; mode: string } | null {
|
|
|
93
114
|
return null;
|
|
94
115
|
}
|
|
95
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Env that must reach stock `claude` for DeepSeek routing.
|
|
119
|
+
* Passed via process/tmux env — never interpolated into the printed command.
|
|
120
|
+
*/
|
|
121
|
+
function deepSeekWorkerEnv(): Record<string, string> | undefined {
|
|
122
|
+
const base = resolvedDeepSeekBaseUrl();
|
|
123
|
+
if (!base) return undefined;
|
|
124
|
+
const env: Record<string, string> = { ANTHROPIC_BASE_URL: base };
|
|
125
|
+
const token =
|
|
126
|
+
process.env.ANTHROPIC_AUTH_TOKEN ||
|
|
127
|
+
process.env.ANTHROPIC_API_KEY ||
|
|
128
|
+
process.env.DEEPSEEK_API_KEY ||
|
|
129
|
+
"";
|
|
130
|
+
if (token) env.ANTHROPIC_AUTH_TOKEN = token;
|
|
131
|
+
const model = process.env.ANTHROPIC_MODEL;
|
|
132
|
+
if (model) env.ANTHROPIC_MODEL = model;
|
|
133
|
+
return env;
|
|
134
|
+
}
|
|
135
|
+
|
|
96
136
|
export const claudeDsAdapter: Adapter = {
|
|
97
137
|
kind: "claude-ds",
|
|
98
138
|
label: "DeepSeek (via Claude Code harness)",
|
|
@@ -124,6 +164,10 @@ export const claudeDsAdapter: Adapter = {
|
|
|
124
164
|
|
|
125
165
|
const ask = process.env.CURSOR_ROUTE_ASK === "1" || process.env.CLAUDE_DS_ASK === "1";
|
|
126
166
|
const skip = alwaysApprove && !ask;
|
|
167
|
+
// Stock `claude` needs DeepSeek env injected into the worker process
|
|
168
|
+
// (tmux panes may not inherit client env from a long-lived server).
|
|
169
|
+
const env =
|
|
170
|
+
resolved.mode.startsWith("claude → DeepSeek") ? deepSeekWorkerEnv() : undefined;
|
|
127
171
|
|
|
128
172
|
if (resolved.mode.startsWith("claude-ds")) {
|
|
129
173
|
const parts = [
|
|
@@ -136,6 +180,7 @@ export const claudeDsAdapter: Adapter = {
|
|
|
136
180
|
worker: "claude-ds",
|
|
137
181
|
command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
|
|
138
182
|
alwaysApprove: skip,
|
|
183
|
+
env,
|
|
139
184
|
};
|
|
140
185
|
}
|
|
141
186
|
|
|
@@ -149,6 +194,7 @@ export const claudeDsAdapter: Adapter = {
|
|
|
149
194
|
worker: "claude-ds",
|
|
150
195
|
command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
|
|
151
196
|
alwaysApprove: skip,
|
|
197
|
+
env,
|
|
152
198
|
};
|
|
153
199
|
},
|
|
154
200
|
};
|
package/src/adapters/grok.ts
CHANGED
|
@@ -3,11 +3,15 @@ import type { Adapter, WorkerHealth } from "./types.ts";
|
|
|
3
3
|
import { shellQuote } from "../util.ts";
|
|
4
4
|
|
|
5
5
|
function findGrok(): string | null {
|
|
6
|
+
// Env override lets tests pin a fake grok (and power users pick a specific binary).
|
|
7
|
+
if (process.env.CURSOR_ROUTE_GROK_BIN) return process.env.CURSOR_ROUTE_GROK_BIN;
|
|
6
8
|
try {
|
|
7
|
-
return
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
return (
|
|
10
|
+
execSync("command -v grok", {
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
+
}).trim() || null
|
|
14
|
+
);
|
|
11
15
|
} catch {
|
|
12
16
|
return null;
|
|
13
17
|
}
|
|
@@ -34,8 +38,9 @@ export const grokAdapter: Adapter = {
|
|
|
34
38
|
};
|
|
35
39
|
},
|
|
36
40
|
buildLaunch({ promptFile, cwd, alwaysApprove }) {
|
|
41
|
+
const binary = findGrok() || "grok";
|
|
37
42
|
const parts = [
|
|
38
|
-
|
|
43
|
+
shellQuote(binary),
|
|
39
44
|
"-p",
|
|
40
45
|
`"$(cat ${shellQuote(promptFile)})"`,
|
|
41
46
|
"--cwd",
|
package/src/adapters/index.ts
CHANGED
|
@@ -2,10 +2,12 @@ import type { WorkerKind } from "../config.ts";
|
|
|
2
2
|
import type { Adapter } from "./types.ts";
|
|
3
3
|
import { grokAdapter } from "./grok.ts";
|
|
4
4
|
import { claudeDsAdapter } from "./claude-ds.ts";
|
|
5
|
+
import { openRouterAdapter } from "./openrouter.ts";
|
|
5
6
|
|
|
6
7
|
const registry: Record<WorkerKind, Adapter> = {
|
|
7
8
|
grok: grokAdapter,
|
|
8
9
|
"claude-ds": claudeDsAdapter,
|
|
10
|
+
openrouter: openRouterAdapter,
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
export function getAdapter(worker: WorkerKind): Adapter {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test, afterEach } from "bun:test";
|
|
2
|
+
import { openRouterAdapter } from "./openrouter.ts";
|
|
3
|
+
|
|
4
|
+
const KEY = "sk-or-v1-local-test-value-000000000000";
|
|
5
|
+
|
|
6
|
+
function setKey(): void {
|
|
7
|
+
process.env.OPENROUTER_API_KEY = KEY;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
12
|
+
delete process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
13
|
+
delete process.env.OPENROUTER_BASE_URL;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("openrouter adapter", () => {
|
|
17
|
+
test("health fails without OPENROUTER_API_KEY", () => {
|
|
18
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
19
|
+
const h = openRouterAdapter.health();
|
|
20
|
+
expect(h.worker).toBe("openrouter");
|
|
21
|
+
expect(h.ok).toBe(false);
|
|
22
|
+
expect(h.detail).toContain("OPENROUTER_API_KEY");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("health passes with a fake key (no network)", () => {
|
|
26
|
+
setKey();
|
|
27
|
+
const h = openRouterAdapter.health();
|
|
28
|
+
expect(h.ok).toBe(true);
|
|
29
|
+
expect(h.detail).toContain("openrouter/free");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("buildLaunch passes key via env and never echoes it in the command", () => {
|
|
33
|
+
setKey();
|
|
34
|
+
const plan = openRouterAdapter.buildLaunch({
|
|
35
|
+
promptFile: "/tmp/abc123.prompt",
|
|
36
|
+
cwd: "/tmp",
|
|
37
|
+
alwaysApprove: true,
|
|
38
|
+
});
|
|
39
|
+
expect(plan.worker).toBe("openrouter");
|
|
40
|
+
expect(plan.command).toContain("--prompt-file");
|
|
41
|
+
expect(plan.command).not.toContain(KEY);
|
|
42
|
+
expect(plan.env?.OPENROUTER_API_KEY).toBe(KEY);
|
|
43
|
+
// No approval concept for a pure HTTP call.
|
|
44
|
+
expect(plan.alwaysApprove).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("buildLaunch without key still prints a command (dry-run friendly) and no env", () => {
|
|
48
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
49
|
+
const plan = openRouterAdapter.buildLaunch({
|
|
50
|
+
promptFile: "/tmp/abc123.prompt",
|
|
51
|
+
cwd: "/tmp",
|
|
52
|
+
alwaysApprove: true,
|
|
53
|
+
});
|
|
54
|
+
expect(plan.command).toContain("--prompt-file");
|
|
55
|
+
expect(plan.env).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { Adapter, WorkerHealth } from "./types.ts";
|
|
5
|
+
import { shellQuote } from "../util.ts";
|
|
6
|
+
import { openRouterModel, openRouterBaseUrl } from "../config.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve how to invoke the one-shot runner. Prefer the compiled dist via node
|
|
10
|
+
* (no loader); else Bun on src. No npx/tsx — same policy as mark-complete.
|
|
11
|
+
*/
|
|
12
|
+
function resolveRunner(): { command: string } | null {
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const compiled = join(here, "..", "..", "dist", "openrouter-run.js");
|
|
15
|
+
if (existsSync(compiled)) {
|
|
16
|
+
return { command: `node ${shellQuote(compiled)}` };
|
|
17
|
+
}
|
|
18
|
+
const srcFile = join(here, "..", "openrouter-run.ts");
|
|
19
|
+
if (existsSync(srcFile)) {
|
|
20
|
+
return { command: `bun ${shellQuote(srcFile)}` };
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function openRouterEnv(): Record<string, string> | undefined {
|
|
26
|
+
const key = process.env.OPENROUTER_API_KEY;
|
|
27
|
+
if (!key) return undefined;
|
|
28
|
+
const env: Record<string, string> = { OPENROUTER_API_KEY: key };
|
|
29
|
+
const model = process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
30
|
+
if (model) env.CURSOR_ROUTE_OPENROUTER_MODEL = model;
|
|
31
|
+
const base = process.env.OPENROUTER_BASE_URL;
|
|
32
|
+
if (base) env.OPENROUTER_BASE_URL = base;
|
|
33
|
+
return env;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const openRouterAdapter: Adapter = {
|
|
37
|
+
kind: "openrouter",
|
|
38
|
+
label: "OpenRouter (free easy lane)",
|
|
39
|
+
health(): WorkerHealth {
|
|
40
|
+
const runner = resolveRunner();
|
|
41
|
+
if (!process.env.OPENROUTER_API_KEY) {
|
|
42
|
+
return {
|
|
43
|
+
worker: "openrouter",
|
|
44
|
+
ok: false,
|
|
45
|
+
binary: runner?.command ?? null,
|
|
46
|
+
detail:
|
|
47
|
+
"OPENROUTER_API_KEY not set — export your OpenRouter key (easy lane model defaults to openrouter/free)",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (!runner) {
|
|
51
|
+
return {
|
|
52
|
+
worker: "openrouter",
|
|
53
|
+
ok: false,
|
|
54
|
+
binary: null,
|
|
55
|
+
detail: "openrouter-run not found — run bun run build (or use Bun from a source clone)",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
worker: "openrouter",
|
|
60
|
+
ok: true,
|
|
61
|
+
binary: runner.command,
|
|
62
|
+
detail: `ok (model ${openRouterModel()} @ ${openRouterBaseUrl()})`,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
buildLaunch({ promptFile }) {
|
|
66
|
+
const runner = resolveRunner();
|
|
67
|
+
if (!runner) throw new Error("openrouter runner not available — run: bun run build");
|
|
68
|
+
// Missing key is tolerated here so `--dry-run` can still print the command;
|
|
69
|
+
// real starts are gated by the health preflight (which requires the key).
|
|
70
|
+
const env = openRouterEnv();
|
|
71
|
+
|
|
72
|
+
// No interactive approval concept for a pure HTTP call — nothing to auto-approve.
|
|
73
|
+
return {
|
|
74
|
+
worker: "openrouter",
|
|
75
|
+
command: `${runner.command} --prompt-file ${shellQuote(promptFile)}`,
|
|
76
|
+
alwaysApprove: false,
|
|
77
|
+
env,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
package/src/adapters/types.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface LaunchPlan {
|
|
|
12
12
|
/** Full shell command to run inside tmux (prompt already inlined via cat). */
|
|
13
13
|
command: string;
|
|
14
14
|
alwaysApprove: boolean;
|
|
15
|
+
/** Extra env for the worker process (never print secret values). */
|
|
16
|
+
env?: Record<string, string>;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface Adapter {
|