@swarmclawai/swarmclaw 1.5.3 → 1.5.33

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.
Files changed (78) hide show
  1. package/README.md +167 -147
  2. package/bin/swarmclaw.js +35 -1
  3. package/package.json +2 -2
  4. package/scripts/run-next-build.mjs +87 -2
  5. package/skills/swarmclaw/SKILL.md +151 -0
  6. package/src/app/api/agents/[id]/route.ts +8 -0
  7. package/src/app/api/agents/agents-route.test.ts +114 -0
  8. package/src/app/api/agents/route.ts +10 -0
  9. package/src/app/api/connectors/route.ts +25 -13
  10. package/src/app/api/credentials/[id]/route.ts +8 -1
  11. package/src/app/api/schedules/[id]/route.ts +8 -0
  12. package/src/app/api/secrets/[id]/route.ts +10 -0
  13. package/src/app/api/setup/check-provider/route.ts +45 -0
  14. package/src/app/api/setup/doctor/route.ts +5 -0
  15. package/src/cli/binary.test.js +11 -0
  16. package/src/cli/index.js +4 -4
  17. package/src/cli/index.test.js +5 -2
  18. package/src/cli/index.ts +1 -1
  19. package/src/components/agents/agent-sheet.tsx +16 -4
  20. package/src/components/agents/inspector-panel.tsx +5 -0
  21. package/src/components/auth/setup-wizard/step-agents.tsx +19 -1
  22. package/src/components/chat/activity-moment.tsx +3 -0
  23. package/src/components/chat/chat-header.tsx +23 -2
  24. package/src/components/chat/tool-call-bubble.tsx +20 -0
  25. package/src/components/layout/sidebar-rail.tsx +29 -0
  26. package/src/hooks/setup-done-detection.test.ts +4 -2
  27. package/src/hooks/setup-done-detection.ts +1 -1
  28. package/src/lib/orchestrator-config.ts +5 -0
  29. package/src/lib/provider-sets.ts +4 -4
  30. package/src/lib/providers/acp-client.ts +116 -0
  31. package/src/lib/providers/cli-utils.test.ts +9 -1
  32. package/src/lib/providers/cli-utils.ts +89 -4
  33. package/src/lib/providers/cursor-cli.ts +172 -0
  34. package/src/lib/providers/goose.ts +149 -0
  35. package/src/lib/providers/index.ts +29 -1
  36. package/src/lib/providers/qwen-code-cli.ts +152 -0
  37. package/src/lib/server/agents/agent-availability.ts +2 -2
  38. package/src/lib/server/agents/agent-thread-session.ts +8 -0
  39. package/src/lib/server/agents/task-session.ts +8 -0
  40. package/src/lib/server/capability-router.ts +8 -2
  41. package/src/lib/server/chat-execution/chat-execution-utils.ts +13 -0
  42. package/src/lib/server/chat-execution/chat-turn-finalization.ts +8 -0
  43. package/src/lib/server/chat-execution/chat-turn-preparation.ts +5 -1
  44. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +3 -0
  45. package/src/lib/server/chat-execution/iteration-timers.test.ts +84 -0
  46. package/src/lib/server/chat-execution/iteration-timers.ts +18 -1
  47. package/src/lib/server/chat-execution/prompt-sections.ts +3 -1
  48. package/src/lib/server/chat-execution/stream-agent-chat.ts +5 -0
  49. package/src/lib/server/chatrooms/chatroom-helpers.ts +13 -0
  50. package/src/lib/server/chats/chat-session-service.ts +18 -0
  51. package/src/lib/server/connectors/session.ts +8 -0
  52. package/src/lib/server/context-manager.ts +5 -0
  53. package/src/lib/server/provider-health.ts +13 -3
  54. package/src/lib/server/provider-model-discovery.test.ts +8 -0
  55. package/src/lib/server/provider-model-discovery.ts +1 -1
  56. package/src/lib/server/runtime/daemon-state/core.ts +2 -2
  57. package/src/lib/server/runtime/queue/core.ts +30 -4
  58. package/src/lib/server/runtime/session-run-manager/enqueue.ts +1 -1
  59. package/src/lib/server/session-reset-policy.test.ts +16 -0
  60. package/src/lib/server/session-reset-policy.ts +9 -1
  61. package/src/lib/server/session-tools/context.ts +2 -2
  62. package/src/lib/server/session-tools/delegate.ts +334 -14
  63. package/src/lib/server/session-tools/index.ts +5 -2
  64. package/src/lib/server/session-tools/session-info.ts +4 -1
  65. package/src/lib/server/storage-auth-docker.test.ts +244 -0
  66. package/src/lib/server/storage-auth.test.ts +150 -0
  67. package/src/lib/server/storage-auth.ts +57 -22
  68. package/src/lib/server/storage-normalization.ts +19 -0
  69. package/src/lib/server/storage.ts +3 -0
  70. package/src/lib/server/tasks/task-resume.ts +23 -1
  71. package/src/lib/server/tool-aliases.ts +1 -1
  72. package/src/lib/server/tool-capability-policy.ts +4 -1
  73. package/src/lib/setup-defaults.test.ts +6 -0
  74. package/src/lib/setup-defaults.ts +146 -0
  75. package/src/lib/tool-definitions.ts +1 -1
  76. package/src/types/misc.ts +4 -1
  77. package/src/types/provider.ts +1 -1
  78. package/src/types/session.ts +9 -0
