@swarmclawai/swarmclaw 0.7.3 → 0.7.5

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 (152) hide show
  1. package/README.md +47 -40
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +4 -87
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/agent-thread-session.test.ts +85 -0
  88. package/src/lib/server/agent-thread-session.ts +123 -0
  89. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  90. package/src/lib/server/build-llm.test.ts +13 -5
  91. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  92. package/src/lib/server/chat-execution.ts +159 -71
  93. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  94. package/src/lib/server/chatroom-helpers.ts +99 -6
  95. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  96. package/src/lib/server/connectors/manager.ts +89 -61
  97. package/src/lib/server/connectors/slack.ts +1 -1
  98. package/src/lib/server/daemon-state.ts +3 -2
  99. package/src/lib/server/data-dir.test.ts +56 -0
  100. package/src/lib/server/data-dir.ts +15 -9
  101. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  102. package/src/lib/server/eval/agent-regression.ts +1742 -0
  103. package/src/lib/server/eval/runner.ts +11 -1
  104. package/src/lib/server/eval/store.ts +2 -1
  105. package/src/lib/server/heartbeat-service.ts +23 -8
  106. package/src/lib/server/heartbeat-wake.ts +6 -2
  107. package/src/lib/server/main-agent-loop.ts +13 -6
  108. package/src/lib/server/openclaw-exec-config.ts +4 -2
  109. package/src/lib/server/openclaw-gateway.ts +123 -36
  110. package/src/lib/server/orchestrator-lg.ts +1 -2
  111. package/src/lib/server/orchestrator.ts +3 -2
  112. package/src/lib/server/plugins.test.ts +9 -1
  113. package/src/lib/server/plugins.ts +12 -2
  114. package/src/lib/server/provider-model-discovery.ts +481 -0
  115. package/src/lib/server/queue.ts +1 -1
  116. package/src/lib/server/runtime-settings.test.ts +119 -0
  117. package/src/lib/server/runtime-settings.ts +12 -92
  118. package/src/lib/server/schedule-normalization.ts +187 -0
  119. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  120. package/src/lib/server/session-tools/crud.ts +27 -3
  121. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  122. package/src/lib/server/session-tools/discovery.ts +18 -8
  123. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  124. package/src/lib/server/session-tools/file.ts +8 -2
  125. package/src/lib/server/session-tools/http.ts +9 -3
  126. package/src/lib/server/session-tools/index.ts +31 -1
  127. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  128. package/src/lib/server/session-tools/monitor.ts +14 -7
  129. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  130. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  131. package/src/lib/server/session-tools/platform.ts +1 -1
  132. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  133. package/src/lib/server/session-tools/sandbox.ts +51 -92
  134. package/src/lib/server/session-tools/session-info.ts +22 -1
  135. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  136. package/src/lib/server/session-tools/shell.ts +2 -2
  137. package/src/lib/server/session-tools/subagent.ts +3 -1
  138. package/src/lib/server/session-tools/web.ts +73 -30
  139. package/src/lib/server/storage.ts +29 -3
  140. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  141. package/src/lib/server/stream-agent-chat.ts +139 -4
  142. package/src/lib/server/structured-extract.ts +1 -1
  143. package/src/lib/server/task-mention.ts +0 -1
  144. package/src/lib/server/tool-aliases.ts +37 -6
  145. package/src/lib/server/tool-capability-policy.ts +1 -1
  146. package/src/lib/setup-defaults.ts +352 -11
  147. package/src/lib/tool-definitions.ts +3 -4
  148. package/src/lib/validation/schemas.ts +55 -1
  149. package/src/stores/use-app-store.ts +43 -1
  150. package/src/stores/use-chatroom-store.ts +153 -26
  151. package/src/types/index.ts +189 -6
  152. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
package/src/app/page.tsx CHANGED
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { initAudioContext } from '@/lib/tts'
6
6
  import { getStoredAccessKey, clearStoredAccessKey, api } from '@/lib/api-client'
7
+ import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
7
8
  import { connectWs, disconnectWs } from '@/lib/ws-client'
8
9
  import { fetchWithTimeout } from '@/lib/fetch-timeout'
9
10
  import { useWs } from '@/hooks/use-ws'
@@ -12,10 +13,17 @@ import { UserPicker } from '@/components/auth/user-picker'
12
13
  import { SetupWizard } from '@/components/auth/setup-wizard'
