cursor-route 0.1.1

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.
@@ -0,0 +1,54 @@
1
+ import { execSync } from "node:child_process";
2
+ import type { Adapter, WorkerHealth } from "./types.ts";
3
+ import { shellQuote } from "../util.ts";
4
+
5
+ function findGrok(): string | null {
6
+ try {
7
+ return execSync("command -v grok", {
8
+ encoding: "utf8",
9
+ stdio: ["ignore", "pipe", "ignore"],
10
+ }).trim() || null;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export const grokAdapter: Adapter = {
17
+ kind: "grok",
18
+ label: "Grok CLI (xAI)",
19
+ health(): WorkerHealth {
20
+ const binary = findGrok();
21
+ if (!binary) {
22
+ return {
23
+ worker: "grok",
24
+ ok: false,
25
+ binary: null,
26
+ detail: "grok not on PATH — install from https://x.ai/cli then run: grok login",
27
+ };
28
+ }
29
+ return {
30
+ worker: "grok",
31
+ ok: true,
32
+ binary,
33
+ detail: "ok (auth checked at first start — run grok login if jobs fail)",
34
+ };
35
+ },
36
+ buildLaunch({ promptFile, cwd, alwaysApprove }) {
37
+ const parts = [
38
+ "grok",
39
+ "-p",
40
+ `"$(cat ${shellQuote(promptFile)})"`,
41
+ "--cwd",
42
+ shellQuote(cwd),
43
+ "--no-auto-update",
44
+ "--output-format",
45
+ "plain",
46
+ ];
47
+ if (alwaysApprove) parts.push("--always-approve");
48
+ return {
49
+ worker: "grok",
50
+ command: parts.join(" "),
51
+ alwaysApprove,
52
+ };
53
+ },
54
+ };
@@ -0,0 +1,21 @@
1
+ import type { WorkerKind } from "../config.ts";
2
+ import type { Adapter } from "./types.ts";
3
+ import { grokAdapter } from "./grok.ts";
4
+ import { claudeDsAdapter } from "./claude-ds.ts";
5
+
6
+ const registry: Record<WorkerKind, Adapter> = {
7
+ grok: grokAdapter,
8
+ "claude-ds": claudeDsAdapter,
9
+ };
10
+
11
+ export function getAdapter(worker: WorkerKind): Adapter {
12
+ const a = registry[worker];
13
+ if (!a) throw new Error(`Unknown worker: ${worker}`);
14
+ return a;
15
+ }
16
+
17
+ export function allAdapters(): Adapter[] {
18
+ return Object.values(registry);
19
+ }
20
+
21
+ export type { Adapter, WorkerHealth, LaunchPlan } from "./types.ts";
@@ -0,0 +1,26 @@
1
+ import type { WorkerKind } from "../config.ts";
2
+
3
+ export interface WorkerHealth {
4
+ worker: WorkerKind;
5
+ ok: boolean;
6
+ binary: string | null;
7
+ detail: string;
8
+ }
9
+
10
+ export interface LaunchPlan {
11
+ worker: WorkerKind;
12
+ /** Full shell command to run inside tmux (prompt already inlined via cat). */
13
+ command: string;
14
+ alwaysApprove: boolean;
15
+ }
16
+
17
+ export interface Adapter {
18
+ kind: WorkerKind;
19
+ label: string;
20
+ health(): WorkerHealth;
21
+ buildLaunch(opts: {
22
+ promptFile: string;
23
+ cwd: string;
24
+ alwaysApprove: boolean;
25
+ }): LaunchPlan;
26
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveWorker } from "./jobs.ts";
3
+ import { config } from "./config.ts";
4
+ import { shellQuote, newJobId } from "./util.ts";
5
+ import { runHealth } from "./health.ts";
6
+ import { looksLikeSecretMaterial } from "./secrets.ts";
7
+ import { isDeepSeekRouted } from "./adapters/claude-ds.ts";
8
+
9
+ describe("resolveWorker", () => {
10
+ test("lane mid → claude-ds", () => {
11
+ expect(resolveWorker({ prompt: "x", lane: "mid" })).toBe("claude-ds");
12
+ });
13
+ test("lane hard → grok", () => {
14
+ expect(resolveWorker({ prompt: "x", lane: "hard" })).toBe("grok");
15
+ });
16
+ test("explicit worker wins", () => {
17
+ expect(resolveWorker({ prompt: "x", lane: "hard", worker: "claude-ds" })).toBe(
18
+ "claude-ds",
19
+ );
20
+ });
21
+ test("default worker", () => {
22
+ expect(resolveWorker({ prompt: "x" })).toBe(config.defaultWorker);
23
+ });
24
+ });
25
+
26
+ describe("util", () => {
27
+ test("shellQuote", () => {
28
+ expect(shellQuote("a b")).toBe("'a b'");
29
+ expect(shellQuote("it's")).toBe("'it'\\''s'");
30
+ });
31
+ test("newJobId length", () => {
32
+ expect(newJobId()).toHaveLength(8);
33
+ });
34
+ });
35
+
36
+ describe("secrets", () => {
37
+ test("allows discussing API keys in prose", () => {
38
+ expect(looksLikeSecretMaterial("load the API key from env")).toBe(false);
39
+ });
40
+ test("blocks sk- material", () => {
41
+ expect(looksLikeSecretMaterial("token sk-abcdefghijklmnopqrstuvwxyz1234")).toBe(true);
42
+ });
43
+ test("blocks ghp_ material", () => {
44
+ expect(looksLikeSecretMaterial("ghp_abcdefghijklmnopqrstuvwx")).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("deepseek routing", () => {
49
+ test("detects deepseek base url", () => {
50
+ const prev = process.env.ANTHROPIC_BASE_URL;
51
+ process.env.ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic";
52
+ try {
53
+ expect(isDeepSeekRouted()).toBe(true);
54
+ } finally {
55
+ if (prev === undefined) delete process.env.ANTHROPIC_BASE_URL;
56
+ else process.env.ANTHROPIC_BASE_URL = prev;
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("health", () => {
62
+ test("returns structured report", () => {
63
+ const r = runHealth();
64
+ expect(r.product).toBe("cursor-route");
65
+ expect(r.checks.length).toBeGreaterThan(3);
66
+ expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
67
+ expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
68
+ });
69
+
70
+ test("relaxed env can pass without tmux", () => {
71
+ const prev = process.env.CURSOR_ROUTE_RELAXED;
72
+ process.env.CURSOR_ROUTE_RELAXED = "1";
73
+ try {
74
+ const r = runHealth();
75
+ const tmux = r.checks.find((c) => c.name === "tmux");
76
+ if (tmux && !tmux.ok) {
77
+ expect(r.ok).toBe(true);
78
+ expect(r.checks.some((c) => c.name === "relaxed")).toBe(true);
79
+ }
80
+ } finally {
81
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
82
+ else process.env.CURSOR_ROUTE_RELAXED = prev;
83
+ }
84
+ });
85
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * cursor-route CLI — Cursor brain, Grok + DeepSeek workers in tmux.
4
+ */
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { resolve, basename } from "node:path";
7
+ import { config, WORKERS, LANES, type WorkerKind, type Lane } from "./config.ts";
8
+ import { runHealth, printHealth } from "./health.ts";
9
+ import {
10
+ startJob,
11
+ listJobs,
12
+ readJob,
13
+ killJob,
14
+ cleanJobs,
15
+ jobPaths,
16
+ refreshStatus,
17
+ } from "./jobs.ts";
18
+ import {
19
+ capturePane,
20
+ sendKeys,
21
+ attachHint,
22
+ listManagedSessions,
23
+ sessionExists,
24
+ } from "./tmux.ts";
25
+ import { looksLikeSecretMaterial } from "./secrets.ts";
26
+
27
+ function usage(exitCode = 0): never {
28
+ console.log(`cursor-route v${config.version}
29
+
30
+ Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army.
31
+
32
+ Usage:
33
+ cursor-route --version
34
+ cursor-route health [--json]
35
+ cursor-route start <prompt> [options]
36
+ cursor-route start --prompt-file <path> [options]
37
+ cursor-route jobs [--json] [--limit N]
38
+ cursor-route status <jobId> [--json]
39
+ cursor-route capture <jobId> [lines]
40
+ cursor-route send <jobId> <message>
41
+ cursor-route attach <jobId>
42
+ cursor-route kill <jobId>
43
+ cursor-route sessions
44
+ cursor-route clean [--days N]
45
+
46
+ Start options:
47
+ --worker <grok|claude-ds> Worker adapter (default: grok)
48
+ --lane <mid|hard> Lane → worker (mid=claude-ds, hard=grok)
49
+ --dir <path> Working directory (default: cwd)
50
+ --ask Disable always-approve for this job
51
+ --dry-run Print launch command; do not start
52
+ --no-tmux Headless background process (no attach/send)
53
+ --json JSON output where supported
54
+
55
+ Env:
56
+ CURSOR_ROUTE_ASK=1 Opt out of always-approve
57
+ CURSOR_ROUTE_JOBS_DIR Override jobs dir (default: ~/.local/share/cursor-route/jobs)
58
+ CURSOR_ROUTE_RELAXED=1 health OK without tmux (headless CI)
59
+ CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
60
+ `);
61
+ process.exit(exitCode);
62
+ }
63
+
64
+ function parseArgs(argv: string[]) {
65
+ const flags: Record<string, string | boolean> = {};
66
+ const positional: string[] = [];
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const a = argv[i];
69
+ if (
70
+ a === "--json" ||
71
+ a === "--ask" ||
72
+ a === "--dry-run" ||
73
+ a === "--no-tmux" ||
74
+ a === "-h" ||
75
+ a === "--help" ||
76
+ a === "--version" ||
77
+ a === "-V"
78
+ ) {
79
+ if (a === "-h" || a === "--help") flags.help = true;
80
+ if (a === "--version" || a === "-V") flags.version = true;
81
+ if (a === "--dry-run") flags.dryRun = true;
82
+ if (a === "--no-tmux") flags.noTmux = true;
83
+ if (a === "--ask") flags.ask = true;
84
+ if (a === "--json") flags.json = true;
85
+ continue;
86
+ }
87
+ if (a.startsWith("--")) {
88
+ let key = a.slice(2);
89
+ let val: string | boolean = true;
90
+ if (key.includes("=")) {
91
+ const eq = key.indexOf("=");
92
+ val = key.slice(eq + 1);
93
+ key = key.slice(0, eq);
94
+ } else {
95
+ const next = argv[i + 1];
96
+ if (next && !next.startsWith("--")) {
97
+ val = next;
98
+ i++;
99
+ }
100
+ }
101
+ flags[key] = val;
102
+ continue;
103
+ }
104
+ positional.push(a);
105
+ }
106
+ return { flags, positional };
107
+ }
108
+
109
+ function asWorker(v: unknown): WorkerKind | undefined {
110
+ if (typeof v !== "string") return undefined;
111
+ if ((WORKERS as string[]).includes(v)) return v as WorkerKind;
112
+ throw new Error(`Invalid --worker ${v}; expected ${WORKERS.join("|")}`);
113
+ }
114
+
115
+ function asLane(v: unknown): Lane | undefined {
116
+ if (typeof v !== "string") return undefined;
117
+ if ((LANES as string[]).includes(v)) return v as Lane;
118
+ throw new Error(`Invalid --lane ${v}; expected ${LANES.join("|")}`);
119
+ }
120
+
121
+ function refuseSecrets(text: string, context: string): void {
122
+ if (looksLikeSecretMaterial(text)) {
123
+ console.error(
124
+ `Refusing ${context}: looks like secret key material. Remove tokens/keys and retry.`,
125
+ );
126
+ process.exit(3);
127
+ }
128
+ }
129
+
130
+ function refuseDangerousPromptFile(path: string): void {
131
+ const base = basename(path);
132
+ const resolved = resolve(path);
133
+ if (
134
+ base.startsWith(".env") ||
135
+ resolved.includes("/.ssh/") ||
136
+ base === "id_rsa" ||
137
+ base === "id_ed25519" ||
138
+ base.endsWith(".pem")
139
+ ) {
140
+ console.error(`Refusing --prompt-file path that looks credential-related: ${path}`);
141
+ process.exit(3);
142
+ }
143
+ }
144
+
145
+ async function main() {
146
+ const argv = process.argv.slice(2);
147
+ if (argv.length === 0) usage(0);
148
+
149
+ if (argv[0] === "--version" || argv[0] === "-V") {
150
+ console.log(config.version);
151
+ return;
152
+ }
153
+ if (argv[0] === "-h" || argv[0] === "--help") usage(0);
154
+
155
+ const cmd = argv[0];
156
+ const { flags: f, positional: pos } = parseArgs(argv.slice(1));
157
+ const json = Boolean(f.json);
158
+
159
+ if (cmd === "health") {
160
+ const report = runHealth();
161
+ printHealth(report, json);
162
+ process.exit(report.ok ? 0 : 1);
163
+ }
164
+
165
+ if (cmd === "start") {
166
+ let prompt = "";
167
+ if (f["prompt-file"]) {
168
+ const p = resolve(String(f["prompt-file"]));
169
+ refuseDangerousPromptFile(p);
170
+ if (!existsSync(p)) {
171
+ console.error(`prompt file not found: ${p}`);
172
+ process.exit(2);
173
+ }
174
+ prompt = readFileSync(p, "utf8");
175
+ } else {
176
+ prompt = pos.join(" ").trim();
177
+ }
178
+ if (!prompt) {
179
+ console.error("start requires a prompt or --prompt-file");
180
+ process.exit(2);
181
+ }
182
+ refuseSecrets(prompt, "to start");
183
+
184
+ const result = startJob({
185
+ prompt,
186
+ worker: asWorker(f.worker),
187
+ lane: asLane(f.lane),
188
+ cwd: f.dir ? resolve(String(f.dir)) : process.cwd(),
189
+ alwaysApprove: !f.ask,
190
+ dryRun: Boolean(f.dryRun),
191
+ noTmux: Boolean(f.noTmux),
192
+ });
193
+
194
+ if (!result.ok) {
195
+ console.error(result.error);
196
+ process.exit(1);
197
+ }
198
+
199
+ if (json) {
200
+ console.log(JSON.stringify({ ...result.job, command: result.command }, null, 2));
201
+ } else if (result.dryRun) {
202
+ console.log(`dry-run job ${result.job.id}`);
203
+ console.log(`worker: ${result.job.worker}`);
204
+ console.log(`command: ${result.command}`);
205
+ } else {
206
+ console.log(`started ${result.job.id} (${result.job.worker})`);
207
+ console.log(`session: ${result.job.tmuxSession}`);
208
+ if (String(result.job.tmuxSession).startsWith("headless-")) {
209
+ console.log(`mode: headless (--no-tmux); use capture/status (no attach/send)`);
210
+ if (result.job.pid) console.log(`pid: ${result.job.pid}`);
211
+ } else {
212
+ console.log(`attach: ${attachHint(result.job.id)}`);
213
+ }
214
+ console.log(`capture: cursor-route capture ${result.job.id}`);
215
+ }
216
+ return;
217
+ }
218
+
219
+ if (cmd === "jobs") {
220
+ const limit = f.limit ? Number(f.limit) : config.jobsListLimit;
221
+ const jobs = listJobs(limit);
222
+ if (json) {
223
+ console.log(JSON.stringify(jobs, null, 2));
224
+ } else if (jobs.length === 0) {
225
+ console.log('No jobs yet. Try: cursor-route start "say hello" --worker grok');
226
+ } else {
227
+ for (const j of jobs) {
228
+ const age = j.startedAt || j.createdAt;
229
+ console.log(
230
+ `${j.id} ${j.status.padEnd(10)} ${j.worker.padEnd(10)} ${age} ${j.prompt.slice(0, 48).replace(/\n/g, " ")}`,
231
+ );
232
+ }
233
+ }
234
+ return;
235
+ }
236
+
237
+ if (cmd === "status") {
238
+ const id = pos[0];
239
+ if (!id) {
240
+ console.error("status requires <jobId>");
241
+ process.exit(2);
242
+ }
243
+ let job = readJob(id);
244
+ if (!job) {
245
+ console.error(`Job not found: ${id}`);
246
+ process.exit(1);
247
+ }
248
+ job = refreshStatus(job);
249
+ const alive = job.tmuxSession.startsWith("headless-")
250
+ ? Boolean(job.pid && (() => {
251
+ try {
252
+ process.kill(job!.pid!, 0);
253
+ return true;
254
+ } catch {
255
+ return false;
256
+ }
257
+ })())
258
+ : sessionExists(job.tmuxSession);
259
+ const view = { ...job, sessionAlive: alive };
260
+ if (json) console.log(JSON.stringify(view, null, 2));
261
+ else {
262
+ console.log(`${job.id} ${job.status} worker=${job.worker} sessionAlive=${alive}`);
263
+ if (job.error) console.log(`error: ${job.error}`);
264
+ }
265
+ return;
266
+ }
267
+
268
+ if (cmd === "capture") {
269
+ const id = pos[0];
270
+ const lines = pos[1] ? Number(pos[1]) : 50;
271
+ if (!id) {
272
+ console.error("capture requires <jobId>");
273
+ process.exit(2);
274
+ }
275
+ const job = readJob(id);
276
+ if (!job) {
277
+ console.error(`Job not found: ${id}`);
278
+ process.exit(1);
279
+ }
280
+ if (!job.tmuxSession.startsWith("headless-") && sessionExists(job.tmuxSession)) {
281
+ const out = capturePane(job.tmuxSession, lines);
282
+ if (out.trim()) {
283
+ process.stdout.write(out.endsWith("\n") ? out : out + "\n");
284
+ return;
285
+ }
286
+ }
287
+ const logPath = jobPaths(id).log;
288
+ if (existsSync(logPath)) {
289
+ const log = readFileSync(logPath, "utf8");
290
+ const parts = log.split("\n");
291
+ console.log(parts.slice(-lines).join("\n"));
292
+ return;
293
+ }
294
+ console.error("No live session or log for this job");
295
+ process.exit(1);
296
+ }
297
+
298
+ if (cmd === "send") {
299
+ const id = pos[0];
300
+ const message = pos.slice(1).join(" ").trim();
301
+ if (!id || !message) {
302
+ console.error('send requires <jobId> "<message>"');
303
+ process.exit(2);
304
+ }
305
+ refuseSecrets(message, "to send");
306
+ const job = readJob(id);
307
+ if (!job) {
308
+ console.error(`Job not found: ${id}`);
309
+ process.exit(1);
310
+ }
311
+ if (job.tmuxSession.startsWith("headless-")) {
312
+ console.error("send is not supported for --no-tmux jobs");
313
+ process.exit(1);
314
+ }
315
+ if (!sendKeys(job.tmuxSession, message)) {
316
+ console.error(`Failed to send — is session alive? ${attachHint(id)}`);
317
+ process.exit(1);
318
+ }
319
+ console.log(`sent to ${id}`);
320
+ return;
321
+ }
322
+
323
+ if (cmd === "attach") {
324
+ const id = pos[0];
325
+ if (!id) {
326
+ console.error("attach requires <jobId>");
327
+ process.exit(2);
328
+ }
329
+ console.log(attachHint(id));
330
+ return;
331
+ }
332
+
333
+ if (cmd === "kill") {
334
+ const id = pos[0];
335
+ if (!id) {
336
+ console.error("kill requires <jobId>");
337
+ process.exit(2);
338
+ }
339
+ const r = killJob(id);
340
+ if (!r.ok) {
341
+ console.error(r.error);
342
+ process.exit(1);
343
+ }
344
+ console.log(`killed ${id}`);
345
+ return;
346
+ }
347
+
348
+ if (cmd === "sessions") {
349
+ const sessions = listManagedSessions();
350
+ if (json) console.log(JSON.stringify(sessions, null, 2));
351
+ else if (sessions.length === 0) console.log("No active cursor-route tmux sessions");
352
+ else sessions.forEach((s) => console.log(s));
353
+ return;
354
+ }
355
+
356
+ if (cmd === "clean") {
357
+ const days = f.days ? Number(f.days) : 7;
358
+ const n = cleanJobs(days);
359
+ console.log(`cleaned ${n} job(s) older than ${days}d`);
360
+ return;
361
+ }
362
+
363
+ console.error(`Unknown command: ${cmd}`);
364
+ usage(2);
365
+ }
366
+
367
+ main().catch((err) => {
368
+ console.error(err instanceof Error ? err.message : err);
369
+ process.exit(1);
370
+ });
package/src/config.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { defaultJobsDir } from "./runtime.ts";
4
+
5
+ export type WorkerKind = "grok" | "claude-ds";
6
+ export type Lane = "mid" | "hard";
7
+
8
+ export const WORKERS: WorkerKind[] = ["grok", "claude-ds"];
9
+ export const LANES: Lane[] = ["mid", "hard"];
10
+
11
+ export const config = {
12
+ product: "cursor-route",
13
+ version: "0.1.1",
14
+ jobsDir: defaultJobsDir(),
15
+ tmuxPrefix: "cursor-route",
16
+ defaultWorker: "grok" as WorkerKind,
17
+ /** Lane → default worker (Cemini /route public core). */
18
+ laneWorkers: {
19
+ mid: "claude-ds" as WorkerKind,
20
+ hard: "grok" as WorkerKind,
21
+ },
22
+ jobsListLimit: 20,
23
+ };
24
+
25
+ export function sessionName(jobId: string): string {
26
+ return `${config.tmuxPrefix}-${jobId}`;
27
+ }
28
+
29
+ /** Home for docs only — not used as jobsDir. */
30
+ export function productHomeHint(): string {
31
+ return join(process.env.HOME || homedir(), ".cursor-route");
32
+ }