@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/LICENSE +201 -0
- package/README.md +20 -0
- package/dist/acp.d.ts +20 -0
- package/dist/acp.js +178 -0
- package/dist/bridge-config.d.ts +40 -0
- package/dist/bridge-config.js +95 -0
- package/dist/bridge.d.ts +18 -0
- package/dist/bridge.js +37 -0
- package/dist/cursorkit-path.d.ts +5 -0
- package/dist/cursorkit-path.js +10 -0
- package/dist/driver.d.ts +9 -0
- package/dist/driver.js +429 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +32 -0
- package/dist/launch.d.ts +4 -0
- package/dist/launch.js +118 -0
- package/dist/subagents.d.ts +4 -0
- package/dist/subagents.js +33 -0
- package/dist/test/bridge-config.test.d.ts +1 -0
- package/dist/test/bridge-config.test.js +57 -0
- package/dist/test/driver.test.d.ts +1 -0
- package/dist/test/driver.test.js +116 -0
- package/dist/test/ide-launch.test.d.ts +1 -0
- package/dist/test/ide-launch.test.js +218 -0
- package/dist/test/subagents.test.d.ts +1 -0
- package/dist/test/subagents.test.js +52 -0
- package/package.json +49 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/launch.d.ts
ADDED
|
@@ -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 {};
|