13
14
  import { AppLayout } from '@/components/layout/app-layout'
14
15
  import { useViewRouter } from '@/hooks/use-view-router'
16
+ import type { Agent } from '@/types'
15
17
 
16
18
  const AUTH_CHECK_TIMEOUT_MS = 8_000
19
+ const POST_AUTH_BOOTSTRAP_TIMEOUT_MS = 8_000
17
20
 
18
- function FullScreenLoader() {
21
+ function FullScreenLoader(props: {
22
+ stage?: string | null
23
+ stalled?: boolean
24
+ onReload?: () => void
25
+ onReset?: () => void
26
+ }) {
19
27
  return (
20
28
  <div className="h-full flex flex-col items-center justify-center bg-bg overflow-hidden select-none">
21
29
  {/* Animated orbital ring */}
@@ -106,6 +114,42 @@ function FullScreenLoader() {
106
114
  />
107
115
  </div>
108
116
 
117
+ {props.stage ? (
118
+ <p
119
+ className="mt-4 text-[12px] text-text-3"
120
+ style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.4s both' }}
121
+ >
122
+ {props.stage}
123
+ </p>
124
+ ) : null}
125
+
126
+ {props.stalled ? (
127
+ <div
128
+ className="mt-6 max-w-[360px] px-4 text-center"
129
+ style={{ animation: 'fade-up 0.6s var(--ease-spring) 0.5s both' }}
130
+ >
131
+ <p className="text-[12px] text-text-2">
132
+ Startup is taking longer than expected. This usually means the browser kept stale local state while the dev server restarted.
133
+ </p>
134
+ <div className="mt-4 flex items-center justify-center gap-3">
135
+ <button
136
+ type="button"
137
+ onClick={props.onReload}
138
+ className="px-4 py-2 rounded-[12px] border border-white/[0.08] bg-surface text-[12px] text-text-2 transition-colors hover:bg-surface-2"
139
+ >
140
+ Reload
141
+ </button>
142
+ <button
143
+ type="button"
144
+ onClick={props.onReset}
145
+ className="px-4 py-2 rounded-[12px] border border-white/[0.08] bg-transparent text-[12px] text-text-3 transition-colors hover:bg-white/[0.04]"
146
+ >
147
+ Reset Local Session
148
+ </button>
149
+ </div>
150
+ </div>
151
+ ) : null}
152
+
109
153
  {/* Loading animation keyframes */}
110
154
  <style>{`
111
155
  @keyframes sc-orbit {
@@ -150,8 +194,9 @@ export default function Home() {
150
194
 
151
195
  const [authChecked, setAuthChecked] = useState(false)
152
196
  const [authenticated, setAuthenticated] = useState(false)
197
+ const [bootTimedOut, setBootTimedOut] = useState(false)
153
198
  const [setupDone, setSetupDone] = useState<boolean | null>(() => {
154
- if (typeof window !== 'undefined' && localStorage.getItem('sc_setup_done') === '1') return true
199
+ if (safeStorageGet('sc_setup_done') === '1') return true
155
200
  return null
156
201
  })
157
202
 
@@ -183,7 +228,8 @@ export default function Home() {
183
228
  setAuthenticated(false)
184
229
  }
185
230
  } catch {
186
- setAuthenticated(true)
231
+ clearStoredAccessKey()
232
+ setAuthenticated(false)
187
233
  } finally {
188
234
  setAuthChecked(true)
189
235
  }
@@ -193,7 +239,10 @@ export default function Home() {
193
239
  const syncUserFromServer = useCallback(async () => {
194
240
  if (currentUser) return // already have a name locally
195
241
  try {
196
- const settings = await api<{ userName?: string }>('GET', '/settings')
242
+ const settings = await api<{ userName?: string }>('GET', '/settings', undefined, {
243
+ timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
244
+ retries: 0,
245
+ })
197
246
  if (settings.userName) {
198
247
  setUser(settings.userName)
199
248
  }
@@ -229,11 +278,14 @@ export default function Home() {
229
278
  let cancelled = false
230
279
  ;(async () => {
231
280
  try {
232
- const state = useAppStore.getState()
233
- await state.loadAgents()
281
+ const agents = await api<Record<string, Agent>>('GET', '/agents', undefined, {
282
+ timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
283
+ retries: 0,
284
+ })
234
285
  if (cancelled) return
286
+ useAppStore.setState({ agents })
235
287
 
236
- const { agents, currentAgentId, appSettings } = useAppStore.getState()
288
+ const { currentAgentId, appSettings } = useAppStore.getState()
237
289
  // Priority: persisted agent > settings default > first agent
238
290
  const targetId = (currentAgentId && agents[currentAgentId])
239
291
  ? currentAgentId
@@ -256,14 +308,23 @@ export default function Home() {
256
308
  let cancelled = false
257
309
  ;(async () => {
258
310
  try {
259
- const [settings, creds] = await Promise.all([
260
- api<{ setupCompleted?: boolean }>('GET', '/settings'),
261
- api<Record<string, unknown>>('GET', '/credentials'),
311
+ const [settingsResult, credsResult] = await Promise.allSettled([
312
+ api<{ setupCompleted?: boolean }>('GET', '/settings', undefined, {
313
+ timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
314
+ retries: 0,
315
+ }),
316
+ api<Record<string, unknown>>('GET', '/credentials', undefined, {
317
+ timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS,
318
+ retries: 0,
319
+ }),
262
320
  ])
263
321
  if (cancelled) return
322
+ const settings = settingsResult.status === 'fulfilled' ? settingsResult.value : {}
323
+ const creds = credsResult.status === 'fulfilled' ? credsResult.value : {}
324
+ const bothFailed = settingsResult.status === 'rejected' && credsResult.status === 'rejected'
264
325
  const hasCreds = Object.keys(creds).length > 0
265
- const done = settings.setupCompleted === true || hasCreds
266
- if (done) localStorage.setItem('sc_setup_done', '1')
326
+ const done = bothFailed ? true : settings.setupCompleted === true || hasCreds
327
+ if (done) safeStorageSet('sc_setup_done', '1')
267
328
  setSetupDone(done)
268
329
  } catch {
269
330
  if (!cancelled) setSetupDone(true) // on error, skip wizard
@@ -293,10 +354,60 @@ export default function Home() {
293
354
 
294
355
  useViewRouter()
295
356
 
296
- if (!hydrated || !authChecked) return <FullScreenLoader />
357
+ const bootStage = !hydrated
358
+ ? 'Restoring local session'
359
+ : !authChecked
360
+ ? 'Checking access'
361
+ : authenticated && currentUser && setupDone === null
362
+ ? 'Loading setup state'
363
+ : authenticated && currentUser && !agentReady
364
+ ? 'Restoring agent workspace'
365
+ : null
366
+
367
+ useEffect(() => {
368
+ if (!bootStage) {
369
+ setBootTimedOut(false)
370
+ return
371
+ }
372
+ const timer = window.setTimeout(() => setBootTimedOut(true), 15_000)
373
+ return () => window.clearTimeout(timer)
374
+ }, [bootStage])
375
+
376
+ const reloadApp = useCallback(() => {
377
+ window.location.reload()
378
+ }, [])
379
+
380
+ const resetLocalSession = useCallback(() => {
381
+ clearStoredAccessKey()
382
+ disconnectWs()
383
+ safeStorageRemove('sc_user')
384
+ safeStorageRemove('sc_agent')
385
+ safeStorageRemove('sc_setup_done')
386
+ window.location.assign('/')
387
+ }, [])
388
+
389
+ if (!hydrated || !authChecked) {
390
+ return (
391
+ <FullScreenLoader
392
+ stage={bootStage}
393
+ stalled={bootTimedOut}
394
+ onReload={reloadApp}
395
+ onReset={resetLocalSession}
396
+ />
397
+ )
398
+ }
297
399
  if (!authenticated) return <AccessKeyGate onAuthenticated={() => setAuthenticated(true)} />
298
400
  if (!currentUser) return <UserPicker />
299
- if (setupDone === null || !agentReady) return <FullScreenLoader />
300
- if (!setupDone) return <SetupWizard onComplete={() => { localStorage.setItem('sc_setup_done', '1'); setSetupDone(true) }} />
401
+ if (setupDone === null || !agentReady) {
402
+ return (
403
+ <FullScreenLoader
404
+ stage={bootStage}
405
+ stalled={bootTimedOut}
406
+ onReload={reloadApp}
407
+ onReset={resetLocalSession}
408
+ />
409
+ )
410
+ }
411
+ if (!setupDone) return <SetupWizard onComplete={() => { safeStorageSet('sc_setup_done', '1'); setSetupDone(true) }} />
301
412
  return <AppLayout />
302
413
  }
@@ -0,0 +1,142 @@
1
+ 'use strict'
2
+ /* eslint-disable @typescript-eslint/no-require-imports */
3
+
4
+ const test = require('node:test')
5
+ const assert = require('node:assert/strict')
6
+ const fs = require('node:fs')
7
+ const os = require('node:os')
8
+ const path = require('node:path')
9
+ const { spawnSync } = require('node:child_process')
10
+ const { buildLegacyTsCliArgs } = require('../../bin/swarmclaw.js')
11
+
12
+ const CLI_BIN = path.join(__dirname, '..', '..', 'bin', 'swarmclaw.js')
13
+ const PACKAGE_JSON = require('../../package.json')
14
+ const APP_ROOT = path.join(__dirname, '..', '..')
15
+
16
+ function runBinary(args, options = {}) {
17
+ return spawnSync(process.execPath, [CLI_BIN, ...args], {
18
+ cwd: options.cwd || APP_ROOT,
19
+ env: {
20
+ ...process.env,
21
+ ...options.env,
22
+ },
23
+ encoding: 'utf8',
24
+ })
25
+ }
26
+
27
+ function runWithMockedFetch(args, options = {}) {
28
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-binary-fetch-'))
29
+ const capturePath = path.join(tmpDir, 'capture.json')
30
+ const preloadPath = path.join(tmpDir, 'mock-fetch.cjs')
31
+
32
+ fs.writeFileSync(
33
+ preloadPath,
34
+ `
35
+ const fs = require('node:fs')
36
+ globalThis.fetch = async (url, init = {}) => {
37
+ const capture = {
38
+ url: String(url),
39
+ method: init.method || 'GET',
40
+ headers: init.headers || {},
41
+ body: typeof init.body === 'string'
42
+ ? init.body
43
+ : (Buffer.isBuffer(init.body) ? init.body.toString('utf8') : null),
44
+ }
45
+ fs.writeFileSync(process.env.SWARMCLAW_TEST_CAPTURE, JSON.stringify(capture), 'utf8')
46
+ return new Response(JSON.stringify([]), {
47
+ status: 200,
48
+ headers: { 'content-type': 'application/json' },
49
+ })
50
+ }
51
+ `,
52
+ 'utf8',
53
+ )
54
+
55
+ const nodeOptions = [process.env.NODE_OPTIONS, `--require=${preloadPath}`]
56
+ .filter(Boolean)
57
+ .join(' ')
58
+
59
+ const result = runBinary(args, {
60
+ ...options,
61
+ env: {
62
+ ...options.env,
63
+ NODE_OPTIONS: nodeOptions,
64
+ SWARMCLAW_TEST_CAPTURE: capturePath,
65
+ },
66
+ })
67
+
68
+ const capture = fs.existsSync(capturePath)
69
+ ? JSON.parse(fs.readFileSync(capturePath, 'utf8'))
70
+ : null
71
+
72
+ fs.rmSync(tmpDir, { recursive: true, force: true })
73
+ return { result, capture }
74
+ }
75
+
76
+ test('legacy-routed binary commands honor SWARMCLAW_API_KEY', () => {
77
+ const { result, capture } = runWithMockedFetch(
78
+ ['runs', 'list', '--raw', '--url', 'http://localhost:3456'],
79
+ {
80
+ env: {
81
+ SWARMCLAW_API_KEY: 'legacy-api-key',
82
+ SWARMCLAW_ACCESS_KEY: '',
83
+ SC_ACCESS_KEY: '',
84
+ },
85
+ },
86
+ )
87
+
88
+ assert.equal(result.status, 0, result.stderr)
89
+ assert.equal(result.stdout.trim(), '[]')
90
+ assert.equal(capture.headers['X-Access-Key'], 'legacy-api-key')
91
+ })
92
+
93
+ test('legacy-routed binary commands fall back to platform-api-key.txt', () => {
94
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-binary-keyfile-'))
95
+ fs.writeFileSync(path.join(tmpDir, 'platform-api-key.txt'), 'file-fallback-key\n', 'utf8')
96
+
97
+ const { result, capture } = runWithMockedFetch(
98
+ ['runs', 'list', '--raw', '--url', 'http://localhost:3456'],
99
+ {
100
+ cwd: tmpDir,
101
+ env: {
102
+ SWARMCLAW_API_KEY: '',
103
+ SWARMCLAW_ACCESS_KEY: '',
104
+ SC_ACCESS_KEY: '',
105
+ },
106
+ },
107
+ )
108
+
109
+ assert.equal(result.status, 0, result.stderr)
110
+ assert.equal(result.stdout.trim(), '[]')
111
+ assert.equal(capture.headers['X-Access-Key'], 'file-fallback-key')
112
+
113
+ fs.rmSync(tmpDir, { recursive: true, force: true })
114
+ })
115
+
116
+ test('binary server help exits successfully', () => {
117
+ const result = runBinary(['server', '--help'])
118
+ assert.equal(result.status, 0, result.stderr)
119
+ assert.match(result.stdout, /Usage: swarmclaw server/i)
120
+ })
121
+
122
+ test('binary update help exits successfully', () => {
123
+ const result = runBinary(['update', '--help'])
124
+ assert.equal(result.status, 0, result.stderr)
125
+ assert.match(result.stdout, /Usage: swarmclaw update/i)
126
+ })
127
+
128
+ test('binary version output matches package version', () => {
129
+ const result = runBinary(['--version'])
130
+ assert.equal(result.status, 0, result.stderr)
131
+ assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
132
+ })
133
+
134
+ test('legacy TS launcher falls back to tsx import when strip-types is unavailable', () => {
135
+ const cliPath = path.join(APP_ROOT, 'src', 'cli', 'index.ts')
136
+ const args = buildLegacyTsCliArgs(cliPath, ['runs', 'list'], {
137
+ supportsStripTypes: false,
138
+ hasTsxRuntime: true,
139
+ })
140
+
141
+ assert.deepEqual(args, ['--no-warnings', '--import', 'tsx', cliPath, 'runs', 'list'])
142
+ })
package/src/cli/index.js CHANGED
@@ -181,6 +181,17 @@ const COMMAND_GROUPS = [
181
181
  cmd('suite', 'POST', '/eval/suite', 'Run a full eval suite against an agent', { expectsJsonBody: true }),
182
182
  ],
183
183
  },
184
+ {
185
+ name: 'external-agents',
186
+ description: 'Manage external agent runtimes',
187
+ commands: [
188
+ cmd('list', 'GET', '/external-agents', 'List external agent runtimes'),
189
+ cmd('create', 'POST', '/external-agents', 'Register an external agent runtime', { expectsJsonBody: true }),
190
+ cmd('update', 'PUT', '/external-agents/:id', 'Update an external agent runtime', { expectsJsonBody: true }),
191
+ cmd('delete', 'DELETE', '/external-agents/:id', 'Delete an external agent runtime'),
192
+ cmd('heartbeat', 'POST', '/external-agents/:id/heartbeat', 'Record an external agent heartbeat', { expectsJsonBody: true }),
193
+ ],
194
+ },
184
195
  {
185
196
  name: 'files',
186
197
  description: 'Serve and manage local files',
@@ -189,6 +200,17 @@ const COMMAND_GROUPS = [
189
200
  cmd('open', 'POST', '/files/open', 'Open a local file path via the host default app/browser', { expectsJsonBody: true }),
190
201
  ],
191
202
  },
203
+ {
204
+ name: 'gateways',
205
+ description: 'Manage named OpenClaw gateway profiles',
206
+ commands: [
207
+ cmd('list', 'GET', '/gateways', 'List configured gateway profiles'),
208
+ cmd('create', 'POST', '/gateways', 'Create a gateway profile', { expectsJsonBody: true }),
209
+ cmd('update', 'PUT', '/gateways/:id', 'Update a gateway profile', { expectsJsonBody: true }),
210
+ cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
211
+ cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
212
+ ],
213
+ },
192
214
  {
193
215
  name: 'ip',
194
216
  description: 'Get local IP/port metadata',
@@ -364,6 +386,7 @@ const COMMAND_GROUPS = [
364
386
  cmd('update', 'PUT', '/providers/:id', 'Update provider', { expectsJsonBody: true }),
365
387
  cmd('delete', 'DELETE', '/providers/:id', 'Delete provider'),
366
388
  cmd('configs', 'GET', '/providers/configs', 'List saved provider configs'),
389
+ cmd('discover-models', 'GET', '/providers/:id/discover-models', 'Discover provider models via endpoint or credential checks'),
367
390
  cmd('ollama', 'GET', '/providers/ollama', 'List local Ollama models (use --query endpoint=http://localhost:11434)'),
368
391
  cmd('openclaw-health', 'GET', '/providers/openclaw/health', 'Probe OpenClaw endpoint/auth (use --query endpoint= --query credentialId= --query model=)'),
369
392
  cmd('models', 'GET', '/providers/:id/models', 'Get provider model overrides'),
@@ -429,10 +452,6 @@ const COMMAND_GROUPS = [
429
452
  cmd('messages-delete', 'DELETE', '/chats/:id/messages', 'Delete a message from a chat', { expectsJsonBody: true }),
430
453
  cmd('fork', 'POST', '/chats/:id/fork', 'Fork chat from a specific message index', { expectsJsonBody: true }),
431
454
  cmd('edit-resend', 'POST', '/chats/:id/edit-resend', 'Edit and resend from a specific message index', { expectsJsonBody: true }),
432
- cmd('main-loop', 'GET', '/chats/:id/main-loop', 'Get main mission loop state'),
433
- cmd('main-loop-action', 'POST', '/chats/:id/main-loop', 'Control main mission loop (pause/resume/set_goal/set_mode/clear_events/nudge)', {
434
- expectsJsonBody: true,
435
- }),
436
455
  cmd('chat', 'POST', '/chats/:id/chat', 'Send chat message (streaming)', {
437
456
  expectsJsonBody: true,
438
457
  responseType: 'sse',
@@ -677,7 +696,7 @@ function normalizeBaseUrl(raw) {
677
696
 
678
697
  function resolveAccessKey(opts, env, cwd) {
679
698
  if (opts.accessKey) return String(opts.accessKey).trim()
680
- const envKey = env.SWARMCLAW_API_KEY || env.SC_ACCESS_KEY || ''
699
+ const envKey = env.SWARMCLAW_API_KEY || env.SC_ACCESS_KEY || env.SWARMCLAW_ACCESS_KEY || ''
681
700
  if (envKey) return String(envKey).trim()
682
701
 
683
702
  const keyFile = path.join(cwd, 'platform-api-key.txt')
@@ -927,9 +946,11 @@ async function consumeSse(body, stdout, stderr, jsonOutput) {
927
946
  const reader = body.getReader()
928
947
  const decoder = new TextDecoder()
929
948
  let buffer = ''
949
+ const eventBoundary = /\r?\n\r?\n/
930
950
 
931
951
  function flushChunk(rawChunk) {
932
952
  const lines = rawChunk
953
+ .replace(/\r\n/g, '\n')
933
954
  .split('\n')
934
955
  .map((line) => line.trimEnd())
935
956
  .filter(Boolean)
@@ -972,12 +993,14 @@ async function consumeSse(body, stdout, stderr, jsonOutput) {
972
993
  if (done) break
973
994
  buffer += decoder.decode(value, { stream: true })
974
995
 
975
- let splitIndex = buffer.indexOf('\n\n')
976
- while (splitIndex >= 0) {
996
+ let match = eventBoundary.exec(buffer)
997
+ while (match) {
998
+ const splitIndex = match.index
999
+ const delimiterLength = match[0].length
977
1000
  const chunk = buffer.slice(0, splitIndex)
978
- buffer = buffer.slice(splitIndex + 2)
1001
+ buffer = buffer.slice(splitIndex + delimiterLength)
979
1002
  flushChunk(chunk)
980
- splitIndex = buffer.indexOf('\n\n')
1003
+ match = eventBoundary.exec(buffer)
981
1004
  }
982
1005
  }
983
1006
 
@@ -1090,7 +1113,7 @@ function renderGeneralHelp() {
1090
1113
  '',
1091
1114
  'Global options:',
1092
1115
  ' --base-url <url> API base URL (default: http://localhost:3456)',
1093
- ' --access-key <key> Access key override (else SWARMCLAW_API_KEY or platform-api-key.txt)',
1116
+ ' --access-key <key> Access key override (else SWARMCLAW_API_KEY/SWARMCLAW_ACCESS_KEY or platform-api-key.txt)',
1094
1117
  ' --data <json|@file|-> Request JSON body',
1095
1118
  ' --query key=value Query parameter (repeatable)',
1096
1119
  ' --header key=value Extra HTTP header (repeatable)',
@@ -1215,7 +1238,7 @@ async function runCli(argv, deps = {}) {
1215
1238
  }
1216
1239
 
1217
1240
  const accessKey = resolveAccessKey(parsed.opts, env, cwd)
1218
- const baseUrl = parsed.opts.baseUrl || env.SWARMCLAW_BASE_URL || 'http://localhost:3456'
1241
+ const baseUrl = parsed.opts.baseUrl || env.SWARMCLAW_BASE_URL || env.SWARMCLAW_URL || 'http://localhost:3456'
1219
1242
 
1220
1243
  const headerEntries = []
1221
1244
  for (const raw of parsed.opts.headers) {
@@ -163,6 +163,37 @@ test('runCli sends authenticated request and emits compact JSON when --json is s
163
163
  assert.equal(stderr.toString(), '')
164
164
  })
165
165
 
166
+ test('runCli falls back to platform-api-key.txt when env key is missing', async () => {
167
+ const stdout = makeWritable()
168
+ const stderr = makeWritable()
169
+ const calls = []
170
+
171
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-keyfile-'))
172
+ fs.writeFileSync(path.join(tmpDir, 'platform-api-key.txt'), 'file-key\n', 'utf8')
173
+
174
+ const fetchImpl = async (url, init) => {
175
+ calls.push({ url: String(url), init })
176
+ return jsonResponse({ ok: true })
177
+ }
178
+
179
+ const exitCode = await runCli(
180
+ ['runs', 'list', '--json'],
181
+ {
182
+ fetchImpl,
183
+ stdout,
184
+ stderr,
185
+ env: {},
186
+ cwd: tmpDir,
187
+ }
188
+ )
189
+
190
+ assert.equal(exitCode, 0)
191
+ assert.equal(calls[0].init.headers['X-Access-Key'], 'file-key')
192
+ assert.equal(stderr.toString(), '')
193
+
194
+ fs.rmSync(tmpDir, { recursive: true, force: true })
195
+ })
196
+
166
197
  test('upload command sends binary body and x-filename header', async () => {
167
198
  const stdout = makeWritable()
168
199
  const stderr = makeWritable()
@@ -197,6 +228,32 @@ test('upload command sends binary body and x-filename header', async () => {
197
228
  fs.rmSync(tmpDir, { recursive: true, force: true })
198
229
  })
199
230
 
231
+ test('binary responses require --out when stdout is a TTY', async () => {
232
+ const stdout = makeWritable()
233
+ stdout.isTTY = true
234
+ const stderr = makeWritable()
235
+
236
+ const fetchImpl = async () =>
237
+ new Response(Buffer.from('hello'), {
238
+ status: 200,
239
+ headers: { 'content-type': 'application/octet-stream' },
240
+ })
241
+
242
+ const exitCode = await runCli(
243
+ ['uploads', 'get', 'artifact.bin'],
244
+ {
245
+ fetchImpl,
246
+ stdout,
247
+ stderr,
248
+ env: {},
249
+ cwd: process.cwd(),
250
+ }
251
+ )
252
+
253
+ assert.equal(exitCode, 1)
254
+ assert.match(stderr.toString(), /binary response requires --out <file>/i)
255
+ })
256
+
200
257
  test('wait polls run endpoint until terminal state', async () => {
201
258
  const stdout = makeWritable()
202
259
  const stderr = makeWritable()
@@ -236,6 +293,144 @@ test('wait polls run endpoint until terminal state', async () => {
236
293
  assert.match(stdout.toString(), /"status": "completed"/)
237
294
  })
238
295
 
296
+ test('runCli parses CRLF-delimited SSE events correctly', async () => {
297
+ const stdout = makeWritable()
298
+ const stderr = makeWritable()
299
+
300
+ const fetchImpl = async () => new Response(
301
+ 'data: {"t":"md","text":"first"}\r\n\r\ndata: {"t":"md","text":"second"}\r\n\r\n',
302
+ {
303
+ status: 200,
304
+ headers: { 'content-type': 'text/event-stream' },
305
+ }
306
+ )
307
+
308
+ const exitCode = await runCli(
309
+ ['chatrooms', 'chat', 'room-1', '--data', '{}'],
310
+ {
311
+ fetchImpl,
312
+ stdout,
313
+ stderr,
314
+ env: {},
315
+ cwd: process.cwd(),
316
+ }
317
+ )
318
+
319
+ assert.equal(exitCode, 0)
320
+ assert.equal(stdout.toString(), 'first\nsecond\n')
321
+ assert.equal(stderr.toString(), '')
322
+ })
323
+
324
+ test('binary responses require --out when stdout is a TTY', async () => {
325
+ const stdout = makeWritable()
326
+ stdout.isTTY = true
327
+ const stderr = makeWritable()
328
+
329
+ const fetchImpl = async () => new Response(Buffer.from('ok'), {
330
+ status: 200,
331
+ headers: { 'content-type': 'application/octet-stream' },
332
+ })
333
+
334
+ const exitCode = await runCli(
335
+ ['memory-images', 'get', 'image-1.png'],
336
+ {
337
+ fetchImpl,
338
+ stdout,
339
+ stderr,
340
+ env: {},
341
+ cwd: process.cwd(),
342
+ }
343
+ )
344
+
345
+ assert.equal(exitCode, 1)
346
+ assert.equal(stdout.toString(), '')
347
+ assert.match(stderr.toString(), /binary response requires --out <file>/i)
348
+ })
349
+
350
+ test('client-side collection lookups fail cleanly when the entity is missing', async () => {
351
+ const stdout = makeWritable()
352
+ const stderr = makeWritable()
353
+
354
+ const fetchImpl = async () => jsonResponse([{ id: 'agent-2', name: 'Other Agent' }])
355
+
356
+ const exitCode = await runCli(
357
+ ['agents', 'get', 'agent-1'],
358
+ {
359
+ fetchImpl,
360
+ stdout,
361
+ stderr,
362
+ env: {},
363
+ cwd: process.cwd(),
364
+ }
365
+ )
366
+
367
+ assert.equal(exitCode, 1)
368
+ assert.equal(stdout.toString(), '')
369
+ assert.match(stderr.toString(), /entity not found for id: agent-1/i)
370
+ })
371
+
372
+ test('runCli loads request JSON from @file inputs', async () => {
373
+ const stdout = makeWritable()
374
+ const stderr = makeWritable()
375
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-data-'))
376
+ const dataPath = path.join(tmpDir, 'payload.json')
377
+ fs.writeFileSync(dataPath, JSON.stringify({ title: 'From file', status: 'backlog' }), 'utf8')
378
+
379
+ const calls = []
380
+ const fetchImpl = async (url, init) => {
381
+ calls.push({ url: String(url), init })
382
+ return jsonResponse({ ok: true })
383
+ }
384
+
385
+ const exitCode = await runCli(
386
+ ['tasks', 'create', '--data', `@${dataPath}`],
387
+ {
388
+ fetchImpl,
389
+ stdout,
390
+ stderr,
391
+ env: {},
392
+ cwd: process.cwd(),
393
+ }
394
+ )
395
+
396
+ assert.equal(exitCode, 0)
397
+ assert.equal(calls.length, 1)
398
+ assert.equal(calls[0].init.headers['Content-Type'], 'application/json')
399
+ assert.deepEqual(JSON.parse(calls[0].init.body), { title: 'From file', status: 'backlog' })
400
+
401
+ fs.rmSync(tmpDir, { recursive: true, force: true })
402
+ })
403
+
404
+ test('runCli falls back to platform-api-key.txt when no env key is provided', async () => {
405
+ const stdout = makeWritable()
406
+ const stderr = makeWritable()
407
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-key-'))
408
+ fs.writeFileSync(path.join(tmpDir, 'platform-api-key.txt'), 'file-key\n', 'utf8')
409
+
410
+ const calls = []
411
+ const fetchImpl = async (url, init) => {
412
+ calls.push({ url: String(url), init })
413
+ return jsonResponse({ ok: true })
414
+ }
415
+
416
+ const exitCode = await runCli(
417
+ ['runs', 'list'],
418
+ {
419
+ fetchImpl,
420
+ stdout,
421
+ stderr,
422
+ env: {},
423
+ cwd: tmpDir,
424
+ }
425
+ )
426
+
427
+ assert.equal(exitCode, 0)
428
+ assert.equal(calls.length, 1)
429
+ assert.equal(calls[0].init.headers['X-Access-Key'], 'file-key')
430
+
431
+ fs.rmSync(tmpDir, { recursive: true, force: true })
432
+ })
433
+
239
434
  test('all command definitions execute with a mocked API transport', async () => {
240
435
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-all-'))
241
436
  const uploadPath = path.join(tmpDir, 'upload.txt')