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/dist/cli.js ADDED
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * cursor-route CLI — Cursor brain, Grok + DeepSeek workers in tmux.
4
+ */
5
+ import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
6
+ import { resolve, basename } from "node:path";
7
+ import { config, WORKERS, LANES } from "./config.js";
8
+ import { runHealth, printHealth } from "./health.js";
9
+ import { startJob, listJobs, readJob, killJob, cleanJobs, jobPaths, refreshStatus, } from "./jobs.js";
10
+ import { capturePane, sendKeys, attachHint, listManagedSessions, sessionExists, } from "./tmux.js";
11
+ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.js";
12
+ function usage(exitCode = 0) {
13
+ console.log(`cursor-route v${config.version}
14
+
15
+ Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army.
16
+
17
+ Usage:
18
+ cursor-route --version
19
+ cursor-route health [--json]
20
+ cursor-route start <prompt> [options]
21
+ cursor-route start --prompt-file <path> [options]
22
+ cursor-route jobs [--json] [--limit N]
23
+ cursor-route status <jobId> [--json]
24
+ cursor-route capture <jobId> [lines]
25
+ cursor-route send <jobId> <message>
26
+ cursor-route attach <jobId>
27
+ cursor-route kill <jobId>
28
+ cursor-route sessions
29
+ cursor-route clean [--days N]
30
+
31
+ Start options:
32
+ --worker <grok|claude-ds> Worker adapter (default: grok)
33
+ --lane <mid|hard> Lane → worker (mid=claude-ds, hard=grok)
34
+ --dir <path> Working directory (default: cwd)
35
+ --ask Disable always-approve for this job
36
+ --dry-run Print launch command; do not start
37
+ --no-tmux Headless background process (no attach/send)
38
+ --json JSON output where supported
39
+
40
+ Env:
41
+ CURSOR_ROUTE_ASK=1 Opt out of always-approve
42
+ CURSOR_ROUTE_JOBS_DIR Override jobs dir (default: ~/.local/share/cursor-route/jobs)
43
+ CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
44
+ CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
45
+ CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
46
+ CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
47
+ CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
48
+ `);
49
+ process.exit(exitCode);
50
+ }
51
+ function parseArgs(argv) {
52
+ const flags = {};
53
+ const positional = [];
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ if (a === "--") {
57
+ positional.push(...argv.slice(i + 1));
58
+ break;
59
+ }
60
+ if (a === "--json" ||
61
+ a === "--ask" ||
62
+ a === "--dry-run" ||
63
+ a === "--no-tmux" ||
64
+ a === "-h" ||
65
+ a === "--help" ||
66
+ a === "--version" ||
67
+ a === "-V") {
68
+ if (a === "-h" || a === "--help")
69
+ flags.help = true;
70
+ if (a === "--version" || a === "-V")
71
+ flags.version = true;
72
+ if (a === "--dry-run")
73
+ flags.dryRun = true;
74
+ if (a === "--no-tmux")
75
+ flags.noTmux = true;
76
+ if (a === "--ask")
77
+ flags.ask = true;
78
+ if (a === "--json")
79
+ flags.json = true;
80
+ continue;
81
+ }
82
+ if (a.startsWith("--")) {
83
+ let key = a.slice(2);
84
+ let val = true;
85
+ if (key.includes("=")) {
86
+ const eq = key.indexOf("=");
87
+ val = key.slice(eq + 1);
88
+ key = key.slice(0, eq);
89
+ }
90
+ else {
91
+ const next = argv[i + 1];
92
+ if (next && !next.startsWith("--")) {
93
+ val = next;
94
+ i++;
95
+ }
96
+ }
97
+ flags[key] = val;
98
+ continue;
99
+ }
100
+ positional.push(a);
101
+ }
102
+ return { flags, positional };
103
+ }
104
+ /** Require a string value for flags that must not be bare booleans. */
105
+ function requireStringFlag(flags, key) {
106
+ if (!(key in flags))
107
+ return undefined;
108
+ const v = flags[key];
109
+ if (typeof v !== "string" || !v.trim()) {
110
+ console.error(`--${key} requires a value`);
111
+ process.exit(2);
112
+ }
113
+ return v;
114
+ }
115
+ function requireNonNegNumber(raw, label, fallback) {
116
+ if (raw === undefined)
117
+ return fallback;
118
+ const n = Number(raw);
119
+ if (!Number.isFinite(n) || n < 0) {
120
+ console.error(`${label} must be a non-negative number`);
121
+ process.exit(2);
122
+ }
123
+ return n;
124
+ }
125
+ function asWorker(v) {
126
+ if (typeof v !== "string")
127
+ return undefined;
128
+ if (WORKERS.includes(v))
129
+ return v;
130
+ throw new Error(`Invalid --worker ${v}; expected ${WORKERS.join("|")}`);
131
+ }
132
+ function asLane(v) {
133
+ if (typeof v !== "string")
134
+ return undefined;
135
+ if (LANES.includes(v))
136
+ return v;
137
+ throw new Error(`Invalid --lane ${v}; expected ${LANES.join("|")}`);
138
+ }
139
+ function refuseSecrets(text, context) {
140
+ if (looksLikeSecretMaterial(text)) {
141
+ console.error(`Refusing ${context}: looks like secret key material. Remove tokens/keys and retry.`);
142
+ process.exit(3);
143
+ }
144
+ }
145
+ function refuseDangerousPromptFile(path) {
146
+ let resolved;
147
+ try {
148
+ resolved = realpathSync(path);
149
+ }
150
+ catch {
151
+ resolved = resolve(path);
152
+ }
153
+ const base = basename(resolved);
154
+ const lower = resolved.toLowerCase();
155
+ if (base.startsWith(".env") ||
156
+ lower.includes("/.ssh/") ||
157
+ lower.includes("/.aws/") ||
158
+ lower.includes("/.kube/") ||
159
+ base === ".npmrc" ||
160
+ base === ".git-credentials" ||
161
+ base === ".pgpass" ||
162
+ base === "id_rsa" ||
163
+ base === "id_ed25519" ||
164
+ base === "id_ecdsa" ||
165
+ base === "id_dsa" ||
166
+ base === "credentials" ||
167
+ base.endsWith(".pem") ||
168
+ base.endsWith(".key") ||
169
+ base.endsWith(".p12") ||
170
+ base.endsWith(".pfx")) {
171
+ console.error(`Refusing --prompt-file path that looks credential-related: ${path}`);
172
+ process.exit(3);
173
+ }
174
+ }
175
+ async function main() {
176
+ const argv = process.argv.slice(2);
177
+ if (argv.length === 0)
178
+ usage(0);
179
+ if (argv[0] === "--version" || argv[0] === "-V") {
180
+ console.log(config.version);
181
+ return;
182
+ }
183
+ if (argv[0] === "-h" || argv[0] === "--help")
184
+ usage(0);
185
+ const cmd = argv[0];
186
+ const { flags: f, positional: pos } = parseArgs(argv.slice(1));
187
+ const json = Boolean(f.json);
188
+ if (cmd === "health") {
189
+ const report = runHealth();
190
+ printHealth(report, json);
191
+ process.exit(report.ok ? 0 : 1);
192
+ }
193
+ if (cmd === "start") {
194
+ requireStringFlag(f, "worker");
195
+ requireStringFlag(f, "lane");
196
+ const promptFile = requireStringFlag(f, "prompt-file");
197
+ const dirFlag = requireStringFlag(f, "dir");
198
+ let prompt = "";
199
+ if (promptFile) {
200
+ const p = resolve(promptFile);
201
+ refuseDangerousPromptFile(p);
202
+ if (!existsSync(p)) {
203
+ console.error(`prompt file not found: ${p}`);
204
+ process.exit(2);
205
+ }
206
+ prompt = readFileSync(p, "utf8");
207
+ }
208
+ else {
209
+ prompt = pos.join(" ").trim();
210
+ }
211
+ if (!prompt) {
212
+ console.error("start requires a prompt or --prompt-file");
213
+ process.exit(2);
214
+ }
215
+ refuseSecrets(prompt, "to start");
216
+ let cwd = process.cwd();
217
+ if (dirFlag) {
218
+ cwd = resolve(dirFlag);
219
+ try {
220
+ if (!statSync(cwd).isDirectory()) {
221
+ console.error(`--dir is not a directory: ${cwd}`);
222
+ process.exit(2);
223
+ }
224
+ }
225
+ catch {
226
+ console.error(`--dir does not exist: ${cwd}`);
227
+ process.exit(2);
228
+ }
229
+ }
230
+ const result = startJob({
231
+ prompt,
232
+ worker: asWorker(f.worker),
233
+ lane: asLane(f.lane),
234
+ cwd,
235
+ alwaysApprove: !f.ask,
236
+ dryRun: Boolean(f.dryRun),
237
+ noTmux: Boolean(f.noTmux),
238
+ });
239
+ if (!result.ok) {
240
+ console.error(result.error);
241
+ process.exit(1);
242
+ }
243
+ if (json) {
244
+ console.log(JSON.stringify({
245
+ ...result.job,
246
+ command: result.command ? redactSecrets(result.command) : result.command,
247
+ }, null, 2));
248
+ }
249
+ else if (result.dryRun) {
250
+ console.log(`dry-run job ${result.job.id}`);
251
+ console.log(`worker: ${result.job.worker}`);
252
+ console.log(`command: ${redactSecrets(result.command || "")}`);
253
+ }
254
+ else {
255
+ console.log(`started ${result.job.id} (${result.job.worker})`);
256
+ console.log(`session: ${result.job.tmuxSession}`);
257
+ if (String(result.job.tmuxSession).startsWith("headless-")) {
258
+ console.log(`mode: headless (--no-tmux); use capture/status (no attach/send)`);
259
+ if (result.job.pid)
260
+ console.log(`pid: ${result.job.pid}`);
261
+ }
262
+ else {
263
+ console.log(`attach: ${attachHint(result.job.id)}`);
264
+ }
265
+ console.log(`capture: cursor-route capture ${result.job.id}`);
266
+ }
267
+ return;
268
+ }
269
+ if (cmd === "jobs") {
270
+ const limitRaw = requireStringFlag(f, "limit");
271
+ const limit = requireNonNegNumber(limitRaw, "--limit", config.jobsListLimit);
272
+ const jobs = listJobs(limit);
273
+ if (json) {
274
+ console.log(JSON.stringify(jobs, null, 2));
275
+ }
276
+ else if (jobs.length === 0) {
277
+ console.log('No jobs yet. Try: cursor-route start "say hello" --worker grok');
278
+ }
279
+ else {
280
+ for (const j of jobs) {
281
+ const age = j.startedAt || j.createdAt;
282
+ console.log(`${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`);
283
+ }
284
+ }
285
+ return;
286
+ }
287
+ if (cmd === "status") {
288
+ const id = pos[0];
289
+ if (!id) {
290
+ console.error("status requires <jobId>");
291
+ process.exit(2);
292
+ }
293
+ let job = readJob(id);
294
+ if (!job) {
295
+ console.error(`Job not found: ${id}`);
296
+ process.exit(1);
297
+ }
298
+ job = refreshStatus(job);
299
+ const alive = job.tmuxSession.startsWith("headless-")
300
+ ? Boolean(job.pid && (() => {
301
+ try {
302
+ process.kill(job.pid, 0);
303
+ return true;
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ })())
309
+ : sessionExists(job.tmuxSession);
310
+ const view = { ...job, sessionAlive: alive };
311
+ if (json)
312
+ console.log(JSON.stringify(view, null, 2));
313
+ else {
314
+ console.log(`${job.id} ${job.status} worker=${job.worker} sessionAlive=${alive}`);
315
+ if (job.error)
316
+ console.log(`error: ${job.error}`);
317
+ }
318
+ return;
319
+ }
320
+ if (cmd === "capture") {
321
+ const id = pos[0];
322
+ const linesRaw = pos[1];
323
+ const lines = linesRaw
324
+ ? requireNonNegNumber(linesRaw, "capture lines", 50)
325
+ : 50;
326
+ if (!id) {
327
+ console.error("capture requires <jobId>");
328
+ process.exit(2);
329
+ }
330
+ const job = readJob(id);
331
+ if (!job) {
332
+ console.error(`Job not found: ${id}`);
333
+ process.exit(1);
334
+ }
335
+ if (!job.tmuxSession.startsWith("headless-") && sessionExists(job.tmuxSession)) {
336
+ const out = capturePane(job.tmuxSession, lines);
337
+ if (out.trim()) {
338
+ process.stdout.write(out.endsWith("\n") ? out : out + "\n");
339
+ return;
340
+ }
341
+ }
342
+ const logPath = jobPaths(id).log;
343
+ if (existsSync(logPath)) {
344
+ const log = readFileSync(logPath, "utf8");
345
+ const parts = log.split("\n");
346
+ console.log(parts.slice(-lines).join("\n"));
347
+ return;
348
+ }
349
+ console.error("No live session or log for this job");
350
+ process.exit(1);
351
+ }
352
+ if (cmd === "send") {
353
+ const id = pos[0];
354
+ const message = pos.slice(1).join(" ").trim();
355
+ if (!id || !message) {
356
+ console.error('send requires <jobId> "<message>"');
357
+ process.exit(2);
358
+ }
359
+ refuseSecrets(message, "to send");
360
+ const job = readJob(id);
361
+ if (!job) {
362
+ console.error(`Job not found: ${id}`);
363
+ process.exit(1);
364
+ }
365
+ if (job.tmuxSession.startsWith("headless-")) {
366
+ console.error("send is not supported for --no-tmux jobs");
367
+ process.exit(1);
368
+ }
369
+ if (!sendKeys(job.tmuxSession, message)) {
370
+ console.error(`Failed to send — is session alive? ${attachHint(id)}`);
371
+ process.exit(1);
372
+ }
373
+ console.log(`sent to ${id}`);
374
+ return;
375
+ }
376
+ if (cmd === "attach") {
377
+ const id = pos[0];
378
+ if (!id) {
379
+ console.error("attach requires <jobId>");
380
+ process.exit(2);
381
+ }
382
+ const job = readJob(id);
383
+ if (!job) {
384
+ console.error(`Job not found: ${id}`);
385
+ process.exit(1);
386
+ }
387
+ if (job.tmuxSession.startsWith("headless-")) {
388
+ console.error("headless job — use capture/status (no tmux attach)");
389
+ process.exit(1);
390
+ }
391
+ console.log(attachHint(id));
392
+ return;
393
+ }
394
+ if (cmd === "kill") {
395
+ const id = pos[0];
396
+ if (!id) {
397
+ console.error("kill requires <jobId>");
398
+ process.exit(2);
399
+ }
400
+ const r = killJob(id);
401
+ if (!r.ok) {
402
+ console.error(r.error);
403
+ process.exit(1);
404
+ }
405
+ console.log(`killed ${id}`);
406
+ return;
407
+ }
408
+ if (cmd === "sessions") {
409
+ const sessions = listManagedSessions();
410
+ if (json)
411
+ console.log(JSON.stringify(sessions, null, 2));
412
+ else if (sessions.length === 0)
413
+ console.log("No active cursor-route tmux sessions");
414
+ else
415
+ sessions.forEach((s) => console.log(s));
416
+ return;
417
+ }
418
+ if (cmd === "clean") {
419
+ const daysRaw = requireStringFlag(f, "days");
420
+ const days = requireNonNegNumber(daysRaw, "--days", 7);
421
+ const n = cleanJobs(days);
422
+ console.log(`cleaned ${n} job(s) older than ${days}d`);
423
+ return;
424
+ }
425
+ console.error(`Unknown command: ${cmd}`);
426
+ usage(2);
427
+ }
428
+ main().catch((err) => {
429
+ console.error(err instanceof Error ? err.message : err);
430
+ process.exit(1);
431
+ });
package/dist/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { defaultJobsDir } from "./runtime.js";
4
+ export const WORKERS = ["grok", "claude-ds"];
5
+ export const LANES = ["mid", "hard"];
6
+ function maxConcurrentJobsFromEnv() {
7
+ const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
8
+ if (raw) {
9
+ const n = Number(raw);
10
+ if (Number.isInteger(n) && n > 0)
11
+ return n;
12
+ }
13
+ return 50;
14
+ }
15
+ /**
16
+ * Live getters for env-derived paths/limits so tests can set
17
+ * CURSOR_ROUTE_JOBS_DIR / CURSOR_ROUTE_MAX_JOBS before exercising jobs
18
+ * even if another module imported config earlier.
19
+ */
20
+ export const config = {
21
+ product: "cursor-route",
22
+ version: "0.1.4",
23
+ get jobsDir() {
24
+ return defaultJobsDir();
25
+ },
26
+ tmuxPrefix: "cursor-route",
27
+ defaultWorker: "grok",
28
+ /** Lane → default worker (Cemini /route public core). */
29
+ laneWorkers: {
30
+ mid: "claude-ds",
31
+ hard: "grok",
32
+ },
33
+ jobsListLimit: 20,
34
+ /** Max simultaneously active (running|pending) jobs. Override: CURSOR_ROUTE_MAX_JOBS. */
35
+ get maxConcurrentJobs() {
36
+ return maxConcurrentJobsFromEnv();
37
+ },
38
+ };
39
+ export function sessionName(jobId) {
40
+ return `${config.tmuxPrefix}-${jobId}`;
41
+ }
42
+ /** Home for docs only — not used as jobsDir. */
43
+ export function productHomeHint() {
44
+ return join(process.env.HOME || homedir(), ".cursor-route");
45
+ }
package/dist/health.js ADDED
@@ -0,0 +1,100 @@
1
+ import { execSync } from "node:child_process";
2
+ import { allAdapters } from "./adapters/index.js";
3
+ import { config } from "./config.js";
4
+ import { isTmuxAvailable } from "./tmux.js";
5
+ import { commandExists } from "./util.js";
6
+ export function runHealth() {
7
+ const checks = [];
8
+ const tmuxOk = isTmuxAvailable();
9
+ checks.push({
10
+ name: "tmux",
11
+ ok: tmuxOk,
12
+ detail: tmuxOk
13
+ ? "ok"
14
+ : "missing — install: brew install tmux (macOS) or apt install tmux (Linux)",
15
+ });
16
+ const bunOk = commandExists("bun");
17
+ const nodeOk = commandExists("node");
18
+ checks.push({
19
+ name: "runtime",
20
+ ok: bunOk || nodeOk,
21
+ detail: bunOk ? "bun ok" : nodeOk ? "node ok (compiled dist)" : "need bun or node 20+",
22
+ });
23
+ const scriptOk = (() => {
24
+ try {
25
+ execSync("command -v script", { stdio: "ignore" });
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ })();
32
+ checks.push({
33
+ name: "script(1)",
34
+ ok: scriptOk,
35
+ detail: scriptOk ? "ok (tty log capture)" : "missing — needed for job logs on macOS/Linux",
36
+ });
37
+ for (const adapter of allAdapters()) {
38
+ const h = adapter.health();
39
+ checks.push({
40
+ name: `worker:${h.worker}`,
41
+ ok: h.ok,
42
+ detail: h.binary ? `${h.detail} @ ${h.binary}` : h.detail,
43
+ });
44
+ }
45
+ // Optional supervisor probe (v0 skill-only; Cursor CLI agent is informational)
46
+ const agentBin = (commandExists("agent") && "agent") ||
47
+ (commandExists("cursor-agent") && "cursor-agent") ||
48
+ null;
49
+ checks.push({
50
+ name: "cursor_cli",
51
+ ok: true, // informational — does not fail health
52
+ detail: agentBin
53
+ ? `optional ok (${agentBin} on PATH) — v0 supervisor is Cursor skill, not CLI`
54
+ : "optional — Cursor CLI agent not on PATH (skill-only supervisor is fine for v0)",
55
+ });
56
+ // At least one worker must be healthy for a green health gate.
57
+ // CURSOR_ROUTE_RELAXED=1: pass without tmux and without workers (CI / infra smoke).
58
+ const workerOk = checks.some((c) => c.name.startsWith("worker:") && c.ok);
59
+ const relaxed = process.env.CURSOR_ROUTE_RELAXED === "1";
60
+ const hardOk = (tmuxOk || relaxed) && (bunOk || nodeOk) && scriptOk;
61
+ const ok = hardOk && (workerOk || relaxed);
62
+ if (relaxed) {
63
+ checks.push({
64
+ name: "relaxed",
65
+ ok: true,
66
+ detail: workerOk
67
+ ? "CURSOR_ROUTE_RELAXED=1 — tmux optional (headless OK)"
68
+ : "CURSOR_ROUTE_RELAXED=1 — tmux/workers optional (CI / infra smoke)",
69
+ });
70
+ }
71
+ checks.push({
72
+ name: "jobs_dir",
73
+ ok: true,
74
+ detail: config.jobsDir,
75
+ });
76
+ return {
77
+ ok,
78
+ product: config.product,
79
+ version: config.version,
80
+ checks,
81
+ };
82
+ }
83
+ export function printHealth(report, asJson) {
84
+ if (asJson) {
85
+ console.log(JSON.stringify(report, null, 2));
86
+ return;
87
+ }
88
+ console.log(`${report.product} v${report.version}`);
89
+ console.log(report.ok ? "health: OK" : "health: NEEDS SETUP");
90
+ console.log("");
91
+ for (const c of report.checks) {
92
+ const mark = c.ok ? "✓" : "✗";
93
+ console.log(` ${mark} ${c.name.padEnd(16)} ${c.detail}`);
94
+ }
95
+ if (!report.ok) {
96
+ console.log("");
97
+ console.log("Fix the ✗ items, then re-run: cursor-route health");
98
+ console.log("Tip: start with one worker (grok OR claude-ds) before parallel demos.");
99
+ }
100
+ }