@@ -12,6 +12,24 @@ import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
12
12
  import { AgentAvatar } from '@/components/agents/agent-avatar'
13
13
  import { isOrchestratorProviderEligible } from '@/lib/orchestrator-config'
14
14
 
15
+ const SETUP_PROVIDERS_WITH_MODEL_DISCOVERY = new Set([
16
+ 'openai',
17
+ 'openrouter',
18
+ 'anthropic',
19
+ 'google',
20
+ 'deepseek',
21
+ 'groq',
22
+ 'together',
23
+ 'mistral',
24
+ 'xai',
25
+ 'fireworks',
26
+ 'nebius',
27
+ 'deepinfra',
28
+ 'ollama',
29
+ 'openclaw',
30
+ 'hermes',
31
+ ])
32
+
15
33
  /* ── Model combobox: search discovered models or type a custom one ── */
16
34
 
17
35
  function ModelCombobox({
@@ -36,7 +54,7 @@ function ModelCombobox({
36
54
  const fetched = useRef<string | null>(null)
37
55
 
38
56
  const effectiveEndpoint = endpointOverride || provider?.endpoint || null
39
- const supportsModelDiscovery = Boolean(provider && provider.setupProvider !== 'custom')
57
+ const supportsModelDiscovery = Boolean(provider && SETUP_PROVIDERS_WITH_MODEL_DISCOVERY.has(provider.setupProvider))
40
58
 
41
59
  const fetchModels = useCallback(async (force?: boolean) => {
42
60
  if (!provider || !supportsModelDiscovery) return
@@ -12,6 +12,9 @@ const NOTABLE_TOOLS: Record<string, { label: string; color: string; icon: 'brain
12
12
  delegate_to_claude_code: { label: 'Delegated to Claude Code', color: '#38BDF8', icon: 'delegate' },
13
13
  delegate_to_codex_cli: { label: 'Delegated to Codex', color: '#38BDF8', icon: 'delegate' },
14
14
  delegate_to_opencode_cli: { label: 'Delegated to OpenCode', color: '#38BDF8', icon: 'delegate' },
15
+ delegate_to_gemini_cli: { label: 'Delegated to Gemini CLI', color: '#38BDF8', icon: 'delegate' },
16
+ delegate_to_cursor_cli: { label: 'Delegated to Cursor CLI', color: '#38BDF8', icon: 'delegate' },
17
+ delegate_to_qwen_code_cli: { label: 'Delegated to Qwen Code', color: '#38BDF8', icon: 'delegate' },
15
18
  delegate_to_agent: { label: 'Delegating task', color: '#6366F1', icon: 'delegate' },
16
19
  check_delegation_status: { label: 'Checking delegation', color: '#6366F1', icon: 'delegate' },
17
20
  web_search: { label: 'Searched the web', color: '#22C55E', icon: 'search' },
@@ -164,6 +164,12 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
164
164
  const fromSessionOpenCode = session.opencodeSessionId
165
165
  ? { label: 'OpenCode', id: session.opencodeSessionId, command: `opencode run \"<task>\" --session ${session.opencodeSessionId}` }
166
166
  : null
167
+ const fromSessionCursor = session.cursorSessionId
168
+ ? { label: 'Cursor', id: session.cursorSessionId, command: `cursor-agent --resume ${session.cursorSessionId} --print \"<task>\"` }
169
+ : null
170
+ const fromSessionQwen = session.qwenSessionId
171
+ ? { label: 'Qwen Code', id: session.qwenSessionId, command: `qwen --resume ${session.qwenSessionId} -p \"<task>\"` }
172
+ : null
167
173
  const fromDelegateClaude = session.delegateResumeIds?.claudeCode
168
174
  ? { label: 'Claude', id: session.delegateResumeIds.claudeCode, command: `claude --resume ${session.delegateResumeIds.claudeCode}` }
169
175
  : null
@@ -176,15 +182,25 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
176
182
  const fromDelegateGemini = session.delegateResumeIds?.gemini
177
183
  ? { label: 'Gemini', id: session.delegateResumeIds.gemini, command: `gemini --resume ${session.delegateResumeIds.gemini} --prompt \"<task>\"` }
178
184
  : null
185
+ const fromDelegateCursor = session.delegateResumeIds?.cursor
186
+ ? { label: 'Cursor', id: session.delegateResumeIds.cursor, command: `cursor-agent --resume ${session.delegateResumeIds.cursor} --print \"<task>\"` }
187
+ : null
188
+ const fromDelegateQwen = session.delegateResumeIds?.qwen
189
+ ? { label: 'Qwen Code', id: session.delegateResumeIds.qwen, command: `qwen --resume ${session.delegateResumeIds.qwen} -p \"<task>\"` }
190
+ : null
179
191
  return fromSessionClaude
180
192
  || fromSessionCodex
181
193
  || fromSessionOpenCode
194
+ || fromSessionCursor
195
+ || fromSessionQwen
182
196
  || fromDelegateClaude
183
197
  || fromDelegateCodex
184
198
  || fromDelegateOpenCode
185
199
  || fromDelegateGemini
200
+ || fromDelegateCursor
201
+ || fromDelegateQwen
186
202
  || null
187
- }, [session.claudeSessionId, session.codexThreadId, session.opencodeSessionId, session.delegateResumeIds])
203
+ }, [session.claudeSessionId, session.codexThreadId, session.opencodeSessionId, session.cursorSessionId, session.qwenSessionId, session.delegateResumeIds])
188
204
 
189
205
  const handleCopySessionId = () => {
190
206
  if (!resumeHandle) return
@@ -202,7 +218,12 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
202
218
  claudeSessionId: null,
203
219
  codexThreadId: null,
204
220
  opencodeSessionId: null,
205
- delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
221
+ geminiSessionId: null,
222
+ copilotSessionId: null,
223
+ cursorSessionId: null,
224
+ qwenSessionId: null,
225
+ acpSessionId: null,
226
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null, copilot: null, cursor: null, qwen: null },
206
227
  })
207
228
  await refreshSession(session.id)
208
229
  } catch { /* best-effort */ }
