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/README.md +53 -12
- package/SECURITY.md +6 -5
- package/dist/adapters/claude-ds.js +33 -9
- package/dist/adapters/deepseek.js +19 -0
- package/dist/adapters/index.js +4 -0
- package/dist/adapters/openrouter.js +78 -0
- package/dist/cli.js +36 -11
- package/dist/config.js +30 -3
- package/dist/jobs.js +4 -1
- package/dist/openrouter-run.js +95 -0
- package/docs/briefs/WORKING.md +55 -0
- package/docs/demo-notes.md +7 -4
- package/llms.txt +4 -4
- package/package.json +4 -2
- package/skills/route-orch/SKILL.md +24 -6
- package/src/adapters/claude-ds.ts +38 -9
- package/src/adapters/deepseek.ts +24 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/openrouter.test.ts +57 -0
- package/src/adapters/openrouter.ts +80 -0
- package/src/adapters/types.ts +3 -1
- package/src/cli.test.ts +75 -2
- package/src/cli.ts +41 -11
- package/src/config.ts +37 -5
- package/src/integration.test.ts +7 -0
- package/src/jobs.ts +16 -1
- package/src/openrouter-run.ts +102 -0
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 {
|
|
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>
|
|
48
|
-
--lane <mid|hard>
|
|
49
|
-
--
|
|
50
|
-
--
|
|
51
|
-
--
|
|
52
|
-
--
|
|
53
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
export type
|
|
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
|
-
|
|
9
|
-
export
|
|
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.
|
|
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 {
|
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 () => {
|
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 {
|
|
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));
|