@swarmclawai/swarmclaw 1.7.0 → 1.7.2
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 +25 -9
- package/bin/swarmclaw.js +87 -0
- package/electron-dist/main.js +218 -0
- package/package.json +2 -2
- package/scripts/run-next-build.mjs +1 -1
- package/src/app/api/setup/check-provider/route.ts +5 -62
- package/src/app/api/setup/doctor/route.ts +19 -9
- package/src/app/home/page.tsx +19 -10
- package/src/cli/index.js +8 -2
- package/src/cli/index.ts +12 -3
- package/src/components/agents/inspector-panel.tsx +25 -3
- package/src/components/auth/setup-wizard/index.tsx +6 -2
- package/src/components/auth/setup-wizard/step-next.tsx +46 -39
- package/src/components/auth/setup-wizard/step-providers.tsx +113 -140
- package/src/components/auth/setup-wizard/types.ts +5 -2
- package/src/components/auth/setup-wizard/utils.test.ts +0 -19
- package/src/components/auth/setup-wizard/utils.ts +0 -69
- package/src/components/chat/chat-card.tsx +5 -0
- package/src/components/home/home-launchpad.tsx +123 -71
- package/src/components/layout/update-banner.tsx +43 -9
- package/src/lib/home-launchpad.test.ts +1 -31
- package/src/lib/home-launchpad.ts +0 -58
- package/src/lib/provider-sets.test.ts +19 -0
- package/src/lib/provider-sets.ts +8 -3
- package/src/lib/providers/cli-provider-metadata.test.ts +38 -0
- package/src/lib/providers/cli-provider-metadata.ts +208 -0
- package/src/lib/providers/cli-utils.test.ts +65 -1
- package/src/lib/providers/cli-utils.ts +26 -44
- package/src/lib/providers/codex-cli.ts +71 -75
- package/src/lib/providers/generic-cli.ts +2 -31
- package/src/lib/providers/index.ts +14 -44
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +189 -0
- package/src/lib/server/chat-execution/chat-turn-finalization.ts +26 -19
- package/src/lib/server/cli-provider-readiness.test.ts +45 -0
- package/src/lib/server/cli-provider-readiness.ts +84 -0
- package/src/lib/server/provider-health.test.ts +6 -0
- package/src/lib/server/provider-health.ts +2 -2
- package/src/lib/setup-defaults.test.ts +8 -0
- package/src/lib/setup-defaults.ts +38 -178
- package/src/stores/slices/session-slice.test.ts +40 -2
- package/src/stores/slices/session-slice.ts +41 -1
- package/tsconfig.json +1 -0
|
@@ -8,7 +8,8 @@ import { streamDroidCliChat } from './droid-cli'
|
|
|
8
8
|
import { streamCursorCliChat } from './cursor-cli'
|
|
9
9
|
import { streamQwenCodeCliChat } from './qwen-code-cli'
|
|
10
10
|
import { streamGooseChat } from './goose'
|
|
11
|
-
import { streamGenericCliChat
|
|
11
|
+
import { streamGenericCliChat } from './generic-cli'
|
|
12
|
+
import { GENERIC_CLI_PROVIDER_METADATA, isCliProviderId } from './cli-provider-metadata'
|
|
12
13
|
import { streamOpenAiChat } from './openai'
|
|
13
14
|
import { streamOllamaChat } from './ollama'
|
|
14
15
|
import { streamAnthropicChat } from './anthropic'
|
|
@@ -52,53 +53,22 @@ interface BuiltinProviderConfig extends ProviderInfo {
|
|
|
52
53
|
handler: ProviderHandler
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const GENERIC_CLI_DISPLAY_NAMES: Record<string, string> = {
|
|
56
|
-
'aider-cli': 'Aider CLI',
|
|
57
|
-
'amp-cli': 'Amp CLI',
|
|
58
|
-
'augment-cli': 'Augment CLI',
|
|
59
|
-
'adal-cli': 'AdaL CLI',
|
|
60
|
-
'bob-cli': 'IBM Bob CLI',
|
|
61
|
-
'cline-cli': 'Cline CLI',
|
|
62
|
-
'codebuddy-cli': 'CodeBuddy CLI',
|
|
63
|
-
'command-code-cli': 'Command Code CLI',
|
|
64
|
-
'continue-cli': 'Continue CLI',
|
|
65
|
-
'cortex-cli': 'Cortex Code CLI',
|
|
66
|
-
'crush-cli': 'Crush CLI',
|
|
67
|
-
'deepagents-cli': 'Deep Agents CLI',
|
|
68
|
-
'firebender-cli': 'Firebender CLI',
|
|
69
|
-
'iflow-cli': 'iFlow CLI',
|
|
70
|
-
'junie-cli': 'Junie CLI',
|
|
71
|
-
'kilo-code-cli': 'Kilo Code CLI',
|
|
72
|
-
'kimi-cli': 'Kimi Code CLI',
|
|
73
|
-
'kode-cli': 'Kode CLI',
|
|
74
|
-
'mcpjam-cli': 'MCPJam CLI',
|
|
75
|
-
'mistral-vibe-cli': 'Mistral Vibe CLI',
|
|
76
|
-
'mux-cli': 'Mux CLI',
|
|
77
|
-
'neovate-cli': 'Neovate CLI',
|
|
78
|
-
'openhands-cli': 'OpenHands CLI',
|
|
79
|
-
'pochi-cli': 'Pochi CLI',
|
|
80
|
-
'qoder-cli': 'Qoder CLI',
|
|
81
|
-
'replit-cli': 'Replit Agent CLI',
|
|
82
|
-
'roo-code-cli': 'Roo Code CLI',
|
|
83
|
-
'trae-cn-cli': 'TRAE CN CLI',
|
|
84
|
-
'warp-cli': 'Warp Agent CLI',
|
|
85
|
-
'windsurf-cli': 'Windsurf CLI',
|
|
86
|
-
'zencoder-cli': 'Zencoder CLI',
|
|
87
|
-
}
|
|
88
|
-
|
|
89
56
|
function buildGenericCliEntries(): Record<string, BuiltinProviderConfig> {
|
|
90
57
|
const entries: Record<string, BuiltinProviderConfig> = {}
|
|
91
|
-
for (const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
models: ['default'],
|
|
58
|
+
for (const provider of GENERIC_CLI_PROVIDER_METADATA) {
|
|
59
|
+
entries[provider.id] = {
|
|
60
|
+
id: provider.id,
|
|
61
|
+
name: provider.displayName,
|
|
62
|
+
models: [provider.defaultModel],
|
|
97
63
|
requiresApiKey: false,
|
|
98
|
-
optionalApiKey:
|
|
64
|
+
optionalApiKey: provider.optionalApiKey,
|
|
99
65
|
requiresEndpoint: false,
|
|
100
66
|
handler: {
|
|
101
|
-
streamChat: (opts) => streamGenericCliChat({
|
|
67
|
+
streamChat: (opts) => streamGenericCliChat({
|
|
68
|
+
...opts,
|
|
69
|
+
binaryName: provider.binaryName,
|
|
70
|
+
displayName: provider.displayName,
|
|
71
|
+
}),
|
|
102
72
|
},
|
|
103
73
|
}
|
|
104
74
|
}
|
|
@@ -530,7 +500,7 @@ export function getProviderList(): ProviderInfo[] {
|
|
|
530
500
|
...info,
|
|
531
501
|
models: overrides[info.id] || info.models,
|
|
532
502
|
defaultModels: info.models,
|
|
533
|
-
supportsModelDiscovery: !
|
|
503
|
+
supportsModelDiscovery: !isCliProviderId(info.id) && info.id !== 'fireworks',
|
|
534
504
|
}
|
|
535
505
|
})
|
|
536
506
|
|
|
@@ -109,6 +109,195 @@ test('executeSessionChatTurn syncs updated agent runtime fields onto its thread
|
|
|
109
109
|
assert.equal(output.connectorContext, null)
|
|
110
110
|
})
|
|
111
111
|
|
|
112
|
+
test('executeSessionChatTurn persists codex thread id discovered on run-session object', () => {
|
|
113
|
+
const output = runWithTempDataDir<{
|
|
114
|
+
codexThreadId: string | null
|
|
115
|
+
}>(`
|
|
116
|
+
const storageMod = await import('@/lib/server/storage')
|
|
117
|
+
const providersMod = await import('@/lib/providers/index')
|
|
118
|
+
const execMod = await import('@/lib/server/chat-execution/chat-execution')
|
|
119
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
120
|
+
const executeSessionChatTurn = execMod.executeSessionChatTurn
|
|
121
|
+
|| execMod.default?.executeSessionChatTurn
|
|
122
|
+
|| execMod['module.exports']?.executeSessionChatTurn
|
|
123
|
+
const providers = providersMod.PROVIDERS
|
|
124
|
+
|| providersMod.default?.PROVIDERS
|
|
125
|
+
|| providersMod['module.exports']?.PROVIDERS
|
|
126
|
+
|
|
127
|
+
providers['test-codex-sync-provider'] = {
|
|
128
|
+
id: 'test-codex-sync-provider',
|
|
129
|
+
name: 'Codex Sync Test Provider',
|
|
130
|
+
models: ['unit'],
|
|
131
|
+
requiresApiKey: false,
|
|
132
|
+
requiresEndpoint: false,
|
|
133
|
+
handler: {
|
|
134
|
+
async streamChat(opts) {
|
|
135
|
+
opts.session.codexThreadId = 'thread_sync_123'
|
|
136
|
+
return 'ok'
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
storage.saveAgents({
|
|
143
|
+
codexsync: {
|
|
144
|
+
id: 'codexsync',
|
|
145
|
+
name: 'Codex Sync Agent',
|
|
146
|
+
description: 'Codex thread id sync test',
|
|
147
|
+
provider: 'test-codex-sync-provider',
|
|
148
|
+
model: 'unit',
|
|
149
|
+
credentialId: null,
|
|
150
|
+
apiEndpoint: null,
|
|
151
|
+
fallbackCredentialIds: [],
|
|
152
|
+
disabled: false,
|
|
153
|
+
heartbeatEnabled: false,
|
|
154
|
+
heartbeatIntervalSec: null,
|
|
155
|
+
extensions: [],
|
|
156
|
+
createdAt: now,
|
|
157
|
+
updatedAt: now,
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
storage.saveSessions({
|
|
162
|
+
codex_session: {
|
|
163
|
+
id: 'codex_session',
|
|
164
|
+
name: 'Codex Session',
|
|
165
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
166
|
+
user: 'default',
|
|
167
|
+
provider: 'test-codex-sync-provider',
|
|
168
|
+
model: 'unit',
|
|
169
|
+
claudeSessionId: null,
|
|
170
|
+
codexThreadId: null,
|
|
171
|
+
opencodeSessionId: null,
|
|
172
|
+
geminiSessionId: null,
|
|
173
|
+
copilotSessionId: null,
|
|
174
|
+
droidSessionId: null,
|
|
175
|
+
cursorSessionId: null,
|
|
176
|
+
qwenSessionId: null,
|
|
177
|
+
acpSessionId: null,
|
|
178
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null, copilot: null, droid: null, cursor: null, qwen: null },
|
|
179
|
+
messages: [],
|
|
180
|
+
createdAt: now,
|
|
181
|
+
lastActiveAt: now,
|
|
182
|
+
sessionType: 'human',
|
|
183
|
+
agentId: 'codexsync',
|
|
184
|
+
extensions: [],
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
await executeSessionChatTurn({
|
|
189
|
+
sessionId: 'codex_session',
|
|
190
|
+
message: 'hello',
|
|
191
|
+
runId: 'run-codex-sync',
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const persisted = storage.loadSession('codex_session')
|
|
195
|
+
console.log(JSON.stringify({
|
|
196
|
+
codexThreadId: persisted?.codexThreadId || null,
|
|
197
|
+
}))
|
|
198
|
+
`)
|
|
199
|
+
|
|
200
|
+
assert.equal(output.codexThreadId, 'thread_sync_123')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('executeSessionChatTurn persists intentional codex resume id clears from run-session object', () => {
|
|
204
|
+
const output = runWithTempDataDir<{
|
|
205
|
+
codexThreadId: string | null
|
|
206
|
+
delegateCodex: string | null
|
|
207
|
+
}>(`
|
|
208
|
+
const storageMod = await import('@/lib/server/storage')
|
|
209
|
+
const providersMod = await import('@/lib/providers/index')
|
|
210
|
+
const execMod = await import('@/lib/server/chat-execution/chat-execution')
|
|
211
|
+
const storage = storageMod.default || storageMod['module.exports'] || storageMod
|
|
212
|
+
const executeSessionChatTurn = execMod.executeSessionChatTurn
|
|
213
|
+
|| execMod.default?.executeSessionChatTurn
|
|
214
|
+
|| execMod['module.exports']?.executeSessionChatTurn
|
|
215
|
+
const providers = providersMod.PROVIDERS
|
|
216
|
+
|| providersMod.default?.PROVIDERS
|
|
217
|
+
|| providersMod['module.exports']?.PROVIDERS
|
|
218
|
+
|
|
219
|
+
providers['test-codex-clear-provider'] = {
|
|
220
|
+
id: 'test-codex-clear-provider',
|
|
221
|
+
name: 'Codex Clear Test Provider',
|
|
222
|
+
models: ['unit'],
|
|
223
|
+
requiresApiKey: false,
|
|
224
|
+
requiresEndpoint: false,
|
|
225
|
+
handler: {
|
|
226
|
+
async streamChat(opts) {
|
|
227
|
+
opts.session.codexThreadId = null
|
|
228
|
+
opts.session.delegateResumeIds = {
|
|
229
|
+
...(opts.session.delegateResumeIds || {}),
|
|
230
|
+
codex: null,
|
|
231
|
+
}
|
|
232
|
+
return 'ok'
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const now = Date.now()
|
|
238
|
+
storage.saveAgents({
|
|
239
|
+
codexclear: {
|
|
240
|
+
id: 'codexclear',
|
|
241
|
+
name: 'Codex Clear Agent',
|
|
242
|
+
description: 'Codex resume id clear test',
|
|
243
|
+
provider: 'test-codex-clear-provider',
|
|
244
|
+
model: 'unit',
|
|
245
|
+
credentialId: null,
|
|
246
|
+
apiEndpoint: null,
|
|
247
|
+
fallbackCredentialIds: [],
|
|
248
|
+
disabled: false,
|
|
249
|
+
heartbeatEnabled: false,
|
|
250
|
+
heartbeatIntervalSec: null,
|
|
251
|
+
extensions: [],
|
|
252
|
+
createdAt: now,
|
|
253
|
+
updatedAt: now,
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
storage.saveSessions({
|
|
258
|
+
codex_clear_session: {
|
|
259
|
+
id: 'codex_clear_session',
|
|
260
|
+
name: 'Codex Clear Session',
|
|
261
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
262
|
+
user: 'default',
|
|
263
|
+
provider: 'test-codex-clear-provider',
|
|
264
|
+
model: 'unit',
|
|
265
|
+
claudeSessionId: null,
|
|
266
|
+
codexThreadId: 'thread_old_123',
|
|
267
|
+
opencodeSessionId: null,
|
|
268
|
+
geminiSessionId: null,
|
|
269
|
+
copilotSessionId: null,
|
|
270
|
+
droidSessionId: null,
|
|
271
|
+
cursorSessionId: null,
|
|
272
|
+
qwenSessionId: null,
|
|
273
|
+
acpSessionId: null,
|
|
274
|
+
delegateResumeIds: { claudeCode: null, codex: 'thread_old_123', opencode: null, gemini: null, copilot: null, droid: null, cursor: null, qwen: null },
|
|
275
|
+
messages: [],
|
|
276
|
+
createdAt: now,
|
|
277
|
+
lastActiveAt: now,
|
|
278
|
+
sessionType: 'human',
|
|
279
|
+
agentId: 'codexclear',
|
|
280
|
+
extensions: [],
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
await executeSessionChatTurn({
|
|
285
|
+
sessionId: 'codex_clear_session',
|
|
286
|
+
message: 'hello',
|
|
287
|
+
runId: 'run-codex-clear',
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const persisted = storage.loadSession('codex_clear_session')
|
|
291
|
+
console.log(JSON.stringify({
|
|
292
|
+
codexThreadId: persisted?.codexThreadId ?? null,
|
|
293
|
+
delegateCodex: persisted?.delegateResumeIds?.codex ?? null,
|
|
294
|
+
}))
|
|
295
|
+
`)
|
|
296
|
+
|
|
297
|
+
assert.equal(output.codexThreadId, null)
|
|
298
|
+
assert.equal(output.delegateCodex, null)
|
|
299
|
+
})
|
|
300
|
+
|
|
112
301
|
test('executeSessionChatTurn keeps tool-only heartbeats off the visible main-thread history and clears stale connector state', () => {
|
|
113
302
|
const output = runWithTempDataDir<{
|
|
114
303
|
connectorContext: Record<string, unknown> | null
|
|
@@ -435,18 +435,25 @@ export async function finalizeChatTurn(params: {
|
|
|
435
435
|
;(current as unknown as Record<string, unknown>)[key] = normalized
|
|
436
436
|
}
|
|
437
437
|
}
|
|
438
|
+
const preferRunValue = (runValue: unknown, fallbackValue: unknown) => (
|
|
439
|
+
runValue !== undefined ? runValue : fallbackValue
|
|
440
|
+
)
|
|
438
441
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
persistField('
|
|
442
|
-
persistField('
|
|
443
|
-
persistField('
|
|
444
|
-
persistField('
|
|
445
|
-
persistField('
|
|
446
|
-
persistField('
|
|
447
|
-
persistField('
|
|
448
|
-
|
|
449
|
-
|
|
442
|
+
// Provider handlers receive `sessionForRun` and may mutate CLI resume IDs there.
|
|
443
|
+
// Persist from run-session first, allowing null to intentionally clear IDs.
|
|
444
|
+
persistField('claudeSessionId', preferRunValue(sessionForRun.claudeSessionId, session.claudeSessionId))
|
|
445
|
+
persistField('codexThreadId', preferRunValue(sessionForRun.codexThreadId, session.codexThreadId))
|
|
446
|
+
persistField('opencodeSessionId', preferRunValue(sessionForRun.opencodeSessionId, session.opencodeSessionId))
|
|
447
|
+
persistField('geminiSessionId', preferRunValue(sessionForRun.geminiSessionId, session.geminiSessionId))
|
|
448
|
+
persistField('copilotSessionId', preferRunValue(sessionForRun.copilotSessionId, session.copilotSessionId))
|
|
449
|
+
persistField('droidSessionId', preferRunValue(sessionForRun.droidSessionId, session.droidSessionId))
|
|
450
|
+
persistField('cursorSessionId', preferRunValue(sessionForRun.cursorSessionId, session.cursorSessionId))
|
|
451
|
+
persistField('qwenSessionId', preferRunValue(sessionForRun.qwenSessionId, session.qwenSessionId))
|
|
452
|
+
persistField('acpSessionId', preferRunValue(sessionForRun.acpSessionId, session.acpSessionId))
|
|
453
|
+
|
|
454
|
+
const sourceResume = (sessionForRun.delegateResumeIds && typeof sessionForRun.delegateResumeIds === 'object')
|
|
455
|
+
? sessionForRun.delegateResumeIds
|
|
456
|
+
: session.delegateResumeIds
|
|
450
457
|
if (sourceResume && typeof sourceResume === 'object') {
|
|
451
458
|
const currentResume = (current.delegateResumeIds && typeof current.delegateResumeIds === 'object')
|
|
452
459
|
? current.delegateResumeIds
|
|
@@ -454,14 +461,14 @@ export async function finalizeChatTurn(params: {
|
|
|
454
461
|
const sr = sourceResume as Record<string, unknown>
|
|
455
462
|
const cr = currentResume as Record<string, unknown>
|
|
456
463
|
const nextResume = {
|
|
457
|
-
claudeCode: normalizeResumeId(sr.claudeCode
|
|
458
|
-
codex: normalizeResumeId(sr.codex
|
|
459
|
-
opencode: normalizeResumeId(sr.opencode
|
|
460
|
-
gemini: normalizeResumeId(sr.gemini
|
|
461
|
-
copilot: normalizeResumeId(sr.copilot
|
|
462
|
-
droid: normalizeResumeId(sr.droid
|
|
463
|
-
cursor: normalizeResumeId(sr.cursor
|
|
464
|
-
qwen: normalizeResumeId(sr.qwen
|
|
464
|
+
claudeCode: normalizeResumeId(preferRunValue(sr.claudeCode, cr.claudeCode)),
|
|
465
|
+
codex: normalizeResumeId(preferRunValue(sr.codex, cr.codex)),
|
|
466
|
+
opencode: normalizeResumeId(preferRunValue(sr.opencode, cr.opencode)),
|
|
467
|
+
gemini: normalizeResumeId(preferRunValue(sr.gemini, cr.gemini)),
|
|
468
|
+
copilot: normalizeResumeId(preferRunValue(sr.copilot, cr.copilot)),
|
|
469
|
+
droid: normalizeResumeId(preferRunValue(sr.droid, cr.droid)),
|
|
470
|
+
cursor: normalizeResumeId(preferRunValue(sr.cursor, cr.cursor)),
|
|
471
|
+
qwen: normalizeResumeId(preferRunValue(sr.qwen, cr.qwen)),
|
|
465
472
|
}
|
|
466
473
|
if (JSON.stringify(currentResume) !== JSON.stringify(nextResume)) {
|
|
467
474
|
current.delegateResumeIds = nextResume
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { checkCliProviderReady } from './cli-provider-readiness'
|
|
5
|
+
|
|
6
|
+
describe('checkCliProviderReady', () => {
|
|
7
|
+
it('accepts a generic CLI provider when its binary is present', () => {
|
|
8
|
+
const result = checkCliProviderReady('aider-cli', {
|
|
9
|
+
resolveBinary: (name) => name === 'aider' ? '/usr/local/bin/aider' : null,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
assert.equal(result.ok, true)
|
|
13
|
+
assert.equal(result.displayName, 'Aider CLI')
|
|
14
|
+
assert.equal(result.binaryName, 'aider')
|
|
15
|
+
assert.equal(result.generic, true)
|
|
16
|
+
assert.match(result.message, /binary is available/)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('reports a missing generic CLI binary with install guidance', () => {
|
|
20
|
+
const result = checkCliProviderReady('windsurf-cli', {
|
|
21
|
+
resolveBinary: () => null,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
assert.equal(result.ok, false)
|
|
25
|
+
assert.equal(result.displayName, 'Windsurf CLI')
|
|
26
|
+
assert.equal(result.binaryName, 'windsurf')
|
|
27
|
+
assert.match(result.message, /Install `windsurf`/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps auth-aware checks for bespoke CLI providers', () => {
|
|
31
|
+
const result = checkCliProviderReady('claude-cli', {
|
|
32
|
+
resolveBinary: () => '/usr/local/bin/claude',
|
|
33
|
+
env: { ...process.env },
|
|
34
|
+
probeAuth: () => ({
|
|
35
|
+
authenticated: false,
|
|
36
|
+
errorMessage: 'Claude CLI is not authenticated.',
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
assert.equal(result.ok, false)
|
|
41
|
+
assert.equal(result.displayName, 'Claude Code CLI')
|
|
42
|
+
assert.equal(result.generic, false)
|
|
43
|
+
assert.equal(result.message, 'Claude CLI is not authenticated.')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLI_PROVIDER_METADATA_BY_ID,
|
|
3
|
+
isCliProviderId,
|
|
4
|
+
type CliAuthBackend,
|
|
5
|
+
type CliProviderMetadata,
|
|
6
|
+
} from '@/lib/providers/cli-provider-metadata'
|
|
7
|
+
import { buildCliEnv, probeCliAuth, resolveCliBinary, type AuthProbeResult } from '@/lib/providers/cli-utils'
|
|
8
|
+
|
|
9
|
+
export interface CliProviderReadyResult {
|
|
10
|
+
ok: boolean
|
|
11
|
+
message: string
|
|
12
|
+
providerId?: string
|
|
13
|
+
displayName?: string
|
|
14
|
+
binaryName?: string
|
|
15
|
+
binaryPath?: string
|
|
16
|
+
generic?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CheckCliProviderReadyOptions {
|
|
20
|
+
cwd?: string
|
|
21
|
+
env?: NodeJS.ProcessEnv
|
|
22
|
+
resolveBinary?: (name: string) => string | null
|
|
23
|
+
probeAuth?: (
|
|
24
|
+
binary: string,
|
|
25
|
+
backend: CliAuthBackend,
|
|
26
|
+
env: NodeJS.ProcessEnv,
|
|
27
|
+
cwd?: string,
|
|
28
|
+
) => AuthProbeResult
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function missingBinaryMessage(meta: CliProviderMetadata): string {
|
|
32
|
+
return `${meta.displayName} is not installed. Install \`${meta.binaryName}\` and ensure it is on your PATH.`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function checkCliProviderReady(
|
|
36
|
+
providerId: string,
|
|
37
|
+
options: CheckCliProviderReadyOptions = {},
|
|
38
|
+
): CliProviderReadyResult {
|
|
39
|
+
if (!isCliProviderId(providerId)) {
|
|
40
|
+
return { ok: false, message: 'Unknown CLI provider.', providerId }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const meta = CLI_PROVIDER_METADATA_BY_ID[providerId]
|
|
44
|
+
const resolveBinary = options.resolveBinary || resolveCliBinary
|
|
45
|
+
const binaryPath = resolveBinary(meta.binaryName)
|
|
46
|
+
if (!binaryPath) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
message: missingBinaryMessage(meta),
|
|
50
|
+
providerId,
|
|
51
|
+
displayName: meta.displayName,
|
|
52
|
+
binaryName: meta.binaryName,
|
|
53
|
+
generic: meta.generic,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (meta.authBackend) {
|
|
58
|
+
const env = options.env || buildCliEnv()
|
|
59
|
+
const auth = (options.probeAuth || probeCliAuth)(binaryPath, meta.authBackend, env, options.cwd || process.cwd())
|
|
60
|
+
if (!auth.authenticated) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
message: auth.errorMessage || `${meta.displayName} is not configured.`,
|
|
64
|
+
providerId,
|
|
65
|
+
displayName: meta.displayName,
|
|
66
|
+
binaryName: meta.binaryName,
|
|
67
|
+
binaryPath,
|
|
68
|
+
generic: meta.generic,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
message: meta.authBackend
|
|
76
|
+
? `${meta.displayName} is installed and ready.`
|
|
77
|
+
: `${meta.displayName} binary is available. If it requires account setup, complete that in \`${meta.binaryName}\` before running agent turns.`,
|
|
78
|
+
providerId,
|
|
79
|
+
displayName: meta.displayName,
|
|
80
|
+
binaryName: meta.binaryName,
|
|
81
|
+
binaryPath,
|
|
82
|
+
generic: meta.generic,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -179,4 +179,10 @@ describe('provider-health', () => {
|
|
|
179
179
|
globalThis.fetch = originalFetch
|
|
180
180
|
}
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
it('skips extended CLI providers instead of treating them as HTTP providers', async () => {
|
|
184
|
+
const result = await providerHealth.pingProvider('aider-cli', undefined, undefined)
|
|
185
|
+
assert.equal(result.ok, true)
|
|
186
|
+
assert.equal(result.message, 'CLI provider - skipped.')
|
|
187
|
+
})
|
|
182
188
|
})
|
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'child_process'
|
|
|
2
2
|
import { errorMessage, hmrSingleton, jitteredBackoff } from '@/lib/shared-utils'
|
|
3
3
|
import { upsertStoredItem, loadCollection } from './storage'
|
|
4
4
|
import { log } from './logger'
|
|
5
|
+
import { isCliProviderId } from '@/lib/providers/cli-provider-metadata'
|
|
5
6
|
|
|
6
7
|
const TAG = 'provider-health'
|
|
7
8
|
|
|
@@ -352,9 +353,8 @@ export async function pingProvider(
|
|
|
352
353
|
apiKey: string | undefined,
|
|
353
354
|
endpoint: string | undefined,
|
|
354
355
|
): Promise<{ ok: boolean; message: string }> {
|
|
355
|
-
const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose']
|
|
356
356
|
const OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS = new Set(['hermes'])
|
|
357
|
-
if (
|
|
357
|
+
if (isCliProviderId(provider)) return { ok: true, message: 'CLI provider - skipped.' }
|
|
358
358
|
|
|
359
359
|
try {
|
|
360
360
|
if (provider === 'anthropic') {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { test } from 'node:test'
|
|
3
|
+
import { CLI_PROVIDER_METADATA } from './providers/cli-provider-metadata'
|
|
3
4
|
import { DEFAULT_AGENTS, getDefaultModelForProvider } from './setup-defaults'
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
@@ -48,6 +49,13 @@ test('getDefaultModelForProvider returns expected defaults for cursor, qwen, and
|
|
|
48
49
|
assert.equal(getDefaultModelForProvider('goose'), 'default')
|
|
49
50
|
})
|
|
50
51
|
|
|
52
|
+
test('every CLI provider has setup default agent coverage', () => {
|
|
53
|
+
for (const provider of CLI_PROVIDER_METADATA) {
|
|
54
|
+
assert.equal(getDefaultModelForProvider(provider.id), provider.defaultModel)
|
|
55
|
+
assert.ok(DEFAULT_AGENTS[provider.id].description.includes(provider.displayName))
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
51
59
|
test('getDefaultModelForProvider returns non-empty for ollama', () => {
|
|
52
60
|
const model = getDefaultModelForProvider('ollama')
|
|
53
61
|
assert.ok(model, 'ollama model should be truthy')
|