@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.
package/dist/driver.js ADDED
@@ -0,0 +1,429 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Readable, Writable } from "node:stream";
3
+ import { z } from "zod";
4
+ import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@zed-industries/agent-client-protocol";
5
+ import { AsyncChannel, HarnessError, DEFAULT_AUTOMATION_APPROVAL_POLICY, PendingRequests, asHarnessError, buildChildEnv, createCachedHarnessDriver, decideApproval, probeCliVersion, resolveDriverEnv, terminate } from "@velum-labs/routekit-harness-core";
6
+ const RESUME_CURSOR_VERSION = 1;
7
+ const DEFAULT_COMMAND = "cursor-agent";
8
+ const AUTH_METHOD_ID = "cursor_login";
9
+ export const cursorDriverConfigSchema = z.object({
10
+ command: z.string().default(DEFAULT_COMMAND),
11
+ /** OpenAI-compatible endpoint cursor-agent's model calls route to (the gateway/bridge). */
12
+ endpoint: z.string().optional(),
13
+ model: z.string().optional()
14
+ });
15
+ function nowIso() {
16
+ return new Date().toISOString();
17
+ }
18
+ function cursorReasoningEffort(reasoning) {
19
+ if (reasoning === undefined || reasoning.mode === "auto")
20
+ return undefined;
21
+ if (reasoning.mode === "effort")
22
+ return reasoning.effort;
23
+ throw new HarnessError("invalid_config", `Cursor ACP cannot represent reasoning mode "${reasoning.mode}"`);
24
+ }
25
+ /** Map an ACP tool kind onto the canonical item type. */
26
+ function itemTypeForToolKind(kind) {
27
+ switch (kind) {
28
+ case "execute":
29
+ return "command_execution";
30
+ case "edit":
31
+ case "delete":
32
+ case "move":
33
+ return "file_change";
34
+ case "search":
35
+ case "fetch":
36
+ return "web_search";
37
+ default:
38
+ return "dynamic_tool_call";
39
+ }
40
+ }
41
+ /** Map an ACP permission option kind onto our approval decision. */
42
+ function decisionForOptionKind(kind) {
43
+ switch (kind) {
44
+ case "allow_always":
45
+ return "acceptForSession";
46
+ case "allow_once":
47
+ return "accept";
48
+ case "reject_always":
49
+ case "reject_once":
50
+ return "decline";
51
+ default:
52
+ return "decline";
53
+ }
54
+ }
55
+ /** The request type an ACP permission maps to, from its tool-call kind. */
56
+ function requestTypeForToolKind(kind) {
57
+ switch (kind) {
58
+ case "execute":
59
+ return "exec_command_approval";
60
+ case "edit":
61
+ case "delete":
62
+ case "move":
63
+ return "file_change_approval";
64
+ default:
65
+ return "tool_approval";
66
+ }
67
+ }
68
+ class CursorSession {
69
+ #kind = "cursor";
70
+ #child;
71
+ #connection;
72
+ #pending = new PendingRequests();
73
+ #approvalPolicy;
74
+ #reasoning;
75
+ #optionKindById = new Map();
76
+ #sessionId;
77
+ #channel;
78
+ #turnId;
79
+ #openItems = new Set();
80
+ #stopped = false;
81
+ constructor(input) {
82
+ this.#child = input.child;
83
+ this.#connection = input.connection;
84
+ this.#sessionId = input.sessionId;
85
+ this.#approvalPolicy = input.approvalPolicy;
86
+ this.#reasoning = input.reasoning;
87
+ }
88
+ get sessionId() {
89
+ return this.#sessionId;
90
+ }
91
+ /** Fed by the ACP Client handler (see makeClient) for every session update. */
92
+ ingestUpdate(params) {
93
+ const channel = this.#channel;
94
+ if (channel === undefined)
95
+ return;
96
+ const update = params.update;
97
+ const base = {
98
+ kind: this.#kind,
99
+ sessionId: this.#sessionId,
100
+ at: nowIso(),
101
+ ...(this.#turnId !== undefined ? { turnId: this.#turnId } : {})
102
+ };
103
+ const raw = { source: "acp.session.update", method: update.sessionUpdate };
104
+ switch (update.sessionUpdate) {
105
+ case "agent_message_chunk":
106
+ if (update.content.type === "text") {
107
+ channel.push({ ...base, type: "content.delta", stream: "assistant_text", text: update.content.text, raw });
108
+ }
109
+ return;
110
+ case "agent_thought_chunk":
111
+ if (update.content.type === "text") {
112
+ channel.push({ ...base, type: "content.delta", stream: "reasoning_text", text: update.content.text, raw });
113
+ }
114
+ return;
115
+ case "tool_call": {
116
+ const itemType = itemTypeForToolKind(update.kind);
117
+ this.#openItems.add(update.toolCallId);
118
+ channel.push({
119
+ ...base,
120
+ type: "item.started",
121
+ itemId: update.toolCallId,
122
+ itemType,
123
+ ...(update.title !== undefined ? { title: update.title } : {}),
124
+ raw
125
+ });
126
+ return;
127
+ }
128
+ case "tool_call_update": {
129
+ if (update.status === "completed" || update.status === "failed") {
130
+ if (this.#openItems.delete(update.toolCallId)) {
131
+ channel.push({
132
+ ...base,
133
+ type: "item.completed",
134
+ itemId: update.toolCallId,
135
+ itemType: itemTypeForToolKind(update.kind),
136
+ status: update.status === "failed" ? "failed" : "completed",
137
+ raw
138
+ });
139
+ }
140
+ }
141
+ return;
142
+ }
143
+ case "user_message_chunk":
144
+ case "plan":
145
+ case "available_commands_update":
146
+ case "current_mode_update":
147
+ return;
148
+ default: {
149
+ const exhausted = update;
150
+ throw new Error(`unsupported ACP session update: ${String(exhausted)}`);
151
+ }
152
+ }
153
+ }
154
+ /** Fed by the ACP Client handler for permission requests. */
155
+ async requestPermission(params) {
156
+ const kind = params.toolCall.kind;
157
+ const requestType = requestTypeForToolKind(kind);
158
+ for (const option of params.options)
159
+ this.#optionKindById.set(option.optionId, option.kind);
160
+ const auto = decideApproval(this.#approvalPolicy, requestType);
161
+ const decision = auto ?? (await this.#surface(params, requestType));
162
+ if (decision === "decline" || decision === "cancel") {
163
+ const reject = params.options.find((option) => option.kind === "reject_once" || option.kind === "reject_always");
164
+ return reject !== undefined
165
+ ? { outcome: { outcome: "selected", optionId: reject.optionId } }
166
+ : { outcome: { outcome: "cancelled" } };
167
+ }
168
+ const preferredKind = decision === "acceptForSession" ? "allow_always" : "allow_once";
169
+ const option = params.options.find((entry) => entry.kind === preferredKind) ??
170
+ params.options.find((entry) => entry.kind === "allow_once" || entry.kind === "allow_always");
171
+ return option !== undefined
172
+ ? { outcome: { outcome: "selected", optionId: option.optionId } }
173
+ : { outcome: { outcome: "cancelled" } };
174
+ }
175
+ async #surface(params, requestType) {
176
+ const channel = this.#channel;
177
+ const request = this.#pending.open({
178
+ requestType,
179
+ ...(params.toolCall.title !== undefined && params.toolCall.title !== null
180
+ ? { detail: params.toolCall.title }
181
+ : {})
182
+ });
183
+ if (channel !== undefined) {
184
+ channel.push({
185
+ kind: this.#kind,
186
+ sessionId: this.#sessionId,
187
+ at: nowIso(),
188
+ ...(this.#turnId !== undefined ? { turnId: this.#turnId } : {}),
189
+ type: "request.opened",
190
+ requestId: request.requestId,
191
+ requestType,
192
+ ...(request.detail !== undefined ? { detail: request.detail } : {}),
193
+ raw: { source: "acp.request.permission" }
194
+ });
195
+ }
196
+ const decision = await request.decision;
197
+ if (channel !== undefined) {
198
+ channel.push({
199
+ kind: this.#kind,
200
+ sessionId: this.#sessionId,
201
+ at: nowIso(),
202
+ ...(this.#turnId !== undefined ? { turnId: this.#turnId } : {}),
203
+ type: "request.resolved",
204
+ requestId: request.requestId,
205
+ decision,
206
+ raw: { source: "acp.request.permission" }
207
+ });
208
+ }
209
+ return decision;
210
+ }
211
+ async *sendTurn(input) {
212
+ if (this.#stopped)
213
+ throw new HarnessError("session_closed", "cursor session is stopped");
214
+ const channel = new AsyncChannel();
215
+ this.#channel = channel;
216
+ this.#turnId = `${this.#sessionId}:turn:${Date.now()}`;
217
+ const turnId = this.#turnId;
218
+ const base = { kind: this.#kind, sessionId: this.#sessionId, at: nowIso(), turnId };
219
+ channel.push({ ...base, type: "turn.started" });
220
+ // An already-aborted turn never reaches the agent: settle it directly so
221
+ // it cannot resolve as completed off a prompt the agent ignored.
222
+ if (input.signal?.aborted === true) {
223
+ channel.push({ ...base, type: "turn.completed", endReason: "aborted" });
224
+ channel.close();
225
+ try {
226
+ yield* channel;
227
+ }
228
+ finally {
229
+ this.#channel = undefined;
230
+ this.#turnId = undefined;
231
+ }
232
+ return;
233
+ }
234
+ if (input.reasoning !== undefined &&
235
+ JSON.stringify(input.reasoning) !== JSON.stringify(this.#reasoning)) {
236
+ const effort = cursorReasoningEffort(input.reasoning);
237
+ if (effort !== undefined) {
238
+ await this.#connection.extMethod("session/set_config_option", {
239
+ sessionId: this.#sessionId,
240
+ configId: "reasoning",
241
+ value: effort
242
+ });
243
+ }
244
+ this.#reasoning = input.reasoning;
245
+ }
246
+ const onAbort = () => {
247
+ this.#pending.settleAll("cancel");
248
+ void this.#connection.cancel({ sessionId: this.#sessionId }).catch(() => undefined);
249
+ };
250
+ if (input.signal !== undefined) {
251
+ input.signal.addEventListener("abort", onAbort, { once: true });
252
+ }
253
+ this.#connection
254
+ .prompt({
255
+ sessionId: this.#sessionId,
256
+ prompt: [{ type: "text", text: input.prompt }]
257
+ })
258
+ .then((response) => {
259
+ channel.push({
260
+ ...base,
261
+ type: "turn.completed",
262
+ endReason: response.stopReason === "cancelled" ? "aborted" : "completed",
263
+ raw: { source: "acp.prompt.response", payload: { stopReason: response.stopReason } }
264
+ });
265
+ channel.close();
266
+ })
267
+ .catch((error) => {
268
+ const harnessError = asHarnessError(error);
269
+ channel.push({
270
+ ...base,
271
+ type: "turn.failed",
272
+ errorCode: harnessError.code,
273
+ message: harnessError.message
274
+ });
275
+ channel.close();
276
+ });
277
+ try {
278
+ yield* channel;
279
+ }
280
+ finally {
281
+ input.signal?.removeEventListener("abort", onAbort);
282
+ this.#channel = undefined;
283
+ this.#turnId = undefined;
284
+ this.#openItems.clear();
285
+ }
286
+ }
287
+ async respondToRequest(requestId, decision) {
288
+ if (!this.#pending.resolve(requestId, decision)) {
289
+ throw new HarnessError("protocol_parse", `unknown pending request ${requestId}`);
290
+ }
291
+ }
292
+ async interrupt() {
293
+ this.#pending.settleAll("cancel");
294
+ await this.#connection.cancel({ sessionId: this.#sessionId }).catch(() => undefined);
295
+ }
296
+ resumeCursor() {
297
+ return {
298
+ version: RESUME_CURSOR_VERSION,
299
+ kind: this.#kind,
300
+ data: { sessionId: this.#sessionId }
301
+ };
302
+ }
303
+ async stop() {
304
+ if (this.#stopped)
305
+ return;
306
+ this.#stopped = true;
307
+ this.#pending.settleAll("cancel");
308
+ this.#channel?.close();
309
+ terminate(this.#child);
310
+ }
311
+ }
312
+ function resumeSessionId(resume) {
313
+ if (resume === undefined || resume.kind !== "cursor")
314
+ return undefined;
315
+ const data = resume.data;
316
+ return typeof data.sessionId === "string" ? data.sessionId : undefined;
317
+ }
318
+ class CursorInstance {
319
+ kind = "cursor";
320
+ #config;
321
+ #context;
322
+ #status;
323
+ #sessions = new Set();
324
+ constructor(config, context, status) {
325
+ this.#config = config;
326
+ this.#context = context;
327
+ this.#status = status;
328
+ }
329
+ status() {
330
+ return this.#status;
331
+ }
332
+ async startSession(options) {
333
+ const args = this.#config.endpoint !== undefined ? ["-e", this.#config.endpoint, "acp"] : ["acp"];
334
+ const child = spawn(this.#config.command, args, {
335
+ cwd: options.cwd,
336
+ env: buildChildEnv({ base: resolveDriverEnv(this.#context), allow: [/^CURSOR_/] }),
337
+ detached: true,
338
+ stdio: ["pipe", "pipe", "pipe"]
339
+ });
340
+ if (child.stdin === null || child.stdout === null) {
341
+ terminate(child);
342
+ throw new HarnessError("session_closed", "cursor-agent has no stdio");
343
+ }
344
+ let session;
345
+ const client = {
346
+ sessionUpdate: async (params) => {
347
+ session?.ingestUpdate(params);
348
+ },
349
+ requestPermission: async (params) => {
350
+ if (session === undefined)
351
+ return { outcome: { outcome: "cancelled" } };
352
+ return session.requestPermission(params);
353
+ }
354
+ };
355
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
356
+ const connection = new ClientSideConnection(() => client, stream);
357
+ try {
358
+ await connection.initialize({
359
+ protocolVersion: PROTOCOL_VERSION,
360
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }
361
+ });
362
+ await connection.authenticate({ methodId: AUTH_METHOD_ID }).catch(() => undefined);
363
+ const resumedId = resumeSessionId(options.resume);
364
+ let sessionId;
365
+ if (resumedId !== undefined) {
366
+ await connection.loadSession({ sessionId: resumedId, cwd: options.cwd, mcpServers: [] });
367
+ sessionId = resumedId;
368
+ }
369
+ else {
370
+ const created = await connection.newSession({ cwd: options.cwd, mcpServers: [] });
371
+ sessionId = created.sessionId;
372
+ }
373
+ if (options.model ?? this.#config.model) {
374
+ await connection
375
+ .setSessionModel({ sessionId, modelId: (options.model ?? this.#config.model) })
376
+ .catch(() => undefined);
377
+ }
378
+ const reasoningEffort = cursorReasoningEffort(options.reasoning);
379
+ if (reasoningEffort !== undefined) {
380
+ await connection.extMethod("session/set_config_option", {
381
+ sessionId,
382
+ configId: "reasoning",
383
+ value: reasoningEffort
384
+ });
385
+ }
386
+ session = new CursorSession({
387
+ child,
388
+ connection,
389
+ sessionId,
390
+ approvalPolicy: options.approvalPolicy ?? DEFAULT_AUTOMATION_APPROVAL_POLICY,
391
+ reasoning: options.reasoning
392
+ });
393
+ this.#sessions.add(session);
394
+ return session;
395
+ }
396
+ catch (error) {
397
+ terminate(child);
398
+ throw asHarnessError(error);
399
+ }
400
+ }
401
+ async dispose() {
402
+ for (const session of this.#sessions)
403
+ await session.stop();
404
+ this.#sessions.clear();
405
+ }
406
+ }
407
+ /** Probe cursor-agent: version via `cursor-agent --version`. */
408
+ async function probeCursor(config, context) {
409
+ const env = buildChildEnv({ base: resolveDriverEnv(context), allow: [/^CURSOR_/] });
410
+ return probeCliVersion({
411
+ kind: "cursor",
412
+ command: config.command,
413
+ cliName: "cursor-agent",
414
+ env,
415
+ // Auth is verified by the ACP handshake at session start; the version
416
+ // probe cannot see login state cheaply.
417
+ auth: { status: "unknown" },
418
+ notInstalledMessage: `Cursor CLI "${config.command}" was not found on PATH.`
419
+ });
420
+ }
421
+ export function createCursorDriver() {
422
+ return createCachedHarnessDriver({
423
+ kind: "cursor",
424
+ configSchema: cursorDriverConfigSchema,
425
+ probeConfig: () => cursorDriverConfigSchema.parse({}),
426
+ probeStatus: probeCursor,
427
+ createInstance: (config, context, status) => new CursorInstance(config, context, status)
428
+ });
429
+ }
@@ -0,0 +1,11 @@
1
+ import type { ToolIntegration } from "@velum-labs/routekit-tools";
2
+ export declare const cursorTool: ToolIntegration;
3
+ export { buildCursorAcpProducer } from "./acp.js";
4
+ export { startCursorBridge } from "./bridge.js";
5
+ export { CURSOR_AGENT_TOOL_MAX_ITERATIONS, CURSOR_AGENT_TOOL_POLICY, cursorBridgeEnv, cursorBridgeModelEnv, cursorIdeEnv, cursorIdeModelsJson } from "./bridge-config.js";
6
+ export { resolveCursorkitCli } from "./cursorkit-path.js";
7
+ export type { CursorkitCli } from "./cursorkit-path.js";
8
+ export { cursorIdeInstructions, cursorInstructions, launchCursor } from "./launch.js";
9
+ export { CURSOR_AGENTS_DIRNAME, cursorSubagentMarkdown, scaffoldCursorSubagents } from "./subagents.js";
10
+ export { createCursorDriver, cursorDriverConfigSchema } from "./driver.js";
11
+ export type { CursorDriverConfig } from "./driver.js";
package/dist/index.js ADDED
@@ -0,0 +1,32 @@
1
+ import { createCursorDriver, cursorDriverConfigSchema } from "./driver.js";
2
+ import { launchCursor } from "./launch.js";
3
+ const driver = createCursorDriver();
4
+ export const cursorTool = {
5
+ id: "cursor",
6
+ displayName: "Cursor",
7
+ pickerHint: "Cursor CLI or desktop",
8
+ binary: "cursor-agent",
9
+ packageName: "@velum-labs/routekit-tool-cursor",
10
+ installHint: "install the Cursor CLI: https://cursor.com/cli",
11
+ authSummary: "Cursor uses a logged-in cursor-agent CLI and a local bridge.",
12
+ setupSnippet: ({ gatewayUrl, model = "gateway-model", note }) => `cursor-agent --endpoint ${note === undefined || note.length === 0 ? gatewayUrl : note} --model ${model}`,
13
+ launch: launchCursor,
14
+ driver: {
15
+ kind: driver.kind,
16
+ driver,
17
+ configForRoute: (route) => cursorDriverConfigSchema.parse({ endpoint: route.gatewayUrl, model: route.model })
18
+ },
19
+ capabilities: {
20
+ streaming: "full",
21
+ tools: "full",
22
+ images: "degraded",
23
+ reasoning_controls: "degraded"
24
+ }
25
+ };
26
+ export { buildCursorAcpProducer } from "./acp.js";
27
+ export { startCursorBridge } from "./bridge.js";
28
+ export { CURSOR_AGENT_TOOL_MAX_ITERATIONS, CURSOR_AGENT_TOOL_POLICY, cursorBridgeEnv, cursorBridgeModelEnv, cursorIdeEnv, cursorIdeModelsJson } from "./bridge-config.js";
29
+ export { resolveCursorkitCli } from "./cursorkit-path.js";
30
+ export { cursorIdeInstructions, cursorInstructions, launchCursor } from "./launch.js";
31
+ export { CURSOR_AGENTS_DIRNAME, cursorSubagentMarkdown, scaffoldCursorSubagents } from "./subagents.js";
32
+ export { createCursorDriver, cursorDriverConfigSchema } from "./driver.js";
@@ -0,0 +1,4 @@
1
+ import type { ToolLaunchContext } from "@velum-labs/routekit-tools";
2
+ export declare function cursorIdeInstructions(model: string): string;
3
+ export declare function cursorInstructions(publicUrl: string, model: string, apiKey?: string): string;
4
+ export declare function launchCursor(ctx: ToolLaunchContext): Promise<number>;
package/dist/launch.js ADDED
@@ -0,0 +1,118 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { definedEnv, spawnLogged, spawnTool, terminate, waitForOutput } from "@velum-labs/routekit-runtime";
4
+ import { cursorIdeEnv } from "./bridge-config.js";
5
+ import { startCursorBridge } from "./bridge.js";
6
+ import { resolveCursorkitCli } from "./cursorkit-path.js";
7
+ import { scaffoldCursorSubagents } from "./subagents.js";
8
+ function bridgeModels(ctx) {
9
+ return ctx.spec.models.flatMap((model) => [
10
+ {
11
+ id: model.id,
12
+ ...(model.label !== undefined ? { displayName: model.label } : {}),
13
+ ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {})
14
+ },
15
+ ...(model.aliases ?? []).map((alias) => ({
16
+ id: alias,
17
+ ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {})
18
+ }))
19
+ ]);
20
+ }
21
+ export function cursorIdeInstructions(model) {
22
+ return `Cursor IDE is connected to the gateway. Choose "${model}" in the Agent model picker.`;
23
+ }
24
+ export function cursorInstructions(publicUrl, model, apiKey) {
25
+ return [
26
+ "In Cursor Settings -> Models, enable Override OpenAI Base URL and set:",
27
+ ` Override OpenAI Base URL : ${publicUrl}/v1/cursor`,
28
+ ` Model name : ${model}`,
29
+ ` OpenAI API Key : ${apiKey ?? "routekit-local"}`
30
+ ].join("\n");
31
+ }
32
+ function cursorCliAuthEnv(env = process.env) {
33
+ const apiKey = env.CURSOR_API_KEY;
34
+ const configDirectory = env.CURSOR_CONFIG_DIR;
35
+ return definedEnv({
36
+ CURSOR_API_KEY: typeof apiKey === "string" && apiKey.length > 0 ? apiKey : undefined,
37
+ CURSOR_CONFIG_DIR: typeof configDirectory === "string" && configDirectory.length > 0
38
+ ? configDirectory
39
+ : undefined
40
+ });
41
+ }
42
+ async function launchCursorCli(ctx) {
43
+ const started = await startCursorBridge({
44
+ gatewayUrl: ctx.spec.gatewayUrl,
45
+ modelLabel: ctx.spec.defaultModel,
46
+ models: bridgeModels(ctx),
47
+ ...(ctx.spec.auth?.token !== undefined
48
+ ? { apiKey: ctx.spec.auth.token }
49
+ : {}),
50
+ ...(ctx.spec.logsDir !== undefined
51
+ ? { logFile: join(ctx.spec.logsDir, "cursor-bridge.log") }
52
+ : {}),
53
+ ...(ctx.spec.tls?.caCertPath !== undefined
54
+ ? { caCertPath: ctx.spec.tls.caCertPath }
55
+ : {}),
56
+ log: ctx.log
57
+ });
58
+ const bridgeUrl = ctx.registerPort("cursor", started.port);
59
+ ctx.registerDisposer(() => {
60
+ ctx.unregisterPort("cursor");
61
+ terminate(started.child);
62
+ });
63
+ ctx.prepareForPassthrough();
64
+ return await spawnTool("cursor-agent", ["--endpoint", bridgeUrl, "--model", ctx.spec.defaultModel, ...ctx.spec.args], cursorCliAuthEnv(), ctx.spec.cwd);
65
+ }
66
+ async function launchCursorRemote(ctx) {
67
+ const publicUrl = ctx.spec.publicUrl;
68
+ if (publicUrl === undefined) {
69
+ throw new Error("Cursor remote configuration requires a public gateway URL");
70
+ }
71
+ ctx.log(cursorInstructions(publicUrl, ctx.spec.defaultModel, ctx.spec.auth?.token));
72
+ await new Promise(() => { });
73
+ return 0;
74
+ }
75
+ async function launchCursorIde(ctx) {
76
+ const { serveCli } = resolveCursorkitCli();
77
+ const repo = ctx.spec.cwd ?? process.cwd();
78
+ const stateDir = ctx.spec.logsDir !== undefined
79
+ ? join(ctx.spec.logsDir, "cursor-ide")
80
+ : join(repo, ".cursor-rpc-ide");
81
+ mkdirSync(stateDir, { recursive: true });
82
+ const proc = spawnLogged(process.execPath, [serveCli, "ck"], {
83
+ cwd: stateDir,
84
+ env: cursorIdeEnv({
85
+ repo,
86
+ gatewayUrl: ctx.spec.gatewayUrl,
87
+ modelLabel: ctx.spec.defaultModel,
88
+ models: bridgeModels(ctx),
89
+ ...(ctx.spec.auth?.token !== undefined ? { apiKey: ctx.spec.auth.token } : {}),
90
+ ...(ctx.spec.tls?.caCertPath !== undefined
91
+ ? { caCertPath: ctx.spec.tls.caCertPath }
92
+ : {})
93
+ }),
94
+ ...(ctx.spec.logsDir !== undefined
95
+ ? { logFile: join(ctx.spec.logsDir, "cursor-ide.log") }
96
+ : {})
97
+ });
98
+ await waitForOutput(proc, /ck ready|bridge listening/, {
99
+ timeoutMs: 60_000,
100
+ label: "Cursor desktop bridge"
101
+ });
102
+ ctx.registerDisposer(() => terminate(proc.child));
103
+ ctx.log(cursorIdeInstructions(ctx.spec.defaultModel));
104
+ return await new Promise((resolve) => {
105
+ proc.child.once("exit", (code) => resolve(code ?? 0));
106
+ });
107
+ }
108
+ export async function launchCursor(ctx) {
109
+ const profiles = ctx.spec.agentProfiles ?? [];
110
+ if (profiles.length > 0) {
111
+ scaffoldCursorSubagents(ctx.spec.cwd ?? process.cwd(), profiles, ctx.log);
112
+ }
113
+ if (ctx.spec.ide === true)
114
+ return launchCursorIde(ctx);
115
+ if (ctx.spec.publicUrl !== undefined)
116
+ return launchCursorRemote(ctx);
117
+ return launchCursorCli(ctx);
118
+ }
@@ -0,0 +1,4 @@
1
+ import type { AgentProfile } from "@velum-labs/routekit-tools";
2
+ export declare const CURSOR_AGENTS_DIRNAME: string;
3
+ export declare function cursorSubagentMarkdown(profile: AgentProfile): string;
4
+ export declare function scaffoldCursorSubagents(repo: string, profiles: readonly AgentProfile[], log?: (line: string) => void): string[];
@@ -0,0 +1,33 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const CURSOR_AGENTS_DIRNAME = join(".cursor", "agents");
4
+ export function cursorSubagentMarkdown(profile) {
5
+ return [
6
+ "---",
7
+ `name: ${profile.id}`,
8
+ `description: ${profile.description}`,
9
+ `model: ${profile.model}`,
10
+ "---",
11
+ "",
12
+ profile.instructions,
13
+ ""
14
+ ].join("\n");
15
+ }
16
+ export function scaffoldCursorSubagents(repo, profiles, log) {
17
+ const written = [];
18
+ try {
19
+ const dir = join(repo, CURSOR_AGENTS_DIRNAME);
20
+ mkdirSync(dir, { recursive: true });
21
+ for (const profile of profiles) {
22
+ const path = join(dir, `${profile.id}.md`);
23
+ if (existsSync(path))
24
+ continue;
25
+ writeFileSync(path, cursorSubagentMarkdown(profile));
26
+ written.push(path);
27
+ }
28
+ }
29
+ catch (error) {
30
+ log?.(`could not scaffold Cursor agents (${error instanceof Error ? error.message : String(error)})`);
31
+ }
32
+ return written;
33
+ }
@@ -0,0 +1 @@
1
+ export {};