@swarmclawai/swarmclaw 1.7.1 → 1.7.3

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 CHANGED
@@ -399,6 +399,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.7.3 Highlights
403
+
404
+ Desktop packaging fix for Linux AppImage and deb builds.
405
+
406
+ - **Linux desktop native modules match Electron.** Packaged Linux builds now copy Electron-rebuilt native addons into the embedded Next standalone server, fixing the `better-sqlite3` Node ABI mismatch reported in [#65](https://github.com/swarmclawai/swarmclaw/issues/65).
407
+ - **Desktop packaging regression coverage.** The Electron `afterPack` hook now has a Linux standalone native-module sync test wired into `npm run test:cli`.
408
+ - **macOS desktop note.** macOS builds remain ad-hoc signed and not notarized in this release, so the existing Gatekeeper/quarantine workaround still applies until Developer ID signing is available.
409
+
410
+ ### v1.7.2 Highlights
411
+
412
+ CLI provider usability follow-up for v1.7.0/v1.7.1. The expanded coding-agent roster is now easier to find, configure, and validate from onboarding and setup diagnostics.
413
+
414
+ - **Shared CLI provider registry.** Bespoke and generic CLI providers now share one metadata source for display names, binary names, capabilities, setup defaults, and provider-set behavior, reducing drift across onboarding, runtime routing, setup doctor, and capability prompts.
415
+ - **Onboarding exposes the full CLI roster.** The setup wizard groups providers by CLI agents, gateways/local runtimes, API providers, and custom endpoints, with search so the 31 extended CLI providers added in v1.7 are usable without digging through settings.
416
+ - **Connection checks for every CLI provider.** Bespoke CLIs keep auth-aware checks, while generic CLIs verify that the expected binary is on PATH and return actionable install guidance when missing.
417
+ - **Update banner polish.** Source installs now show the target stable tag/version, remember dismissal per release target, and make the required restart after update explicit.
418
+ - **macOS desktop note.** macOS builds remain ad-hoc signed and not notarized in this release, so the existing Gatekeeper/quarantine workaround still applies until Developer ID signing is available.
419
+
402
420
  ### v1.7.1 Highlights
403
421
 
404
422
  Republish of v1.7.0 from the correct commit. The v1.7.0 tarball on npm was inadvertently published from a pre-rebase tree that did not include the v1.6.1 codex continuity fixes (PR #62) or the v1.6.2 plan doc. v1.7.1 ships the same coding-agent-roster expansion on top of the correct v1.6.1+ history.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -84,10 +84,10 @@
84
84
  "lint:baseline": "node ./scripts/lint-baseline.mjs check",
85
85
  "lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
86
86
  "cli": "node ./bin/swarmclaw.js",
87
- "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
87
+ "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
88
88
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
89
89
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
90
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
91
91
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
92
92
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
93
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -11,7 +11,7 @@ import { ensureBuildBootstrapPaths } from './build-bootstrap-env.mjs'
11
11
 
12
12
  const require = createRequire(import.meta.url)
13
13
 
14
- export const DEFAULT_MAX_OLD_SPACE_SIZE_MB = '8192'
14
+ export const DEFAULT_MAX_OLD_SPACE_SIZE_MB = '12288'
15
15
  export const MIN_MAX_OLD_SPACE_SIZE_MB = 1024
16
16
  export const FALLBACK_MIN_MAX_OLD_SPACE_SIZE_MB = 512
17
17
  export const RESERVED_BUILD_MEMORY_MB = 768
@@ -2,39 +2,12 @@ import { NextResponse } from 'next/server'
2
2
  import { loadCredentials, decryptKey, loadProviderConfigs } from '@/lib/server/storage'
3
3
  import { listCredentialIdsByProvider } from '@/lib/server/credentials/credential-service'
4
4
  import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/openclaw'
5
- import { buildCliEnv, probeCliAuth, resolveCliBinary } from '@/lib/providers/cli-utils'
5
+ import { isCliProviderId } from '@/lib/providers/cli-provider-metadata'
6
+ import { checkCliProviderReady } from '@/lib/server/cli-provider-readiness'
6
7
  import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
7
8
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
8
9
  import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
9
10
 
10
- type SetupProvider =
11
- | 'claude-cli'
12
- | 'codex-cli'
13
- | 'opencode-cli'
14
- | 'gemini-cli'
15
- | 'copilot-cli'
16
- | 'droid-cli'
17
- | 'cursor-cli'
18
- | 'qwen-code-cli'
19
- | 'goose'
20
- | 'openai'
21
- | 'openrouter'
22
- | 'anthropic'
23
- | 'google'
24
- | 'deepseek'
25
- | 'groq'
26
- | 'together'
27
- | 'mistral'
28
- | 'xai'
29
- | 'fireworks'
30
- | 'nebius'
31
- | 'deepinfra'
32
- | 'ollama'
33
- | 'openclaw'
34
- | 'hermes'
35
-
36
- type CliSetupProvider = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'gemini-cli' | 'copilot-cli' | 'droid-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose'
37
-
38
11
  interface SetupCheckBody {
39
12
  provider?: string
40
13
  apiKey?: string
@@ -280,43 +253,13 @@ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok:
280
253
  return { ok: true, message: 'Connected to OpenClaw gateway.', normalizedEndpoint, deviceId, recommendedModel }
281
254
  }
282
255
 
283
- function checkCliProvider(provider: CliSetupProvider): { ok: boolean; message: string } {
284
- const env = buildCliEnv()
285
- const config = {
286
- 'claude-cli': { binary: 'claude', backend: 'claude' as const, label: 'Claude Code CLI' },
287
- 'codex-cli': { binary: 'codex', backend: 'codex' as const, label: 'OpenAI Codex CLI' },
288
- 'opencode-cli': { binary: 'opencode', backend: 'opencode' as const, label: 'OpenCode CLI' },
289
- 'gemini-cli': { binary: 'gemini', backend: 'gemini' as const, label: 'Gemini CLI' },
290
- 'copilot-cli': { binary: 'copilot', backend: 'copilot' as const, label: 'GitHub Copilot CLI' },
291
- 'droid-cli': { binary: 'droid', backend: 'droid' as const, label: 'Factory Droid CLI' },
292
- 'cursor-cli': { binary: 'cursor-agent', backend: 'cursor' as const, label: 'Cursor Agent CLI' },
293
- 'qwen-code-cli': { binary: 'qwen', backend: 'qwen' as const, label: 'Qwen Code CLI' },
294
- goose: { binary: 'goose', backend: 'goose' as const, label: 'Goose CLI' },
295
- }[provider]
296
-
297
- if (!config) return { ok: false, message: 'Unknown CLI provider.' }
298
- const binary = resolveCliBinary(config.binary)
299
- if (!binary) {
300
- return {
301
- ok: false,
302
- message: `${config.label} is not installed. Install \`${config.binary}\` and ensure it is on your PATH.`,
303
- }
304
- }
305
- const auth = probeCliAuth(binary, config.backend, env, process.cwd())
306
- if (!auth.authenticated) {
307
- return { ok: false, message: auth.errorMessage || `${config.label} is not configured.` }
308
- }
309
- return { ok: true, message: `${config.label} is installed and ready.` }
310
- }
311
-
312
256
  export async function POST(req: Request) {
313
257
  const body = parseBody(await req.json().catch(() => ({})))
314
- const provider = clean(body.provider) as SetupProvider
258
+ const provider = clean(body.provider)
315
259
  let apiKey = clean(body.apiKey)
316
260
  const credentialId = clean(body.credentialId)
317
261
  let endpoint = clean(body.endpoint)
318
262
  const model = clean(body.model)
319
- const CLI_PROVIDERS = new Set<CliSetupProvider>(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose'])
320
263
 
321
264
  // Resolve credentialId to an API key if no raw key was provided
322
265
  if (!apiKey && credentialId) {
@@ -355,8 +298,8 @@ export async function POST(req: Request) {
355
298
  } catch { /* best effort */ }
356
299
  }
357
300
 
358
- if (CLI_PROVIDERS.has(provider as CliSetupProvider)) {
359
- const result = checkCliProvider(provider as CliSetupProvider)
301
+ if (isCliProviderId(provider)) {
302
+ const result = checkCliProviderReady(provider)
360
303
  return NextResponse.json(result)
361
304
  }
362
305
 
@@ -6,6 +6,7 @@ import { DATA_DIR } from '@/lib/server/data-dir'
6
6
  import { loadAgents, loadCredentials, loadSettings, loadCollection } from '@/lib/server/storage'
7
7
  import { dedup, errorMessage } from '@/lib/shared-utils'
8
8
  import { detectDocker } from '@/lib/server/sandbox/docker-detect'
9
+ import { BESPOKE_CLI_PROVIDER_METADATA, GENERIC_CLI_PROVIDER_METADATA } from '@/lib/providers/cli-provider-metadata'
9
10
 
10
11
  type CheckStatus = 'pass' | 'warn' | 'fail'
11
12
 
@@ -198,15 +199,11 @@ export async function GET(req: Request) {
198
199
  } catch { /* best-effort */ }
199
200
 
200
201
  const optionalBinaries: Array<{ id: string; label: string; command: string }> = [
201
- { id: 'claude-cli', label: 'Claude Code CLI', command: 'claude' },
202
- { id: 'codex-cli', label: 'OpenAI Codex CLI', command: 'codex' },
203
- { id: 'opencode-cli', label: 'OpenCode CLI', command: 'opencode' },
204
- { id: 'gemini-cli', label: 'Gemini CLI', command: 'gemini' },
205
- { id: 'copilot-cli', label: 'GitHub Copilot CLI', command: 'copilot' },
206
- { id: 'droid-cli', label: 'Factory Droid CLI', command: 'droid' },
207
- { id: 'cursor-cli', label: 'Cursor Agent CLI', command: 'cursor-agent' },
208
- { id: 'qwen-code-cli', label: 'Qwen Code CLI', command: 'qwen' },
209
- { id: 'goose', label: 'Goose CLI', command: 'goose' },
202
+ ...BESPOKE_CLI_PROVIDER_METADATA.map((provider) => ({
203
+ id: provider.id,
204
+ label: provider.displayName,
205
+ command: provider.binaryName,
206
+ })),
210
207
  { id: 'google-workspace-cli', label: 'Google Workspace CLI', command: 'gws' },
211
208
  ]
212
209
 
@@ -223,6 +220,19 @@ export async function GET(req: Request) {
223
220
  )
224
221
  }
225
222
 
223
+ const genericCliInstalled = GENERIC_CLI_PROVIDER_METADATA
224
+ .filter((provider) => commandExists(provider.binaryName))
225
+ .map((provider) => provider.displayName)
226
+ pushCheck(
227
+ checks,
228
+ 'extended-cli-providers',
229
+ 'Extended CLI provider roster',
230
+ genericCliInstalled.length > 0 ? 'pass' : 'warn',
231
+ genericCliInstalled.length > 0
232
+ ? `${genericCliInstalled.length} extended CLI provider(s) detected: ${genericCliInstalled.slice(0, 8).join(', ')}${genericCliInstalled.length > 8 ? ', ...' : ''}.`
233
+ : `${GENERIC_CLI_PROVIDER_METADATA.length} optional extended CLI provider(s) are available in SwarmClaw; install any matching CLI when you want to use it.`,
234
+ )
235
+
226
236
  const extensionSettings = (settings?.extensionSettings && typeof settings.extensionSettings === 'object')
227
237
  ? settings.extensionSettings as Record<string, Record<string, unknown>>
228
238
  : {}
@@ -18,6 +18,7 @@ export function StepProviders({
18
18
  onContinue,
19
19
  onSkip,
20
20
  }: StepProvidersProps) {
21
+ const [providerSearch, setProviderSearch] = useState('')
21
22
  const [doctorState, setDoctorState] = useState<'idle' | 'checking' | 'done' | 'error'>('idle')
22
23
  const [doctorError, setDoctorError] = useState('')
23
24
  const [doctorReport, setDoctorReport] = useState<SetupDoctorResponse | null>(null)
@@ -36,6 +37,23 @@ export function StepProviders({
36
37
  }
37
38
  }
38
39
 
40
+ const normalizedSearch = providerSearch.trim().toLowerCase()
41
+ const visibleProviders = SETUP_PROVIDERS.filter((candidate) => {
42
+ if (!normalizedSearch) return true
43
+ return [
44
+ candidate.name,
45
+ candidate.description,
46
+ candidate.badge || '',
47
+ candidate.id,
48
+ ].some((part) => part.toLowerCase().includes(normalizedSearch))
49
+ })
50
+ const providerGroups = [
51
+ { id: 'cli', label: 'CLI Agents', items: visibleProviders.filter((candidate) => candidate.category === 'cli') },
52
+ { id: 'gateway', label: 'Gateways and Local Runtimes', items: visibleProviders.filter((candidate) => candidate.category === 'gateway' || candidate.category === 'local') },
53
+ { id: 'api', label: 'API Providers', items: visibleProviders.filter((candidate) => !candidate.category || candidate.category === 'api') },
54
+ { id: 'custom', label: 'Custom', items: visibleProviders.filter((candidate) => candidate.category === 'custom') },
55
+ ].filter((group) => group.items.length > 0)
56
+
39
57
  return (
40
58
  <StepShell>
41
59
  <h1 className="font-display text-[36px] font-800 leading-[1.05] tracking-[-0.04em] mb-3">
@@ -50,51 +68,72 @@ export function StepProviders({
50
68
 
51
69
  <ConfiguredProviderChips providers={configuredProviders} onRemove={onRemoveProvider} />
52
70
 
71
+ <input
72
+ type="search"
73
+ value={providerSearch}
74
+ onChange={(e) => setProviderSearch(e.target.value)}
75
+ placeholder="Search providers, CLIs, or runtimes..."
76
+ className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface text-text text-[13px]
77
+ outline-none transition-all duration-200 placeholder:text-text-3/50 focus:border-accent-bright/30 mb-4"
78
+ />
79
+
53
80
  <div className="flex flex-col gap-3 max-h-[42vh] overflow-y-auto pr-1">
54
- {SETUP_PROVIDERS.map((candidate) => {
55
- const isConfigured = configuredProviderIds.has(candidate.id)
56
- return (
57
- <button
58
- key={candidate.id}
59
- onClick={() => onSelectProvider(candidate.id)}
60
- className={`w-full px-5 py-4 rounded-[14px] border bg-surface text-left
61
- transition-all duration-200 flex items-start gap-4 cursor-pointer
62
- ${isConfigured
63
- ? 'border-emerald-500/25 hover:border-emerald-500/40 hover:bg-surface-hover'
64
- : 'border-white/[0.08] hover:border-accent-bright/30 hover:bg-surface-hover'
65
- }`}
66
- >
67
- <div className={`w-10 h-10 rounded-[10px] border flex items-center justify-center shrink-0 mt-0.5 ${
68
- isConfigured ? 'bg-emerald-500/10 border-emerald-500/20' : 'bg-white/[0.04] border-white/[0.06]'
69
- }`}>
70
- <span className={`text-[16px] font-display font-700 ${isConfigured ? 'text-emerald-400' : 'text-accent-bright'}`}>
71
- {candidate.icon}
72
- </span>
73
- </div>
74
- <div className="flex-1">
75
- <div className="text-[15px] font-display font-600 text-text mb-1">
76
- {candidate.name}
77
- {isConfigured ? (
78
- <span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-300 text-[10px] uppercase tracking-[0.08em] font-600">
79
- Connected · Edit
80
- </span>
81
- ) : candidate.badge ? (
82
- <span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-accent-bright/15 text-accent-bright text-[10px] uppercase tracking-[0.08em] font-600">
83
- {candidate.badge}
81
+ {providerGroups.map((group) => (
82
+ <div key={group.id} className="space-y-2">
83
+ <div className="px-1 text-[10px] font-700 uppercase tracking-[0.1em] text-text-3/70">
84
+ {group.label}
85
+ </div>
86
+ {group.items.map((candidate) => {
87
+ const isConfigured = configuredProviderIds.has(candidate.id)
88
+ return (
89
+ <button
90
+ key={candidate.id}
91
+ onClick={() => onSelectProvider(candidate.id)}
92
+ className={`w-full px-5 py-4 rounded-[14px] border bg-surface text-left
93
+ transition-all duration-200 flex items-start gap-4 cursor-pointer
94
+ ${isConfigured
95
+ ? 'border-emerald-500/25 hover:border-emerald-500/40 hover:bg-surface-hover'
96
+ : 'border-white/[0.08] hover:border-accent-bright/30 hover:bg-surface-hover'
97
+ }`}
98
+ >
99
+ <div className={`w-10 h-10 rounded-[10px] border flex items-center justify-center shrink-0 mt-0.5 ${
100
+ isConfigured ? 'bg-emerald-500/10 border-emerald-500/20' : 'bg-white/[0.04] border-white/[0.06]'
101
+ }`}>
102
+ <span className={`text-[16px] font-display font-700 ${isConfigured ? 'text-emerald-400' : 'text-accent-bright'}`}>
103
+ {candidate.icon}
84
104
  </span>
85
- ) : null}
86
- </div>
87
- <div className="text-[13px] text-text-3 leading-relaxed">{candidate.description}</div>
88
- {!candidate.requiresKey && !isConfigured && (
89
- <div className="mt-1.5 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 text-[11px] font-500">
90
- <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
91
- No API key required
92
105
  </div>
93
- )}
94
- </div>
95
- </button>
96
- )
97
- })}
106
+ <div className="flex-1">
107
+ <div className="text-[15px] font-display font-600 text-text mb-1">
108
+ {candidate.name}
109
+ {isConfigured ? (
110
+ <span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-300 text-[10px] uppercase tracking-[0.08em] font-600">
111
+ Connected · Edit
112
+ </span>
113
+ ) : candidate.badge ? (
114
+ <span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-accent-bright/15 text-accent-bright text-[10px] uppercase tracking-[0.08em] font-600">
115
+ {candidate.badge}
116
+ </span>
117
+ ) : null}
118
+ </div>
119
+ <div className="text-[13px] text-text-3 leading-relaxed">{candidate.description}</div>
120
+ {!candidate.requiresKey && !isConfigured && (
121
+ <div className="mt-1.5 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 text-[11px] font-500">
122
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
123
+ No API key required
124
+ </div>
125
+ )}
126
+ </div>
127
+ </button>
128
+ )
129
+ })}
130
+ </div>
131
+ ))}
132
+ {providerGroups.length === 0 && (
133
+ <div className="px-5 py-6 rounded-[14px] border border-white/[0.08] bg-surface text-center text-[13px] text-text-3">
134
+ No providers match that search.
135
+ </div>
136
+ )}
98
137
  </div>
99
138
 
100
139
  <div className="mt-4 text-left">
@@ -1,22 +1,49 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useState } from 'react'
4
+ import { safeStorageGet, safeStorageSet } from '@/lib/app/safe-storage'
4
5
 
5
6
  const CHECK_INTERVAL = 5 * 60_000 // 5 minutes
7
+ const DISMISSED_UPDATE_KEY = 'sc_update_banner_dismissed_target'
6
8
 
7
9
  type VersionInfo = {
8
- localSha: string
9
- remoteSha: string
10
+ source: 'git' | 'package'
11
+ version: string
12
+ localSha: string | null
13
+ localTag: string | null
14
+ remoteSha: string | null
15
+ remoteTag: string | null
16
+ channel: 'stable' | 'main'
10
17
  updateAvailable: boolean
11
18
  behindBy: number
12
19
  }
13
20
 
21
+ type UpdateResponse = {
22
+ success: boolean
23
+ newSha?: string
24
+ targetTag?: string | null
25
+ channel?: 'stable' | 'main'
26
+ needsRestart?: boolean
27
+ error?: string
28
+ }
29
+
14
30
  type UpdateState = 'idle' | 'updating' | 'done' | 'error'
15
31
 
32
+ function updateTargetKey(version: VersionInfo): string {
33
+ return version.remoteTag || version.remoteSha || `${version.channel}:${version.behindBy}`
34
+ }
35
+
36
+ function updateTargetLabel(version: VersionInfo): string {
37
+ if (version.remoteTag) return version.remoteTag
38
+ if (version.remoteSha) return `${version.channel === 'stable' ? 'stable release' : 'main'} ${version.remoteSha}`
39
+ return 'latest release'
40
+ }
41
+
16
42
  export function UpdateBanner() {
17
43
  const [version, setVersion] = useState<VersionInfo | null>(null)
18
44
  const [updateState, setUpdateState] = useState<UpdateState>('idle')
19
- const [dismissed, setDismissed] = useState<string | null>(null)
45
+ const [dismissed, setDismissed] = useState<string | null>(() => safeStorageGet(DISMISSED_UPDATE_KEY))
46
+ const [appliedTarget, setAppliedTarget] = useState<string | null>(null)
20
47
  const [errorMsg, setErrorMsg] = useState('')
21
48
 
22
49
  useEffect(() => {
@@ -36,8 +63,9 @@ export function UpdateBanner() {
36
63
  setErrorMsg('')
37
64
  try {
38
65
  const res = await fetch('/api/version/update', { method: 'POST' })
39
- const data = await res.json()
66
+ const data = await res.json() as UpdateResponse
40
67
  if (data.success) {
68
+ setAppliedTarget(data.targetTag || data.newSha || (version ? updateTargetLabel(version) : null))
41
69
  setUpdateState('done')
42
70
  } else {
43
71
  setUpdateState('error')
@@ -50,12 +78,17 @@ export function UpdateBanner() {
50
78
  }
51
79
 
52
80
  const handleDismiss = () => {
53
- if (version) setDismissed(version.remoteSha)
81
+ if (!version) return
82
+ const target = updateTargetKey(version)
83
+ setDismissed(target)
84
+ safeStorageSet(DISMISSED_UPDATE_KEY, target)
54
85
  }
55
86
 
56
87
  // Don't show if no update, or user dismissed this specific remote SHA
57
88
  if (!version?.updateAvailable) return null
58
- if (dismissed === version.remoteSha && updateState === 'idle') return null
89
+ if (dismissed === updateTargetKey(version) && updateState === 'idle') return null
90
+
91
+ const targetLabel = updateTargetLabel(version)
59
92
 
60
93
  return (
61
94
  <div className="px-4 py-1.5 border-b border-white/[0.04] text-[10px] flex items-center gap-2 shrink-0 bg-accent-bright/[0.04]">
@@ -65,7 +98,8 @@ export function UpdateBanner() {
65
98
  <path d="M12 19V5M5 12l7-7 7 7" />
66
99
  </svg>
67
100
  <span className="text-text-3 flex-1 min-w-0 truncate">
68
- <span className="text-accent-bright font-600">{version.behindBy}</span> update{version.behindBy !== 1 ? 's' : ''} available
101
+ <span className="text-accent-bright font-600">{targetLabel}</span> available
102
+ {version.behindBy > 0 ? ` - ${version.behindBy} commit${version.behindBy === 1 ? '' : 's'} ahead` : ''}
69
103
  </span>
70
104
  <button
71
105
  onClick={handleUpdate}
@@ -90,7 +124,7 @@ export function UpdateBanner() {
90
124
  <>
91
125
  <span className="w-3 h-3 border-[1.5px] border-accent-bright/30 border-t-accent-bright rounded-full shrink-0"
92
126
  style={{ animation: 'spin 0.8s linear infinite' }} />
93
- <span className="text-text-3">Updating...</span>
127
+ <span className="text-text-3">Updating to {targetLabel}...</span>
94
128
  </>
95
129
  )}
96
130
 
@@ -99,7 +133,7 @@ export function UpdateBanner() {
99
133
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-success shrink-0">
100
134
  <polyline points="20 6 9 17 4 12" />
101
135
  </svg>
102
- <span className="text-text-3 flex-1">Updated! Restart to apply.</span>
136
+ <span className="text-text-3 flex-1">Updated{appliedTarget ? ` to ${appliedTarget}` : ''}. Restart SwarmClaw to apply.</span>
103
137
  </>
104
138
  )}
105
139
 
@@ -0,0 +1,19 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { GENERIC_CLI_PROVIDER_METADATA } from './providers/cli-provider-metadata'
5
+ import {
6
+ NATIVE_CAPABILITY_PROVIDER_IDS,
7
+ NON_LANGGRAPH_PROVIDER_IDS,
8
+ WORKER_ONLY_PROVIDER_IDS,
9
+ } from './provider-sets'
10
+
11
+ describe('provider sets', () => {
12
+ it('routes generic CLI providers through direct provider runtime', () => {
13
+ for (const provider of GENERIC_CLI_PROVIDER_METADATA) {
14
+ assert.equal(NON_LANGGRAPH_PROVIDER_IDS.has(provider.id), true, `${provider.id} should bypass LangGraph`)
15
+ assert.equal(NATIVE_CAPABILITY_PROVIDER_IDS.has(provider.id), true, `${provider.id} should be native-capability`)
16
+ assert.equal(WORKER_ONLY_PROVIDER_IDS.has(provider.id), true, `${provider.id} should be worker-only`)
17
+ }
18
+ })
19
+ })
@@ -1,14 +1,19 @@
1
+ import { CLI_PROVIDER_METADATA } from '@/lib/providers/cli-provider-metadata'
2
+
3
+ const CLI_PROVIDER_IDS = CLI_PROVIDER_METADATA.map((provider) => provider.id)
4
+ const DIRECT_CLI_PROVIDER_IDS = CLI_PROVIDER_IDS.filter((providerId) => providerId !== 'goose')
5
+
1
6
  /** 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', 'opencode-web', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli'])
7
+ export const NON_LANGGRAPH_PROVIDER_IDS = new Set([...DIRECT_CLI_PROVIDER_IDS, 'opencode-web'])
3
8
 
4
9
  /** Providers that manage their own runtime/tool loop even when reached over an API endpoint. */
5
10
  export const RUNTIME_MANAGED_PROVIDER_IDS = new Set(['hermes', 'goose'])
6
11
 
7
12
  /** 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', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
13
+ export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set([...CLI_PROVIDER_IDS, 'openclaw', 'hermes'])
9
14
 
10
15
  /** 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', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
16
+ export const WORKER_ONLY_PROVIDER_IDS = new Set([...CLI_PROVIDER_IDS, 'openclaw', 'hermes'])
12
17
 
13
18
  /** CLI providers that support MCP server and skill injection at runtime (via provider-specific config mechanisms). */
14
19
  export const MCP_INJECTION_PROVIDER_IDS = new Set(['copilot-cli', 'codex-cli'])
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import {
5
+ BESPOKE_CLI_PROVIDER_METADATA,
6
+ CLI_PROVIDER_METADATA,
7
+ CLI_PROVIDER_METADATA_BY_ID,
8
+ GENERIC_CLI_PROVIDER_METADATA,
9
+ isCliProviderId,
10
+ } from './cli-provider-metadata'
11
+ import { GENERIC_CLI_BINARIES } from './generic-cli'
12
+ import { CLI_PROVIDER_CAPABILITIES } from './cli-utils'
13
+
14
+ describe('CLI provider metadata', () => {
15
+ it('has one unique entry per CLI provider', () => {
16
+ const ids = CLI_PROVIDER_METADATA.map((provider) => provider.id)
17
+ assert.equal(new Set(ids).size, ids.length)
18
+ assert.ok(BESPOKE_CLI_PROVIDER_METADATA.length > 0)
19
+ assert.equal(GENERIC_CLI_PROVIDER_METADATA.length, 31)
20
+ })
21
+
22
+ it('drives binary and capability maps for every provider', () => {
23
+ for (const provider of CLI_PROVIDER_METADATA) {
24
+ assert.equal(CLI_PROVIDER_METADATA_BY_ID[provider.id]?.displayName, provider.displayName)
25
+ assert.equal(isCliProviderId(provider.id), true)
26
+ assert.equal(CLI_PROVIDER_CAPABILITIES[provider.id], provider.capability)
27
+ assert.ok(provider.binaryName.length > 0)
28
+ assert.ok(provider.defaultModel.length > 0)
29
+ }
30
+ })
31
+
32
+ it('keeps generic CLI binary mappings sourced from metadata', () => {
33
+ assert.deepEqual(
34
+ Object.fromEntries(GENERIC_CLI_PROVIDER_METADATA.map((provider) => [provider.id, provider.binaryName])),
35
+ GENERIC_CLI_BINARIES,
36
+ )
37
+ })
38
+ })