@@ -26,6 +26,10 @@ const TOOL_COLORS: Record<string, string> = {
26
26
  delegate_to_claude_code: '#6366F1',
27
27
  delegate_to_codex_cli: '#0EA5E9',
28
28
  delegate_to_opencode_cli: '#14B8A6',
29
+ delegate_to_gemini_cli: '#2563EB',
30
+ delegate_to_copilot_cli: '#6366F1',
31
+ delegate_to_cursor_cli: '#F97316',
32
+ delegate_to_qwen_code_cli: '#22C55E',
29
33
  whoami_tool: '#8B5CF6',
30
34
  connector_message_tool: '#EC4899',
31
35
  search_history_tool: '#8B5CF6',
@@ -75,12 +79,20 @@ export const TOOL_LABELS: Record<string, string> = {
75
79
  claude_code: 'Claude Code',
76
80
  codex_cli: 'Codex CLI',
77
81
  opencode_cli: 'OpenCode CLI',
82
+ gemini_cli: 'Gemini CLI',
83
+ copilot_cli: 'Copilot CLI',
84
+ cursor_cli: 'Cursor CLI',
85
+ qwen_code_cli: 'Qwen Code CLI',
78
86
  spawn_subagent: 'Subagent',
79
87
  delegate_to_agent: 'Agent Delegation',
80
88
  check_delegation_status: 'Check Delegation',
81
89
  delegate_to_claude_code: 'Claude Code',
82
90
  delegate_to_codex_cli: 'Codex CLI',
83
91
  delegate_to_opencode_cli: 'OpenCode CLI',
92
+ delegate_to_gemini_cli: 'Gemini CLI',
93
+ delegate_to_copilot_cli: 'Copilot CLI',
94
+ delegate_to_cursor_cli: 'Cursor CLI',
95
+ delegate_to_qwen_code_cli: 'Qwen Code CLI',
84
96
  whoami_tool: 'Who Am I',
85
97
  connector_message_tool: 'Connector Message',
86
98
  search_history_tool: 'Search History',
@@ -115,12 +127,20 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
115
127
  claude_code: 'Enable delegation to Claude Code CLI',
116
128
  codex_cli: 'Enable delegation to OpenAI Codex CLI',
117
129
  opencode_cli: 'Enable delegation to OpenCode CLI',
130
+ gemini_cli: 'Enable delegation to Gemini CLI',
131
+ copilot_cli: 'Enable delegation to GitHub Copilot CLI',
132
+ cursor_cli: 'Enable delegation to Cursor Agent CLI',
133
+ qwen_code_cli: 'Enable delegation to Qwen Code CLI',
118
134
  spawn_subagent: 'Spawn native subagents with lineage tracking and batch support',
119
135
  delegate_to_agent: 'Delegate a task to another agent',
120
136
  check_delegation_status: 'Check the status of a delegated task',
121
137
  delegate_to_claude_code: 'Delegate complex coding tasks to Claude Code',
122
138
  delegate_to_codex_cli: 'Delegate complex coding tasks to Codex CLI',
123
139
  delegate_to_opencode_cli: 'Delegate complex coding tasks to OpenCode CLI',
140
+ delegate_to_gemini_cli: 'Delegate complex coding tasks to Gemini CLI',
141
+ delegate_to_copilot_cli: 'Delegate complex coding tasks to GitHub Copilot CLI',
142
+ delegate_to_cursor_cli: 'Delegate complex coding tasks to Cursor Agent CLI',
143
+ delegate_to_qwen_code_cli: 'Delegate complex coding tasks to Qwen Code CLI',
124
144
  whoami_tool: 'Reveal the current agent and chat context',
125
145
  connector_message_tool: 'Send proactive outbound messages via running connectors',
126
146
  search_history_tool: 'Search chat history for relevant prior context',
@@ -16,6 +16,7 @@ import type { AppView } from '@/types'
16
16
 
17
17
  const RAIL_EXPANDED_KEY = 'sc_rail_expanded'
18
18
  const GITHUB_REPO_URL = 'https://github.com/swarmclawai/swarmclaw'
19
+ const DISCORD_URL = 'https://discord.gg/sbEavS8cPV'
19
20
  const NETWORK_LINKS = [
20
21
  { href: 'https://www.swarmdock.ai', label: 'SwarmDock', abbr: 'DO' },
21
22
  { href: 'https://swarmrecall.ai', label: 'SwarmRecall', abbr: 'RE' },
@@ -487,6 +488,34 @@ export function SidebarRail({
487
488
  </a>
488
489
  </RailTooltip>
489
490
  )}
491
+ {railExpanded ? (
492
+ <a
493
+ href={DISCORD_URL}
494
+ target="_blank"
495
+ rel="noopener noreferrer"
496
+ className="w-full flex items-center gap-2.5 px-3 py-2 rounded-[10px] text-[13px] font-500 cursor-pointer transition-all
497
+ bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04] no-underline"
498
+ style={{ fontFamily: 'inherit' }}
499
+ >
500
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
501
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
502
+ <path d="M8 10h.01M12 10h.01M16 10h.01" />
503
+ </svg>
504
+ Join Discord
505
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="ml-auto opacity-40">
506
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
507
+ </svg>
508
+ </a>
509
+ ) : (
510
+ <RailTooltip label="Join Discord" description="Open the SwarmClaw community">
511
+ <a href={DISCORD_URL} target="_blank" rel="noopener noreferrer" className="rail-btn">
512
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
513
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
514
+ <path d="M8 10h.01M12 10h.01M16 10h.01" />
515
+ </svg>
516
+ </a>
517
+ </RailTooltip>
518
+ )}
490
519
  {railExpanded && <DaemonIndicator />}
491
520
  {railExpanded ? (
492
521
  <NotificationCenter variant="row" align="left" direction="up" />
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
2
2
  import { test } from 'node:test'
3
3
  import { resolveSetupDone } from './setup-done-detection'
4
4
 
5
- test('resolveSetupDone returns true when both fetches failed', () => {
6
- assert.equal(resolveSetupDone({}, {}, true), true)
5
+ test('resolveSetupDone returns false when both fetches failed so the wizard is shown', () => {
6
+ // When both API calls fail we cannot determine setup state — default to
7
+ // showing the wizard so the user doesn't land in a broken app.
8
+ assert.equal(resolveSetupDone({}, {}, true), false)
7
9
  })
8
10
 
9
11
  test('resolveSetupDone returns true when setupCompleted is true', () => {
@@ -7,7 +7,7 @@ export function resolveSetupDone(
7
7
  creds: Record<string, unknown>,
8
8
  bothFailed: boolean,
9
9
  ): boolean {
10
- if (bothFailed) return true
10
+ if (bothFailed) return false
11
11
  const hasCreds = Object.keys(creds).length > 0
12
12
  return settings.setupCompleted === true || hasCreds
13
13
  }
@@ -4,6 +4,11 @@ export const NON_ORCHESTRATOR_PROVIDERS = new Set([
4
4
  'claude-cli',
5
5
  'codex-cli',
6
6
  'opencode-cli',
7
+ 'gemini-cli',
8
+ 'copilot-cli',
9
+ 'cursor-cli',
10
+ 'qwen-code-cli',
11
+ 'goose',
7
12
  'openclaw',
8
13
  'hermes',
9
14
  ])
@@ -1,11 +1,11 @@
1
1
  /** CLI providers that use their own tool execution outside the shared tool-runtime path. */
2
- export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli'])
2
+ export const NON_LANGGRAPH_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli'])
3
3
 
4
4
  /** Providers that manage their own runtime/tool loop even when reached over an API endpoint. */
5
- export const RUNTIME_MANAGED_PROVIDER_IDS = new Set(['hermes'])
5
+ export const RUNTIME_MANAGED_PROVIDER_IDS = new Set(['hermes', 'goose'])
6
6
 
7
7
  /** Providers with native tool/capability support (CLI providers + OpenClaw + Hermes). */
8
- export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw', 'hermes'])
8
+ export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
9
9
 
10
10
  /** Providers that can only act as workers — no coordinator role, no heartbeat, no advanced settings. */
11
- export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'openclaw', 'hermes'])
11
+ export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
@@ -0,0 +1,116 @@
1
+ import { spawn, type ChildProcess } from 'child_process'
2
+ import { EventEmitter } from 'events'
3
+
4
+ interface JsonRpcRequest {
5
+ jsonrpc: '2.0'
6
+ id: number
7
+ method: string
8
+ params?: unknown
9
+ }
10
+
11
+ interface JsonRpcResponse {
12
+ jsonrpc: '2.0'
13
+ id?: number | string | null
14
+ result?: unknown
15
+ error?: {
16
+ code?: number
17
+ message?: string
18
+ data?: unknown
19
+ }
20
+ method?: string
21
+ params?: unknown
22
+ }
23
+
24
+ export interface AcpClientOptions {
25
+ command: string
26
+ args?: string[]
27
+ cwd?: string
28
+ env?: NodeJS.ProcessEnv
29
+ timeoutMs?: number
30
+ }
31
+
32
+ export class AcpClient extends EventEmitter {
33
+ private readonly proc: ChildProcess
34
+ private readonly timeoutMs: number
35
+ private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>()
36
+ private nextId = 1
37
+ private stdoutBuf = ''
38
+
39
+ constructor(options: AcpClientOptions) {
40
+ super()
41
+ this.timeoutMs = Math.max(1_000, options.timeoutMs || 30_000)
42
+ this.proc = spawn(options.command, options.args || [], {
43
+ cwd: options.cwd,
44
+ env: options.env,
45
+ stdio: ['pipe', 'pipe', 'pipe'],
46
+ })
47
+
48
+ this.proc.stdout?.on('data', (chunk: Buffer) => this.handleStdout(chunk.toString()))
49
+ this.proc.stderr?.on('data', (chunk: Buffer) => {
50
+ this.emit('stderr', chunk.toString())
51
+ })
52
+ this.proc.on('error', (err) => this.failPending(err))
53
+ this.proc.on('close', () => this.failPending(new Error('ACP process closed')))
54
+ }
55
+
56
+ async request(method: string, params?: unknown): Promise<unknown> {
57
+ const id = this.nextId++
58
+ const payload: JsonRpcRequest = { jsonrpc: '2.0', id, method }
59
+ if (params !== undefined) payload.params = params
60
+
61
+ const response = await new Promise<unknown>((resolve, reject) => {
62
+ const timer = setTimeout(() => {
63
+ this.pending.delete(id)
64
+ reject(new Error(`ACP request timed out: ${method}`))
65
+ }, this.timeoutMs)
66
+ this.pending.set(id, { resolve, reject, timer })
67
+ this.proc.stdin?.write(`${JSON.stringify(payload)}\n`)
68
+ })
69
+
70
+ return response
71
+ }
72
+
73
+ notify(method: string, params?: unknown): void {
74
+ const payload = params === undefined
75
+ ? { jsonrpc: '2.0', method }
76
+ : { jsonrpc: '2.0', method, params }
77
+ this.proc.stdin?.write(`${JSON.stringify(payload)}\n`)
78
+ }
79
+
80
+ close(): void {
81
+ this.failPending(new Error('ACP client closed'))
82
+ try { this.proc.kill('SIGTERM') } catch { /* ignore */ }
83
+ }
84
+
85
+ private handleStdout(raw: string): void {
86
+ this.stdoutBuf += raw
87
+ const lines = this.stdoutBuf.split('\n')
88
+ this.stdoutBuf = lines.pop() || ''
89
+
90
+ for (const line of lines) {
91
+ if (!line.trim()) continue
92
+ try {
93
+ const message = JSON.parse(line) as JsonRpcResponse
94
+ if (typeof message.id === 'number' && this.pending.has(message.id)) {
95
+ const entry = this.pending.get(message.id)!
96
+ clearTimeout(entry.timer)
97
+ this.pending.delete(message.id)
98
+ if (message.error?.message) entry.reject(new Error(message.error.message))
99
+ else entry.resolve(message.result)
100
+ continue
101
+ }
102
+ this.emit('notification', message)
103
+ } catch {
104
+ this.emit('raw', line)
105
+ }
106
+ }
107
+ }
108
+
109
+ private failPending(err: Error): void {
110
+ for (const [, entry] of this.pending) {
111
+ clearTimeout(entry.timer)
112
+ entry.reject(err)
113
+ }
114
+ this.pending.clear()
115
+ }
116
+ }
@@ -93,6 +93,10 @@ describe('isCliProvider', () => {
93
93
  assert.equal(isCliProvider('codex-cli'), true)
94
94
  assert.equal(isCliProvider('opencode-cli'), true)
95
95
  assert.equal(isCliProvider('gemini-cli'), true)
96
+ assert.equal(isCliProvider('copilot-cli'), true)
97
+ assert.equal(isCliProvider('cursor-cli'), true)
98
+ assert.equal(isCliProvider('qwen-code-cli'), true)
99
+ assert.equal(isCliProvider('goose'), true)
96
100
  })
97
101
 
98
102
  it('returns false for non-CLI providers', () => {
@@ -108,11 +112,15 @@ describe('isCliProvider', () => {
108
112
  // ---------------------------------------------------------------------------
109
113
 
110
114
  describe('CLI_PROVIDER_CAPABILITIES', () => {
111
- it('has entries for all 4 CLI providers', () => {
115
+ it('has entries for all supported local CLI-backed providers', () => {
112
116
  assert.ok('claude-cli' in CLI_PROVIDER_CAPABILITIES)
113
117
  assert.ok('codex-cli' in CLI_PROVIDER_CAPABILITIES)
114
118
  assert.ok('opencode-cli' in CLI_PROVIDER_CAPABILITIES)
115
119
  assert.ok('gemini-cli' in CLI_PROVIDER_CAPABILITIES)
120
+ assert.ok('copilot-cli' in CLI_PROVIDER_CAPABILITIES)
121
+ assert.ok('cursor-cli' in CLI_PROVIDER_CAPABILITIES)
122
+ assert.ok('qwen-code-cli' in CLI_PROVIDER_CAPABILITIES)
123
+ assert.ok('goose' in CLI_PROVIDER_CAPABILITIES)
116
124
  })
117
125
 
118
126
  it('each entry is a non-empty string', () => {
@@ -2,8 +2,7 @@
2
2
  * Shared CLI utility module for CLI-based providers and delegation backends.
3
3
  *
4
4
  * Consolidates environment building, binary discovery, auth probing,
5
- * abort handling, and config forwarding used by claude-cli, codex-cli,
6
- * opencode-cli, and gemini-cli providers.
5
+ * abort handling, and config forwarding used by CLI-backed providers.
7
6
  */
8
7
 
9
8
  import fs from 'fs'
@@ -46,6 +45,24 @@ const KNOWN_BINARY_PATHS: Record<string, string[]> = {
46
45
  '/opt/homebrew/bin/copilot',
47
46
  path.join(os.homedir(), '.npm-global/bin/copilot'),
48
47
  ],
48
+ 'cursor-agent': [
49
+ path.join(os.homedir(), '.local/bin/cursor-agent'),
50
+ '/usr/local/bin/cursor-agent',
51
+ '/opt/homebrew/bin/cursor-agent',
52
+ path.join(os.homedir(), '.npm-global/bin/cursor-agent'),
53
+ ],
54
+ qwen: [
55
+ path.join(os.homedir(), '.local/bin/qwen'),
56
+ '/usr/local/bin/qwen',
57
+ '/opt/homebrew/bin/qwen',
58
+ path.join(os.homedir(), '.npm-global/bin/qwen'),
59
+ ],
60
+ goose: [
61
+ path.join(os.homedir(), '.local/bin/goose'),
62
+ '/usr/local/bin/goose',
63
+ '/opt/homebrew/bin/goose',
64
+ path.join(os.homedir(), '.cargo/bin/goose'),
65
+ ],
49
66
  }
50
67
 
51
68
  function getNvmBinaryPaths(name: string): string[] {
@@ -145,11 +162,11 @@ export interface AuthProbeResult {
145
162
  }
146
163
 
147
164
  /**
148
- * Unified auth check for all 4 CLI tools.
165
+ * Unified auth check for supported CLI-backed providers.
149
166
  */
150
167
  export function probeCliAuth(
151
168
  binary: string,
152
- backend: 'claude' | 'codex' | 'opencode' | 'gemini' | 'copilot',
169
+ backend: 'claude' | 'codex' | 'opencode' | 'gemini' | 'copilot' | 'cursor' | 'qwen' | 'goose',
153
170
  env: NodeJS.ProcessEnv,
154
171
  cwd?: string,
155
172
  ): AuthProbeResult {
@@ -260,6 +277,71 @@ export function probeCliAuth(
260
277
  return { authenticated: true }
261
278
  }
262
279
 
280
+ if (backend === 'cursor') {
281
+ try {
282
+ const probe = spawnSync(binary, ['status'], {
283
+ cwd, env, encoding: 'utf-8', timeout: 8_000,
284
+ })
285
+ const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`.toLowerCase()
286
+ if ((probe.status ?? 1) === 0 || probeText.includes('authenticated') || probeText.includes('logged in')) {
287
+ return { authenticated: true }
288
+ }
289
+ } catch { /* ignore */ }
290
+
291
+ const configPaths = [
292
+ path.join(os.homedir(), '.cursor', 'config.json'),
293
+ path.join(os.homedir(), '.config', 'cursor', 'config.json'),
294
+ ]
295
+ const hasConfig = configPaths.some((p) => fs.existsSync(p))
296
+ if (!hasConfig) {
297
+ return {
298
+ authenticated: false,
299
+ errorMessage: 'Cursor Agent CLI is not authenticated. Run `cursor-agent login` and try again.',
300
+ }
301
+ }
302
+ return { authenticated: true }
303
+ }
304
+
305
+ if (backend === 'qwen') {
306
+ const envKeys = ['QWEN_API_KEY', 'DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY']
307
+ if (envKeys.some((key) => typeof env[key] === 'string' && env[key]?.trim())) {
308
+ return { authenticated: true }
309
+ }
310
+ const configPaths = [
311
+ path.join(os.homedir(), '.qwen', 'settings.json'),
312
+ path.join(os.homedir(), '.config', 'qwen', 'settings.json'),
313
+ ]
314
+ const hasConfig = configPaths.some((p) => fs.existsSync(p))
315
+ if (!hasConfig) {
316
+ return {
317
+ authenticated: false,
318
+ errorMessage: 'Qwen Code CLI is not configured. Run `qwen`, finish `/auth`, or configure ~/.qwen/settings.json and try again.',
319
+ }
320
+ }
321
+ return { authenticated: true }
322
+ }
323
+
324
+ if (backend === 'goose') {
325
+ const envKeys = ['GOOSE_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY']
326
+ if (envKeys.some((key) => typeof env[key] === 'string' && env[key]?.trim())) {
327
+ return { authenticated: true }
328
+ }
329
+ const configPaths = [
330
+ path.join(os.homedir(), '.config', 'goose', 'config.yaml'),
331
+ path.join(os.homedir(), '.config', 'goose', 'config.yml'),
332
+ path.join(os.homedir(), '.goose', 'config.yaml'),
333
+ path.join(os.homedir(), '.goose', 'config.yml'),
334
+ ]
335
+ const hasConfig = configPaths.some((p) => fs.existsSync(p))
336
+ if (!hasConfig) {
337
+ return {
338
+ authenticated: false,
339
+ errorMessage: 'Goose CLI is not configured. Run `goose configure` and try again.',
340
+ }
341
+ }
342
+ return { authenticated: true }
343
+ }
344
+
263
345
  return { authenticated: true }
264
346
  }
265
347
 
@@ -345,6 +427,9 @@ export const CLI_PROVIDER_CAPABILITIES: Record<string, string> = {
345
427
  'opencode-cli': 'code analysis, generation across multiple LLM backends',
346
428
  'gemini-cli': 'code generation, analysis with Gemini models',
347
429
  'copilot-cli': 'code generation, analysis, multi-model support via GitHub Copilot',
430
+ 'cursor-cli': 'full-agent coding workflows, multi-file edits, project-aware code changes',
431
+ 'qwen-code-cli': 'terminal-native coding workflows, code generation, review, and automation',
432
+ goose: 'agentic coding workflows with extensions, tools, and runtime-managed execution',
348
433
  }
349
434
 
350
435
  /** Check if a provider ID is a CLI-based provider. */