cursor-route 0.1.4 → 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 +28 -6
- package/SECURITY.md +6 -5
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/openrouter.js +78 -0
- package/dist/cli.js +12 -9
- package/dist/config.js +12 -3
- package/dist/openrouter-run.js +95 -0
- package/docs/demo-notes.md +3 -2
- package/llms.txt +3 -3
- package/package.json +3 -2
- package/skills/route-orch/SKILL.md +8 -6
- package/src/adapters/index.ts +2 -0
- package/src/adapters/openrouter.test.ts +57 -0
- package/src/adapters/openrouter.ts +80 -0
- package/src/cli.test.ts +6 -0
- package/src/cli.ts +12 -9
- package/src/config.ts +16 -5
- package/src/integration.test.ts +7 -0
- package/src/openrouter-run.ts +102 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cursor-route
|
|
2
2
|
|
|
3
|
-
**Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army.**
|
|
3
|
+
**Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) are the parallel army.**
|
|
4
4
|
|
|
5
5
|
Lane-aware `/route` orchestration in tmux — not another multi-provider fleet, not a Codex clone.
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ cursor-route capture <jobId>
|
|
|
17
17
|
|---------|-------|---------|------|
|
|
18
18
|
| [codex-orchestrator](https://github.com/kingbootoshi/codex-orchestrator) | Claude Code | Codex | Viral tmux panes |
|
|
19
19
|
| [CAO](https://github.com/awslabs/cli-agent-orchestrator) | Supervisor CLI | Many (incl. Cursor CLI) | Enterprise MCP + Web UI |
|
|
20
|
-
| **cursor-route** | **Cursor Agent** | **Grok CLI + claude-ds** | Cost-aware lanes you already pay for |
|
|
20
|
+
| **cursor-route** | **Cursor Agent** | **Grok CLI + claude-ds + OpenRouter (easy)** | Cost-aware lanes you already pay for |
|
|
21
21
|
|
|
22
22
|
If you already live in Cursor, X Premium (Grok CLI), and DeepSeek — stop paying a third coding agent just to parallelize.
|
|
23
23
|
|
|
@@ -28,6 +28,7 @@ If you already live in Cursor, X Premium (Grok CLI), and DeepSeek — stop payin
|
|
|
28
28
|
| Cursor | Premium plan | Plan / synthesize / verify (orchestrator) |
|
|
29
29
|
| Grok CLI | X Premium | `--lane hard` implement |
|
|
30
30
|
| DeepSeek via claude-ds | DeepSeek API / plan | `--lane mid` implement |
|
|
31
|
+
| OpenRouter free models | OpenRouter API (free tier) | `--lane easy` wording/drafts — non-secret prompts only (see Security) |
|
|
31
32
|
| Codex / extra Claude | Optional | Not required for v0 |
|
|
32
33
|
|
|
33
34
|
Exact dollars vary — the point is **reuse subscriptions you already have**.
|
|
@@ -41,6 +42,7 @@ Exact dollars vary — the point is **reuse subscriptions you already have**.
|
|
|
41
42
|
| [tmux](https://github.com/tmux/tmux) | Worker panes |
|
|
42
43
|
| [Bun](https://bun.sh) *(or Node 20+)* | Runs the CLI |
|
|
43
44
|
| [Grok CLI](https://x.ai/cli) and/or Claude Code + DeepSeek (`claude-ds`) | Workers |
|
|
45
|
+
| OpenRouter API key (`OPENROUTER_API_KEY`) | Easy lane (`--lane easy` / `--worker openrouter`) |
|
|
44
46
|
| `script(1)` | Job logs (macOS/Linux) |
|
|
45
47
|
|
|
46
48
|
```bash
|
|
@@ -54,6 +56,7 @@ npm i -g cursor-route
|
|
|
54
56
|
# Auth workers
|
|
55
57
|
grok login # if using Grok
|
|
56
58
|
# configure claude-ds — see DeepSeek setup below
|
|
59
|
+
export OPENROUTER_API_KEY=... # if using the easy lane (see OpenRouter setup below)
|
|
57
60
|
|
|
58
61
|
cursor-route health
|
|
59
62
|
# without tmux / workers (CI / headless infra smoke):
|
|
@@ -98,6 +101,7 @@ Then say **`/route-orch`** or **spawn workers** in Cursor — the skill delegate
|
|
|
98
101
|
|
|
99
102
|
| Lane | Worker | Intent |
|
|
100
103
|
|------|--------|--------|
|
|
104
|
+
| `easy` | `openrouter` | Wording / drafts on OpenRouter free models (non-secret prompts only — see Security) |
|
|
101
105
|
| `mid` | `claude-ds` | Default implement on DeepSeek |
|
|
102
106
|
| `hard` | `grok` | Hard implement on Grok CLI |
|
|
103
107
|
|
|
@@ -130,6 +134,25 @@ Persist the same vars under `~/.claude/settings.json` → `"env": { … }` if yo
|
|
|
130
134
|
|
|
131
135
|
No DeepSeek yet? Use `--lane hard` / `--worker grok` (X Premium).
|
|
132
136
|
|
|
137
|
+
## OpenRouter setup (the free easy lane)
|
|
138
|
+
|
|
139
|
+
`--lane easy` / `--worker openrouter` sends wording/draft prompts to OpenRouter's
|
|
140
|
+
free model route (`openrouter/free`). Get a key at [openrouter.ai/keys](https://openrouter.ai/keys).
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
export OPENROUTER_API_KEY=sk-or-v1-... # from openrouter.ai/keys
|
|
144
|
+
# optional:
|
|
145
|
+
export CURSOR_ROUTE_OPENROUTER_MODEL=openrouter/free # default
|
|
146
|
+
export OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # default
|
|
147
|
+
|
|
148
|
+
cursor-route health # worker:openrouter should be ✓
|
|
149
|
+
cursor-route start --lane easy "Rewrite this FAQ answer in 3 sentences"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Non-secret prompts only:** free OpenRouter models may log prompts, so the easy lane is for
|
|
153
|
+
**wording/drafts without credentials**. The same refuse gate as every lane blocks
|
|
154
|
+
key-shaped material in `start` / `send`, and the runner re-checks the prompt file.
|
|
155
|
+
|
|
133
156
|
## Jobs directory
|
|
134
157
|
|
|
135
158
|
Jobs default to `~/.local/share/cursor-route/jobs` (override with `CURSOR_ROUTE_JOBS_DIR`).
|
|
@@ -144,7 +167,7 @@ This is **not** inside a git clone of this repo.
|
|
|
144
167
|
## FAQ
|
|
145
168
|
|
|
146
169
|
**What is cursor-route?**
|
|
147
|
-
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,
|
|
170
|
+
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 for wording/drafts.
|
|
148
171
|
|
|
149
172
|
**How is this different from Codex orchestrator?**
|
|
150
173
|
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.
|
|
@@ -156,7 +179,7 @@ No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANT
|
|
|
156
179
|
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.
|
|
157
180
|
|
|
158
181
|
**Is it free?**
|
|
159
|
-
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.
|
|
182
|
+
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`).
|
|
160
183
|
|
|
161
184
|
## Related
|
|
162
185
|
|
|
@@ -202,5 +225,4 @@ MIT © Cemini — see [LICENSE](LICENSE).
|
|
|
202
225
|
- Native DeepSeek harness adapter
|
|
203
226
|
- Codebase map injection (`--map`)
|
|
204
227
|
- Cursor CLI `agent` as alternate supervisor
|
|
205
|
-
- Web UI / CAO-style MCP supervisor
|
|
206
|
-
- OpenRouter easy lane (secrets-safe)
|
|
228
|
+
- Web UI / CAO-style MCP supervisor
|
package/SECURITY.md
CHANGED
|
@@ -14,14 +14,15 @@ Do **not** open a public issue for credential leaks or RCE-class bugs.
|
|
|
14
14
|
## Hard rules for users and agents
|
|
15
15
|
|
|
16
16
|
1. **No secrets in prompts or job files.** Do not paste API keys, SSH private keys, Discord tokens, or `.env` contents into `cursor-route start` / `send` prompts.
|
|
17
|
-
2. **
|
|
18
|
-
3. **
|
|
19
|
-
4. **
|
|
20
|
-
5. **
|
|
17
|
+
2. **Free OpenRouter easy-lane models may log prompts.** `--lane easy` / `--worker openrouter` is for **non-secret** wording/drafts only. The refuse gate (`start` / `send`, plus the runner's re-check) applies to every lane — never send key-shaped material to the easy lane.
|
|
18
|
+
3. **Always-approve is powerful.** Default headless workers skip interactive permission prompts (`--always-approve` / `--dangerously-skip-permissions`). Opt out with `--ask` / `CURSOR_ROUTE_ASK=1` when you need HITL.
|
|
19
|
+
4. **Install from known channels only.** Prefer `npm i -g cursor-route` or a git clone of this repo. Do not pipe unknown `curl | bash` installers as the primary path.
|
|
20
|
+
5. **Workers inherit your auth.** Grok CLI and Claude Code / claude-ds use *your* local login. Treat worker panes like you would treat your own shell.
|
|
21
|
+
6. **No LIVE Discord / trading egress demos** from this tool. Orchestration is local.
|
|
21
22
|
|
|
22
23
|
## Supply chain
|
|
23
24
|
|
|
24
|
-
- Declared runtime npm dependencies: **none**. System needs: Node ≥20 or Bun, `tmux`,
|
|
25
|
+
- Declared runtime npm dependencies: **none**. System needs: Node ≥20 or Bun, `tmux`, worker CLIs (Grok / claude-ds), and an OpenRouter API key for the easy lane.
|
|
25
26
|
- The npm package ships a compiled `dist/` (plain JS, no loader). The bin shim and the completion hook run the compiled JS via `node`; there is **no network fetch of `tsx` on the happy path**.
|
|
26
27
|
- Running from a git clone without a build uses Bun to execute `src/` directly (Bun runs TypeScript natively) — also offline-friendly.
|
|
27
28
|
- Review `package.json` before upgrading.
|
package/dist/adapters/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { grokAdapter } from "./grok.js";
|
|
2
2
|
import { claudeDsAdapter } from "./claude-ds.js";
|
|
3
|
+
import { openRouterAdapter } from "./openrouter.js";
|
|
3
4
|
const registry = {
|
|
4
5
|
grok: grokAdapter,
|
|
5
6
|
"claude-ds": claudeDsAdapter,
|
|
7
|
+
openrouter: openRouterAdapter,
|
|
6
8
|
};
|
|
7
9
|
export function getAdapter(worker) {
|
|
8
10
|
const a = registry[worker];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { shellQuote } from "../util.js";
|
|
5
|
+
import { openRouterModel, openRouterBaseUrl } from "../config.js";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve how to invoke the one-shot runner. Prefer the compiled dist via node
|
|
8
|
+
* (no loader); else Bun on src. No npx/tsx — same policy as mark-complete.
|
|
9
|
+
*/
|
|
10
|
+
function resolveRunner() {
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const compiled = join(here, "..", "..", "dist", "openrouter-run.js");
|
|
13
|
+
if (existsSync(compiled)) {
|
|
14
|
+
return { command: `node ${shellQuote(compiled)}` };
|
|
15
|
+
}
|
|
16
|
+
const srcFile = join(here, "..", "openrouter-run.ts");
|
|
17
|
+
if (existsSync(srcFile)) {
|
|
18
|
+
return { command: `bun ${shellQuote(srcFile)}` };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function openRouterEnv() {
|
|
23
|
+
const key = process.env.OPENROUTER_API_KEY;
|
|
24
|
+
if (!key)
|
|
25
|
+
return undefined;
|
|
26
|
+
const env = { OPENROUTER_API_KEY: key };
|
|
27
|
+
const model = process.env.CURSOR_ROUTE_OPENROUTER_MODEL;
|
|
28
|
+
if (model)
|
|
29
|
+
env.CURSOR_ROUTE_OPENROUTER_MODEL = model;
|
|
30
|
+
const base = process.env.OPENROUTER_BASE_URL;
|
|
31
|
+
if (base)
|
|
32
|
+
env.OPENROUTER_BASE_URL = base;
|
|
33
|
+
return env;
|
|
34
|
+
}
|
|
35
|
+
export const openRouterAdapter = {
|
|
36
|
+
kind: "openrouter",
|
|
37
|
+
label: "OpenRouter (free easy lane)",
|
|
38
|
+
health() {
|
|
39
|
+
const runner = resolveRunner();
|
|
40
|
+
if (!process.env.OPENROUTER_API_KEY) {
|
|
41
|
+
return {
|
|
42
|
+
worker: "openrouter",
|
|
43
|
+
ok: false,
|
|
44
|
+
binary: runner?.command ?? null,
|
|
45
|
+
detail: "OPENROUTER_API_KEY not set — export your OpenRouter key (easy lane model defaults to openrouter/free)",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (!runner) {
|
|
49
|
+
return {
|
|
50
|
+
worker: "openrouter",
|
|
51
|
+
ok: false,
|
|
52
|
+
binary: null,
|
|
53
|
+
detail: "openrouter-run not found — run bun run build (or use Bun from a source clone)",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
worker: "openrouter",
|
|
58
|
+
ok: true,
|
|
59
|
+
binary: runner.command,
|
|
60
|
+
detail: `ok (model ${openRouterModel()} @ ${openRouterBaseUrl()})`,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
buildLaunch({ promptFile }) {
|
|
64
|
+
const runner = resolveRunner();
|
|
65
|
+
if (!runner)
|
|
66
|
+
throw new Error("openrouter runner not available — run: bun run build");
|
|
67
|
+
// Missing key is tolerated here so `--dry-run` can still print the command;
|
|
68
|
+
// real starts are gated by the health preflight (which requires the key).
|
|
69
|
+
const env = openRouterEnv();
|
|
70
|
+
// No interactive approval concept for a pure HTTP call — nothing to auto-approve.
|
|
71
|
+
return {
|
|
72
|
+
worker: "openrouter",
|
|
73
|
+
command: `${runner.command} --prompt-file ${shellQuote(promptFile)}`,
|
|
74
|
+
alwaysApprove: false,
|
|
75
|
+
env,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
@@ -12,7 +12,7 @@ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.js";
|
|
|
12
12
|
function usage(exitCode = 0) {
|
|
13
13
|
console.log(`cursor-route v${config.version}
|
|
14
14
|
|
|
15
|
-
Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army.
|
|
15
|
+
Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) are the parallel army.
|
|
16
16
|
|
|
17
17
|
Usage:
|
|
18
18
|
cursor-route --version
|
|
@@ -29,13 +29,13 @@ Usage:
|
|
|
29
29
|
cursor-route clean [--days N]
|
|
30
30
|
|
|
31
31
|
Start options:
|
|
32
|
-
--worker <grok|claude-ds>
|
|
33
|
-
--lane <mid|hard>
|
|
34
|
-
--dir <path>
|
|
35
|
-
--ask
|
|
36
|
-
--dry-run
|
|
37
|
-
--no-tmux
|
|
38
|
-
--json
|
|
32
|
+
--worker <grok|claude-ds|openrouter> Worker adapter (default: grok)
|
|
33
|
+
--lane <easy|mid|hard> Lane → worker (easy=openrouter, 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
39
|
|
|
40
40
|
Env:
|
|
41
41
|
CURSOR_ROUTE_ASK=1 Opt out of always-approve
|
|
@@ -45,6 +45,9 @@ Env:
|
|
|
45
45
|
CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
|
|
46
46
|
CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
|
|
47
47
|
CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
|
|
48
|
+
OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
|
|
49
|
+
CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
|
|
50
|
+
OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
|
|
48
51
|
`);
|
|
49
52
|
process.exit(exitCode);
|
|
50
53
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { defaultJobsDir } from "./runtime.js";
|
|
4
|
-
export const WORKERS = ["grok", "claude-ds"];
|
|
5
|
-
export const LANES = ["mid", "hard"];
|
|
4
|
+
export const WORKERS = ["grok", "claude-ds", "openrouter"];
|
|
5
|
+
export const LANES = ["easy", "mid", "hard"];
|
|
6
|
+
/** OpenRouter model for the easy lane (env CURSOR_ROUTE_OPENROUTER_MODEL). */
|
|
7
|
+
export function openRouterModel() {
|
|
8
|
+
return process.env.CURSOR_ROUTE_OPENROUTER_MODEL || "openrouter/free";
|
|
9
|
+
}
|
|
10
|
+
/** OpenRouter API base URL (env OPENROUTER_BASE_URL). */
|
|
11
|
+
export function openRouterBaseUrl() {
|
|
12
|
+
return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
13
|
+
}
|
|
6
14
|
function maxConcurrentJobsFromEnv() {
|
|
7
15
|
const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
|
|
8
16
|
if (raw) {
|
|
@@ -19,7 +27,7 @@ function maxConcurrentJobsFromEnv() {
|
|
|
19
27
|
*/
|
|
20
28
|
export const config = {
|
|
21
29
|
product: "cursor-route",
|
|
22
|
-
version: "0.1.
|
|
30
|
+
version: "0.1.5",
|
|
23
31
|
get jobsDir() {
|
|
24
32
|
return defaultJobsDir();
|
|
25
33
|
},
|
|
@@ -27,6 +35,7 @@ export const config = {
|
|
|
27
35
|
defaultWorker: "grok",
|
|
28
36
|
/** Lane → default worker (Cemini /route public core). */
|
|
29
37
|
laneWorkers: {
|
|
38
|
+
easy: "openrouter",
|
|
30
39
|
mid: "claude-ds",
|
|
31
40
|
hard: "grok",
|
|
32
41
|
},
|
|
@@ -0,0 +1,95 @@
|
|
|
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.js";
|
|
10
|
+
import { looksLikeSecretMaterial } from "./secrets.js";
|
|
11
|
+
const SYSTEM_PROMPT = "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.";
|
|
12
|
+
function fail(msg, code) {
|
|
13
|
+
console.error(`cursor-route/openrouter-run: ${msg}`);
|
|
14
|
+
process.exit(code);
|
|
15
|
+
}
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const flags = {};
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === "-h" || a === "--help") {
|
|
21
|
+
console.log("usage: cursor-route/openrouter-run --prompt-file <path>\n" +
|
|
22
|
+
"env: OPENROUTER_API_KEY (required), CURSOR_ROUTE_OPENROUTER_MODEL, OPENROUTER_BASE_URL");
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
if (a === "--prompt-file") {
|
|
26
|
+
const v = argv[i + 1];
|
|
27
|
+
if (!v || v.startsWith("--"))
|
|
28
|
+
fail("--prompt-file requires a value", 2);
|
|
29
|
+
flags.promptFile = v;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
else if (a.startsWith("--prompt-file=")) {
|
|
33
|
+
flags.promptFile = a.slice("--prompt-file=".length);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
fail(`unknown argument: ${a}`, 2);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return flags;
|
|
40
|
+
}
|
|
41
|
+
async function main() {
|
|
42
|
+
const { promptFile } = parseArgs(process.argv.slice(2));
|
|
43
|
+
if (!promptFile)
|
|
44
|
+
fail("--prompt-file <path> is required", 2);
|
|
45
|
+
let prompt;
|
|
46
|
+
try {
|
|
47
|
+
prompt = readFileSync(promptFile, "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
fail(`cannot read prompt file: ${e.message}`, 2);
|
|
51
|
+
}
|
|
52
|
+
if (!prompt.trim())
|
|
53
|
+
fail("prompt file is empty", 2);
|
|
54
|
+
if (looksLikeSecretMaterial(prompt)) {
|
|
55
|
+
fail("refusing prompt: looks like secret key material — easy lane is for non-secret drafts", 3);
|
|
56
|
+
}
|
|
57
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
58
|
+
if (!apiKey)
|
|
59
|
+
fail("OPENROUTER_API_KEY is not set", 2);
|
|
60
|
+
const base = openRouterBaseUrl().replace(/\/+$/, "");
|
|
61
|
+
const url = `${base}/chat/completions`;
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await fetch(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${apiKey}`,
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
// OpenRouter etiquette: identify the app so the provider can see usage source.
|
|
70
|
+
"HTTP-Referer": "https://github.com/cemini23/cursor-route",
|
|
71
|
+
"X-Title": "cursor-route",
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model: openRouterModel(),
|
|
75
|
+
messages: [
|
|
76
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
77
|
+
{ role: "user", content: prompt },
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
fail(`network error calling ${url}: ${e.message}`, 1);
|
|
84
|
+
}
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const body = await res.text().catch(() => "");
|
|
87
|
+
fail(`OpenRouter API ${res.status}: ${body.slice(0, 500)}`, 1);
|
|
88
|
+
}
|
|
89
|
+
const json = (await res.json());
|
|
90
|
+
const content = json.choices?.[0]?.message?.content ?? "";
|
|
91
|
+
if (!content)
|
|
92
|
+
fail("OpenRouter returned no assistant content", 1);
|
|
93
|
+
process.stdout.write(content.endsWith("\n") ? content : content + "\n");
|
|
94
|
+
}
|
|
95
|
+
main().catch((e) => fail(e instanceof Error ? e.message : String(e), 1));
|
package/docs/demo-notes.md
CHANGED
|
@@ -4,16 +4,17 @@ Simulated capture output for README / tweet assets — replace with a real GIF o
|
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
$ cursor-route --version
|
|
7
|
-
0.1.
|
|
7
|
+
0.1.5
|
|
8
8
|
|
|
9
9
|
$ cursor-route health
|
|
10
|
-
cursor-route v0.1.
|
|
10
|
+
cursor-route v0.1.5
|
|
11
11
|
health: OK
|
|
12
12
|
✓ tmux
|
|
13
13
|
✓ runtime bun ok
|
|
14
14
|
✓ script(1)
|
|
15
15
|
✓ worker:grok
|
|
16
16
|
✓ worker:claude-ds
|
|
17
|
+
✓ worker:openrouter
|
|
17
18
|
✓ jobs_dir ~/.local/share/cursor-route/jobs
|
|
18
19
|
|
|
19
20
|
$ cursor-route start --lane mid "Add a failing test then make it pass"
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cursor-route
|
|
2
2
|
|
|
3
|
-
> Cursor stays the planner. DeepSeek (mid)
|
|
3
|
+
> Cursor stays the planner. DeepSeek (mid), Grok CLI (hard), and OpenRouter free models (easy) run parallel coding workers in tmux.
|
|
4
4
|
|
|
5
5
|
MIT CLI + Cursor skill. npm: https://www.npmjs.com/package/cursor-route
|
|
6
6
|
GitHub: https://github.com/cemini23/cursor-route
|
|
@@ -8,7 +8,7 @@ GitHub: https://github.com/cemini23/cursor-route
|
|
|
8
8
|
## FAQ
|
|
9
9
|
|
|
10
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,
|
|
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
12
|
|
|
13
13
|
### How is this different from Codex orchestrator?
|
|
14
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.
|
|
@@ -20,7 +20,7 @@ No. The mid worker is DeepSeek. Claude Code is the harness, configured with `ANT
|
|
|
20
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
21
|
|
|
22
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.
|
|
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
24
|
|
|
25
25
|
## Install
|
|
26
26
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 \u2014 lane-aware /route orchestration in tmux.",
|
|
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": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"grok",
|
|
39
39
|
"deepseek",
|
|
40
40
|
"claude-ds",
|
|
41
|
+
"openrouter",
|
|
41
42
|
"tmux",
|
|
42
43
|
"orchestrator",
|
|
43
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.
|
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/cli.test.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
|
|
|
7
7
|
import { isDeepSeekRouted, isDeepSeekBaseUrl } from "./adapters/claude-ds.ts";
|
|
8
8
|
|
|
9
9
|
describe("resolveWorker", () => {
|
|
10
|
+
test("lane easy → openrouter", () => {
|
|
11
|
+
expect(resolveWorker({ prompt: "x", lane: "easy" })).toBe("openrouter");
|
|
12
|
+
});
|
|
10
13
|
test("lane mid → claude-ds", () => {
|
|
11
14
|
expect(resolveWorker({ prompt: "x", lane: "mid" })).toBe("claude-ds");
|
|
12
15
|
});
|
|
@@ -17,6 +20,9 @@ describe("resolveWorker", () => {
|
|
|
17
20
|
expect(resolveWorker({ prompt: "x", lane: "hard", worker: "claude-ds" })).toBe(
|
|
18
21
|
"claude-ds",
|
|
19
22
|
);
|
|
23
|
+
expect(resolveWorker({ prompt: "x", lane: "mid", worker: "openrouter" })).toBe(
|
|
24
|
+
"openrouter",
|
|
25
|
+
);
|
|
20
26
|
});
|
|
21
27
|
test("default worker", () => {
|
|
22
28
|
expect(resolveWorker({ prompt: "x" })).toBe(config.defaultWorker);
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
@@ -27,7 +27,7 @@ import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
|
|
|
27
27
|
function usage(exitCode = 0): never {
|
|
28
28
|
console.log(`cursor-route v${config.version}
|
|
29
29
|
|
|
30
|
-
Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) are the parallel army.
|
|
30
|
+
Cursor stays the brain. Grok CLI + DeepSeek (claude-ds) + OpenRouter (easy) are the parallel army.
|
|
31
31
|
|
|
32
32
|
Usage:
|
|
33
33
|
cursor-route --version
|
|
@@ -44,13 +44,13 @@ Usage:
|
|
|
44
44
|
cursor-route clean [--days N]
|
|
45
45
|
|
|
46
46
|
Start options:
|
|
47
|
-
--worker <grok|claude-ds>
|
|
48
|
-
--lane <mid|hard>
|
|
49
|
-
--dir <path>
|
|
50
|
-
--ask
|
|
51
|
-
--dry-run
|
|
52
|
-
--no-tmux
|
|
53
|
-
--json
|
|
47
|
+
--worker <grok|claude-ds|openrouter> Worker adapter (default: grok)
|
|
48
|
+
--lane <easy|mid|hard> Lane → worker (easy=openrouter, 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
54
|
|
|
55
55
|
Env:
|
|
56
56
|
CURSOR_ROUTE_ASK=1 Opt out of always-approve
|
|
@@ -60,6 +60,9 @@ Env:
|
|
|
60
60
|
CURSOR_ROUTE_ALLOW_ANTHROPIC=1 Allow mid-lane on Anthropic Claude (expensive; not default)
|
|
61
61
|
CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
|
|
62
62
|
CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
|
|
63
|
+
OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
|
|
64
|
+
CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
|
|
65
|
+
OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
|
|
63
66
|
`);
|
|
64
67
|
process.exit(exitCode);
|
|
65
68
|
}
|
package/src/config.ts
CHANGED
|
@@ -2,11 +2,21 @@ 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
|
+
export type WorkerKind = "grok" | "claude-ds" | "openrouter";
|
|
6
|
+
export type Lane = "easy" | "mid" | "hard";
|
|
7
7
|
|
|
8
|
-
export const WORKERS: WorkerKind[] = ["grok", "claude-ds"];
|
|
9
|
-
export const LANES: Lane[] = ["mid", "hard"];
|
|
8
|
+
export const WORKERS: WorkerKind[] = ["grok", "claude-ds", "openrouter"];
|
|
9
|
+
export const LANES: Lane[] = ["easy", "mid", "hard"];
|
|
10
|
+
|
|
11
|
+
/** OpenRouter model for the easy lane (env CURSOR_ROUTE_OPENROUTER_MODEL). */
|
|
12
|
+
export function openRouterModel(): string {
|
|
13
|
+
return process.env.CURSOR_ROUTE_OPENROUTER_MODEL || "openrouter/free";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** OpenRouter API base URL (env OPENROUTER_BASE_URL). */
|
|
17
|
+
export function openRouterBaseUrl(): string {
|
|
18
|
+
return process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
function maxConcurrentJobsFromEnv(): number {
|
|
12
22
|
const raw = process.env.CURSOR_ROUTE_MAX_JOBS;
|
|
@@ -24,7 +34,7 @@ function maxConcurrentJobsFromEnv(): number {
|
|
|
24
34
|
*/
|
|
25
35
|
export const config = {
|
|
26
36
|
product: "cursor-route",
|
|
27
|
-
version: "0.1.
|
|
37
|
+
version: "0.1.5",
|
|
28
38
|
get jobsDir(): string {
|
|
29
39
|
return defaultJobsDir();
|
|
30
40
|
},
|
|
@@ -32,6 +42,7 @@ export const config = {
|
|
|
32
42
|
defaultWorker: "grok" as WorkerKind,
|
|
33
43
|
/** Lane → default worker (Cemini /route public core). */
|
|
34
44
|
laneWorkers: {
|
|
45
|
+
easy: "openrouter" as WorkerKind,
|
|
35
46
|
mid: "claude-ds" as WorkerKind,
|
|
36
47
|
hard: "grok" as WorkerKind,
|
|
37
48
|
},
|
package/src/integration.test.ts
CHANGED
|
@@ -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 () => {
|
|
@@ -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));
|