@velum-labs/routekit-tool-cursor 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { cursorBridgeEnv, cursorIdeModelsJson } from "../bridge-config.js";
4
+ test("cursorIdeModelsJson preserves opaque model order and removes duplicates", () => {
5
+ const parsed = JSON.parse(cursorIdeModelsJson({
6
+ gatewayUrl: "http://127.0.0.1:9999",
7
+ modelLabel: "opaque-primary",
8
+ models: [
9
+ {
10
+ id: "opaque-primary",
11
+ reasoning: {
12
+ status: "supported",
13
+ efforts: [{ id: "quick" }, { id: "deep" }],
14
+ provenance: "provider"
15
+ }
16
+ },
17
+ { id: "opaque-secondary" },
18
+ { id: "native-model" },
19
+ { id: "opaque-secondary" }
20
+ ]
21
+ }));
22
+ assert.equal(parsed.version, 2);
23
+ assert.deepEqual(parsed.models.map((entry) => entry.id), ["opaque-primary", "opaque-secondary", "native-model"]);
24
+ assert.ok(parsed.models.every((entry) => entry.baseUrl.startsWith("http://127.0.0.1:9999")));
25
+ assert.ok(parsed.models.every((entry) => entry.providerModel === entry.id));
26
+ assert.deepEqual(parsed.models[0]?.reasoning?.efforts, [
27
+ { id: "quick" },
28
+ { id: "deep" }
29
+ ]);
30
+ });
31
+ test("cursorBridgeEnv seeds BRIDGE_MODELS_JSON for multiple opaque models", () => {
32
+ const env = cursorBridgeEnv({
33
+ port: 4321,
34
+ gatewayUrl: "http://127.0.0.1:9999",
35
+ modelName: "opaque-primary",
36
+ models: [
37
+ { id: "opaque-primary" },
38
+ { id: "opaque-secondary" },
39
+ { id: "native-model" }
40
+ ],
41
+ baseEnv: {}
42
+ });
43
+ // MODEL_NAME stays the session default for single-model bridges.
44
+ assert.equal(env.MODEL_NAME, "opaque-primary");
45
+ const models = JSON.parse(env.BRIDGE_MODELS_JSON ?? "[]");
46
+ assert.deepEqual(models.map((entry) => entry.id), ["opaque-primary", "opaque-secondary", "native-model"]);
47
+ });
48
+ test("cursorBridgeEnv omits BRIDGE_MODELS_JSON for one model", () => {
49
+ const env = cursorBridgeEnv({
50
+ port: 4321,
51
+ gatewayUrl: "http://127.0.0.1:9999",
52
+ modelName: "opaque-primary",
53
+ models: [{ id: "opaque-primary" }],
54
+ baseEnv: {}
55
+ });
56
+ assert.equal(env.BRIDGE_MODELS_JSON, undefined);
57
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { test } from "node:test";
7
+ import { driverContractSuite } from "@velum-labs/routekit-harness-core/testing";
8
+ import { createCursorDriver } from "../driver.js";
9
+ // The fake ACP agent lives beside this test in src (tsc does not copy .mjs),
10
+ // so resolve it relative to the compiled test's location back into src.
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const FAKE_AGENT = join(here, "..", "..", "src", "test", "fake-acp-agent.mjs");
13
+ const driver = createCursorDriver();
14
+ // A wrapper executable so the driver's appended `acp` arg lands after the fake
15
+ // agent script: `<wrapper> acp` -> `node <fake-agent> acp`. `--version` is
16
+ // answered directly so the probe sees an installed CLI.
17
+ let cachedWrapper;
18
+ function wrapperCommand() {
19
+ if (cachedWrapper !== undefined)
20
+ return cachedWrapper;
21
+ const dir = mkdtempSync(join(tmpdir(), "cursor-driver-"));
22
+ const wrapper = join(dir, "cursor-agent-fake");
23
+ writeFileSync(wrapper, `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "cursor-agent 2026.1.1"; exit 0; fi\nexec "${process.execPath}" "${FAKE_AGENT}" "$@"\n`);
24
+ chmodSync(wrapper, 0o755);
25
+ cachedWrapper = wrapper;
26
+ return wrapper;
27
+ }
28
+ driverContractSuite({
29
+ name: "cursor driver",
30
+ createInstance: async () => driver.createInstance(driver.configSchema.parse({ command: wrapperCommand() })),
31
+ startOptions: () => ({ cwd: here }),
32
+ supportsResume: true,
33
+ turnTimeoutMs: 15_000
34
+ });
35
+ test("cursor driver maps ACP session updates into canonical events", async () => {
36
+ const instance = await driver.createInstance(driver.configSchema.parse({ command: wrapperCommand() }));
37
+ try {
38
+ const session = await instance.startSession({ cwd: here });
39
+ const events = [];
40
+ for await (const event of session.sendTurn({ prompt: "hello cursor" })) {
41
+ events.push(event);
42
+ }
43
+ const types = events.map((event) => event.type);
44
+ assert.ok(types.includes("turn.started"));
45
+ const delta = events.find((event) => event.type === "content.delta");
46
+ assert.ok(delta && delta.text.includes("hello cursor"));
47
+ const completed = events.find((event) => event.type === "turn.completed");
48
+ assert.equal(completed?.endReason, "completed");
49
+ assert.ok(events.every((event) => event.kind === "cursor"));
50
+ assert.ok(session.resumeCursor()?.data);
51
+ await session.stop();
52
+ }
53
+ finally {
54
+ await instance.dispose();
55
+ }
56
+ });
57
+ test("cursor driver forwards effort through the ACP config option", async () => {
58
+ const instance = await driver.createInstance(driver.configSchema.parse({ command: wrapperCommand() }));
59
+ try {
60
+ const session = await instance.startSession({
61
+ cwd: here,
62
+ reasoning: { mode: "effort", effort: "deep" }
63
+ });
64
+ for await (const _event of session.sendTurn({ prompt: "reason about this" })) {
65
+ // Drain.
66
+ }
67
+ await session.stop();
68
+ }
69
+ finally {
70
+ await instance.dispose();
71
+ }
72
+ });
73
+ test("cursor driver auto-approves under the automation policy", async () => {
74
+ const instance = await driver.createInstance(driver.configSchema.parse({ command: wrapperCommand() }));
75
+ try {
76
+ // Automation policy (autoApprove:all) is the default: exec approval is granted
77
+ // server-side without a surfaced request, so the turn completes.
78
+ const session = await instance.startSession({ cwd: here });
79
+ const events = [];
80
+ for await (const event of session.sendTurn({ prompt: "please APPROVE and continue" })) {
81
+ events.push(event);
82
+ }
83
+ assert.ok(!events.some((event) => event.type === "request.opened"));
84
+ const completed = events.find((event) => event.type === "turn.completed");
85
+ assert.equal(completed?.endReason, "completed");
86
+ await session.stop();
87
+ }
88
+ finally {
89
+ await instance.dispose();
90
+ }
91
+ });
92
+ test("cursor driver surfaces approvals under autoApprove none and resolves them", async () => {
93
+ const instance = await driver.createInstance(driver.configSchema.parse({ command: wrapperCommand() }));
94
+ try {
95
+ const session = await instance.startSession({
96
+ cwd: here,
97
+ approvalPolicy: { autoApprove: "none" }
98
+ });
99
+ const events = [];
100
+ for await (const event of session.sendTurn({ prompt: "please APPROVE and continue" })) {
101
+ events.push(event);
102
+ if (event.type === "request.opened") {
103
+ assert.equal(event.requestType, "exec_command_approval");
104
+ await session.respondToRequest(event.requestId, "accept");
105
+ }
106
+ }
107
+ assert.ok(events.some((event) => event.type === "request.opened"));
108
+ assert.ok(events.some((event) => event.type === "request.resolved"));
109
+ const completed = events.find((event) => event.type === "turn.completed");
110
+ assert.equal(completed?.endReason, "completed");
111
+ await session.stop();
112
+ }
113
+ finally {
114
+ await instance.dispose();
115
+ }
116
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,218 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { test } from "node:test";
7
+ import { launchCursor } from "../launch.js";
8
+ /**
9
+ * A stub for `cursorkit ck`: records the env + cwd it was launched with, prints
10
+ * the readiness line `launchCursorIde` waits for, then idles until SIGTERM so
11
+ * the disposer-driven teardown path is exercised.
12
+ */
13
+ function writeCkStub(path, outFile) {
14
+ writeFileSync(path, [
15
+ "const fs = require('node:fs');",
16
+ `fs.writeFileSync(${JSON.stringify(outFile)}, JSON.stringify({`,
17
+ " argv: process.argv.slice(2),",
18
+ " cwd: process.cwd(),",
19
+ " workspace: process.env.CK_WORKSPACE_PATH,",
20
+ " models: process.env.BRIDGE_MODELS_JSON,",
21
+ " modelBaseUrl: process.env.MODEL_BASE_URL,",
22
+ " modelApiKey: process.env.MODEL_API_KEY,",
23
+ " caCerts: process.env.NODE_EXTRA_CA_CERTS,",
24
+ " leakedBridge: process.env.BRIDGE_PORT",
25
+ "}));",
26
+ "process.stdout.write('ck ready\\n');",
27
+ "const timer = setInterval(() => {}, 1000);",
28
+ "process.on('SIGTERM', () => { clearInterval(timer); process.exit(0); });"
29
+ ].join("\n"));
30
+ }
31
+ test("launchCursor CLI forwards only supported Cursor auth inputs", async () => {
32
+ const workdir = mkdtempSync(join(tmpdir(), "cursor-cli-auth-"));
33
+ const bridgeStub = join(workdir, "bridge.cjs");
34
+ const agentStub = join(workdir, "cursor-agent");
35
+ const recorder = join(workdir, "record-agent.cjs");
36
+ const observations = join(workdir, "agent-observations.ndjson");
37
+ writeFileSync(bridgeStub, [
38
+ "process.stdout.write('bridge listening\\n');",
39
+ "const timer = setInterval(() => {}, 1000);",
40
+ "process.on('SIGTERM', () => { clearInterval(timer); process.exit(0); });"
41
+ ].join("\n"));
42
+ writeFileSync(recorder, [
43
+ 'const { appendFileSync } = require("node:fs");',
44
+ `appendFileSync(${JSON.stringify(observations)}, JSON.stringify({`,
45
+ " auth: process.env.CURSOR_API_KEY ?? null,",
46
+ " config: process.env.CURSOR_CONFIG_DIR ?? null,",
47
+ " unrelated: process.env.UNRELATED_SECRET ?? null",
48
+ "}) + '\\n');"
49
+ ].join("\n"));
50
+ writeFileSync(agentStub, `#!/bin/sh\nexec "${process.execPath}" "${recorder}" "$@"\n`);
51
+ chmodSync(agentStub, 0o755);
52
+ const previous = {
53
+ path: process.env.PATH,
54
+ serveCli: process.env.ROUTEKIT_CURSORKIT_SERVE_CLI,
55
+ apiKey: process.env.CURSOR_API_KEY,
56
+ configDirectory: process.env.CURSOR_CONFIG_DIR,
57
+ unrelated: process.env.UNRELATED_SECRET
58
+ };
59
+ process.env.PATH = `${workdir}:${process.env.PATH ?? ""}`;
60
+ process.env.ROUTEKIT_CURSORKIT_SERVE_CLI = bridgeStub;
61
+ process.env.UNRELATED_SECRET = "must-not-leak";
62
+ try {
63
+ const stagedConfig = join(workdir, "staged-config");
64
+ for (const [, apiKey, configDirectory, expected] of [
65
+ [
66
+ "env-key",
67
+ "cursor-test-key",
68
+ undefined,
69
+ { auth: "cursor-test-key", config: null }
70
+ ],
71
+ [
72
+ "staged-config",
73
+ undefined,
74
+ stagedConfig,
75
+ { auth: null, config: stagedConfig }
76
+ ],
77
+ ["absent", undefined, undefined, { auth: null, config: null }]
78
+ ]) {
79
+ if (apiKey === undefined)
80
+ delete process.env.CURSOR_API_KEY;
81
+ else
82
+ process.env.CURSOR_API_KEY = apiKey;
83
+ if (configDirectory === undefined)
84
+ delete process.env.CURSOR_CONFIG_DIR;
85
+ else
86
+ process.env.CURSOR_CONFIG_DIR = configDirectory;
87
+ const disposers = [];
88
+ const ctx = {
89
+ spec: {
90
+ gatewayUrl: "http://127.0.0.1:9999",
91
+ defaultModel: "primary",
92
+ models: [{ id: "primary" }],
93
+ args: [],
94
+ cwd: workdir
95
+ },
96
+ log: () => undefined,
97
+ prepareForPassthrough: () => undefined,
98
+ registerPort: (_name, port) => `http://127.0.0.1:${port}`,
99
+ unregisterPort: () => undefined,
100
+ registerDisposer: (dispose) => disposers.push(dispose)
101
+ };
102
+ try {
103
+ assert.equal(await launchCursor(ctx), 0);
104
+ const observed = readFileSync(observations, "utf8")
105
+ .trim()
106
+ .split("\n")
107
+ .map((line) => JSON.parse(line))
108
+ .at(-1);
109
+ assert.deepEqual(observed, {
110
+ ...expected,
111
+ unrelated: null
112
+ });
113
+ }
114
+ finally {
115
+ for (const dispose of disposers)
116
+ await dispose();
117
+ }
118
+ }
119
+ }
120
+ finally {
121
+ for (const [name, value] of [
122
+ ["PATH", previous.path],
123
+ ["ROUTEKIT_CURSORKIT_SERVE_CLI", previous.serveCli],
124
+ ["CURSOR_API_KEY", previous.apiKey],
125
+ ["CURSOR_CONFIG_DIR", previous.configDirectory],
126
+ ["UNRELATED_SECRET", previous.unrelated]
127
+ ]) {
128
+ if (value === undefined)
129
+ delete process.env[name];
130
+ else
131
+ process.env[name] = value;
132
+ }
133
+ rmSync(workdir, { recursive: true, force: true });
134
+ }
135
+ });
136
+ test("launchCursor --ide drives the desktop launcher with the gateway-wired model", async () => {
137
+ const workdir = mkdtempSync(join(tmpdir(), "cursor-ide-"));
138
+ const repo = mkdtempSync(join(tmpdir(), "cursor-ide-repo-"));
139
+ const logsDir = join(workdir, "logs");
140
+ const stub = join(workdir, "ck-stub.cjs");
141
+ const outFile = join(workdir, "ck-invocation.json");
142
+ writeCkStub(stub, outFile);
143
+ const previousOverride = process.env.ROUTEKIT_CURSORKIT_SERVE_CLI;
144
+ // A leftover BRIDGE_* var that must be scrubbed before spawning the launcher.
145
+ const previousLeak = process.env.BRIDGE_PORT;
146
+ process.env.ROUTEKIT_CURSORKIT_SERVE_CLI = stub;
147
+ process.env.BRIDGE_PORT = "59999";
148
+ const disposers = [];
149
+ const logs = [];
150
+ const ctx = {
151
+ spec: {
152
+ gatewayUrl: "http://127.0.0.1:9999",
153
+ defaultModel: "primary",
154
+ models: [{ id: "primary", aliases: ["primary-alias"] }, { id: "gpt" }, { id: "sonnet" }],
155
+ args: [],
156
+ cwd: repo,
157
+ tls: { caCertPath: "/tmp/portless-ca.pem" },
158
+ logsDir,
159
+ ide: true
160
+ },
161
+ log: (line) => logs.push(line),
162
+ prepareForPassthrough: () => undefined,
163
+ registerPort: (_name, port) => `http://127.0.0.1:${port}`,
164
+ unregisterPort: () => undefined,
165
+ registerDisposer: (dispose) => disposers.push(dispose)
166
+ };
167
+ try {
168
+ const launched = launchCursor(ctx);
169
+ try {
170
+ // Wait until the launcher is fully up: a registered disposer means it
171
+ // passed the readiness gate and recorded its teardown (the stub wrote its
172
+ // invocation file before announcing readiness).
173
+ for (let i = 0; i < 200 && disposers.length === 0; i++) {
174
+ await delay(50);
175
+ }
176
+ assert.equal(disposers.length, 1);
177
+ assert.ok(existsSync(outFile), "the ck stub should have been invoked");
178
+ const invocation = JSON.parse(readFileSync(outFile, "utf8"));
179
+ assert.deepEqual(invocation.argv, ["ck"]);
180
+ // Opens the user's repo but keeps state out of it (cwd is the scratch dir).
181
+ assert.equal(invocation.workspace, repo);
182
+ assert.notEqual(invocation.cwd, repo);
183
+ assert.equal(invocation.modelBaseUrl, "http://127.0.0.1:9999/v1");
184
+ assert.equal(invocation.modelApiKey, "local");
185
+ assert.equal(invocation.caCerts, "/tmp/portless-ca.pem");
186
+ // A parent's BRIDGE_* env is scrubbed; only our seeded models flow through.
187
+ assert.equal(invocation.leakedBridge, undefined);
188
+ const models = JSON.parse(invocation.models ?? "[]");
189
+ assert.deepEqual(models.map((entry) => entry.id), ["primary", "primary-alias", "gpt", "sonnet"]);
190
+ assert.ok(models.every((entry) => entry.baseUrl === "http://127.0.0.1:9999/v1"));
191
+ }
192
+ finally {
193
+ // Always tear the desktop launcher down so the launch promise resolves and
194
+ // the test process can exit even when an assertion fails.
195
+ for (const dispose of disposers) {
196
+ await dispose();
197
+ }
198
+ const code = await launched;
199
+ assert.equal(code, 0);
200
+ }
201
+ }
202
+ finally {
203
+ if (previousOverride === undefined) {
204
+ delete process.env.ROUTEKIT_CURSORKIT_SERVE_CLI;
205
+ }
206
+ else {
207
+ process.env.ROUTEKIT_CURSORKIT_SERVE_CLI = previousOverride;
208
+ }
209
+ if (previousLeak === undefined) {
210
+ delete process.env.BRIDGE_PORT;
211
+ }
212
+ else {
213
+ process.env.BRIDGE_PORT = previousLeak;
214
+ }
215
+ rmSync(workdir, { recursive: true, force: true });
216
+ rmSync(repo, { recursive: true, force: true });
217
+ }
218
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { after, test } from "node:test";
6
+ import { CURSOR_AGENTS_DIRNAME, cursorSubagentMarkdown, scaffoldCursorSubagents } from "../subagents.js";
7
+ const PROFILES = [
8
+ {
9
+ id: "reviewer",
10
+ model: "opaque-model",
11
+ description: "Review changes.",
12
+ instructions: "Return findings."
13
+ },
14
+ {
15
+ id: "implementer",
16
+ model: "other-model",
17
+ description: "Implement changes.",
18
+ instructions: "Make the requested change."
19
+ }
20
+ ];
21
+ const tmpRoots = [];
22
+ function freshRepo() {
23
+ const dir = mkdtempSync(join(tmpdir(), "cursor-subagents-"));
24
+ tmpRoots.push(dir);
25
+ return dir;
26
+ }
27
+ after(() => {
28
+ for (const dir of tmpRoots)
29
+ rmSync(dir, { recursive: true, force: true });
30
+ });
31
+ test("cursorSubagentMarkdown serializes a generic profile", () => {
32
+ const md = cursorSubagentMarkdown(PROFILES[0]);
33
+ assert.match(md, /name: reviewer/);
34
+ assert.match(md, /model: opaque-model/);
35
+ assert.match(md, /Return findings/);
36
+ });
37
+ test("scaffoldCursorSubagents writes profiles and never overwrites", () => {
38
+ const repo = freshRepo();
39
+ assert.equal(scaffoldCursorSubagents(repo, PROFILES).length, 2);
40
+ const path = join(repo, CURSOR_AGENTS_DIRNAME, "reviewer.md");
41
+ assert.ok(existsSync(path));
42
+ writeFileSync(path, "USER EDIT\n");
43
+ assert.deepEqual(scaffoldCursorSubagents(repo, PROFILES), []);
44
+ assert.equal(readFileSync(path, "utf8"), "USER EDIT\n");
45
+ });
46
+ test("scaffoldCursorSubagents is best-effort", () => {
47
+ const repo = freshRepo();
48
+ writeFileSync(join(repo, ".cursor"), "not a directory");
49
+ const lines = [];
50
+ assert.deepEqual(scaffoldCursorSubagents(repo, PROFILES, (line) => lines.push(line)), []);
51
+ assert.equal(lines.length, 1);
52
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@velum-labs/routekit-tool-cursor",
3
+ "private": false,
4
+ "version": "0.9.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/tool-cursor"
9
+ },
10
+ "description": "Product-neutral Cursor launcher, bridge serializer, and canonical ACP driver.",
11
+ "license": "Apache-2.0",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "LICENSE"
22
+ ],
23
+ "publishConfig": {
24
+ "registry": "https://registry.npmjs.org",
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "dependencies": {
29
+ "@velum-labs/cursorkit": "0.2.0",
30
+ "@zed-industries/agent-client-protocol": "0.4.5",
31
+ "zod": "4.4.3",
32
+ "@velum-labs/routekit-contracts": "0.9.0",
33
+ "@velum-labs/routekit-runtime": "0.9.0",
34
+ "@velum-labs/routekit-harness-core": "0.9.0",
35
+ "@velum-labs/routekit-tools": "0.9.0"
36
+ },
37
+ "keywords": [
38
+ "llm",
39
+ "coding-agent",
40
+ "cursor",
41
+ "harness",
42
+ "adapter"
43
+ ],
44
+ "scripts": {
45
+ "build": "tsc -b",
46
+ "clean": "tsc -b --clean",
47
+ "test": "node --test \"dist/test/*.test.js\""
48
+ }
49
+ }