@swarmclawai/swarmclaw 1.6.1 → 1.7.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/README.md +14 -5
- package/package.json +2 -2
- package/src/app/home/page.tsx +10 -19
- package/src/components/auth/setup-wizard/index.tsx +2 -6
- package/src/components/auth/setup-wizard/step-next.tsx +39 -46
- package/src/components/auth/setup-wizard/step-providers.tsx +142 -76
- package/src/components/auth/setup-wizard/types.ts +2 -5
- package/src/components/auth/setup-wizard/utils.test.ts +19 -0
- package/src/components/auth/setup-wizard/utils.ts +69 -0
- package/src/components/home/home-launchpad.tsx +71 -123
- package/src/lib/home-launchpad.test.ts +31 -1
- package/src/lib/home-launchpad.ts +58 -0
- package/src/lib/providers/cli-utils.test.ts +10 -0
- package/src/lib/providers/cli-utils.ts +31 -0
- package/src/lib/providers/generic-cli.test.ts +71 -0
- package/src/lib/providers/generic-cli.ts +138 -0
- package/src/lib/providers/index.ts +56 -1
- package/src/types/provider.ts +1 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getLaunchPathCards,
|
|
6
|
+
type LaunchPathAction,
|
|
7
|
+
type LaunchPathCardCopy,
|
|
8
|
+
type LaunchPathId,
|
|
9
|
+
} from '@/lib/home-launchpad'
|
|
5
10
|
import type { Agent } from '@/types'
|
|
6
11
|
|
|
7
12
|
function SnapshotItem({ label, value, hint }: { label: string; value: string; hint: string }) {
|
|
@@ -15,47 +20,58 @@ function SnapshotItem({ label, value, hint }: { label: string; value: string; hi
|
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
function PathCard({
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
description,
|
|
21
|
-
primaryLabel,
|
|
22
|
-
secondaryLabel,
|
|
23
|
-
onPrimary,
|
|
24
|
-
onSecondary,
|
|
23
|
+
card,
|
|
24
|
+
onAction,
|
|
25
25
|
}: {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
description: string
|
|
29
|
-
primaryLabel: string
|
|
30
|
-
secondaryLabel: string
|
|
31
|
-
onPrimary: () => void
|
|
32
|
-
onSecondary: () => void
|
|
26
|
+
card: LaunchPathCardCopy
|
|
27
|
+
onAction: (id: LaunchPathId, action: LaunchPathAction) => void
|
|
33
28
|
}) {
|
|
34
29
|
return (
|
|
35
30
|
<div className="flex min-h-[220px] flex-col rounded-[18px] border border-white/[0.07] bg-white/[0.03] p-5">
|
|
36
|
-
<div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">{kicker}</div>
|
|
37
|
-
<div className="mt-3 text-[18px] font-display font-700 tracking-normal text-text">{title}</div>
|
|
38
|
-
<p className="mt-2 flex-1 text-[13px] leading-relaxed text-text-3/72">{description}</p>
|
|
31
|
+
<div className="text-[11px] font-700 uppercase tracking-[0.12em] text-text-3/55">{card.kicker}</div>
|
|
32
|
+
<div className="mt-3 text-[18px] font-display font-700 tracking-normal text-text">{card.title}</div>
|
|
33
|
+
<p className="mt-2 flex-1 text-[13px] leading-relaxed text-text-3/72">{card.description}</p>
|
|
39
34
|
<div className="mt-5 flex flex-wrap gap-2">
|
|
40
35
|
<button
|
|
41
36
|
type="button"
|
|
42
|
-
onClick={
|
|
37
|
+
onClick={() => onAction(card.id, 'primary')}
|
|
43
38
|
className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-display font-700 text-black transition-opacity hover:opacity-90"
|
|
44
39
|
>
|
|
45
|
-
{primaryLabel}
|
|
40
|
+
{card.primaryLabel}
|
|
46
41
|
</button>
|
|
47
42
|
<button
|
|
48
43
|
type="button"
|
|
49
|
-
onClick={
|
|
44
|
+
onClick={() => onAction(card.id, 'secondary')}
|
|
50
45
|
className="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-3.5 py-2 text-[12px] font-display font-700 text-text-2 transition-colors hover:bg-white/[0.08]"
|
|
51
46
|
>
|
|
52
|
-
{secondaryLabel}
|
|
47
|
+
{card.secondaryLabel}
|
|
53
48
|
</button>
|
|
54
49
|
</div>
|
|
55
50
|
</div>
|
|
56
51
|
)
|
|
57
52
|
}
|
|
58
53
|
|
|
54
|
+
function SecondaryAction({
|
|
55
|
+
label,
|
|
56
|
+
description,
|
|
57
|
+
onClick,
|
|
58
|
+
}: {
|
|
59
|
+
label: string
|
|
60
|
+
description: string
|
|
61
|
+
onClick: () => void
|
|
62
|
+
}) {
|
|
63
|
+
return (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={onClick}
|
|
67
|
+
className="rounded-[12px] border border-white/[0.07] bg-white/[0.025] px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
|
68
|
+
>
|
|
69
|
+
<div className="text-[12px] font-display font-700 text-text-2">{label}</div>
|
|
70
|
+
<div className="mt-0.5 text-[11px] leading-relaxed text-text-3/65">{description}</div>
|
|
71
|
+
</button>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
type Props = {
|
|
60
76
|
firstAgent: Agent | null
|
|
61
77
|
agentCount: number
|
|
@@ -64,15 +80,8 @@ type Props = {
|
|
|
64
80
|
scheduleCount: number
|
|
65
81
|
connectorCount: number
|
|
66
82
|
todayCost: number
|
|
67
|
-
|
|
68
|
-
onOpenProtocols: () => void
|
|
69
|
-
onOpenBuilder: () => void
|
|
70
|
-
onOpenConnectors: () => void
|
|
83
|
+
onLaunchPathAction: (id: LaunchPathId, action: LaunchPathAction) => void
|
|
71
84
|
onOpenUsage: () => void
|
|
72
|
-
onRunEvalSuite: () => void
|
|
73
|
-
onReviewApprovals: () => void
|
|
74
|
-
onInspectFailedRuns: () => void
|
|
75
|
-
onStartReleaseQaMission: () => void
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export function HomeLaunchpad({
|
|
@@ -83,16 +92,11 @@ export function HomeLaunchpad({
|
|
|
83
92
|
scheduleCount,
|
|
84
93
|
connectorCount,
|
|
85
94
|
todayCost,
|
|
86
|
-
|
|
87
|
-
onOpenProtocols,
|
|
88
|
-
onOpenBuilder,
|
|
89
|
-
onOpenConnectors,
|
|
95
|
+
onLaunchPathAction,
|
|
90
96
|
onOpenUsage,
|
|
91
|
-
onRunEvalSuite,
|
|
92
|
-
onReviewApprovals,
|
|
93
|
-
onInspectFailedRuns,
|
|
94
|
-
onStartReleaseQaMission,
|
|
95
97
|
}: Props) {
|
|
98
|
+
const launchPathCards = getLaunchPathCards({ firstAgentName: firstAgent?.name })
|
|
99
|
+
|
|
96
100
|
return (
|
|
97
101
|
<div className="max-w-[980px] mx-auto px-6 py-10">
|
|
98
102
|
<div className="rounded-[20px] border border-white/[0.06] bg-white/[0.025] p-6">
|
|
@@ -137,93 +141,37 @@ export function HomeLaunchpad({
|
|
|
137
141
|
</div>
|
|
138
142
|
|
|
139
143
|
<div className="mt-6 grid gap-3 lg:grid-cols-3">
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
description="Open a live agent chat, then add memory, local tools, provider routing, or connector access as the work demands."
|
|
144
|
-
primaryLabel={firstAgent ? 'Open Chat' : 'Open Agents'}
|
|
145
|
-
secondaryLabel="Connect Platform"
|
|
146
|
-
onPrimary={onOpenFirstAgent}
|
|
147
|
-
onSecondary={onOpenConnectors}
|
|
148
|
-
/>
|
|
149
|
-
<PathCard
|
|
150
|
-
kicker="Visual workflow"
|
|
151
|
-
title="Shape a reusable run"
|
|
152
|
-
description="Use protocol templates and the builder to turn review, research, planning, or release checks into durable workflows."
|
|
153
|
-
primaryLabel="Open Builder"
|
|
154
|
-
secondaryLabel="Use Templates"
|
|
155
|
-
onPrimary={onOpenBuilder}
|
|
156
|
-
onSecondary={onOpenProtocols}
|
|
157
|
-
/>
|
|
158
|
-
<PathCard
|
|
159
|
-
kicker="Autonomous mission"
|
|
160
|
-
title="Run with budgets"
|
|
161
|
-
description="Start a mission template for release QA, research, support triage, cost audit, or failed-run review with reports and caps."
|
|
162
|
-
primaryLabel="Open Missions"
|
|
163
|
-
secondaryLabel="Quality Center"
|
|
164
|
-
onPrimary={onStartReleaseQaMission}
|
|
165
|
-
onSecondary={onRunEvalSuite}
|
|
166
|
-
/>
|
|
144
|
+
{launchPathCards.map((card) => (
|
|
145
|
+
<PathCard key={card.id} card={card} onAction={onLaunchPathAction} />
|
|
146
|
+
))}
|
|
167
147
|
</div>
|
|
168
148
|
|
|
169
|
-
<div className="mt-6
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
onClick={onOpenConnectors}
|
|
196
|
-
/>
|
|
197
|
-
<LaunchActionCard
|
|
198
|
-
title="Review Usage"
|
|
199
|
-
description="Check cost, provider health, and activity so the workspace stays observable from the start."
|
|
200
|
-
actionLabel="Open Usage"
|
|
201
|
-
onClick={onOpenUsage}
|
|
202
|
-
/>
|
|
203
|
-
<LaunchActionCard
|
|
204
|
-
title="Run Eval Suite"
|
|
205
|
-
description="Open the Quality Center and run scenario or suite checks against an agent before shipping."
|
|
206
|
-
actionLabel="Open Eval Lab"
|
|
207
|
-
onClick={onRunEvalSuite}
|
|
208
|
-
/>
|
|
209
|
-
<LaunchActionCard
|
|
210
|
-
title="Review Approvals"
|
|
211
|
-
description="Clear pending human-loop, tool, connector, skill, agent, and budget requests from one desk."
|
|
212
|
-
actionLabel="Open Approvals"
|
|
213
|
-
onClick={onReviewApprovals}
|
|
214
|
-
/>
|
|
215
|
-
<LaunchActionCard
|
|
216
|
-
title="Inspect Failed Runs"
|
|
217
|
-
description="Filter recent run failures and open replay evidence without leaving the operator workflow."
|
|
218
|
-
actionLabel="Open Run Review"
|
|
219
|
-
onClick={onInspectFailedRuns}
|
|
220
|
-
/>
|
|
221
|
-
<LaunchActionCard
|
|
222
|
-
title="Start Release QA Mission"
|
|
223
|
-
description="Use a budgeted mission template to collect release readiness evidence and quality notes."
|
|
224
|
-
actionLabel="Open Missions"
|
|
225
|
-
onClick={onStartReleaseQaMission}
|
|
226
|
-
/>
|
|
149
|
+
<div className="mt-6 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4">
|
|
150
|
+
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
151
|
+
<div>
|
|
152
|
+
<div className="text-[12px] font-display font-700 text-text">Keep the workspace observable</div>
|
|
153
|
+
<p className="mt-1 text-[12px] leading-relaxed text-text-3/65">
|
|
154
|
+
Provider spend, connector status, and quality evidence stay nearby after you pick a path.
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="grid gap-2 sm:grid-cols-3 md:min-w-[520px]">
|
|
158
|
+
<SecondaryAction
|
|
159
|
+
label="Usage"
|
|
160
|
+
description="Cost and provider health"
|
|
161
|
+
onClick={onOpenUsage}
|
|
162
|
+
/>
|
|
163
|
+
<SecondaryAction
|
|
164
|
+
label="Connectors"
|
|
165
|
+
description="Platform bridges"
|
|
166
|
+
onClick={() => onLaunchPathAction('assistant', 'secondary')}
|
|
167
|
+
/>
|
|
168
|
+
<SecondaryAction
|
|
169
|
+
label="Quality"
|
|
170
|
+
description="Evals and run review"
|
|
171
|
+
onClick={() => onLaunchPathAction('mission', 'secondary')}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
227
175
|
</div>
|
|
228
176
|
|
|
229
177
|
<div className="mt-8 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { test } from 'node:test'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_BUILDER_ROUTE,
|
|
5
|
+
deriveHomeMode,
|
|
6
|
+
getLaunchPathCards,
|
|
7
|
+
isSparseWorkspace,
|
|
8
|
+
resolveLaunchPathHref,
|
|
9
|
+
} from './home-launchpad'
|
|
4
10
|
|
|
5
11
|
test('isSparseWorkspace detects a fresh workspace', () => {
|
|
6
12
|
assert.equal(isSparseWorkspace({
|
|
@@ -47,3 +53,27 @@ test('deriveHomeMode falls back to ops for active workspaces', () => {
|
|
|
47
53
|
todayCost: 0,
|
|
48
54
|
}), 'ops')
|
|
49
55
|
})
|
|
56
|
+
|
|
57
|
+
test('getLaunchPathCards returns the three first-run paths in order', () => {
|
|
58
|
+
const cards = getLaunchPathCards({ firstAgentName: 'Ada' })
|
|
59
|
+
assert.deepEqual(cards.map((card) => card.id), ['assistant', 'workflow', 'mission'])
|
|
60
|
+
assert.equal(cards[0]?.title, 'Work with Ada')
|
|
61
|
+
assert.equal(cards[0]?.primaryLabel, 'Open Chat')
|
|
62
|
+
assert.equal(cards[1]?.primaryLabel, 'Open Builder')
|
|
63
|
+
assert.equal(cards[2]?.secondaryLabel, 'Quality Center')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('getLaunchPathCards falls back to agent creation copy when no agent exists', () => {
|
|
67
|
+
const [assistant] = getLaunchPathCards()
|
|
68
|
+
assert.equal(assistant?.title, 'Create the first agent')
|
|
69
|
+
assert.equal(assistant?.primaryLabel, 'Open Agents')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('resolveLaunchPathHref builds primary and secondary destinations', () => {
|
|
73
|
+
assert.equal(resolveLaunchPathHref('assistant', 'primary', 'agent one'), '/agents/agent%20one')
|
|
74
|
+
assert.equal(resolveLaunchPathHref('assistant', 'secondary', 'agent one'), '/connectors')
|
|
75
|
+
assert.equal(resolveLaunchPathHref('workflow', 'primary'), DEFAULT_BUILDER_ROUTE)
|
|
76
|
+
assert.equal(resolveLaunchPathHref('workflow', 'secondary'), '/protocols')
|
|
77
|
+
assert.equal(resolveLaunchPathHref('mission', 'primary'), '/missions')
|
|
78
|
+
assert.equal(resolveLaunchPathHref('mission', 'secondary'), '/quality')
|
|
79
|
+
})
|
|
@@ -2,6 +2,17 @@ export const HOME_LAUNCHPAD_AFTER_SETUP_KEY = 'sc_launchpad_after_setup_v1'
|
|
|
2
2
|
export const DEFAULT_BUILDER_ROUTE = '/protocols/builder/facilitated_discussion'
|
|
3
3
|
|
|
4
4
|
export type HomeMode = 'launchpad' | 'ops'
|
|
5
|
+
export type LaunchPathId = 'assistant' | 'workflow' | 'mission'
|
|
6
|
+
export type LaunchPathAction = 'primary' | 'secondary'
|
|
7
|
+
|
|
8
|
+
export interface LaunchPathCardCopy {
|
|
9
|
+
id: LaunchPathId
|
|
10
|
+
kicker: string
|
|
11
|
+
title: string
|
|
12
|
+
description: string
|
|
13
|
+
primaryLabel: string
|
|
14
|
+
secondaryLabel: string
|
|
15
|
+
}
|
|
5
16
|
|
|
6
17
|
export interface HomeModeInput {
|
|
7
18
|
hasLaunchpadFlag: boolean
|
|
@@ -28,3 +39,50 @@ export function deriveHomeMode(input: HomeModeInput): HomeMode {
|
|
|
28
39
|
if (input.hasLaunchpadFlag) return 'launchpad'
|
|
29
40
|
return isSparseWorkspace(input) ? 'launchpad' : 'ops'
|
|
30
41
|
}
|
|
42
|
+
|
|
43
|
+
export function getLaunchPathCards(input: { firstAgentName?: string | null } = {}): LaunchPathCardCopy[] {
|
|
44
|
+
const firstAgentName = input.firstAgentName?.trim() || null
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
id: 'assistant',
|
|
48
|
+
kicker: 'Assistant',
|
|
49
|
+
title: firstAgentName ? `Work with ${firstAgentName}` : 'Create the first agent',
|
|
50
|
+
description: 'Open a live agent chat, then add memory, local tools, provider routing, or connector access as the work demands.',
|
|
51
|
+
primaryLabel: firstAgentName ? 'Open Chat' : 'Open Agents',
|
|
52
|
+
secondaryLabel: 'Connect Platform',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'workflow',
|
|
56
|
+
kicker: 'Workflow',
|
|
57
|
+
title: 'Shape a reusable run',
|
|
58
|
+
description: 'Use protocol templates and the builder to turn review, research, planning, or release checks into durable workflows.',
|
|
59
|
+
primaryLabel: 'Open Builder',
|
|
60
|
+
secondaryLabel: 'Use Templates',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'mission',
|
|
64
|
+
kicker: 'Mission',
|
|
65
|
+
title: 'Run with budgets',
|
|
66
|
+
description: 'Start a mission template for release QA, research, support triage, cost audit, or failed-run review with reports and caps.',
|
|
67
|
+
primaryLabel: 'Open Missions',
|
|
68
|
+
secondaryLabel: 'Quality Center',
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveLaunchPathHref(
|
|
74
|
+
id: LaunchPathId,
|
|
75
|
+
action: LaunchPathAction,
|
|
76
|
+
firstAgentId?: string | null,
|
|
77
|
+
): string {
|
|
78
|
+
if (id === 'assistant') {
|
|
79
|
+
if (action === 'primary') {
|
|
80
|
+
return firstAgentId ? `/agents/${encodeURIComponent(firstAgentId)}` : '/agents'
|
|
81
|
+
}
|
|
82
|
+
return '/connectors'
|
|
83
|
+
}
|
|
84
|
+
if (id === 'workflow') {
|
|
85
|
+
return action === 'primary' ? DEFAULT_BUILDER_ROUTE : '/protocols'
|
|
86
|
+
}
|
|
87
|
+
return action === 'primary' ? '/missions' : '/quality'
|
|
88
|
+
}
|
|
@@ -100,6 +100,16 @@ describe('isCliProvider', () => {
|
|
|
100
100
|
assert.equal(isCliProvider('goose'), true)
|
|
101
101
|
})
|
|
102
102
|
|
|
103
|
+
it('returns true for the extended generic-cli roster', () => {
|
|
104
|
+
const sample = [
|
|
105
|
+
'aider-cli', 'cline-cli', 'continue-cli', 'windsurf-cli', 'warp-cli',
|
|
106
|
+
'roo-code-cli', 'kilo-code-cli', 'qoder-cli', 'openhands-cli', 'kimi-cli',
|
|
107
|
+
]
|
|
108
|
+
for (const id of sample) {
|
|
109
|
+
assert.equal(isCliProvider(id), true, `${id} should be recognized as a CLI provider`)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
103
113
|
it('returns false for non-CLI providers', () => {
|
|
104
114
|
assert.equal(isCliProvider('openai'), false)
|
|
105
115
|
assert.equal(isCliProvider('anthropic'), false)
|
|
@@ -457,6 +457,37 @@ export const CLI_PROVIDER_CAPABILITIES: Record<string, string> = {
|
|
|
457
457
|
'cursor-cli': 'full-agent coding workflows, multi-file edits, project-aware code changes',
|
|
458
458
|
'qwen-code-cli': 'terminal-native coding workflows, code generation, review, and automation',
|
|
459
459
|
goose: 'agentic coding workflows with extensions, tools, and runtime-managed execution',
|
|
460
|
+
'aider-cli': 'paired-programming-style multi-file edits and git-aware code changes',
|
|
461
|
+
'amp-cli': 'agentic coding via Sourcegraph Amp',
|
|
462
|
+
'augment-cli': 'codebase-aware agentic edits via Augment',
|
|
463
|
+
'adal-cli': 'AdaL coding agent for terminal-driven workflows',
|
|
464
|
+
'bob-cli': 'IBM watsonx Code Assistant (Bob) terminal coding workflows',
|
|
465
|
+
'cline-cli': 'autonomous file-level edits and terminal automation via Cline',
|
|
466
|
+
'codebuddy-cli': 'CodeBuddy agentic coding workflows',
|
|
467
|
+
'command-code-cli': 'Command Code terminal-native coding agent',
|
|
468
|
+
'continue-cli': 'agentic coding via the Continue CLI',
|
|
469
|
+
'cortex-cli': 'Snowflake Cortex Code agentic workflows',
|
|
470
|
+
'crush-cli': 'Crush terminal coding agent',
|
|
471
|
+
'deepagents-cli': 'long-horizon planning and multi-step coding via Deep Agents',
|
|
472
|
+
'firebender-cli': 'Firebender JetBrains-aligned coding agent',
|
|
473
|
+
'iflow-cli': 'iFlow CLI agentic coding workflows',
|
|
474
|
+
'junie-cli': 'JetBrains Junie coding agent for terminal use',
|
|
475
|
+
'kilo-code-cli': 'Kilo Code agentic coding workflows',
|
|
476
|
+
'kimi-cli': 'Kimi Code CLI coding agent',
|
|
477
|
+
'kode-cli': 'Kode terminal coding agent',
|
|
478
|
+
'mcpjam-cli': 'MCPJam-tooled agentic coding workflows',
|
|
479
|
+
'mistral-vibe-cli': 'Mistral Vibe coding agent',
|
|
480
|
+
'mux-cli': 'Mux multi-tool coding agent',
|
|
481
|
+
'neovate-cli': 'Neovate coding agent for terminal workflows',
|
|
482
|
+
'openhands-cli': 'OpenHands agentic coding via terminal',
|
|
483
|
+
'pochi-cli': 'Pochi coding agent',
|
|
484
|
+
'qoder-cli': 'Qoder agentic coding workflows',
|
|
485
|
+
'replit-cli': 'Replit Agent terminal coding workflows',
|
|
486
|
+
'roo-code-cli': 'Roo Code agentic coding workflows',
|
|
487
|
+
'trae-cn-cli': 'TRAE CN coding agent',
|
|
488
|
+
'warp-cli': 'Warp Agent terminal-native coding workflows',
|
|
489
|
+
'windsurf-cli': 'Windsurf agentic coding workflows',
|
|
490
|
+
'zencoder-cli': 'Zencoder agentic coding workflows',
|
|
460
491
|
}
|
|
461
492
|
|
|
462
493
|
/** Check if a provider ID is a CLI-based provider. */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { GENERIC_CLI_BINARIES, streamGenericCliChat } from './generic-cli'
|
|
5
|
+
|
|
6
|
+
describe('GENERIC_CLI_BINARIES', () => {
|
|
7
|
+
it('maps every generic CLI provider id to a non-empty binary name', () => {
|
|
8
|
+
for (const [providerId, binaryName] of Object.entries(GENERIC_CLI_BINARIES)) {
|
|
9
|
+
assert.equal(typeof binaryName, 'string', `${providerId} binary must be a string`)
|
|
10
|
+
assert.ok(binaryName.length > 0, `${providerId} binary must not be empty`)
|
|
11
|
+
assert.ok(providerId.endsWith('-cli'), `${providerId} should end with -cli for the registry pattern`)
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('does not collide with bespoke CLI provider ids', () => {
|
|
16
|
+
const bespoke = new Set([
|
|
17
|
+
'claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli',
|
|
18
|
+
'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose',
|
|
19
|
+
])
|
|
20
|
+
for (const id of Object.keys(GENERIC_CLI_BINARIES)) {
|
|
21
|
+
assert.equal(bespoke.has(id), false, `${id} must not collide with a bespoke CLI provider`)
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('streamGenericCliChat', () => {
|
|
27
|
+
it('streams stdout lines as deltas via the SSE write callback when the binary exists', async () => {
|
|
28
|
+
// Use `echo` as a stand-in binary. Every POSIX system has it on PATH.
|
|
29
|
+
// Note: `echo "<prompt>"` will print the prompt itself, which exercises
|
|
30
|
+
// the line-buffered pipe.
|
|
31
|
+
const writes: string[] = []
|
|
32
|
+
const active = new Map<string, unknown>()
|
|
33
|
+
|
|
34
|
+
const result = await streamGenericCliChat({
|
|
35
|
+
session: { id: 'test-session', cwd: process.cwd() },
|
|
36
|
+
message: 'hello world',
|
|
37
|
+
write: (data) => writes.push(data),
|
|
38
|
+
active,
|
|
39
|
+
loadHistory: () => [],
|
|
40
|
+
binaryName: 'echo',
|
|
41
|
+
displayName: 'Echo',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
assert.ok(result.includes('hello world'), `expected response to include the prompt, got: ${JSON.stringify(result)}`)
|
|
45
|
+
assert.ok(writes.length > 0, 'should have emitted at least one SSE event')
|
|
46
|
+
const allText = writes.join('')
|
|
47
|
+
assert.ok(allText.includes('hello world'), 'SSE stream should include the echoed prompt')
|
|
48
|
+
assert.ok(!allText.includes('"t":"err"'), 'should not emit an error event on a successful run')
|
|
49
|
+
assert.equal(active.size, 0, 'session should be removed from active map after close')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('emits an error event when the binary cannot be found on PATH', async () => {
|
|
53
|
+
const writes: string[] = []
|
|
54
|
+
const active = new Map<string, unknown>()
|
|
55
|
+
|
|
56
|
+
const result = await streamGenericCliChat({
|
|
57
|
+
session: { id: 'missing-bin-session', cwd: process.cwd() },
|
|
58
|
+
message: 'test',
|
|
59
|
+
write: (data) => writes.push(data),
|
|
60
|
+
active,
|
|
61
|
+
loadHistory: () => [],
|
|
62
|
+
binaryName: 'definitely-not-a-real-binary-zzz-' + Date.now(),
|
|
63
|
+
displayName: 'Nonexistent CLI',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
assert.equal(result, '', 'missing binary should produce empty response')
|
|
67
|
+
assert.equal(writes.length, 1, 'should emit exactly one error event')
|
|
68
|
+
assert.ok(writes[0].includes('"t":"err"'), 'event should carry the err type')
|
|
69
|
+
assert.ok(writes[0].includes('Nonexistent CLI not found'), 'error message should mention the display name')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
import type { StreamChatOptions } from './index'
|
|
3
|
+
import { log } from '../server/logger'
|
|
4
|
+
import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
|
|
5
|
+
import { resolveCliBinary, buildCliEnv, attachAbortHandler, isStderrNoise } from './cli-utils'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Map of swarmclaw provider id to the binary name we look up on PATH.
|
|
9
|
+
* Used by the generic CLI streamer for tools without a bespoke handler.
|
|
10
|
+
*/
|
|
11
|
+
export const GENERIC_CLI_BINARIES: Record<string, string> = {
|
|
12
|
+
'aider-cli': 'aider',
|
|
13
|
+
'amp-cli': 'amp',
|
|
14
|
+
'augment-cli': 'augment',
|
|
15
|
+
'adal-cli': 'adal',
|
|
16
|
+
'bob-cli': 'bob',
|
|
17
|
+
'cline-cli': 'cline',
|
|
18
|
+
'codebuddy-cli': 'codebuddy',
|
|
19
|
+
'command-code-cli': 'commandcode',
|
|
20
|
+
'continue-cli': 'continue',
|
|
21
|
+
'cortex-cli': 'cortex',
|
|
22
|
+
'crush-cli': 'crush',
|
|
23
|
+
'deepagents-cli': 'deepagents',
|
|
24
|
+
'firebender-cli': 'firebender',
|
|
25
|
+
'iflow-cli': 'iflow',
|
|
26
|
+
'junie-cli': 'junie',
|
|
27
|
+
'kilo-code-cli': 'kilocode',
|
|
28
|
+
'kimi-cli': 'kimi',
|
|
29
|
+
'kode-cli': 'kode',
|
|
30
|
+
'mcpjam-cli': 'mcpjam',
|
|
31
|
+
'mistral-vibe-cli': 'vibe',
|
|
32
|
+
'mux-cli': 'mux',
|
|
33
|
+
'neovate-cli': 'neovate',
|
|
34
|
+
'openhands-cli': 'openhands',
|
|
35
|
+
'pochi-cli': 'pochi',
|
|
36
|
+
'qoder-cli': 'qoder',
|
|
37
|
+
'replit-cli': 'replit',
|
|
38
|
+
'roo-code-cli': 'roo',
|
|
39
|
+
'trae-cn-cli': 'trae-cn',
|
|
40
|
+
'warp-cli': 'warp',
|
|
41
|
+
'windsurf-cli': 'windsurf',
|
|
42
|
+
'zencoder-cli': 'zencoder',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface GenericCliOptions extends StreamChatOptions {
|
|
46
|
+
binaryName: string
|
|
47
|
+
displayName: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generic streamer for CLI providers without a bespoke stream parser.
|
|
52
|
+
*
|
|
53
|
+
* Spawns the configured binary with the prompt as the final argv, captures
|
|
54
|
+
* stdout/stderr line-by-line, and emits each line as a delta. No JSON event
|
|
55
|
+
* parsing — callers that need structured event streams should use a tool-
|
|
56
|
+
* specific provider instead.
|
|
57
|
+
*/
|
|
58
|
+
export function streamGenericCliChat(opts: GenericCliOptions): Promise<string> {
|
|
59
|
+
const { session, message, systemPrompt, write, active, signal, binaryName, displayName } = opts
|
|
60
|
+
const processTimeoutMs = loadRuntimeSettings().cliProcessTimeoutMs
|
|
61
|
+
const binary = resolveCliBinary(binaryName)
|
|
62
|
+
if (!binary) {
|
|
63
|
+
const msg = `${displayName} not found. Install \`${binaryName}\` and ensure it is on your PATH, or remove this provider.`
|
|
64
|
+
write(`data: ${JSON.stringify({ t: 'err', text: msg })}\n\n`)
|
|
65
|
+
return Promise.resolve('')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const env = buildCliEnv()
|
|
69
|
+
const prompt = systemPrompt ? `[System instructions]\n${systemPrompt}\n\n${message}` : message
|
|
70
|
+
|
|
71
|
+
log.info('generic-cli', `Spawning: ${binary}`, {
|
|
72
|
+
binaryName,
|
|
73
|
+
cwd: session.cwd,
|
|
74
|
+
hasSystemPrompt: Boolean(systemPrompt),
|
|
75
|
+
promptLength: prompt.length,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const proc = spawn(binary, [prompt], {
|
|
79
|
+
cwd: session.cwd,
|
|
80
|
+
env,
|
|
81
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
timeout: processTimeoutMs,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
active.set(session.id, proc)
|
|
86
|
+
attachAbortHandler(proc, signal)
|
|
87
|
+
|
|
88
|
+
let fullResponse = ''
|
|
89
|
+
let buf = ''
|
|
90
|
+
let stderrText = ''
|
|
91
|
+
|
|
92
|
+
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
93
|
+
buf += chunk.toString()
|
|
94
|
+
const lines = buf.split('\n')
|
|
95
|
+
buf = lines.pop() || ''
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (!line) continue
|
|
98
|
+
fullResponse += `${line}\n`
|
|
99
|
+
write(`data: ${JSON.stringify({ t: 'd', text: `${line}\n` })}\n\n`)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
104
|
+
const text = chunk.toString()
|
|
105
|
+
stderrText += text
|
|
106
|
+
if (stderrText.length > 16_000) stderrText = stderrText.slice(-16_000)
|
|
107
|
+
if (isStderrNoise(text)) {
|
|
108
|
+
log.debug('generic-cli', `stderr noise [${binaryName}/${session.id}]`, text.slice(0, 400))
|
|
109
|
+
} else {
|
|
110
|
+
log.warn('generic-cli', `stderr [${binaryName}/${session.id}]`, text.slice(0, 400))
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
proc.on('close', (code, sig) => {
|
|
116
|
+
active.delete(session.id)
|
|
117
|
+
if (buf) {
|
|
118
|
+
fullResponse += buf
|
|
119
|
+
write(`data: ${JSON.stringify({ t: 'd', text: buf })}\n\n`)
|
|
120
|
+
buf = ''
|
|
121
|
+
}
|
|
122
|
+
if ((code ?? 0) !== 0 && !fullResponse.trim()) {
|
|
123
|
+
const msg = stderrText.trim()
|
|
124
|
+
? `${displayName} exited with code ${code ?? 'unknown'}${sig ? ` (${sig})` : ''}: ${stderrText.trim().slice(0, 1200)}`
|
|
125
|
+
: `${displayName} exited with code ${code ?? 'unknown'}${sig ? ` (${sig})` : ''} and returned no output.`
|
|
126
|
+
write(`data: ${JSON.stringify({ t: 'err', text: msg })}\n\n`)
|
|
127
|
+
}
|
|
128
|
+
log.info('generic-cli', `Process closed [${binaryName}]: code=${code} signal=${sig} response=${fullResponse.length}chars`)
|
|
129
|
+
resolve(fullResponse.trim())
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
proc.on('error', (err) => {
|
|
133
|
+
active.delete(session.id)
|
|
134
|
+
write(`data: ${JSON.stringify({ t: 'err', text: err.message })}\n\n`)
|
|
135
|
+
resolve(fullResponse.trim())
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
}
|