cursor-route 0.1.4 → 0.1.6

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/src/cli.test.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { resolveWorker } from "./jobs.ts";
3
- import { config } from "./config.ts";
3
+ import { config, resolveDsModelAlias } from "./config.ts";
4
4
  import { shellQuote, newJobId } from "./util.ts";
5
5
  import { runHealth } from "./health.ts";
6
6
  import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
7
- import { isDeepSeekRouted, isDeepSeekBaseUrl } from "./adapters/claude-ds.ts";
7
+ import { isDeepSeekRouted, isDeepSeekBaseUrl, claudeDsAdapter } from "./adapters/claude-ds.ts";
8
+ import { deepseekAdapter } from "./adapters/deepseek.ts";
8
9
 
9
10
  describe("resolveWorker", () => {
11
+ test("lane easy → openrouter", () => {
12
+ expect(resolveWorker({ prompt: "x", lane: "easy" })).toBe("openrouter");
13
+ });
10
14
  test("lane mid → claude-ds", () => {
11
15
  expect(resolveWorker({ prompt: "x", lane: "mid" })).toBe("claude-ds");
12
16
  });
@@ -17,12 +21,81 @@ describe("resolveWorker", () => {
17
21
  expect(resolveWorker({ prompt: "x", lane: "hard", worker: "claude-ds" })).toBe(
18
22
  "claude-ds",
19
23
  );
24
+ expect(resolveWorker({ prompt: "x", lane: "mid", worker: "openrouter" })).toBe(
25
+ "openrouter",
26
+ );
20
27
  });
21
28
  test("default worker", () => {
22
29
  expect(resolveWorker({ prompt: "x" })).toBe(config.defaultWorker);
23
30
  });
24
31
  });
25
32
 
