@wu529778790/open-im 1.6.6 → 1.6.7

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Cursor Adapter - 通过 Cursor Agent CLI 执行任务
2
+ * Cursor Adapter - 通过 Cursor CLI 执行任务
3
3
  * 需要预先安装: curl https://cursor.com/install -fsSL | bash
4
4
  */
5
5
  import type { ToolAdapter, RunCallbacks, RunOptions, RunHandle } from './tool-adapter.interface.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Cursor Adapter - 通过 Cursor Agent CLI 执行任务
2
+ * Cursor Adapter - 通过 Cursor CLI 执行任务
3
3
  * 需要预先安装: curl https://cursor.com/install -fsSL | bash
4
4
  */
5
5
  import { runCursor } from '../cursor/cli-runner.js';
@@ -19,11 +19,13 @@ export class CursorAdapter {
19
19
  model: options?.model,
20
20
  chatId: options?.chatId,
21
21
  hookPort: options?.hookPort,
22
+ proxy: options?.proxy,
22
23
  };
23
24
  return runCursor(this.cliPath, prompt, sessionId, workDir, {
24
25
  onText: callbacks.onText,
25
26
  onThinking: callbacks.onThinking,
26
27
  onToolUse: callbacks.onToolUse,
28
+ onSessionInvalid: callbacks.onSessionInvalid,
27
29
  onComplete: (raw) => {
28
30
  const result = {
29
31
  success: raw.success,
@@ -39,9 +41,17 @@ export class CursorAdapter {
39
41
  },
40
42
  onError: (err) => {
41
43
  const msg = typeof err === 'string' ? err : String(err);
42
- const friendly = msg.includes('Authentication required') || msg.includes('agent login')
43
- ? 'Cursor 需要先登录。请在终端运行 agent login,或在 ~/.open-im/config.json env 中添加 "CURSOR_API_KEY"。'
44
- : msg;
44
+ const friendly = msg.includes('Electron') || msg.includes('Chromium') || msg.includes('not in the list of known options')
45
+ ? '当前使用的是 Cursor IDE cursor.cmd,不支持 CLI 模式。请安装独立的 Cursor Agent CLI:在 PowerShell 运行 irm \'https://cursor.com/install?win32=true\' | iex,安装后把 tools.cursor.cliPath 改为 agent。'
46
+ : msg.includes('Authentication required') || msg.includes('agent login') || msg.includes('cursor agent login')
47
+ ? 'Cursor 需要先登录。请在终端运行 agent login,或在 ~/.open-im/config.json 的 env 中添加 "CURSOR_API_KEY"。'
48
+ : msg.includes('No session found') || msg.includes('No conversation found') || msg.includes('Unable to find session') || msg.includes('session not found') || msg.includes('invalid session')
49
+ ? 'Cursor 会话已失效,旧 session 已清理。请直接重试当前请求。'
50
+ : msg.includes('stream disconnected') || msg.includes('error sending request') || msg.includes('Connection refused') || msg.includes('ENOTFOUND') || msg.includes('ETIMEDOUT')
51
+ ? 'Cursor 网络请求失败。如无法访问 Cursor API,可在 tools.cursor.proxy 或 CURSOR_PROXY 中配置代理。'
52
+ : msg.includes('usage limit') || msg.includes('You\'ve hit your usage limit')
53
+ ? 'Cursor 模型用量已超限(如 Opus)。请在 config.json 的 tools.cursor.model 中改为 claude-4-sonnet 或其他非 Opus 模型,或运行 agent --list-models 查看可用模型。'
54
+ : msg;
45
55
  callbacks.onError(friendly);
46
56
  },
47
57
  onSessionId: callbacks.onSessionId,
@@ -96,6 +96,8 @@ export declare const PAGE_TEXTS: {
96
96
  readonly claudeModel: "ANTHROPIC_MODEL";
97
97
  readonly claudeProxy: "Proxy (optional)";
98
98
  readonly codexTimeout: "Codex timeout (ms)";
99
+ readonly cursorTimeout: "Cursor timeout (ms)";
100
+ readonly cursorModel: "Model (e.g. auto)";
99
101
  readonly codebuddyTimeout: "CodeBuddy timeout (ms)";
100
102
  readonly cursorProxy: "Proxy (optional)";
101
103
  readonly hookPort: "Hook port";
@@ -215,6 +217,8 @@ export declare const PAGE_TEXTS: {
215
217
  readonly claudeModel: "ANTHROPIC_MODEL";
216
218
  readonly claudeProxy: "代理(可选)";
217
219
  readonly codexTimeout: "Codex 超时(毫秒)";
220
+ readonly cursorTimeout: "Cursor 超时(毫秒)";
221
+ readonly cursorModel: "模型(如 auto)";
218
222
  readonly codebuddyTimeout: "CodeBuddy 超时(毫秒)";
219
223
  readonly cursorProxy: "代理(可选)";
220
224
  readonly hookPort: "Hook 端口";
@@ -96,6 +96,8 @@ export const PAGE_TEXTS = {
96
96
  claudeModel: "ANTHROPIC_MODEL",
97
97
  claudeProxy: "Proxy (optional)",
98
98
  codexTimeout: "Codex timeout (ms)",
99
+ cursorTimeout: "Cursor timeout (ms)",
100
+ cursorModel: "Model (e.g. auto)",
99
101
  codebuddyTimeout: "CodeBuddy timeout (ms)",
100
102
  cursorProxy: "Proxy (optional)",
101
103
  hookPort: "Hook port",
@@ -215,6 +217,8 @@ export const PAGE_TEXTS = {
215
217
  claudeModel: "ANTHROPIC_MODEL",
216
218
  claudeProxy: "\u4ee3\u7406\uff08\u53ef\u9009\uff09",
217
219
  codexTimeout: "Codex \u8d85\u65f6\uff08\u6beb\u79d2\uff09",
220
+ cursorTimeout: "Cursor \u8d85\u65f6\uff08\u6beb\u79d2\uff09",
221
+ cursorModel: "模型(如 auto)",
218
222
  codebuddyTimeout: "CodeBuddy \u8d85\u65f6\uff08\u6beb\u79d2\uff09",
219
223
  cursorProxy: "\u4ee3\u7406\uff08\u53ef\u9009\uff09",
220
224
  hookPort: "Hook \u7aef\u53e3",
@@ -210,6 +210,8 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
210
210
  { id: "ai-codexTimeoutMs-label", key: "codexTimeout" },
211
211
  { id: "ai-codexProxy-label", key: "codexProxy" },
212
212
  { id: "ai-cursorCliPath-label", key: "cursorCli" },
213
+ { id: "ai-cursorModel-label", key: "cursorModel" },
214
+ { id: "ai-cursorTimeoutMs-label", key: "cursorTimeout" },
213
215
  { id: "ai-cursorProxy-label", key: "cursorProxy" },
214
216
  { id: "ai-codebuddyCliPath-label", key: "codebuddyCli" },
215
217
  { id: "ai-codebuddyTimeoutMs-label", key: "codebuddyTimeout" },
@@ -423,6 +425,8 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
423
425
  { id: "ai-codexTimeoutMs", key: "codexTimeoutMs" },
424
426
  { id: "ai-codexProxy", key: "codexProxy" },
425
427
  { id: "ai-cursorCliPath", key: "cursorCliPath" },
428
+ { id: "ai-cursorModel", key: "cursorModel" },
429
+ { id: "ai-cursorTimeoutMs", key: "cursorTimeoutMs" },
426
430
  { id: "ai-cursorProxy", key: "cursorProxy" },
427
431
  { id: "ai-codebuddyCliPath", key: "codebuddyCliPath" },
428
432
  { id: "ai-codebuddyTimeoutMs", key: "codebuddyTimeoutMs" },
@@ -669,6 +673,8 @@ export const PAGE_SCRIPT = String.raw ` const platformDefinitions = [
669
673
  codexCliPath: getValue("ai-codexCliPath"),
670
674
  codexProxy: getValue("ai-codexProxy"),
671
675
  cursorCliPath: getValue("ai-cursorCliPath"),
676
+ cursorModel: getValue("ai-cursorModel"),
677
+ cursorTimeoutMs: getNumber("ai-cursorTimeoutMs"),
672
678
  cursorProxy: getValue("ai-cursorProxy"),
673
679
  codebuddyCliPath: getValue("ai-codebuddyCliPath"),
674
680
  hookPort: getNumber("ai-hookPort"),
@@ -1175,6 +1175,15 @@ export const PAGE_HTML_PREFIX = String.raw `<!doctype html>
1175
1175
  <label class="form-label" id="ai-cursorCliPath-label">CLI Path</label>
1176
1176
  <input id="ai-cursorCliPath" class="form-input mono" type="text" />
1177
1177
  </div>
1178
+ <div class="form-group">
1179
+ <label class="form-label" id="ai-cursorModel-label">Model</label>
1180
+ <input id="ai-cursorModel" class="form-input mono" type="text" placeholder="auto" />
1181
+ <div class="form-hint" id="ai-cursorModel-hint">如 auto、Claude 4 Sonnet 等,agent --list-models 查看</div>
1182
+ </div>
1183
+ <div class="form-group">
1184
+ <label class="form-label" id="ai-cursorTimeoutMs-label">Timeout (ms)</label>
1185
+ <input id="ai-cursorTimeoutMs" class="form-input" type="number" min="1" />
1186
+ </div>
1178
1187
  <div class="form-group">
1179
1188
  <label class="form-label" id="ai-cursorProxy-label">Proxy (optional)</label>
1180
1189
  <input id="ai-cursorProxy" class="form-input mono" type="text" />
@@ -143,8 +143,10 @@ function buildInitialPayload(file) {
143
143
  claudeModel: file.tools?.claude?.model ?? claudeEnv.ANTHROPIC_MODEL ?? "",
144
144
  claudeProxy: file.tools?.claude?.proxy ?? "",
145
145
  codexTimeoutMs: file.tools?.codex?.timeoutMs ?? 600000,
146
+ cursorTimeoutMs: file.tools?.cursor?.timeoutMs ?? 600000,
146
147
  codebuddyTimeoutMs: file.tools?.codebuddy?.timeoutMs ?? 600000,
147
- cursorCliPath: file.tools?.cursor?.cliPath ?? "agent",
148
+ cursorCliPath: file.tools?.cursor?.cliPath ?? "cursor",
149
+ cursorModel: file.tools?.cursor?.model ?? "auto",
148
150
  codexCliPath: file.tools?.codex?.cliPath ?? "codex",
149
151
  codebuddyCliPath: file.tools?.codebuddy?.cliPath ?? "codebuddy",
150
152
  codexProxy: file.tools?.codex?.proxy ?? "",
@@ -186,6 +188,8 @@ function validatePayload(payload) {
186
188
  errors.push("Claude timeout must be positive.");
187
189
  if (!Number.isFinite(payload.ai.codexTimeoutMs) || payload.ai.codexTimeoutMs <= 0)
188
190
  errors.push("Codex timeout must be positive.");
191
+ if (!Number.isFinite(payload.ai.cursorTimeoutMs) || payload.ai.cursorTimeoutMs <= 0)
192
+ errors.push("Cursor timeout must be positive.");
189
193
  if (!Number.isFinite(payload.ai.codebuddyTimeoutMs) || payload.ai.codebuddyTimeoutMs <= 0)
190
194
  errors.push("CodeBuddy timeout must be positive.");
191
195
  if (!Number.isFinite(payload.ai.hookPort) || payload.ai.hookPort <= 0)
@@ -267,13 +271,14 @@ function createProbeConfig(values) {
267
271
  dingtalkAllowedUserIds: [],
268
272
  aiCommand: "claude",
269
273
  claudeCliPath: "claude",
270
- cursorCliPath: "agent",
274
+ cursorCliPath: "cursor",
271
275
  codexCliPath: "codex",
272
276
  claudeWorkDir: process.cwd(),
273
277
  claudeSkipPermissions: true,
274
278
  defaultPermissionMode: "ask",
275
279
  claudeTimeoutMs: 600000,
276
280
  codexTimeoutMs: 600000,
281
+ cursorTimeoutMs: 600000,
277
282
  codebuddyTimeoutMs: 600000,
278
283
  hookPort: 35801,
279
284
  logDir: "",
@@ -419,9 +424,11 @@ function toFileConfig(payload, existing) {
419
424
  },
420
425
  cursor: {
421
426
  ...existing.tools?.cursor,
422
- cliPath: clean(payload.ai.cursorCliPath) ?? "agent",
427
+ cliPath: clean(payload.ai.cursorCliPath) ?? "cursor",
423
428
  skipPermissions: existing.tools?.cursor?.skipPermissions ?? payload.ai.claudeSkipPermissions,
424
429
  proxy: clean(payload.ai.cursorProxy),
430
+ timeoutMs: payload.ai.cursorTimeoutMs,
431
+ model: clean(payload.ai.cursorModel),
425
432
  },
426
433
  codex: {
427
434
  ...existing.tools?.codex,
@@ -55,3 +55,33 @@ describe("getHealthPlatformSnapshot", () => {
55
55
  expect(snapshot.qq.message).toContain("configured");
56
56
  });
57
57
  });
58
+ describe("Cursor web config defaults", () => {
59
+ it("surfaces cursor as the default CLI path in initial config", async () => {
60
+ vi.resetModules();
61
+ const { startWebConfigServer } = await import("./config-web.js");
62
+ const server = await startWebConfigServer({ mode: "init", cwd: process.cwd(), persistent: true });
63
+ if (!server.url) {
64
+ await server.close();
65
+ throw new Error("Web config server failed to bind (url empty)");
66
+ }
67
+ // 使用原生 fetch(可能被 testPlatformConfig 的 vi.fn 覆盖),改用 http.get
68
+ const { get } = await import("node:http");
69
+ const body = await new Promise((resolve, reject) => {
70
+ get(`${server.url}/api/config`, (res) => {
71
+ let data = "";
72
+ res.on("data", (chunk) => { data += chunk; });
73
+ res.on("end", () => {
74
+ try {
75
+ resolve(JSON.parse(data));
76
+ }
77
+ catch (e) {
78
+ reject(e);
79
+ }
80
+ });
81
+ }).on("error", reject);
82
+ });
83
+ await server.close();
84
+ // 无显式配置时为 cursor;Windows 下可能解析为安装路径
85
+ expect(body?.payload?.ai?.cursorCliPath === "cursor" || body?.payload?.ai?.cursorCliPath?.endsWith("cursor.cmd")).toBe(true);
86
+ });
87
+ });
package/dist/config.d.ts CHANGED
@@ -36,13 +36,18 @@ export interface Config {
36
36
  codebuddyCliPath: string;
37
37
  /** Codex 访问 chatgpt.com 的代理(如 http://127.0.0.1:7890) */
38
38
  codexProxy?: string;
39
- claudeWorkDir: string;
40
- claudeSkipPermissions: boolean;
41
- defaultPermissionMode: 'ask' | 'accept-edits' | 'plan' | 'yolo';
39
+ /** Cursor 访问 API 的代理(如 http://127.0.0.1:7890,CLI 非官方支持) */
40
+ cursorProxy?: string;
42
41
  claudeTimeoutMs: number;
43
42
  codexTimeoutMs: number;
43
+ cursorTimeoutMs: number;
44
44
  codebuddyTimeoutMs: number;
45
+ claudeWorkDir: string;
46
+ claudeSkipPermissions: boolean;
47
+ defaultPermissionMode: 'ask' | 'accept-edits' | 'plan' | 'yolo';
45
48
  claudeModel?: string;
49
+ /** Cursor 专用模型,如 auto(自动选择)、Claude 4 Sonnet 等 */
50
+ cursorModel?: string;
46
51
  hookPort: number;
47
52
  logDir: string;
48
53
  logLevel: LogLevel;
@@ -151,7 +156,11 @@ export interface FileToolCursor {
151
156
  cliPath?: string;
152
157
  /** 是否跳过权限确认(默认 true,与 tools.claude 共用权限服务器) */
153
158
  skipPermissions?: boolean;
159
+ /** HTTP/HTTPS 代理(CLI 非官方支持,部分环境可能生效) */
154
160
  proxy?: string;
161
+ timeoutMs?: number;
162
+ /** 模型名,如 auto、Claude 4 Sonnet、gpt-5.2 等,见 agent --list-models */
163
+ model?: string;
155
164
  }
156
165
  export interface FileToolCodex {
157
166
  cliPath?: string;
package/dist/config.js CHANGED
@@ -6,7 +6,7 @@ catch {
6
6
  }
7
7
  import { readFileSync, writeFileSync, accessSync, constants, existsSync, mkdirSync } from 'node:fs';
8
8
  import { execFileSync } from 'node:child_process';
9
- import { join, dirname, isAbsolute } from 'node:path';
9
+ import { join, dirname, isAbsolute, basename } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { APP_HOME } from './constants.js';
12
12
  const AI_COMMANDS = ['claude', 'codex', 'cursor', 'codebuddy'];
@@ -60,8 +60,11 @@ function migrateToNewConfigFormat(raw) {
60
60
  },
61
61
  cursor: {
62
62
  ...tcur,
63
- cliPath: tcur.cliPath ?? raw.cursorCliPath ?? 'agent',
63
+ cliPath: tcur.cliPath ?? raw.cursorCliPath ?? 'cursor',
64
64
  skipPermissions: tcur.skipPermissions ?? raw.claudeSkipPermissions ?? true,
65
+ proxy: tcur.proxy,
66
+ timeoutMs: tcur.timeoutMs ?? raw.claudeTimeoutMs ?? 600000,
67
+ model: tcur.model ?? raw.cursorModel ?? 'auto',
65
68
  },
66
69
  codex: {
67
70
  ...tcod,
@@ -380,6 +383,7 @@ export function loadConfig() {
380
383
  const tcb = file.tools?.codebuddy ?? {};
381
384
  const claudeCliPath = process.env.CLAUDE_CLI_PATH ?? tc.cliPath ?? 'claude';
382
385
  const codexProxy = process.env.CODEX_PROXY ?? tcod.proxy;
386
+ const cursorProxy = process.env.CURSOR_PROXY ?? tcur.proxy;
383
387
  let codexCliPath = process.env.CODEX_CLI_PATH ?? tcod.cliPath ?? 'codex';
384
388
  if (process.platform === 'win32' && codexCliPath === 'codex') {
385
389
  const npmPaths = [
@@ -397,15 +401,68 @@ export function loadConfig() {
397
401
  }
398
402
  }
399
403
  }
400
- let cursorCliPath = process.env.CURSOR_CLI_PATH ?? tcur.cliPath ?? 'agent';
401
- if (process.platform === 'win32' && cursorCliPath === 'agent') {
402
- const winAgentPath = join(process.env.LOCALAPPDATA || '', 'cursor-agent', 'agent.cmd');
403
- try {
404
- accessSync(winAgentPath, constants.F_OK);
405
- cursorCliPath = winAgentPath;
404
+ let cursorCliPath = process.env.CURSOR_CLI_PATH ?? tcur.cliPath ?? 'cursor';
405
+ const agentPaths = [
406
+ join(process.env.APPDATA || '', 'npm', 'agent.cmd'),
407
+ join(process.env.LOCALAPPDATA || '', 'npm', 'agent.cmd'),
408
+ join(process.env.LOCALAPPDATA || '', 'cursor-agent', 'agent.cmd'),
409
+ join(process.env.LOCALAPPDATA || '', 'Programs', 'cursor-agent', 'agent.cmd'),
410
+ join(process.env.USERPROFILE || '', '.cursor', 'bin', 'agent.cmd'),
411
+ ];
412
+ if (process.platform === 'win32') {
413
+ // agent 需解析为完整路径,否则 spawn 报 ENOENT(Node 子进程 PATH 可能不包含 npm)
414
+ if (cursorCliPath === 'agent' || basename(cursorCliPath).toLowerCase() === 'agent') {
415
+ for (const p of agentPaths) {
416
+ try {
417
+ accessSync(p, constants.F_OK);
418
+ cursorCliPath = p;
419
+ break;
420
+ }
421
+ catch {
422
+ /* 尝试下一个路径 */
423
+ }
424
+ }
425
+ // 若已知路径均未找到,尝试 where agent 解析(用户 PATH 中的 agent)
426
+ if (cursorCliPath === 'agent') {
427
+ try {
428
+ const out = execFileSync('where', ['agent'], { encoding: 'utf-8', windowsHide: true });
429
+ const first = out.split(/\r?\n/)[0]?.trim();
430
+ if (first && existsSync(first))
431
+ cursorCliPath = first;
432
+ }
433
+ catch {
434
+ /* where 失败则保持 agent,后续校验会提示安装 */
435
+ }
436
+ }
406
437
  }
407
- catch {
408
- /* 使用默认 agent */
438
+ if (cursorCliPath === 'cursor') {
439
+ for (const p of agentPaths) {
440
+ try {
441
+ accessSync(p, constants.F_OK);
442
+ cursorCliPath = p;
443
+ break;
444
+ }
445
+ catch {
446
+ /* 尝试下一个路径 */
447
+ }
448
+ }
449
+ }
450
+ if (cursorCliPath === 'cursor') {
451
+ const cursorIdePaths = [
452
+ join(process.env.APPDATA || '', 'npm', 'cursor.cmd'),
453
+ join(process.env.LOCALAPPDATA || '', 'npm', 'cursor.cmd'),
454
+ join(process.env.ProgramFiles || 'C:\\Program Files', 'cursor', 'resources', 'app', 'bin', 'cursor.cmd'),
455
+ ];
456
+ for (const p of cursorIdePaths) {
457
+ try {
458
+ accessSync(p, constants.F_OK);
459
+ cursorCliPath = p;
460
+ break;
461
+ }
462
+ catch {
463
+ /* 尝试下一个路径 */
464
+ }
465
+ }
409
466
  }
410
467
  }
411
468
  let codebuddyCliPath = process.env.CODEBUDDY_CLI_PATH ?? tcb.cliPath ?? 'codebuddy';
@@ -449,6 +506,9 @@ export function loadConfig() {
449
506
  const codexTimeoutMs = process.env.CODEX_TIMEOUT_MS !== undefined
450
507
  ? parseInt(process.env.CODEX_TIMEOUT_MS, 10) || 600000
451
508
  : tcod.timeoutMs ?? 600000;
509
+ const cursorTimeoutMs = process.env.CURSOR_TIMEOUT_MS !== undefined
510
+ ? parseInt(process.env.CURSOR_TIMEOUT_MS, 10) || 600000
511
+ : tcur.timeoutMs ?? 600000;
452
512
  const codebuddyTimeoutMs = process.env.CODEBUDDY_TIMEOUT_MS !== undefined
453
513
  ? parseInt(process.env.CODEBUDDY_TIMEOUT_MS, 10) || 600000
454
514
  : tcb.timeoutMs ?? 600000;
@@ -590,24 +650,23 @@ export function loadConfig() {
590
650
  catch {
591
651
  const installGuide = [
592
652
  '',
593
- '━━━ Cursor CLI 未安装 ━━━',
653
+ '━━━ Cursor Agent CLI 未安装 ━━━',
594
654
  '',
595
- '使用 Cursor 需要先安装 Cursor Agent CLI。',
655
+ 'open-im 需要独立的 Cursor Agent CLI(agent 命令),不是 Cursor IDE 自带的 cursor.cmd。',
596
656
  '',
597
- '安装方法:',
657
+ '安装方法(在 PowerShell 中执行):',
598
658
  '',
599
- ' macOS/Linux: curl https://cursor.com/install -fsSL | bash',
600
- ' Windows: irm \'https://cursor.com/install?win32=true\' | iex',
659
+ ' irm \'https://cursor.com/install?win32=true\' | iex',
601
660
  '',
602
- '安装后运行 agent --version 验证。',
661
+ '安装后运行 agent -p "hello" 验证。',
603
662
  '',
604
663
  ].join('\n');
605
664
  throw new Error(installGuide);
606
665
  }
607
666
  }
608
- // 提示 Cursor 认证:需 agent login 或 CURSOR_API_KEY
667
+ // 提示 Cursor 认证:需 cursor agent login 或 CURSOR_API_KEY
609
668
  if (!process.env.CURSOR_API_KEY) {
610
- console.warn('\n⚠ Cursor 模式:未检测到 CURSOR_API_KEY。首次使用请先运行 agent login,\n' +
669
+ console.warn('\n⚠ Cursor 模式:未检测到 CURSOR_API_KEY。首次使用请先运行 cursor agent login,\n' +
611
670
  ' 或在 ~/.open-im/config.json 的 env 中添加 "CURSOR_API_KEY": "你的 API Key"。\n');
612
671
  }
613
672
  }
@@ -775,13 +834,16 @@ export function loadConfig() {
775
834
  codexCliPath,
776
835
  codebuddyCliPath,
777
836
  codexProxy,
837
+ cursorProxy,
778
838
  claudeWorkDir,
779
839
  claudeSkipPermissions,
780
840
  defaultPermissionMode,
781
841
  claudeTimeoutMs,
782
842
  codexTimeoutMs,
843
+ cursorTimeoutMs,
783
844
  codebuddyTimeoutMs,
784
845
  claudeModel: process.env.CLAUDE_MODEL ?? tc.model,
846
+ cursorModel: process.env.CURSOR_MODEL ?? tcur.model ?? 'auto',
785
847
  hookPort,
786
848
  logDir,
787
849
  logLevel,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const { accessSyncMock, existsSyncMock, mkdirSyncMock, readFileSyncMock, writeFileSyncMock, execFileSyncMock, } = vi.hoisted(() => ({
3
+ accessSyncMock: vi.fn(),
4
+ existsSyncMock: vi.fn(),
5
+ mkdirSyncMock: vi.fn(),
6
+ readFileSyncMock: vi.fn(),
7
+ writeFileSyncMock: vi.fn(),
8
+ execFileSyncMock: vi.fn(),
9
+ }));
10
+ vi.mock('node:fs', async () => {
11
+ const actual = await vi.importActual('node:fs');
12
+ return {
13
+ ...actual,
14
+ accessSync: accessSyncMock,
15
+ existsSync: existsSyncMock,
16
+ mkdirSync: mkdirSyncMock,
17
+ readFileSync: readFileSyncMock,
18
+ writeFileSync: writeFileSyncMock,
19
+ };
20
+ });
21
+ vi.mock('node:child_process', () => ({
22
+ execFileSync: execFileSyncMock,
23
+ }));
24
+ import { loadConfig, loadFileConfig } from './config.js';
25
+ describe('Cursor config defaults', () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ readFileSyncMock.mockImplementation(() => {
29
+ throw new Error('missing');
30
+ });
31
+ existsSyncMock.mockReturnValue(true);
32
+ accessSyncMock.mockImplementation(() => undefined);
33
+ execFileSyncMock.mockImplementation(() => Buffer.from('cursor'));
34
+ delete process.env.CURSOR_CLI_PATH;
35
+ delete process.env.AI_COMMAND;
36
+ });
37
+ it('defaults Cursor CLI path to cursor for runtime config', () => {
38
+ process.env.AI_COMMAND = 'cursor';
39
+ process.env.TELEGRAM_BOT_TOKEN = 'test-token';
40
+ const config = loadConfig();
41
+ // 无显式配置时使用 cursor;Windows 下可能解析为 npm 或安装路径
42
+ expect(config.cursorCliPath === 'cursor' || config.cursorCliPath.endsWith('cursor.cmd')).toBe(true);
43
+ });
44
+ it('migrates old cursorCliPath-less configs to cursor', () => {
45
+ readFileSyncMock.mockReturnValue(JSON.stringify({
46
+ aiCommand: 'cursor',
47
+ claudeCliPath: 'claude',
48
+ claudeWorkDir: 'D:/coding/open-im',
49
+ claudeSkipPermissions: true,
50
+ }));
51
+ const file = loadFileConfig();
52
+ expect(file.tools?.cursor?.cliPath).toBe('cursor');
53
+ expect(writeFileSyncMock).toHaveBeenCalled();
54
+ });
55
+ it('preserves explicit legacy agent cursor configs', () => {
56
+ readFileSyncMock.mockReturnValue(JSON.stringify({
57
+ aiCommand: 'cursor',
58
+ platforms: { telegram: { enabled: true } },
59
+ telegramBotToken: 'test-token',
60
+ tools: {
61
+ cursor: {
62
+ cliPath: 'agent',
63
+ skipPermissions: true,
64
+ },
65
+ },
66
+ }));
67
+ const config = loadConfig();
68
+ expect(config.cursorCliPath).toBe('agent');
69
+ });
70
+ });
@@ -6,6 +6,7 @@ export interface CursorRunCallbacks {
6
6
  onText: (accumulated: string) => void;
7
7
  onThinking?: (accumulated: string) => void;
8
8
  onToolUse?: (toolName: string, toolInput?: Record<string, unknown>) => void;
9
+ onSessionInvalid?: () => void;
9
10
  onComplete: (result: {
10
11
  success: boolean;
11
12
  result: string;
@@ -26,6 +27,8 @@ export interface CursorRunOptions {
26
27
  model?: string;
27
28
  chatId?: string;
28
29
  hookPort?: number;
30
+ /** HTTP/HTTPS 代理(CLI 非官方支持,部分环境可能生效) */
31
+ proxy?: string;
29
32
  }
30
33
  export interface CursorRunHandle {
31
34
  abort: () => void;
@@ -3,6 +3,7 @@
3
3
  * 参考: https://cursor.com/docs/cli/reference/output-format
4
4
  */
5
5
  import { spawn } from 'node:child_process';
6
+ import { basename } from 'node:path';
6
7
  import { createInterface } from 'node:readline';
7
8
  import { createLogger } from '../logger.js';
8
9
  const log = createLogger('CursorCli');
@@ -49,8 +50,16 @@ function extractToolFromCursorEvent(event) {
49
50
  const args = val.args;
50
51
  return { name, input: args };
51
52
  }
53
+ function shouldPrependAgentSubcommand(cliPath) {
54
+ const command = basename(cliPath).toLowerCase();
55
+ return command === 'cursor' || command === 'cursor.cmd' || command === 'cursor.exe';
56
+ }
52
57
  export function runCursor(cliPath, prompt, sessionId, workDir, callbacks, options) {
53
- const args = ['-p', '--output-format', 'stream-json', '--stream-partial-output',
58
+ // 使用 json 格式:输出单行 JSON,stream-json 在 Windows 管道下可能无输出(lines=0)
59
+ const args = [
60
+ ...(shouldPrependAgentSubcommand(cliPath) ? ['agent'] : []),
61
+ '-p', '--output-format', 'json',
62
+ '--trust', // headless 模式必须,否则会卡在 "Workspace Trust Required" 提示
54
63
  '--sandbox', 'disabled', // 禁用 sandbox,避免 Windows 下 shell 命令极慢或卡死
55
64
  ];
56
65
  // Cursor CLI 运行于 stream-json 非交互模式,stdin 设为 ignore。
@@ -78,6 +87,14 @@ export function runCursor(cliPath, prompt, sessionId, workDir, callbacks, option
78
87
  env.CC_IM_CHAT_ID = options.chatId;
79
88
  if (options?.hookPort)
80
89
  env.CC_IM_HOOK_PORT = String(options.hookPort);
90
+ if (options?.proxy) {
91
+ env.HTTPS_PROXY = options.proxy;
92
+ env.HTTP_PROXY = options.proxy;
93
+ env.https_proxy = options.proxy;
94
+ env.http_proxy = options.proxy;
95
+ env.ALL_PROXY = options.proxy;
96
+ env.all_proxy = options.proxy;
97
+ }
81
98
  const argsForLog = args.filter(a => a !== prompt).join(' ');
82
99
  log.info(`Spawning Cursor CLI: path=${cliPath}, cwd=${workDir}, session=${sessionId ?? 'new'}, args=${argsForLog}`);
83
100
  // Windows: .cmd 需通过 cmd.exe 执行,否则 spawn 报 ENOENT
@@ -94,6 +111,8 @@ export function runCursor(cliPath, prompt, sessionId, workDir, callbacks, option
94
111
  let completed = false;
95
112
  let model = '';
96
113
  const toolStats = {};
114
+ let lineCount = 0;
115
+ let stdoutBytes = 0;
97
116
  const MAX_TIMEOUT = 2_147_483_647;
98
117
  const timeoutMs = options?.timeoutMs && options.timeoutMs > 0
99
118
  ? Math.min(options.timeoutMs, MAX_TIMEOUT)
@@ -133,25 +152,31 @@ export function runCursor(cliPath, prompt, sessionId, workDir, callbacks, option
133
152
  // 实时打印 stderr,方便诊断 Cursor CLI 问题
134
153
  log.debug(`[stderr] ${text.trimEnd()}`);
135
154
  });
155
+ child.stdout?.on('data', (chunk) => {
156
+ stdoutBytes += chunk.length;
157
+ });
136
158
  const rl = createInterface({ input: child.stdout });
137
159
  rl.on('line', (line) => {
138
160
  const trimmed = line.trim();
139
161
  if (!trimmed)
140
162
  return;
163
+ lineCount++;
141
164
  let event;
142
165
  try {
143
166
  event = JSON.parse(trimmed);
144
167
  }
145
168
  catch {
169
+ log.warn(`[Cursor] Failed to parse line ${lineCount}: ${trimmed.slice(0, 80)}...`);
146
170
  return;
147
171
  }
148
- log.debug(`[Cursor event] type=${event.type} subtype=${event.subtype}`);
149
172
  const type = event.type;
173
+ log.debug(`[Cursor event] type=${type} subtype=${event.subtype}`);
150
174
  if (type === 'system' && event.subtype === 'init') {
151
175
  model = event.model ?? '';
152
176
  const sid = event.session_id;
153
177
  if (sid)
154
178
  callbacks.onSessionId?.(sid);
179
+ log.info(`[Cursor] Session init: model=${model}, sessionId=${sid ?? 'none'}`);
155
180
  return;
156
181
  }
157
182
  if (type === 'assistant') {
@@ -165,6 +190,15 @@ export function runCursor(cliPath, prompt, sessionId, workDir, callbacks, option
165
190
  }
166
191
  }
167
192
  }
193
+ // 兼容 content 为字符串或结构不同的情况
194
+ const rawContent = msg?.content;
195
+ if (!Array.isArray(content) && typeof rawContent === 'string') {
196
+ accumulated += rawContent;
197
+ callbacks.onText(accumulated);
198
+ }
199
+ if (accumulated.length > 0) {
200
+ log.info(`[Cursor] Assistant text received: ${accumulated.length} chars total`);
201
+ }
168
202
  return;
169
203
  }
170
204
  if (type === 'tool_call') {
@@ -190,8 +224,12 @@ export function runCursor(cliPath, prompt, sessionId, workDir, callbacks, option
190
224
  if (timeoutHandle)
191
225
  clearTimeout(timeoutHandle);
192
226
  const result = event.result ?? '';
227
+ const sid = event.session_id;
228
+ if (sid)
229
+ callbacks.onSessionId?.(sid);
193
230
  if (!accumulated && result)
194
231
  accumulated = result;
232
+ log.info(`[Cursor] Result event: resultLen=${result.length}, accumulatedLen=${accumulated.length}, lines=${lineCount}`);
195
233
  callbacks.onComplete({
196
234
  success: true,
197
235
  result,
@@ -229,9 +267,20 @@ export function runCursor(cliPath, prompt, sessionId, workDir, callbacks, option
229
267
  stderrTail;
230
268
  }
231
269
  }
232
- callbacks.onError(errMsg || `Cursor CLI exited with code ${exitCode}`);
270
+ const fullErr = errMsg || `Cursor CLI exited with code ${exitCode}`;
271
+ const isSessionErr = /no session found|no conversation found|unable to find session|session not found|invalid session/i.test(fullErr);
272
+ if (isSessionErr)
273
+ callbacks.onSessionInvalid?.();
274
+ callbacks.onError(fullErr);
233
275
  }
234
276
  else {
277
+ const stderrPreview = stderrTotal > 0 ? stderrHead.slice(0, 500) : '(无)';
278
+ log.warn(`[Cursor] Process exited 0 without result event: accumulated=${accumulated.length} chars, lines=${lineCount}, stdoutBytes=${stdoutBytes}. stderr(${stderrTotal} chars): ${stderrPreview}`);
279
+ // 检测到 Cursor IDE(非 Agent CLI)时给出明确指引
280
+ if (stderrHead.includes('Electron') || stderrHead.includes('Chromium') || stderrHead.includes('not in the list of known options')) {
281
+ callbacks.onError('当前使用的是 Cursor IDE 的 cursor.cmd,不支持 CLI 模式。请安装独立的 Cursor Agent CLI:在 PowerShell 运行 irm \'https://cursor.com/install?win32=true\' | iex,安装后在配置中把 CLI Path 改为 agent。');
282
+ return;
283
+ }
235
284
  callbacks.onComplete({
236
285
  success: true,
237
286
  result: accumulated,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { PassThrough } from 'node:stream';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ const { spawnMock } = vi.hoisted(() => ({
5
+ spawnMock: vi.fn(),
6
+ }));
7
+ vi.mock('node:child_process', () => ({
8
+ spawn: spawnMock,
9
+ }));
10
+ import { runCursor } from './cli-runner.js';
11
+ class MockChildProcess extends EventEmitter {
12
+ stdout = new PassThrough();
13
+ stderr = new PassThrough();
14
+ pid = 1234;
15
+ killed = false;
16
+ kill() {
17
+ this.killed = true;
18
+ }
19
+ }
20
+ describe('runCursor', () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ spawnMock.mockImplementation(() => new MockChildProcess());
24
+ });
25
+ it('prepends the agent subcommand for the top-level cursor CLI', () => {
26
+ runCursor('cursor', 'hello', undefined, 'D:\\coding\\open-im', {
27
+ onText: vi.fn(),
28
+ onComplete: vi.fn(),
29
+ onError: vi.fn(),
30
+ });
31
+ expect(spawnMock).toHaveBeenCalledWith('cursor', [
32
+ 'agent',
33
+ '-p',
34
+ '--output-format',
35
+ 'json',
36
+ '--trust',
37
+ '--sandbox',
38
+ 'disabled',
39
+ '--force',
40
+ '--workspace',
41
+ 'D:\\coding\\open-im',
42
+ '--',
43
+ 'hello',
44
+ ], expect.objectContaining({ cwd: 'D:\\coding\\open-im' }));
45
+ });
46
+ it('uses cmd.exe for cursor.cmd and still prepends the agent subcommand', () => {
47
+ vi.stubGlobal('process', {
48
+ ...process,
49
+ platform: 'win32',
50
+ env: process.env,
51
+ });
52
+ runCursor('D:\\Program Files\\cursor\\resources\\app\\bin\\cursor.cmd', 'hello', undefined, 'D:\\coding\\open-im', {
53
+ onText: vi.fn(),
54
+ onComplete: vi.fn(),
55
+ onError: vi.fn(),
56
+ });
57
+ expect(spawnMock).toHaveBeenCalledWith('cmd.exe', [
58
+ '/c',
59
+ 'D:\\Program Files\\cursor\\resources\\app\\bin\\cursor.cmd',
60
+ 'agent',
61
+ '-p',
62
+ '--output-format',
63
+ 'json',
64
+ '--trust',
65
+ '--sandbox',
66
+ 'disabled',
67
+ '--force',
68
+ '--workspace',
69
+ 'D:\\coding\\open-im',
70
+ '--',
71
+ 'hello',
72
+ ], expect.objectContaining({ cwd: 'D:\\coding\\open-im', windowsHide: true }));
73
+ });
74
+ it('does not duplicate the agent subcommand for legacy agent executables', () => {
75
+ runCursor('agent', 'hello', undefined, 'D:\\coding\\open-im', {
76
+ onText: vi.fn(),
77
+ onComplete: vi.fn(),
78
+ onError: vi.fn(),
79
+ });
80
+ expect(spawnMock).toHaveBeenCalledWith('agent', [
81
+ '-p',
82
+ '--output-format',
83
+ 'json',
84
+ '--trust',
85
+ '--sandbox',
86
+ 'disabled',
87
+ '--force',
88
+ '--workspace',
89
+ 'D:\\coding\\open-im',
90
+ '--',
91
+ 'hello',
92
+ ], expect.any(Object));
93
+ });
94
+ });
package/dist/setup.js CHANGED
@@ -80,7 +80,7 @@ function printManualInstructions(configPath) {
80
80
  "skipPermissions": true,
81
81
  "timeoutMs": 600000
82
82
  },
83
- "cursor": { "cliPath": "agent", "skipPermissions": true },
83
+ "cursor": { "cliPath": "cursor", "skipPermissions": true, "model": "auto" },
84
84
  "codex": { "cliPath": "codex", "workDir": "${process.cwd().replace(/\\/g, "/")}", "skipPermissions": true, "proxy": "http://127.0.0.1:7890" },
85
85
  "codebuddy": { "cliPath": "codebuddy", "skipPermissions": true, "timeoutMs": 600000 }
86
86
  },
@@ -797,7 +797,7 @@ export async function runInteractiveSetup() {
797
797
  },
798
798
  cursor: {
799
799
  ...baseTools.cursor,
800
- cliPath: baseTools.cursor?.cliPath ?? "agent",
800
+ cliPath: baseTools.cursor?.cliPath ?? "cursor",
801
801
  skipPermissions: baseTools.cursor?.skipPermissions ?? baseTools.claude?.skipPermissions ?? true,
802
802
  },
803
803
  codex: {
@@ -100,23 +100,26 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
100
100
  : undefined)
101
101
  : undefined;
102
102
  }
103
- const timeoutMs = config.aiCommand === 'codex'
103
+ const toolId = toolAdapter.toolId;
104
+ const timeoutMs = toolId === 'codex'
104
105
  ? config.codexTimeoutMs
105
- : config.aiCommand === 'codebuddy'
106
- ? config.codebuddyTimeoutMs
107
- : config.claudeTimeoutMs;
106
+ : toolId === 'cursor'
107
+ ? config.cursorTimeoutMs
108
+ : toolId === 'codebuddy'
109
+ ? config.codebuddyTimeoutMs
110
+ : config.claudeTimeoutMs;
108
111
  const startRun = () => {
109
112
  activeHandle = toolAdapter.run(prompt, currentSessionId, ctx.workDir, {
110
113
  onSessionId: (id) => {
111
114
  currentSessionId = id;
112
115
  if (ctx.threadId)
113
- sessionManager.setSessionIdForThread(ctx.userId, ctx.threadId, config.aiCommand, id);
116
+ sessionManager.setSessionIdForThread(ctx.userId, ctx.threadId, toolId, id);
114
117
  else if (ctx.convId)
115
- sessionManager.setSessionIdForConv(ctx.userId, ctx.convId, config.aiCommand, id);
118
+ sessionManager.setSessionIdForConv(ctx.userId, ctx.convId, toolId, id);
116
119
  },
117
120
  onSessionInvalid: () => {
118
121
  if (ctx.convId)
119
- sessionManager.clearSessionForConv(ctx.userId, ctx.convId, config.aiCommand);
122
+ sessionManager.clearSessionForConv(ctx.userId, ctx.convId, toolId);
120
123
  },
121
124
  onThinking: (t) => {
122
125
  if (!firstContentLogged) {
@@ -125,7 +128,7 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
125
128
  }
126
129
  wasThinking = true;
127
130
  thinkingText = t;
128
- throttledUpdate(`💭 **${getAIToolDisplayName(config.aiCommand)} 思考中...**\n\n${t}`);
131
+ throttledUpdate(`💭 **${getAIToolDisplayName(toolId)} 思考中...**\n\n${t}`);
129
132
  },
130
133
  onText: (accumulated) => {
131
134
  if (!firstContentLogged) {
@@ -190,14 +193,14 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
190
193
  }
191
194
  settled = true;
192
195
  log.error(`Task error for user ${ctx.userId}: ${error}`);
193
- if (config.aiCommand !== 'claude' && !isUsageLimitError(error)) {
196
+ if (toolId !== 'claude' && !isUsageLimitError(error)) {
194
197
  if (ctx.convId)
195
- sessionManager.clearSessionForConv(ctx.userId, ctx.convId, config.aiCommand);
198
+ sessionManager.clearSessionForConv(ctx.userId, ctx.convId, toolId);
196
199
  else
197
- sessionManager.clearActiveToolSession(ctx.userId, config.aiCommand);
198
- log.info(`Session reset for user ${ctx.userId} due to ${config.aiCommand} task error`);
200
+ sessionManager.clearActiveToolSession(ctx.userId, toolId);
201
+ log.info(`Session reset for user ${ctx.userId} due to ${toolId} task error`);
199
202
  }
200
- else if (config.aiCommand === 'codex' && isUsageLimitError(error)) {
203
+ else if (toolId === 'codex' && isUsageLimitError(error)) {
201
204
  log.info(`Keeping codex session for user ${ctx.userId} after usage limit error`);
202
205
  }
203
206
  try {
@@ -213,10 +216,12 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
213
216
  skipPermissions,
214
217
  permissionMode,
215
218
  timeoutMs,
216
- model: sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel,
219
+ model: sessionManager.getModel(ctx.userId, ctx.threadId)
220
+ ?? (toolId === 'cursor' ? config.cursorModel : config.claudeModel),
217
221
  chatId: ctx.chatId,
218
- ...(config.useSdkMode ? {} : { hookPort: config.hookPort }),
219
- ...(config.aiCommand === 'codex' && config.codexProxy ? { proxy: config.codexProxy } : {}),
222
+ ...(toolId === 'claude' && config.useSdkMode ? {} : { hookPort: config.hookPort }),
223
+ ...(toolId === 'codex' && config.codexProxy ? { proxy: config.codexProxy } : {}),
224
+ ...(toolId === 'cursor' && config.cursorProxy ? { proxy: config.cursorProxy } : {}),
220
225
  });
221
226
  return activeHandle;
222
227
  };
@@ -231,7 +236,7 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
231
236
  latestContent: '',
232
237
  settle,
233
238
  startedAt: Date.now(),
234
- toolId: config.aiCommand,
239
+ toolId,
235
240
  };
236
241
  startRun();
237
242
  platformAdapter.onTaskReady(taskState);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, Cursor, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",