cursor-route 0.1.7 → 0.1.8
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/CHANGELOG.md +9 -0
- package/CONTRIBUTING.md +1 -0
- package/README.md +46 -5
- package/dist/adapters/deepseek.js +127 -8
- package/dist/cli.js +7 -4
- package/dist/config.js +5 -2
- package/dist/jobs.js +3 -2
- package/docs/DEMO_GIF.md +17 -1
- package/docs/briefs/WORKING.md +11 -6
- package/docs/demo-notes.md +5 -3
- package/docs/fixtures/generate-hero-demo.sh +109 -0
- package/docs/fixtures/hero-demo.log +52 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/skills/route-orch/SKILL.md +15 -2
- package/src/adapters/deepseek.ts +145 -11
- package/src/adapters/types.ts +3 -1
- package/src/cli.test.ts +360 -25
- package/src/cli.ts +7 -4
- package/src/config.ts +9 -3
- package/src/jobs.ts +5 -4
package/src/adapters/deepseek.ts
CHANGED
|
@@ -1,24 +1,158 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
1
3
|
import type { Adapter, WorkerHealth } from "./types.ts";
|
|
4
|
+
import { shellQuote } from "../util.ts";
|
|
5
|
+
import {
|
|
6
|
+
DS_MODEL_IDS,
|
|
7
|
+
resolveDsModel,
|
|
8
|
+
type DsModelAlias,
|
|
9
|
+
} from "../config.ts";
|
|
2
10
|
|
|
3
11
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
12
|
+
* Experimental official DeepSeek Harness (`dsh`, npm @deepseek-ai/dsh) as a
|
|
13
|
+
* coding worker — `dsh --profile headless` with a per-job Cordis patch that
|
|
14
|
+
* pins the model. Mid lane stays on claude-ds; this is an opt-in worker only
|
|
15
|
+
* (`--worker deepseek`), not a mid replacement.
|
|
16
|
+
*
|
|
17
|
+
* We never write ~/.dsh/settings.yaml (parallel jobs would race) and never
|
|
18
|
+
* put DEEPSEEK_API_KEY in the command or patch — the key travels via plan.env.
|
|
6
19
|
*/
|
|
20
|
+
|
|
21
|
+
function findDsh(): string | null {
|
|
22
|
+
// Env override lets tests pin a fake dsh — but it must exist, so a stale
|
|
23
|
+
// override cannot pass health with a dangling path.
|
|
24
|
+
const override = process.env.CURSOR_ROUTE_DSH_BIN;
|
|
25
|
+
if (override) return existsSync(override) ? override : null;
|
|
26
|
+
try {
|
|
27
|
+
return (
|
|
28
|
+
execSync("command -v dsh", {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
// Bun may ignore mutated process.env.PATH unless env is passed explicitly
|
|
32
|
+
env: { ...process.env },
|
|
33
|
+
}).trim() || null
|
|
34
|
+
);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Same resolution as claude-ds: passed model/modelId wins, else env, else Flash. */
|
|
41
|
+
function pickModel(
|
|
42
|
+
requested?: DsModelAlias,
|
|
43
|
+
modelId?: string,
|
|
44
|
+
): { alias: DsModelAlias; id: string } {
|
|
45
|
+
if (modelId) {
|
|
46
|
+
const alias = requested ?? resolveDsModel(modelId).alias;
|
|
47
|
+
return { alias, id: modelId };
|
|
48
|
+
}
|
|
49
|
+
if (requested) {
|
|
50
|
+
return { alias: requested, id: DS_MODEL_IDS[requested] };
|
|
51
|
+
}
|
|
52
|
+
// Env default (startJob normally resolves this; kept for direct buildLaunch callers)
|
|
53
|
+
return resolveDsModel(process.env.CURSOR_ROUTE_DS_MODEL || process.env.ANTHROPIC_MODEL);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whitelist model ids before interpolating into YAML (no newlines / injection). */
|
|
57
|
+
function assertPatchModelId(modelId: string): string {
|
|
58
|
+
if (!/^[a-z0-9][a-z0-9.\-[\]]*$/i.test(modelId)) {
|
|
59
|
+
throw new Error(`Invalid DeepSeek model id for dsh patch: ${modelId}`);
|
|
60
|
+
}
|
|
61
|
+
return modelId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-job Cordis patch path. Never reuse the prompt path (would overwrite it). */
|
|
65
|
+
export function patchPathForPrompt(promptFile: string): string {
|
|
66
|
+
return promptFile.endsWith(".prompt")
|
|
67
|
+
? promptFile.replace(/\.prompt$/, ".dsh-patch.yml")
|
|
68
|
+
: `${promptFile}.dsh-patch.yml`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Per-job Cordis patch (whole-row replace). `name` is required or dsh silently skips. */
|
|
72
|
+
function patchYaml(modelId: string): string {
|
|
73
|
+
const id = assertPatchModelId(modelId);
|
|
74
|
+
return [
|
|
75
|
+
"- id: agent-default-model",
|
|
76
|
+
" name: '@deepseek-ai/dsh-agent-default-model'",
|
|
77
|
+
" config:",
|
|
78
|
+
" provider: deepseek-official",
|
|
79
|
+
` model: '${id}'`,
|
|
80
|
+
].join("\n") + "\n";
|
|
81
|
+
}
|
|
82
|
+
|
|
7
83
|
export const deepseekAdapter: Adapter = {
|
|
8
84
|
kind: "deepseek",
|
|
9
|
-
label: "Official DeepSeek
|
|
85
|
+
label: "Official DeepSeek Harness (dsh)",
|
|
10
86
|
health(): WorkerHealth {
|
|
87
|
+
const binary = findDsh();
|
|
88
|
+
if (!binary) {
|
|
89
|
+
return {
|
|
90
|
+
worker: "deepseek",
|
|
91
|
+
ok: false,
|
|
92
|
+
binary: null,
|
|
93
|
+
detail:
|
|
94
|
+
"dsh (@deepseek-ai/dsh) not found — install: npm i -g @deepseek-ai/dsh. Mid default remains claude-ds.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!process.env.DEEPSEEK_API_KEY) {
|
|
98
|
+
return {
|
|
99
|
+
worker: "deepseek",
|
|
100
|
+
ok: false,
|
|
101
|
+
binary,
|
|
102
|
+
detail:
|
|
103
|
+
"DEEPSEEK_API_KEY not set — export your DeepSeek API key to use dsh (@deepseek-ai/dsh). Mid default remains claude-ds.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
11
106
|
return {
|
|
12
107
|
worker: "deepseek",
|
|
13
|
-
ok:
|
|
14
|
-
binary
|
|
15
|
-
detail:
|
|
16
|
-
"unreleased — mid lane uses claude-ds (DeepSeek behind Claude Code). See README.",
|
|
108
|
+
ok: true,
|
|
109
|
+
binary,
|
|
110
|
+
detail: "ok (dsh @deepseek-ai/dsh headless; mid default remains claude-ds)",
|
|
17
111
|
};
|
|
18
112
|
},
|
|
19
|
-
buildLaunch() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
);
|
|
113
|
+
buildLaunch({ promptFile, cwd, alwaysApprove, model, modelId, dryRun }) {
|
|
114
|
+
// Missing dsh is tolerated here so `--dry-run` can still print the command;
|
|
115
|
+
// real starts are gated by the health preflight (binary + DEEPSEEK_API_KEY).
|
|
116
|
+
const binary = findDsh() || "dsh";
|
|
117
|
+
const choice = pickModel(model, modelId);
|
|
118
|
+
|
|
119
|
+
// Per-job patch next to the prompt file (never touch ~/.dsh/settings.yaml).
|
|
120
|
+
const patchFile = patchPathForPrompt(promptFile);
|
|
121
|
+
writeFileSync(patchFile, patchYaml(choice.id), { mode: 0o600 });
|
|
122
|
+
if (dryRun) {
|
|
123
|
+
// Dry-run keeps no durable artifacts (jobs.ts removes the prompt likewise).
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(patchFile);
|
|
126
|
+
} catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Launcher flags before the task; prompt inlined via cat (never the key).
|
|
132
|
+
const parts = [
|
|
133
|
+
shellQuote(binary),
|
|
134
|
+
"--profile",
|
|
135
|
+
"headless",
|
|
136
|
+
"--patch",
|
|
137
|
+
shellQuote(patchFile),
|
|
138
|
+
`"$(cat ${shellQuote(promptFile)})"`,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const ask = process.env.CURSOR_ROUTE_ASK === "1";
|
|
142
|
+
const skip = alwaysApprove && !ask;
|
|
143
|
+
const env: Record<string, string> = {
|
|
144
|
+
DSH_PERMISSION_MODE: skip ? "danger-full-access" : "workspace-write",
|
|
145
|
+
};
|
|
146
|
+
// Key travels via env only — never interpolated into command or patch.
|
|
147
|
+
if (process.env.DEEPSEEK_API_KEY) {
|
|
148
|
+
env.DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
worker: "deepseek",
|
|
153
|
+
command: `cd ${shellQuote(cwd)} && ${parts.join(" ")}`,
|
|
154
|
+
alwaysApprove: skip,
|
|
155
|
+
env,
|
|
156
|
+
};
|
|
23
157
|
},
|
|
24
158
|
};
|
package/src/adapters/types.ts
CHANGED
|
@@ -24,9 +24,11 @@ export interface Adapter {
|
|
|
24
24
|
promptFile: string;
|
|
25
25
|
cwd: string;
|
|
26
26
|
alwaysApprove: boolean;
|
|
27
|
-
/** Mid-lane DeepSeek flash|pro (ignored by
|
|
27
|
+
/** Mid-lane DeepSeek flash|pro (claude-ds + deepseek; ignored by grok/openrouter / Anthropic escape hatch). */
|
|
28
28
|
model?: DsModelAlias;
|
|
29
29
|
/** Concrete DeepSeek model id for -Model/--model (preserves pro[1m]). */
|
|
30
30
|
modelId?: string;
|
|
31
|
+
/** True on --dry-run: adapters may drop artifacts they just wrote (e.g. dsh patch). */
|
|
32
|
+
dryRun?: boolean;
|
|
31
33
|
}): LaunchPlan;
|
|
32
34
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { join } from "node:path";
|
|
4
12
|
import { tmpdir } from "node:os";
|
|
5
13
|
import { resolveWorker, startJob } from "./jobs.ts";
|
|
@@ -8,7 +16,7 @@ import { shellQuote, newJobId } from "./util.ts";
|
|
|
8
16
|
import { runHealth } from "./health.ts";
|
|
9
17
|
import { looksLikeSecretMaterial, redactSecrets } from "./secrets.ts";
|
|
10
18
|
import { isDeepSeekRouted, isDeepSeekBaseUrl, claudeDsAdapter } from "./adapters/claude-ds.ts";
|
|
11
|
-
import { deepseekAdapter } from "./adapters/deepseek.ts";
|
|
19
|
+
import { deepseekAdapter, patchPathForPrompt } from "./adapters/deepseek.ts";
|
|
12
20
|
import { grokAdapter } from "./adapters/grok.ts";
|
|
13
21
|
|
|
14
22
|
describe("resolveWorker", () => {
|
|
@@ -190,15 +198,56 @@ describe("startJob product path", () => {
|
|
|
190
198
|
}
|
|
191
199
|
});
|
|
192
200
|
|
|
193
|
-
test("--worker deepseek dry-run
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
test("--worker deepseek dry-run succeeds with fake dsh + key", () => {
|
|
202
|
+
const dir = join(tmpdir(), `cr-dsh-start-${process.pid}`);
|
|
203
|
+
mkdirSync(dir, { recursive: true });
|
|
204
|
+
const bin = join(dir, "dsh");
|
|
205
|
+
writeFileSync(bin, "#!/bin/sh\necho fake-dsh\n");
|
|
206
|
+
chmodSync(bin, 0o755);
|
|
207
|
+
const prev = {
|
|
208
|
+
bin: process.env.CURSOR_ROUTE_DSH_BIN,
|
|
209
|
+
key: process.env.DEEPSEEK_API_KEY,
|
|
210
|
+
ds: process.env.CURSOR_ROUTE_DS_MODEL,
|
|
211
|
+
am: process.env.ANTHROPIC_MODEL,
|
|
212
|
+
jobs: process.env.CURSOR_ROUTE_JOBS_DIR,
|
|
213
|
+
};
|
|
214
|
+
process.env.CURSOR_ROUTE_DSH_BIN = bin;
|
|
215
|
+
// Not a real key: short and dash-separated so the CLI refuse gate would not trip.
|
|
216
|
+
process.env.DEEPSEEK_API_KEY = "sk-test-not-a-real-key";
|
|
217
|
+
delete process.env.CURSOR_ROUTE_DS_MODEL;
|
|
218
|
+
delete process.env.ANTHROPIC_MODEL;
|
|
219
|
+
process.env.CURSOR_ROUTE_JOBS_DIR = join(dir, "jobs");
|
|
220
|
+
try {
|
|
221
|
+
const result = startJob({
|
|
222
|
+
prompt: "ping",
|
|
223
|
+
worker: "deepseek",
|
|
224
|
+
dryRun: true,
|
|
225
|
+
});
|
|
226
|
+
expect(result.ok).toBe(true);
|
|
227
|
+
if (!result.ok) return;
|
|
228
|
+
expect(result.job.worker).toBe("deepseek");
|
|
229
|
+
expect(result.job.model).toBe("flash");
|
|
230
|
+
expect(result.command).toContain("--profile headless");
|
|
231
|
+
expect(result.command).toContain("--patch");
|
|
232
|
+
expect(result.command).toContain("$(cat");
|
|
233
|
+
expect(result.command).toContain("DSH_PERMISSION_MODE");
|
|
234
|
+
expect(result.command).not.toContain("sk-test");
|
|
235
|
+
expect(result.command).not.toContain("npx");
|
|
236
|
+
// Dry-run keeps no durable artifacts (prompt + dsh patch both removed)
|
|
237
|
+
expect(readdirSync(join(dir, "jobs")).length).toBe(0);
|
|
238
|
+
} finally {
|
|
239
|
+
if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_DSH_BIN;
|
|
240
|
+
else process.env.CURSOR_ROUTE_DSH_BIN = prev.bin;
|
|
241
|
+
if (prev.key === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
242
|
+
else process.env.DEEPSEEK_API_KEY = prev.key;
|
|
243
|
+
if (prev.ds === undefined) delete process.env.CURSOR_ROUTE_DS_MODEL;
|
|
244
|
+
else process.env.CURSOR_ROUTE_DS_MODEL = prev.ds;
|
|
245
|
+
if (prev.am === undefined) delete process.env.ANTHROPIC_MODEL;
|
|
246
|
+
else process.env.ANTHROPIC_MODEL = prev.am;
|
|
247
|
+
if (prev.jobs === undefined) delete process.env.CURSOR_ROUTE_JOBS_DIR;
|
|
248
|
+
else process.env.CURSOR_ROUTE_JOBS_DIR = prev.jobs;
|
|
249
|
+
rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
202
251
|
});
|
|
203
252
|
|
|
204
253
|
test("grok command never includes deepseek-v4", () => {
|
|
@@ -220,16 +269,287 @@ describe("startJob product path", () => {
|
|
|
220
269
|
});
|
|
221
270
|
});
|
|
222
271
|
|
|
223
|
-
describe("deepseek adapter
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
272
|
+
describe("deepseek adapter (dsh)", () => {
|
|
273
|
+
/** Executable fake `dsh` in a fresh tmpdir (existsSync passes). */
|
|
274
|
+
const makeFakeDsh = (suffix: string) => {
|
|
275
|
+
const dir = join(tmpdir(), `cr-dsh-${process.pid}-${suffix}`);
|
|
276
|
+
mkdirSync(dir, { recursive: true });
|
|
277
|
+
const bin = join(dir, "dsh");
|
|
278
|
+
writeFileSync(bin, "#!/bin/sh\necho fake-dsh\n");
|
|
279
|
+
chmodSync(bin, 0o755);
|
|
280
|
+
return { dir, bin };
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Set/delete env for the duration of fn; restore afterwards. */
|
|
284
|
+
const withEnv = (
|
|
285
|
+
patch: Record<string, string | undefined>,
|
|
286
|
+
fn: () => void,
|
|
287
|
+
) => {
|
|
288
|
+
const prev = new Map<string, string | undefined>();
|
|
289
|
+
for (const k of Object.keys(patch)) {
|
|
290
|
+
prev.set(k, process.env[k]);
|
|
291
|
+
const v = patch[k];
|
|
292
|
+
if (v === undefined) delete process.env[k];
|
|
293
|
+
else process.env[k] = v;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
fn();
|
|
297
|
+
} finally {
|
|
298
|
+
for (const [k, v] of prev) {
|
|
299
|
+
if (v === undefined) delete process.env[k];
|
|
300
|
+
else process.env[k] = v;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
test("health: no binary and no key → not ok, install hint names the npm package", () => {
|
|
306
|
+
withEnv(
|
|
307
|
+
{
|
|
308
|
+
CURSOR_ROUTE_DSH_BIN: join(tmpdir(), `missing-dsh-${process.pid}`),
|
|
309
|
+
DEEPSEEK_API_KEY: undefined,
|
|
310
|
+
},
|
|
311
|
+
() => {
|
|
312
|
+
const h = deepseekAdapter.health();
|
|
313
|
+
expect(h.ok).toBe(false);
|
|
314
|
+
expect(h.detail).toMatch(/npm i -g @deepseek-ai\/dsh/);
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("health: binary but no key → not ok, tells operator to export DEEPSEEK_API_KEY", () => {
|
|
320
|
+
const { bin } = makeFakeDsh("nokey");
|
|
321
|
+
withEnv({ CURSOR_ROUTE_DSH_BIN: bin, DEEPSEEK_API_KEY: undefined }, () => {
|
|
322
|
+
const h = deepseekAdapter.health();
|
|
323
|
+
expect(h.ok).toBe(false);
|
|
324
|
+
expect(h.detail).toMatch(/DEEPSEEK_API_KEY/);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("health: binary + key → ok", () => {
|
|
329
|
+
const { bin } = makeFakeDsh("ok");
|
|
330
|
+
withEnv({ CURSOR_ROUTE_DSH_BIN: bin, DEEPSEEK_API_KEY: "test-key" }, () => {
|
|
331
|
+
const h = deepseekAdapter.health();
|
|
332
|
+
expect(h.ok).toBe(true);
|
|
333
|
+
expect(h.binary).toBe(bin);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("buildLaunch: flash default patch next to prompt; key only in env", () => {
|
|
338
|
+
const { dir, bin } = makeFakeDsh("flash");
|
|
339
|
+
const promptFile = join(dir, "job.prompt");
|
|
340
|
+
writeFileSync(promptFile, "ping");
|
|
341
|
+
withEnv(
|
|
342
|
+
{
|
|
343
|
+
CURSOR_ROUTE_DSH_BIN: bin,
|
|
344
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
345
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
346
|
+
ANTHROPIC_MODEL: undefined,
|
|
347
|
+
},
|
|
348
|
+
() => {
|
|
349
|
+
const plan = deepseekAdapter.buildLaunch({
|
|
350
|
+
promptFile,
|
|
351
|
+
cwd: dir,
|
|
352
|
+
alwaysApprove: true,
|
|
353
|
+
});
|
|
354
|
+
expect(plan.command).toContain("--profile headless");
|
|
355
|
+
expect(plan.command).toContain("--patch");
|
|
356
|
+
expect(plan.command).toContain("$(cat");
|
|
357
|
+
expect(plan.command).not.toContain("npx");
|
|
358
|
+
expect(plan.command).not.toContain("test-key");
|
|
359
|
+
expect(plan.env?.DSH_PERMISSION_MODE).toBe("danger-full-access");
|
|
360
|
+
expect(plan.env?.DEEPSEEK_API_KEY).toBe("test-key");
|
|
361
|
+
const patch = readFileSync(join(dir, "job.dsh-patch.yml"), "utf8");
|
|
362
|
+
expect(patch).toContain("agent-default-model");
|
|
363
|
+
expect(patch).toContain("provider: deepseek-official");
|
|
364
|
+
expect(patch).toContain("deepseek-v4-flash");
|
|
365
|
+
expect(patch).not.toContain("deepseek-v4-pro");
|
|
366
|
+
expect(patch).not.toContain("test-key");
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
rmSync(dir, { recursive: true, force: true });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("buildLaunch: pro model → deepseek-v4-pro in patch", () => {
|
|
373
|
+
const { dir, bin } = makeFakeDsh("pro");
|
|
374
|
+
const promptFile = join(dir, "job.prompt");
|
|
375
|
+
writeFileSync(promptFile, "ping");
|
|
376
|
+
withEnv(
|
|
377
|
+
{
|
|
378
|
+
CURSOR_ROUTE_DSH_BIN: bin,
|
|
379
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
380
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
381
|
+
ANTHROPIC_MODEL: undefined,
|
|
382
|
+
},
|
|
383
|
+
() => {
|
|
384
|
+
deepseekAdapter.buildLaunch({
|
|
385
|
+
promptFile,
|
|
386
|
+
cwd: dir,
|
|
387
|
+
alwaysApprove: true,
|
|
388
|
+
model: "pro",
|
|
389
|
+
});
|
|
390
|
+
const patch = readFileSync(join(dir, "job.dsh-patch.yml"), "utf8");
|
|
391
|
+
expect(patch).toContain("deepseek-v4-pro");
|
|
392
|
+
expect(patch).not.toContain("deepseek-v4-flash");
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
rmSync(dir, { recursive: true, force: true });
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("buildLaunch: preserves deepseek-v4-pro[1m] in patch via modelId", () => {
|
|
399
|
+
const { dir, bin } = makeFakeDsh("pro1m");
|
|
400
|
+
const promptFile = join(dir, "job.prompt");
|
|
401
|
+
writeFileSync(promptFile, "ping");
|
|
402
|
+
withEnv(
|
|
403
|
+
{
|
|
404
|
+
CURSOR_ROUTE_DSH_BIN: bin,
|
|
405
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
406
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
407
|
+
ANTHROPIC_MODEL: undefined,
|
|
408
|
+
},
|
|
409
|
+
() => {
|
|
410
|
+
deepseekAdapter.buildLaunch({
|
|
411
|
+
promptFile,
|
|
412
|
+
cwd: dir,
|
|
413
|
+
alwaysApprove: true,
|
|
414
|
+
model: "pro",
|
|
415
|
+
modelId: "deepseek-v4-pro[1m]",
|
|
416
|
+
});
|
|
417
|
+
const patch = readFileSync(join(dir, "job.dsh-patch.yml"), "utf8");
|
|
418
|
+
expect(patch).toContain("deepseek-v4-pro[1m]");
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
rmSync(dir, { recursive: true, force: true });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("buildLaunch: alwaysApprove false (--ask) → workspace-write", () => {
|
|
425
|
+
const { dir, bin } = makeFakeDsh("ask");
|
|
426
|
+
const promptFile = join(dir, "job.prompt");
|
|
427
|
+
writeFileSync(promptFile, "ping");
|
|
428
|
+
withEnv(
|
|
429
|
+
{
|
|
430
|
+
CURSOR_ROUTE_DSH_BIN: bin,
|
|
431
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
432
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
433
|
+
ANTHROPIC_MODEL: undefined,
|
|
434
|
+
},
|
|
435
|
+
() => {
|
|
436
|
+
const plan = deepseekAdapter.buildLaunch({
|
|
437
|
+
promptFile,
|
|
438
|
+
cwd: dir,
|
|
439
|
+
alwaysApprove: false,
|
|
440
|
+
});
|
|
441
|
+
expect(plan.env?.DSH_PERMISSION_MODE).toBe("workspace-write");
|
|
442
|
+
expect(plan.env?.DSH_PERMISSION_MODE).not.toBe("danger-full-access");
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
rmSync(dir, { recursive: true, force: true });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("buildLaunch: CURSOR_ROUTE_ASK=1 opts out even when alwaysApprove true", () => {
|
|
449
|
+
const { dir, bin } = makeFakeDsh("ask-env");
|
|
450
|
+
const promptFile = join(dir, "job.prompt");
|
|
451
|
+
writeFileSync(promptFile, "ping");
|
|
452
|
+
withEnv(
|
|
453
|
+
{
|
|
454
|
+
CURSOR_ROUTE_DSH_BIN: bin,
|
|
455
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
456
|
+
CURSOR_ROUTE_ASK: "1",
|
|
457
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
458
|
+
ANTHROPIC_MODEL: undefined,
|
|
459
|
+
},
|
|
460
|
+
() => {
|
|
461
|
+
const plan = deepseekAdapter.buildLaunch({
|
|
462
|
+
promptFile,
|
|
463
|
+
cwd: dir,
|
|
464
|
+
alwaysApprove: true,
|
|
465
|
+
});
|
|
466
|
+
expect(plan.env?.DSH_PERMISSION_MODE).toBe("workspace-write");
|
|
467
|
+
expect(plan.alwaysApprove).toBe(false);
|
|
468
|
+
},
|
|
469
|
+
);
|
|
470
|
+
rmSync(dir, { recursive: true, force: true });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("buildLaunch: missing binary falls back to plain `dsh` for dry-run", () => {
|
|
474
|
+
const { dir } = makeFakeDsh("nobin");
|
|
475
|
+
const promptFile = join(dir, "job.prompt");
|
|
476
|
+
writeFileSync(promptFile, "ping");
|
|
477
|
+
// Point PATH at an empty dir so `command -v dsh` finds nothing,
|
|
478
|
+
// regardless of what the dev machine has installed.
|
|
479
|
+
const empty = join(dir, "empty");
|
|
480
|
+
mkdirSync(empty, { recursive: true });
|
|
481
|
+
withEnv(
|
|
482
|
+
{
|
|
483
|
+
CURSOR_ROUTE_DSH_BIN: undefined,
|
|
484
|
+
DEEPSEEK_API_KEY: undefined,
|
|
485
|
+
PATH: empty,
|
|
486
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
487
|
+
ANTHROPIC_MODEL: undefined,
|
|
488
|
+
},
|
|
489
|
+
() => {
|
|
490
|
+
const plan = deepseekAdapter.buildLaunch({
|
|
491
|
+
promptFile,
|
|
492
|
+
cwd: dir,
|
|
493
|
+
alwaysApprove: true,
|
|
494
|
+
dryRun: true,
|
|
495
|
+
});
|
|
496
|
+
expect(plan.command).toContain("'dsh' --profile headless");
|
|
497
|
+
expect(plan.command).toContain("--patch");
|
|
498
|
+
expect(plan.env?.DEEPSEEK_API_KEY).toBeUndefined();
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
rmSync(dir, { recursive: true, force: true });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("buildLaunch: non-.prompt promptFile does not overwrite the prompt", () => {
|
|
505
|
+
const { dir, bin } = makeFakeDsh("noprompt-ext");
|
|
506
|
+
const promptFile = join(dir, "job");
|
|
507
|
+
writeFileSync(promptFile, "keep-me");
|
|
508
|
+
withEnv(
|
|
509
|
+
{
|
|
510
|
+
CURSOR_ROUTE_DSH_BIN: bin,
|
|
511
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
512
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
513
|
+
ANTHROPIC_MODEL: undefined,
|
|
514
|
+
},
|
|
515
|
+
() => {
|
|
516
|
+
expect(patchPathForPrompt(promptFile)).toBe(`${promptFile}.dsh-patch.yml`);
|
|
517
|
+
deepseekAdapter.buildLaunch({
|
|
518
|
+
promptFile,
|
|
519
|
+
cwd: dir,
|
|
520
|
+
alwaysApprove: true,
|
|
521
|
+
});
|
|
522
|
+
expect(readFileSync(promptFile, "utf8")).toBe("keep-me");
|
|
523
|
+
expect(existsSync(`${promptFile}.dsh-patch.yml`)).toBe(true);
|
|
524
|
+
},
|
|
525
|
+
);
|
|
526
|
+
rmSync(dir, { recursive: true, force: true });
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("buildLaunch: rejects modelId that would break YAML", () => {
|
|
530
|
+
const { dir, bin } = makeFakeDsh("badid");
|
|
531
|
+
const promptFile = join(dir, "job.prompt");
|
|
532
|
+
writeFileSync(promptFile, "ping");
|
|
533
|
+
withEnv(
|
|
534
|
+
{
|
|
535
|
+
CURSOR_ROUTE_DSH_BIN: bin,
|
|
536
|
+
DEEPSEEK_API_KEY: "test-key",
|
|
537
|
+
CURSOR_ROUTE_DS_MODEL: undefined,
|
|
538
|
+
ANTHROPIC_MODEL: undefined,
|
|
539
|
+
},
|
|
540
|
+
() => {
|
|
541
|
+
expect(() =>
|
|
542
|
+
deepseekAdapter.buildLaunch({
|
|
543
|
+
promptFile,
|
|
544
|
+
cwd: dir,
|
|
545
|
+
alwaysApprove: true,
|
|
546
|
+
model: "flash",
|
|
547
|
+
modelId: "x\n injected: true",
|
|
548
|
+
}),
|
|
549
|
+
).toThrow(/Invalid DeepSeek model id/);
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
rmSync(dir, { recursive: true, force: true });
|
|
233
553
|
});
|
|
234
554
|
});
|
|
235
555
|
|
|
@@ -290,23 +610,38 @@ describe("health", () => {
|
|
|
290
610
|
test("returns structured report", () => {
|
|
291
611
|
const r = runHealth();
|
|
292
612
|
expect(r.product).toBe("cursor-route");
|
|
293
|
-
expect(r.version).toBe("0.1.
|
|
613
|
+
expect(r.version).toBe("0.1.8");
|
|
294
614
|
expect(r.checks.length).toBeGreaterThan(3);
|
|
295
615
|
expect(r.checks.some((c) => c.name === "tmux")).toBe(true);
|
|
296
616
|
expect(r.checks.some((c) => c.name === "cursor_cli")).toBe(true);
|
|
297
617
|
});
|
|
298
618
|
|
|
619
|
+
test("config version is 0.1.8", () => {
|
|
620
|
+
expect(config.version).toBe("0.1.8");
|
|
621
|
+
});
|
|
622
|
+
|
|
299
623
|
test("OR-gate: ok can be true while worker:deepseek is false", () => {
|
|
300
|
-
const prev =
|
|
624
|
+
const prev = {
|
|
625
|
+
relaxed: process.env.CURSOR_ROUTE_RELAXED,
|
|
626
|
+
bin: process.env.CURSOR_ROUTE_DSH_BIN,
|
|
627
|
+
key: process.env.DEEPSEEK_API_KEY,
|
|
628
|
+
};
|
|
301
629
|
process.env.CURSOR_ROUTE_RELAXED = "1";
|
|
630
|
+
// Force deepseek unhealthy even on machines that have a real dsh + key.
|
|
631
|
+
process.env.CURSOR_ROUTE_DSH_BIN = join(tmpdir(), `missing-dsh-${process.pid}`);
|
|
632
|
+
delete process.env.DEEPSEEK_API_KEY;
|
|
302
633
|
try {
|
|
303
634
|
const r = runHealth();
|
|
304
635
|
const ds = r.checks.find((c) => c.name === "worker:deepseek");
|
|
305
636
|
expect(ds?.ok).toBe(false);
|
|
306
637
|
expect(r.ok).toBe(true);
|
|
307
638
|
} finally {
|
|
308
|
-
if (prev === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
|
|
309
|
-
else process.env.CURSOR_ROUTE_RELAXED = prev;
|
|
639
|
+
if (prev.relaxed === undefined) delete process.env.CURSOR_ROUTE_RELAXED;
|
|
640
|
+
else process.env.CURSOR_ROUTE_RELAXED = prev.relaxed;
|
|
641
|
+
if (prev.bin === undefined) delete process.env.CURSOR_ROUTE_DSH_BIN;
|
|
642
|
+
else process.env.CURSOR_ROUTE_DSH_BIN = prev.bin;
|
|
643
|
+
if (prev.key === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
644
|
+
else process.env.DEEPSEEK_API_KEY = prev.key;
|
|
310
645
|
}
|
|
311
646
|
});
|
|
312
647
|
|
package/src/cli.ts
CHANGED
|
@@ -52,9 +52,9 @@ Usage:
|
|
|
52
52
|
cursor-route clean [--days N]
|
|
53
53
|
|
|
54
54
|
Start options:
|
|
55
|
-
--worker <grok|claude-ds|openrouter> Worker adapter (default: grok; deepseek =
|
|
55
|
+
--worker <grok|claude-ds|openrouter|deepseek> Worker adapter (default: grok; deepseek = experimental official dsh)
|
|
56
56
|
--lane <easy|mid|hard> Lane → worker (easy=openrouter, mid=claude-ds, hard=grok)
|
|
57
|
-
--model <flash|pro> Mid DeepSeek only (default: flash / CURSOR_ROUTE_DS_MODEL). Parsed
|
|
57
|
+
--model <flash|pro> Mid DeepSeek only (default: flash / CURSOR_ROUTE_DS_MODEL). Parsed for claude-ds + deepseek
|
|
58
58
|
--dir <path> Working directory (default: cwd)
|
|
59
59
|
--ask Disable always-approve for this job
|
|
60
60
|
--dry-run Print launch command; do not start
|
|
@@ -70,6 +70,8 @@ Env:
|
|
|
70
70
|
CURSOR_ROUTE_DS_MODEL Default mid model flash|pro (or deepseek-v4-pro[1m]); overridden by --model
|
|
71
71
|
CURSOR_ROUTE_GROK_BIN Override the grok binary path (tests / power users)
|
|
72
72
|
CURSOR_ROUTE_CLAUDE_DS_BIN Override the claude-ds binary path (tests / power users)
|
|
73
|
+
CURSOR_ROUTE_DSH_BIN Override the dsh binary path (tests / power users)
|
|
74
|
+
DEEPSEEK_API_KEY DeepSeek API key (required for --worker deepseek)
|
|
73
75
|
OPENROUTER_API_KEY OpenRouter key (required for --worker openrouter / --lane easy)
|
|
74
76
|
CURSOR_ROUTE_OPENROUTER_MODEL OpenRouter model (default: openrouter/free)
|
|
75
77
|
OPENROUTER_BASE_URL OpenRouter API base (default: https://openrouter.ai/api/v1)
|
|
@@ -250,8 +252,9 @@ async function main() {
|
|
|
250
252
|
|
|
251
253
|
let model: DsModelAlias | undefined;
|
|
252
254
|
let modelId: string | undefined;
|
|
253
|
-
// --model is DeepSeek
|
|
254
|
-
|
|
255
|
+
// --model is DeepSeek-only (claude-ds mid lane + experimental deepseek worker);
|
|
256
|
+
// ignore (do not validate) for other workers
|
|
257
|
+
if (f.model !== undefined && (resolvedWorker === "claude-ds" || resolvedWorker === "deepseek")) {
|
|
255
258
|
try {
|
|
256
259
|
const choice = asDsModelChoice(f.model);
|
|
257
260
|
if (choice) {
|
package/src/config.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { defaultJobsDir } from "./runtime.ts";
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Workers with a live adapter. `deepseek` is the experimental official
|
|
7
|
+
* DeepSeek Harness (dsh) — an opt-in worker, not a mid default.
|
|
8
|
+
*/
|
|
6
9
|
export type WorkerKind = "grok" | "claude-ds" | "openrouter" | "deepseek";
|
|
7
10
|
export type Lane = "easy" | "mid" | "hard";
|
|
8
11
|
|
|
@@ -86,13 +89,16 @@ function maxConcurrentJobsFromEnv(): number {
|
|
|
86
89
|
*/
|
|
87
90
|
export const config = {
|
|
88
91
|
product: "cursor-route",
|
|
89
|
-
version: "0.1.
|
|
92
|
+
version: "0.1.8",
|
|
90
93
|
get jobsDir(): string {
|
|
91
94
|
return defaultJobsDir();
|
|
92
95
|
},
|
|
93
96
|
tmuxPrefix: "cursor-route",
|
|
94
97
|
defaultWorker: "grok" as WorkerKind,
|
|
95
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Lane → default worker (Cemini /route public core).
|
|
100
|
+
* `deepseek` is experimental only — mid stays on claude-ds.
|
|
101
|
+
*/
|
|
96
102
|
laneWorkers: {
|
|
97
103
|
easy: "openrouter" as WorkerKind,
|
|
98
104
|
mid: "claude-ds" as WorkerKind,
|