cursor-route 0.1.1 → 0.1.4
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 +53 -8
- package/SECURITY.md +3 -1
- 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 +15 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +431 -0
- package/dist/config.js +45 -0
- package/dist/health.js +100 -0
- package/dist/jobs.js +410 -0
- package/dist/mark-complete.js +38 -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 +8 -1
- package/docs/fixtures/claude-ds-smoke.log +1 -1
- package/llms.txt +29 -0
- package/package.json +12 -6
- package/src/adapters/claude-ds.ts +68 -22
- package/src/adapters/grok.ts +10 -5
- package/src/adapters/types.ts +2 -0
- package/src/cli.test.ts +24 -9
- package/src/cli.ts +105 -15
- package/src/config.ts +22 -2
- package/src/health.ts +8 -6
- package/src/integration.test.ts +167 -0
- package/src/jobs.ts +85 -11
- package/src/mark-complete.ts +2 -6
- 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/docs/demo-notes.md
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
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.4
|
|
8
|
+
|
|
6
9
|
$ cursor-route health
|
|
7
|
-
cursor-route v0.1.
|
|
10
|
+
cursor-route v0.1.4
|
|
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
|
+
✓ jobs_dir ~/.local/share/cursor-route/jobs
|
|
14
18
|
|
|
15
19
|
$ cursor-route start --lane mid "Add a failing test then make it pass"
|
|
16
20
|
started a1b2c3d4 (claude-ds)
|
|
@@ -23,3 +27,6 @@ $ cursor-route jobs --json
|
|
|
23
27
|
|
|
24
28
|
Verified locally (2026-08-10): headless `claude-ds` smoke returned `CURSOR_ROUTE_SMOKE_OK`.
|
|
25
29
|
Grok smoke hit 402 (Build usage balance exhausted) — auth/PATH wiring works; top up Grok Build for live demos.
|
|
30
|
+
|
|
31
|
+
Current commands: `health`, `start`, `jobs`, `status`, `capture`, `send`, `attach`, `kill`, `sessions`, `clean`.
|
|
32
|
+
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) and Grok CLI (hard) 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, and Grok CLI handles the hard lane.
|
|
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.
|
|
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,31 +1,37 @@
|
|
|
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.4",
|
|
4
|
+
"description": "Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) 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",
|
|
@@ -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/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 {
|
package/src/cli.test.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { resolveWorker } from "./jobs.ts";
|
|
|
3
3
|
import { config } from "./config.ts";
|
|
4
4
|
import { shellQuote, newJobId } from "./util.ts";
|
|
5
5
|
import { runHealth } from "./health.ts";
|
|
6
|
-
import { looksLikeSecretMaterial } from "./secrets.ts";
|
|
7
|
-
import { isDeepSeekRouted } from "./adapters/claude-ds.ts";
|
|
6
|
+
import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
|
|
7
|
+
import { isDeepSeekRouted, isDeepSeekBaseUrl } from "./adapters/claude-ds.ts";
|
|
8
8
|
|
|
9
9
|
describe("resolveWorker", () => {
|
|
10
10
|
test("lane mid → claude-ds", () => {
|
|
@@ -40,8 +40,24 @@ describe("secrets", () => {
|
|
|
40
40
|
test("blocks sk- material", () => {
|
|
41
41
|
expect(looksLikeSecretMaterial("token sk-abcdefghijklmnopqrstuvwxyz1234")).toBe(true);
|
|
42
42
|
});
|
|
43
|
-
test("blocks
|
|
43
|
+
test("blocks sk-proj and sk-ant", () => {
|
|
44
|
+
expect(
|
|
45
|
+
looksLikeSecretMaterial("sk-proj-abcdefghijklmnopqrstuvwxyz123456"),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
expect(
|
|
48
|
+
looksLikeSecretMaterial("sk-ant-api03-abcdefghijklmnopqrstuvwxyz"),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
test("blocks ghp_ and github_pat", () => {
|
|
44
52
|
expect(looksLikeSecretMaterial("ghp_abcdefghijklmnopqrstuvwx")).toBe(true);
|
|
53
|
+
expect(
|
|
54
|
+
looksLikeSecretMaterial("github_pat_11AAAAAAAAabcdefghijklmnopqrstuvwxyz"),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
test("redactSecrets strips material", () => {
|
|
58
|
+
const out = redactSecrets("see sk-abcdefghijklmnopqrstuvwxyz1234 end");
|
|
59
|
+
expect(out).toContain("[REDACTED]");
|
|
60
|
+
expect(out).not.toContain("sk-abcd");
|
|
45
61
|
});
|
|
46
62
|
});
|
|
47
63
|
|
|
@@ -51,6 +67,8 @@ describe("deepseek routing", () => {
|
|
|
51
67
|
process.env.ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic";
|
|
52
68
|
try {
|
|
53
69
|
expect(isDeepSeekRouted()).toBe(true);
|
|
70
|
+
expect(isDeepSeekBaseUrl("https://api.deepseek.com/anthropic")).toBe(true);
|
|
71
|
+
expect(isDeepSeekBaseUrl("https://evil-deepseek.com.attacker.tld")).toBe(false);
|
|
54
72
|
} finally {
|
|
55
73
|
if (prev === undefined) delete process.env.ANTHROPIC_BASE_URL;
|
|
56
74
|
else process.env.ANTHROPIC_BASE_URL = prev;
|
|
@@ -67,16 +85,13 @@ describe("health", () => {
|
|
|
67
85
|
expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
|
|
68
86
|
});
|
|
69
87
|
|
|
70
|
-
test("relaxed env can pass without
|
|
88
|
+
test("relaxed env can pass without workers", () => {
|
|
71
89
|
const prev = process.env.CURSOR_ROUTE_RELAXED;
|
|
72
90
|
process.env.CURSOR_ROUTE_RELAXED = "1";
|
|
73
91
|
try {
|
|
74
92
|
const r = runHealth();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(r.ok).toBe(true);
|
|
78
|
-
expect(r.checks.some((c) => c.name === "relaxed")).toBe(true);
|
|
79
|
-
}
|
|
93
|
+
expect(r.ok).toBe(true);
|
|
94
|
+
expect(r.checks.some((c) => c.name === "relaxed")).toBe(true);
|
|
80
95
|
} finally {
|
|
81
96
|
if (prev === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
|
|
82
97
|
else process.env.CURSOR_ROUTE_RELAXED = prev;
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* cursor-route CLI — Cursor brain, Grok + DeepSeek workers in tmux.
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
|
|
6
6
|
import { resolve, basename } from "node:path";
|
|
7
7
|
import { config, WORKERS, LANES, type WorkerKind, type Lane } from "./config.ts";
|
|
8
8
|
import { runHealth, printHealth } from "./health.ts";
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
listManagedSessions,
|
|
23
23
|
sessionExists,
|
|
24
24
|
} from "./tmux.ts";
|
|
25
|
-
import { looksLikeSecretMaterial } from "./secrets.ts";
|
|
25
|
+
import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
|
|
26
26
|
|
|
27
27
|
function usage(exitCode = 0): never {
|
|
28
28
|
console.log(`cursor-route v${config.version}
|
|
@@ -55,8 +55,11 @@ Start options:
|
|
|
55
55
|
Env:
|
|
56
56
|
CURSOR_ROUTE_ASK=1 Opt out of always-approve
|
|
57
57
|
CURSOR_ROUTE_JOBS_DIR Override jobs dir (default: ~/.local/share/cursor-route/jobs)
|
|
58
|
-
|
|
58
|
+
CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
|
|
59
|
+
CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
|
|
59
60
|
CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
|
|
61
|
+
CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
|
|
62
|
+
CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
|
|
60
63
|
`);
|
|
61
64
|
process.exit(exitCode);
|
|
62
65
|
}
|
|
@@ -66,6 +69,10 @@ function parseArgs(argv: string[]) {
|
|
|
66
69
|
const positional: string[] = [];
|
|
67
70
|
for (let i = 0; i < argv.length; i++) {
|
|
68
71
|
const a = argv[i];
|
|
72
|
+
if (a === "--") {
|
|
73
|
+
positional.push(...argv.slice(i + 1));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
69
76
|
if (
|
|
70
77
|
a === "--json" ||
|
|
71
78
|
a === "--ask" ||
|
|
@@ -106,6 +113,30 @@ function parseArgs(argv: string[]) {
|
|
|
106
113
|
return { flags, positional };
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
/** Require a string value for flags that must not be bare booleans. */
|
|
117
|
+
function requireStringFlag(
|
|
118
|
+
flags: Record<string, string | boolean>,
|
|
119
|
+
key: string,
|
|
120
|
+
): string | undefined {
|
|
121
|
+
if (!(key in flags)) return undefined;
|
|
122
|
+
const v = flags[key];
|
|
123
|
+
if (typeof v !== "string" || !v.trim()) {
|
|
124
|
+
console.error(`--${key} requires a value`);
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
return v;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function requireNonNegNumber(raw: string | undefined, label: string, fallback: number): number {
|
|
131
|
+
if (raw === undefined) return fallback;
|
|
132
|
+
const n = Number(raw);
|
|
133
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
134
|
+
console.error(`${label} must be a non-negative number`);
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
109
140
|
function asWorker(v: unknown): WorkerKind | undefined {
|
|
110
141
|
if (typeof v !== "string") return undefined;
|
|
111
142
|
if ((WORKERS as string[]).includes(v)) return v as WorkerKind;
|
|
@@ -128,14 +159,31 @@ function refuseSecrets(text: string, context: string): void {
|
|
|
128
159
|
}
|
|
129
160
|
|
|
130
161
|
function refuseDangerousPromptFile(path: string): void {
|
|
131
|
-
|
|
132
|
-
|
|
162
|
+
let resolved: string;
|
|
163
|
+
try {
|
|
164
|
+
resolved = realpathSync(path);
|
|
165
|
+
} catch {
|
|
166
|
+
resolved = resolve(path);
|
|
167
|
+
}
|
|
168
|
+
const base = basename(resolved);
|
|
169
|
+
const lower = resolved.toLowerCase();
|
|
133
170
|
if (
|
|
134
171
|
base.startsWith(".env") ||
|
|
135
|
-
|
|
172
|
+
lower.includes("/.ssh/") ||
|
|
173
|
+
lower.includes("/.aws/") ||
|
|
174
|
+
lower.includes("/.kube/") ||
|
|
175
|
+
base === ".npmrc" ||
|
|
176
|
+
base === ".git-credentials" ||
|
|
177
|
+
base === ".pgpass" ||
|
|
136
178
|
base === "id_rsa" ||
|
|
137
179
|
base === "id_ed25519" ||
|
|
138
|
-
base
|
|
180
|
+
base === "id_ecdsa" ||
|
|
181
|
+
base === "id_dsa" ||
|
|
182
|
+
base === "credentials" ||
|
|
183
|
+
base.endsWith(".pem") ||
|
|
184
|
+
base.endsWith(".key") ||
|
|
185
|
+
base.endsWith(".p12") ||
|
|
186
|
+
base.endsWith(".pfx")
|
|
139
187
|
) {
|
|
140
188
|
console.error(`Refusing --prompt-file path that looks credential-related: ${path}`);
|
|
141
189
|
process.exit(3);
|
|
@@ -163,9 +211,14 @@ async function main() {
|
|
|
163
211
|
}
|
|
164
212
|
|
|
165
213
|
if (cmd === "start") {
|
|
214
|
+
requireStringFlag(f, "worker");
|
|
215
|
+
requireStringFlag(f, "lane");
|
|
216
|
+
const promptFile = requireStringFlag(f, "prompt-file");
|
|
217
|
+
const dirFlag = requireStringFlag(f, "dir");
|
|
218
|
+
|
|
166
219
|
let prompt = "";
|
|
167
|
-
if (
|
|
168
|
-
const p = resolve(
|
|
220
|
+
if (promptFile) {
|
|
221
|
+
const p = resolve(promptFile);
|
|
169
222
|
refuseDangerousPromptFile(p);
|
|
170
223
|
if (!existsSync(p)) {
|
|
171
224
|
console.error(`prompt file not found: ${p}`);
|
|
@@ -181,11 +234,25 @@ async function main() {
|
|
|
181
234
|
}
|
|
182
235
|
refuseSecrets(prompt, "to start");
|
|
183
236
|
|
|
237
|
+
let cwd = process.cwd();
|
|
238
|
+
if (dirFlag) {
|
|
239
|
+
cwd = resolve(dirFlag);
|
|
240
|
+
try {
|
|
241
|
+
if (!statSync(cwd).isDirectory()) {
|
|
242
|
+
console.error(`--dir is not a directory: ${cwd}`);
|
|
243
|
+
process.exit(2);
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
console.error(`--dir does not exist: ${cwd}`);
|
|
247
|
+
process.exit(2);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
184
251
|
const result = startJob({
|
|
185
252
|
prompt,
|
|
186
253
|
worker: asWorker(f.worker),
|
|
187
254
|
lane: asLane(f.lane),
|
|
188
|
-
cwd
|
|
255
|
+
cwd,
|
|
189
256
|
alwaysApprove: !f.ask,
|
|
190
257
|
dryRun: Boolean(f.dryRun),
|
|
191
258
|
noTmux: Boolean(f.noTmux),
|
|
@@ -197,11 +264,20 @@ async function main() {
|
|
|
197
264
|
}
|
|
198
265
|
|
|
199
266
|
if (json) {
|
|
200
|
-
console.log(
|
|
267
|
+
console.log(
|
|
268
|
+
JSON.stringify(
|
|
269
|
+
{
|
|
270
|
+
...result.job,
|
|
271
|
+
command: result.command ? redactSecrets(result.command) : result.command,
|
|
272
|
+
},
|
|
273
|
+
null,
|
|
274
|
+
2,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
201
277
|
} else if (result.dryRun) {
|
|
202
278
|
console.log(`dry-run job ${result.job.id}`);
|
|
203
279
|
console.log(`worker: ${result.job.worker}`);
|
|
204
|
-
console.log(`command: ${result.command}`);
|
|
280
|
+
console.log(`command: ${redactSecrets(result.command || "")}`);
|
|
205
281
|
} else {
|
|
206
282
|
console.log(`started ${result.job.id} (${result.job.worker})`);
|
|
207
283
|
console.log(`session: ${result.job.tmuxSession}`);
|
|
@@ -217,7 +293,8 @@ async function main() {
|
|
|
217
293
|
}
|
|
218
294
|
|
|
219
295
|
if (cmd === "jobs") {
|
|
220
|
-
const
|
|
296
|
+
const limitRaw = requireStringFlag(f, "limit");
|
|
297
|
+
const limit = requireNonNegNumber(limitRaw, "--limit", config.jobsListLimit);
|
|
221
298
|
const jobs = listJobs(limit);
|
|
222
299
|
if (json) {
|
|
223
300
|
console.log(JSON.stringify(jobs, null, 2));
|
|
@@ -267,7 +344,10 @@ async function main() {
|
|
|
267
344
|
|
|
268
345
|
if (cmd === "capture") {
|
|
269
346
|
const id = pos[0];
|
|
270
|
-
const
|
|
347
|
+
const linesRaw = pos[1];
|
|
348
|
+
const lines = linesRaw
|
|
349
|
+
? requireNonNegNumber(linesRaw, "capture lines", 50)
|
|
350
|
+
: 50;
|
|
271
351
|
if (!id) {
|
|
272
352
|
console.error("capture requires <jobId>");
|
|
273
353
|
process.exit(2);
|
|
@@ -326,6 +406,15 @@ async function main() {
|
|
|
326
406
|
console.error("attach requires <jobId>");
|
|
327
407
|
process.exit(2);
|
|
328
408
|
}
|
|
409
|
+
const job = readJob(id);
|
|
410
|
+
if (!job) {
|
|
411
|
+
console.error(`Job not found: ${id}`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
if (job.tmuxSession.startsWith("headless-")) {
|
|
415
|
+
console.error("headless job — use capture/status (no tmux attach)");
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
329
418
|
console.log(attachHint(id));
|
|
330
419
|
return;
|
|
331
420
|
}
|
|
@@ -354,7 +443,8 @@ async function main() {
|
|
|
354
443
|
}
|
|
355
444
|
|
|
356
445
|
if (cmd === "clean") {
|
|
357
|
-
const
|
|
446
|
+
const daysRaw = requireStringFlag(f, "days");
|
|
447
|
+
const days = requireNonNegNumber(daysRaw, "--days", 7);
|
|
358
448
|
const n = cleanJobs(days);
|
|
359
449
|
console.log(`cleaned ${n} job(s) older than ${days}d`);
|
|
360
450
|
return;
|
package/src/config.ts
CHANGED
|
@@ -8,10 +8,26 @@ export type Lane = "mid" | "hard";
|
|
|
8
8
|
export const WORKERS: WorkerKind[] = ["grok", "claude-ds"];
|
|
9
9
|
export const LANES: Lane[] = ["mid", "hard"];
|
|
10
10
|
|
|
11
|
+
function maxConcurrentJobsFromEnv(): number {
|
|
12
|
+
const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
|
|
13
|
+
if (raw) {
|
|
14
|
+
const n = Number(raw);
|
|
15
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
16
|
+
}
|
|
17
|
+
return 50;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Live getters for env-derived paths/limits so tests can set
|
|
22
|
+
* CURSOR_ROUTE_JOBS_DIR / CURSOR_ROUTE_MAX_JOBS before exercising jobs
|
|
23
|
+
* even if another module imported config earlier.
|
|
24
|
+
*/
|
|
11
25
|
export const config = {
|
|
12
26
|
product: "cursor-route",
|
|
13
|
-
version: "0.1.
|
|
14
|
-
jobsDir:
|
|
27
|
+
version: "0.1.4",
|
|
28
|
+
get jobsDir(): string {
|
|
29
|
+
return defaultJobsDir();
|
|
30
|
+
},
|
|
15
31
|
tmuxPrefix: "cursor-route",
|
|
16
32
|
defaultWorker: "grok" as WorkerKind,
|
|
17
33
|
/** Lane → default worker (Cemini /route public core). */
|
|
@@ -20,6 +36,10 @@ export const config = {
|
|
|
20
36
|
hard: "grok" as WorkerKind,
|
|
21
37
|
},
|
|
22
38
|
jobsListLimit: 20,
|
|
39
|
+
/** Max simultaneously active (running|pending) jobs. Override: CURSOR_ROUTE_MAX_JOBS. */
|
|
40
|
+
get maxConcurrentJobs(): number {
|
|
41
|
+
return maxConcurrentJobsFromEnv();
|
|
42
|
+
},
|
|
23
43
|
};
|
|
24
44
|
|
|
25
45
|
export function sessionName(jobId: string): string {
|
package/src/health.ts
CHANGED
|
@@ -32,7 +32,7 @@ export function runHealth(): HealthReport {
|
|
|
32
32
|
checks.push({
|
|
33
33
|
name: "runtime",
|
|
34
34
|
ok: bunOk || nodeOk,
|
|
35
|
-
detail: bunOk ? "bun ok" : nodeOk ? "node ok (
|
|
35
|
+
detail: bunOk ? "bun ok" : nodeOk ? "node ok (compiled dist)" : "need bun or node 20+",
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
const scriptOk = (() => {
|
|
@@ -71,18 +71,20 @@ export function runHealth(): HealthReport {
|
|
|
71
71
|
: "optional — Cursor CLI agent not on PATH (skill-only supervisor is fine for v0)",
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
// At least one worker must be healthy
|
|
75
|
-
//
|
|
74
|
+
// At least one worker must be healthy for a green health gate.
|
|
75
|
+
// CURSOR_ROUTE_RELAXED=1: pass without tmux and without workers (CI / infra smoke).
|
|
76
76
|
const workerOk = checks.some((c) => c.name.startsWith("worker:") && c.ok);
|
|
77
77
|
const relaxed = process.env.CURSOR_ROUTE_RELAXED === "1";
|
|
78
78
|
const hardOk = (tmuxOk || relaxed) && (bunOk || nodeOk) && scriptOk;
|
|
79
|
-
const ok = hardOk && workerOk;
|
|
79
|
+
const ok = hardOk && (workerOk || relaxed);
|
|
80
80
|
|
|
81
|
-
if (relaxed
|
|
81
|
+
if (relaxed) {
|
|
82
82
|
checks.push({
|
|
83
83
|
name: "relaxed",
|
|
84
84
|
ok: true,
|
|
85
|
-
detail:
|
|
85
|
+
detail: workerOk
|
|
86
|
+
? "CURSOR_ROUTE_RELAXED=1 — tmux optional (headless OK)"
|
|
87
|
+
: "CURSOR_ROUTE_RELAXED=1 — tmux/workers optional (CI / infra smoke)",
|
|
86
88
|
});
|
|
87
89
|
}
|
|
88
90
|
|