33
+ describe("resolveDsModelAlias", () => {
34
+ test("defaults to flash", () => {
35
+ expect(resolveDsModelAlias()).toBe("flash");
36
+ expect(resolveDsModelAlias("")).toBe("flash");
37
+ });
38
+ test("accepts aliases and full ids", () => {
39
+ expect(resolveDsModelAlias("flash")).toBe("flash");
40
+ expect(resolveDsModelAlias("pro")).toBe("pro");
41
+ expect(resolveDsModelAlias("deepseek-v4-flash")).toBe("flash");
42
+ expect(resolveDsModelAlias("deepseek-v4-pro")).toBe("pro");
43
+ expect(resolveDsModelAlias("deepseek-v4-pro[1m]")).toBe("pro");
44
+ });
45
+ test("rejects unknown", () => {
46
+ expect(() => resolveDsModelAlias("opus")).toThrow(/flash\|pro/);
47
+ });
48
+ });
49
+
50
+ describe("claude-ds -Model", () => {
51
+ test("shim path passes -Model deepseek-v4-flash by default", () => {
52
+ const prev = process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
53
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
54
+ try {
55
+ const plan = claudeDsAdapter.buildLaunch({
56
+ promptFile: "/tmp/p.prompt",
57
+ cwd: "/tmp",
58
+ alwaysApprove: true,
59
+ });
60
+ expect(plan.command).toContain("-Model");
61
+ expect(plan.command).toContain("deepseek-v4-flash");
62
+ } finally {
63
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
64
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev;
65
+ }
66
+ });
67
+ test("pro alias maps to deepseek-v4-pro", () => {
68
+ const prev = process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
69
+ process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = "/tmp/fake-claude-ds";
70
+ try {
71
+ const plan = claudeDsAdapter.buildLaunch({
72
+ promptFile: "/tmp/p.prompt",
73
+ cwd: "/tmp",
74
+ alwaysApprove: true,
75
+ model: "pro",
76
+ });
77
+ expect(plan.command).toContain("deepseek-v4-pro");
78
+ expect(plan.command).not.toContain("deepseek-v4-flash");
79
+ } finally {
80
+ if (prev === undefined) delete process.env.CURSOR_ROUTE_CLAUDE_DS_BIN;
81
+ else process.env.CURSOR_ROUTE_CLAUDE_DS_BIN = prev;
82
+ }
83
+ });
84
+ });
85
+
86
+ describe("deepseek adapter slot", () => {
87
+ test("health is not ok and buildLaunch throws", () => {
88
+ expect(deepseekAdapter.health().ok).toBe(false);
89
+ expect(() =>
90
+ deepseekAdapter.buildLaunch({
91
+ promptFile: "/tmp/p",
92
+ cwd: "/tmp",
93
+ alwaysApprove: true,
94
+ }),
95
+ ).toThrow(/not available/);
96
+ });
97
+ });
98
+
26
99
  describe("util", () => {
27
100
  test("shellQuote", () => {
28
101
  expect(shellQuote("a b")).toBe("'a b'");
package/src/cli.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * cursor-route CLI — Cursor brain, Grok + DeepSeek workers in tmux.
3
+ * cursor-route CLI — Cursor brain, Grok + DeepSeek + OpenRouter (easy) workers in tmux.
4
4
  */
5
5
  import { readFileSync, existsSync, realpathSync, statSync } from "node:fs";
6
6
  import { resolve, basename } from "node:path";
7
- import { config, WORKERS, LANES, type WorkerKind, type Lane } from "./config.ts";
7
+ import {
8
+ config,
9
+ WORKERS,
10
+ LANES,
11
+ resolveDsModelAlias,
12
+ type WorkerKind,
13
+ type Lane,
14
+ type DsModelAlias,
15
+ } from "./config.ts";
8
16
  import { runHealth, printHealth } from "./health.ts";
9
17
  import {
10
18
  startJob,
@@ -27,7 +35,7 @@ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
27
35
  function usage(exitCode = 0): never {
28
36
  console.log(`cursor-route v${config.version}
29
37
 
30
- Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army.
38
+ Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) are the parallel army.
31
39
 
32
40
  Usage:
33
41
  cursor-route --version
@@ -44,13 +52,14 @@ Usage:
44
52
  cursor-route clean [--days N]
45
53
 
46
54
  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
55
+ --worker <grok|claude-ds|openrouter> Worker adapter (default: grok; deepseek = reserved slot)
56
+ --lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
57
+ --model <flash|pro> Mid DeepSeek model (default: flash). Ignored by grok/openrouter
58
+ --dir <path> Working directory (default: cwd)
59
+ --ask Disable always-approve for this job
60
+ --dry-run Print launch command; do not start
61
+ --no-tmux Headless background process (no attach/send)
62
+ --json JSON output where supported
54
63
 
55
64
  Env:
56
65
  CURSOR_ROUTE_ASK=1 Opt out of always-approve
@@ -58,8 +67,12 @@ Env:
58
67
  CURSOR_ROUTE_MAX_JOBS Max active jobs (default: 50)
59
68
  CURSOR_ROUTE_RELAXED=1 health OK without tmux/workers (CI / infra smoke)
60
69
  CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
70
+ CURSOR_ROUTE_DS_MODEL Default mid model flash|pro (overridden by --model)
61
71
  CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
62
72
  CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
73
+ OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
74
+ CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
75
+ OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
63
76
  `);
64
77
  process.exit(exitCode);
65
78
  }
@@ -149,6 +162,12 @@ function asLane(v: unknown): Lane | undefined {
149
162
  throw new Error(`Invalid --lane ${v}; expected ${LANES.join("|")}`);
150
163
  }
151
164
 
165
+ function asDsModel(v: unknown): DsModelAlias | undefined {
166
+ if (v === undefined || v === true) return undefined;
167
+ if (typeof v !== "string") throw new Error(`Invalid --model; expected flash|pro`);
168
+ return resolveDsModelAlias(v);
169
+ }
170
+
152
171
  function refuseSecrets(text: string, context: string): void {
153
172
  if (looksLikeSecretMaterial(text)) {
154
173
  console.error(
@@ -213,8 +232,16 @@ async function main() {
213
232
  if (cmd === "start") {
214
233
  requireStringFlag(f, "worker");
215
234
  requireStringFlag(f, "lane");
235
+ requireStringFlag(f, "model");
216
236
  const promptFile = requireStringFlag(f, "prompt-file");
217
237
  const dirFlag = requireStringFlag(f, "dir");
238
+ let model: DsModelAlias | undefined;
239
+ try {
240
+ model = asDsModel(f.model);
241
+ } catch (e) {
242
+ console.error((e as Error).message);
243
+ process.exit(2);
244
+ }
218
245
 
219
246
  let prompt = "";
220
247
  if (promptFile) {
@@ -252,6 +279,7 @@ async function main() {
252
279
  prompt,
253
280
  worker: asWorker(f.worker),
254
281
  lane: asLane(f.lane),
282
+ model,
255
283
  cwd,
256
284
  alwaysApprove: !f.ask,
257
285
  dryRun: Boolean(f.dryRun),
@@ -277,9 +305,11 @@ async function main() {
277
305
  } else if (result.dryRun) {
278
306
  console.log(`dry-run job ${result.job.id}`);
279
307
  console.log(`worker: ${result.job.worker}`);
308
+ if (result.job.model) console.log(`model: ${result.job.model}`);
280
309
  console.log(`command: ${redactSecrets(result.command || "")}`);
281
310
  } else {
282
- console.log(`started ${result.job.id} (${result.job.worker})`);
311
+ const modelNote = result.job.model ? `/${result.job.model}` : "";
312
+ console.log(`started ${result.job.id} (${result.job.worker}${modelNote})`);
283
313
  console.log(`session: ${result.job.tmuxSession}`);
284
314
  if (String(result.job.tmuxSession).startsWith("headless-")) {
285
315
  console.log(`mode: headless (--no-tmux); use capture/status (no attach/send)`);
package/src/config.ts CHANGED
@@ -2,11 +2,40 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { defaultJobsDir } from "./runtime.ts";
4
4
 
5
- export type WorkerKind = "grok" | "claude-ds";
6
- export type Lane = "mid" | "hard";
5
+ /** Workers with a live adapter. `deepseek` is a reserved slot (unreleased harness). */
6
+ export type WorkerKind = "grok" | "claude-ds" | "openrouter" | "deepseek";
7
+ export type Lane = "easy" | "mid" | "hard";
7
8
 
8
- export const WORKERS: WorkerKind[] = ["grok", "claude-ds"];
9
- export const LANES: Lane[] = ["mid", "hard"];
9
+ /** Public CLI aliases for mid-lane DeepSeek models. */
10
+ export type DsModelAlias = "flash" | "pro";
11
+
12
+ export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter", "deepseek"];
13
+ export const LANES: Lane[] = ["easy", "mid", "hard"];
14
+ export const DS_MODELS: DsModelAlias[] = ["flash", "pro"];
15
+
16
+ export const DS_MODEL_IDS: Record<DsModelAlias, string> = {
17
+ flash: "deepseek-v4-flash",
18
+ pro: "deepseek-v4-pro",
19
+ };
20
+
21
+ /** Resolve --model flash|pro (or full deepseek-v4-* id) to a CLI alias. Default: flash. */
22
+ export function resolveDsModelAlias(raw?: string | null): DsModelAlias {
23
+ if (!raw || !raw.trim()) return "flash";
24
+ const v = raw.trim().toLowerCase();
25
+ if (v === "flash" || v === "deepseek-v4-flash") return "flash";
26
+ if (v === "pro" || v === "deepseek-v4-pro" || v === "deepseek-v4-pro[1m]") return "pro";
27
+ throw new Error(`Invalid --model ${raw}; expected flash|pro`);
28
+ }
29
+
30
+ /** OpenRouter model for the easy lane (env CURSOR_ROUTE_OPENROUTER_MODEL). */
31
+ export function openRouterModel(): string {
32
+ return process.env.CURSOR_ROUTE_OPENROUTER_MODEL || "openrouter/free";
33
+ }
34
+
35
+ /** OpenRouter API base URL (env OPENROUTER_BASE_URL). */
36
+ export function openRouterBaseUrl(): string {
37
+ return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
38
+ }
10
39
 
11
40
  function maxConcurrentJobsFromEnv(): number {
12
41
  const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
@@ -24,7 +53,7 @@ function maxConcurrentJobsFromEnv(): number {
24
53
  */
25
54
  export const config = {
26
55
  product: "cursor-route",
27
- version: "0.1.4",
56
+ version: "0.1.6",
28
57
  get jobsDir(): string {
29
58
  return defaultJobsDir();
30
59
  },
@@ -32,9 +61,12 @@ export const config = {
32
61
  defaultWorker: "grok" as WorkerKind,
33
62
  /** Lane → default worker (Cemini /route public core). */
34
63
  laneWorkers: {
64
+ easy: "openrouter" as WorkerKind,
35
65
  mid: "claude-ds" as WorkerKind,
36
66
  hard: "grok" as WorkerKind,
37
67
  },
68
+ /** Default mid DeepSeek model (Flash = cheap execute). Override: --model pro */
69
+ defaultDsModel: "flash" as DsModelAlias,
38
70
  jobsListLimit: 20,
39
71
  /** Max simultaneously active (running|pending) jobs. Override: CURSOR_ROUTE_MAX_JOBS. */
40
72
  get maxConcurrentJobs(): number {
@@ -134,6 +134,13 @@ describe("fake-worker integration (headless, no tmux)", () => {
134
134
  20000,
135
135
  );
136
136
 
137
+ test("openrouter worker without key refuses start (preflight)", () => {
138
+ delete process.env.OPENROUTER_API_KEY;
139
+ const r = startJob({ prompt: "say ok", worker: "openrouter", noTmux: true });
140
+ expect(r.ok).toBe(false);
141
+ if (!r.ok) expect(r.error).toContain("openrouter");
142
+ });
143
+
137
144
  test(
138
145
  "concurrent limit: refuses start once active jobs reach CURSOR_ROUTE_MAX_JOBS",
139
146
  async () => {
package/src/jobs.ts CHANGED
@@ -12,7 +12,13 @@ import {
12
12
  import { join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { dirname } from "node:path";
15
- import { config, sessionName, type Lane, type WorkerKind } from "./config.ts";
15
+ import {
16
+ config,
17
+ sessionName,
18
+ type Lane,
19
+ type WorkerKind,
20
+ type DsModelAlias,
21
+ } from "./config.ts";
16
22
  import { getAdapter } from "./adapters/index.ts";
17
23
  import { spawn } from "node:child_process";
18
24
  import { spawnSync } from "node:child_process";
@@ -29,6 +35,8 @@ export interface Job {
29
35
  status: JobStatus;
30
36
  worker: WorkerKind;
31
37
  lane?: Lane;
38
+ /** Mid-lane DeepSeek model alias (flash|pro). Only set for claude-ds. */
39
+ model?: DsModelAlias;
32
40
  prompt: string;
33
41
  cwd: string;
34
42
  alwaysApprove: boolean;
@@ -221,6 +229,8 @@ export interface StartOptions {
221
229
  prompt: string;
222
230
  worker?: WorkerKind;
223
231
  lane?: Lane;
232
+ /** Mid-lane DeepSeek: flash (default) | pro. Ignored by grok/openrouter. */
233
+ model?: DsModelAlias;
224
234
  cwd?: string;
225
235
  alwaysApprove?: boolean;
226
236
  dryRun?: boolean;
@@ -268,12 +278,16 @@ export function startJob(opts: StartOptions): {
268
278
  const paths = jobPaths(id);
269
279
  writeSecure(paths.prompt, opts.prompt);
270
280
 
281
+ const model =
282
+ worker === "claude-ds" ? opts.model ?? config.defaultDsModel : undefined;
283
+
271
284
  let plan;
272
285
  try {
273
286
  plan = adapter.buildLaunch({
274
287
  promptFile: paths.prompt,
275
288
  cwd,
276
289
  alwaysApprove,
290
+ model,
277
291
  });
278
292
  } catch (e) {
279
293
  try {
@@ -290,6 +304,7 @@ export function startJob(opts: StartOptions): {
290
304
  status: "pending",
291
305
  worker,
292
306
  lane: opts.lane,
307
+ model,
293
308
  prompt: opts.prompt,
294
309
  cwd,
295
310
  alwaysApprove: plan.alwaysApprove,
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * One-shot OpenRouter easy-lane runner — reads a prompt file and POSTs it to
4
+ * OpenRouter's chat/completions, printing the assistant reply to stdout.
5
+ * No runtime npm deps (Node 20+ global fetch). Invoked by the openrouter
6
+ * adapter (dist via node, else src via bun). Never echoes OPENROUTER_API_KEY.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { openRouterModel, openRouterBaseUrl } from "./config.ts";
10
+ import { looksLikeSecretMaterial } from "./secrets.ts";
11
+
12
+ const SYSTEM_PROMPT =
13
+ "You are a drafting/rewrite helper for cursor-route. Never invent credentials, API keys, or secrets; if asked for secret material, refuse. Provide educational, general-purpose help.";
14
+
15
+ function fail(msg: string, code: number): never {
16
+ console.error(`cursor-route/openrouter-run: ${msg}`);
17
+ process.exit(code);
18
+ }
19
+
20
+ function parseArgs(argv: string[]): { promptFile?: string } {
21
+ const flags: { promptFile?: string } = {};
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const a = argv[i];
24
+ if (a === "-h" || a === "--help") {
25
+ console.log(
26
+ "usage: cursor-route/openrouter-run --prompt-file <path>\n" +
27
+ "env: OPENROUTER_API_KEY (required), CURSOR_ROUTE_OPENROUTER_MODEL, OPENROUTER_BASE_URL",
28
+ );
29
+ process.exit(0);
30
+ }
31
+ if (a === "--prompt-file") {
32
+ const v = argv[i + 1];
33
+ if (!v || v.startsWith("--")) fail("--prompt-file requires a value", 2);
34
+ flags.promptFile = v;
35
+ i++;
36
+ } else if (a.startsWith("--prompt-file=")) {
37
+ flags.promptFile = a.slice("--prompt-file=".length);
38
+ } else {
39
+ fail(`unknown argument: ${a}`, 2);
40
+ }
41
+ }
42
+ return flags;
43
+ }
44
+
45
+ async function main(): Promise<void> {
46
+ const { promptFile } = parseArgs(process.argv.slice(2));
47
+ if (!promptFile) fail("--prompt-file <path> is required", 2);
48
+
49
+ let prompt: string;
50
+ try {
51
+ prompt = readFileSync(promptFile, "utf8");
52
+ } catch (e) {
53
+ fail(`cannot read prompt file: ${(e as Error).message}`, 2);
54
+ }
55
+ if (!prompt.trim()) fail("prompt file is empty", 2);
56
+ if (looksLikeSecretMaterial(prompt)) {
57
+ fail("refusing prompt: looks like secret key material — easy lane is for non-secret drafts", 3);
58
+ }
59
+
60
+ const apiKey = process.env.OPENROUTER_API_KEY;
61
+ if (!apiKey) fail("OPENROUTER_API_KEY is not set", 2);
62
+
63
+ const base = openRouterBaseUrl().replace(/\/+$/, "");
64
+ const url = `${base}/chat/completions`;
65
+
66
+ let res: Response;
67
+ try {
68
+ res = await fetch(url, {
69
+ method: "POST",
70
+ headers: {
71
+ Authorization: `Bearer ${apiKey}`,
72
+ "Content-Type": "application/json",
73
+ // OpenRouter etiquette: identify the app so the provider can see usage source.
74
+ "HTTP-Referer": "https://github.com/cemini23/cursor-route",
75
+ "X-Title": "cursor-route",
76
+ },
77
+ body: JSON.stringify({
78
+ model: openRouterModel(),
79
+ messages: [
80
+ { role: "system", content: SYSTEM_PROMPT },
81
+ { role: "user", content: prompt },
82
+ ],
83
+ }),
84
+ });
85
+ } catch (e) {
86
+ fail(`network error calling ${url}: ${(e as Error).message}`, 1);
87
+ }
88
+
89
+ if (!res.ok) {
90
+ const body = await res.text().catch(() => "");
91
+ fail(`OpenRouter API ${res.status}: ${body.slice(0, 500)}`, 1);
92
+ }
93
+
94
+ const json = (await res.json()) as {
95
+ choices?: Array<{ message?: { content?: string } }>;
96
+ };
97
+ const content = json.choices?.[0]?.message?.content ?? "";
98
+ if (!content) fail("OpenRouter returned no assistant content", 1);
99
+ process.stdout.write(content.endsWith("\n") ? content : content + "\n");
100
+ }
101
+
102
+ main().catch((e) => fail(e instanceof Error ? e.message : String(e), 1));