@swarmclawai/swarmclaw 0.9.9 → 1.0.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.
Files changed (44) hide show
  1. package/bin/doctor-cmd.js +149 -0
  2. package/bin/doctor-cmd.test.js +50 -0
  3. package/bin/install-root.js +194 -0
  4. package/bin/install-root.test.js +121 -0
  5. package/bin/server-cmd.js +90 -111
  6. package/bin/swarmclaw.js +83 -3
  7. package/bin/update-cmd.js +33 -20
  8. package/bin/update-cmd.test.js +1 -36
  9. package/bin/worker-cmd.js +23 -17
  10. package/next.config.ts +2 -0
  11. package/package.json +11 -10
  12. package/src/app/api/gateways/[id]/health/route.ts +2 -32
  13. package/src/app/api/gateways/health-route.test.ts +1 -1
  14. package/src/app/api/openclaw/dashboard-url/route.test.ts +166 -0
  15. package/src/app/api/openclaw/dashboard-url/route.ts +68 -0
  16. package/src/app/api/setup/check-provider/helpers.ts +28 -0
  17. package/src/app/api/setup/check-provider/route.test.ts +17 -1
  18. package/src/app/api/setup/check-provider/route.ts +29 -36
  19. package/src/app/api/tasks/import/github/helpers.ts +100 -0
  20. package/src/app/api/tasks/import/github/route.test.ts +1 -1
  21. package/src/app/api/tasks/import/github/route.ts +2 -92
  22. package/src/app/api/webhooks/[id]/helpers.ts +253 -0
  23. package/src/app/api/webhooks/[id]/route.ts +2 -243
  24. package/src/app/api/webhooks/route.test.ts +4 -2
  25. package/src/cli/binary.test.js +57 -0
  26. package/src/cli/index.js +14 -1
  27. package/src/cli/server-cmd.test.js +21 -20
  28. package/src/components/auth/setup-wizard/index.tsx +16 -0
  29. package/src/components/auth/setup-wizard/step-agents.tsx +34 -23
  30. package/src/components/auth/setup-wizard/step-connect.tsx +8 -0
  31. package/src/components/auth/setup-wizard/types.ts +2 -0
  32. package/src/components/auth/setup-wizard/utils.test.ts +79 -0
  33. package/src/components/chat/chat-header.tsx +45 -2
  34. package/src/lib/providers/openclaw-exports.test.ts +23 -0
  35. package/src/lib/providers/openclaw.ts +1 -1
  36. package/src/lib/server/data-dir.test.ts +35 -0
  37. package/src/lib/server/data-dir.ts +11 -0
  38. package/src/lib/server/openclaw/health.ts +30 -1
  39. package/src/lib/server/session-tools/file-send.test.ts +18 -2
  40. package/src/lib/server/session-tools/file.ts +11 -7
  41. package/src/lib/server/skills/skill-discovery.test.ts +34 -1
  42. package/src/lib/server/skills/skill-discovery.ts +9 -4
  43. package/src/lib/setup-defaults.test.ts +42 -0
  44. package/src/lib/setup-defaults.ts +1 -1
@@ -7,7 +7,10 @@ import {
7
7
  resolveOpenClawDashboardUrl,
8
8
  getOpenClawErrorHint,
9
9
  withHttpScheme,
10
+ buildStarterDrafts,
11
+ preferredConfiguredProvider,
10
12
  } from './utils'
13
+ import type { ConfiguredProvider } from './types'
11
14
 
12
15
  // ---------------------------------------------------------------------------
13
16
  // stepIndex
@@ -157,3 +160,79 @@ test('withHttpScheme preserves ws://', () => {
157
160
  test('withHttpScheme preserves wss://', () => {
158
161
  assert.equal(withHttpScheme('wss://example.com'), 'wss://example.com')
159
162
  })
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // buildStarterDrafts — OpenClaw provider handling
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function makeConfiguredProvider(overrides: Partial<ConfiguredProvider> & { provider: ConfiguredProvider['provider'] }): ConfiguredProvider {
169
+ return {
170
+ id: 'cp-1',
171
+ name: 'Test Provider',
172
+ credentialId: null,
173
+ endpoint: null,
174
+ defaultModel: '',
175
+ gatewayProfileId: null,
176
+ verified: true,
177
+ ...overrides,
178
+ }
179
+ }
180
+
181
+ test('buildStarterDrafts assigns OpenClaw provider to drafts', () => {
182
+ const cp = makeConfiguredProvider({ provider: 'openclaw', endpoint: 'http://localhost:18789' })
183
+ const drafts = buildStarterDrafts({
184
+ starterKitId: 'personal_assistant',
185
+ intentText: '',
186
+ configuredProviders: [cp],
187
+ })
188
+ assert.ok(drafts.length > 0, 'should produce at least one draft')
189
+ for (const d of drafts) {
190
+ assert.equal(d.provider, 'openclaw')
191
+ assert.equal(d.providerConfigId, cp.id)
192
+ }
193
+ })
194
+
195
+ test('buildStarterDrafts OpenClaw drafts use empty model (not "default")', () => {
196
+ const cp = makeConfiguredProvider({ provider: 'openclaw', defaultModel: '' })
197
+ const drafts = buildStarterDrafts({
198
+ starterKitId: 'personal_assistant',
199
+ intentText: '',
200
+ configuredProviders: [cp],
201
+ })
202
+ for (const d of drafts) {
203
+ // Model should be empty since the gateway controls the model
204
+ assert.equal(d.model, '')
205
+ }
206
+ })
207
+
208
+ test('buildStarterDrafts OpenClaw drafts inherit endpoint from provider', () => {
209
+ const cp = makeConfiguredProvider({ provider: 'openclaw', endpoint: 'http://10.0.0.5:18789' })
210
+ const drafts = buildStarterDrafts({
211
+ starterKitId: 'personal_assistant',
212
+ intentText: '',
213
+ configuredProviders: [cp],
214
+ })
215
+ for (const d of drafts) {
216
+ assert.equal(d.apiEndpoint, 'http://10.0.0.5:18789')
217
+ }
218
+ })
219
+
220
+ test('buildStarterDrafts carries dashboardUrl through from ConfiguredProvider', () => {
221
+ const cp = makeConfiguredProvider({
222
+ provider: 'openclaw',
223
+ endpoint: 'http://localhost:18789',
224
+ dashboardUrl: 'http://localhost:18789?token=my-secret',
225
+ })
226
+ // dashboardUrl lives on the ConfiguredProvider, not the draft — verify it's accessible
227
+ assert.equal(cp.dashboardUrl, 'http://localhost:18789?token=my-secret')
228
+ })
229
+
230
+ test('preferredConfiguredProvider picks openclaw provider for openclaw template', () => {
231
+ const openclawCp = makeConfiguredProvider({ id: 'oc-1', provider: 'openclaw' })
232
+ const openaiCp = makeConfiguredProvider({ id: 'oai-1', provider: 'openai' })
233
+ const result = preferredConfiguredProvider(
234
+ { id: 'tmpl-1', name: 'Test', description: '', systemPrompt: '', tools: [], recommendedProviders: ['openclaw'] },
235
+ [openaiCp, openclawCp],
236
+ )
237
+ assert.equal(result?.id, 'oc-1')
238
+ })
@@ -162,6 +162,8 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
162
162
  const sourceDropdownRef = useRef<HTMLDivElement>(null)
163
163
  const [syncingHistory, setSyncingHistory] = useState(false)
164
164
  const [syncResult, setSyncResult] = useState('')
165
+ const [openClawDashboardUrl, setOpenClawDashboardUrl] = useState<string | null>(null)
166
+ const [openClawDashboardLoading, setOpenClawDashboardLoading] = useState(false)
165
167
  const [renaming, setRenaming] = useState(false)
166
168
  const [renameDraft, setRenameDraft] = useState('')
167
169
  const [renameSaving, setRenameSaving] = useState(false)
@@ -494,6 +496,25 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
494
496
  return () => clearTimeout(timer)
495
497
  }, [syncResult])
496
498
 
499
+ const loadOpenClawDashboardUrl = useCallback(async () => {
500
+ if (!session.agentId || openClawDashboardLoading) return
501
+ setOpenClawDashboardLoading(true)
502
+ try {
503
+ const result = await api<{ url: string }>('GET', `/openclaw/dashboard-url?agentId=${session.agentId}`)
504
+ if (result.url) setOpenClawDashboardUrl(result.url)
505
+ } catch {
506
+ // Fall back to agent endpoint
507
+ const ep = (agent?.apiEndpoint || 'http://localhost:18789').replace(/\/+$/, '')
508
+ setOpenClawDashboardUrl(/^https?:\/\//i.test(ep) ? ep : `http://${ep}`)
509
+ } finally {
510
+ setOpenClawDashboardLoading(false)
511
+ }
512
+ }, [session.agentId, agent?.apiEndpoint, openClawDashboardLoading])
513
+
514
+ useEffect(() => {
515
+ if (isOpenClawAgent && !openClawDashboardUrl) void loadOpenClawDashboardUrl()
516
+ }, [isOpenClawAgent, openClawDashboardUrl, loadOpenClawDashboardUrl])
517
+
497
518
  const startRename = () => {
498
519
  if (!agent) return
499
520
  setRenameDraft(agent.name)
@@ -751,7 +772,29 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
751
772
  </HeaderChip>
752
773
  )
753
774
  })}
754
- {modelName && (
775
+ {isOpenClawAgent ? (
776
+ <a
777
+ href={openClawDashboardUrl || '#'}
778
+ target="_blank"
779
+ rel="noopener noreferrer"
780
+ onClick={(e) => {
781
+ if (openClawDashboardUrl) return
782
+ e.preventDefault()
783
+ if (!openClawDashboardLoading) void loadOpenClawDashboardUrl()
784
+ }}
785
+ className="inline-flex max-w-full items-center gap-1.5 rounded-[9px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-1 text-[10px] font-600 text-text-3/70 backdrop-blur-sm transition-colors hover:border-white/[0.1] hover:bg-white/[0.06] hover:text-accent-bright shrink-0"
786
+ >
787
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
788
+ <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" />
789
+ </svg>
790
+ <span className="truncate max-w-[min(42vw,220px)]">OpenClaw Dashboard</span>
791
+ <svg width="10" height="10" viewBox="0 0 14 14" fill="none" className="shrink-0 opacity-50">
792
+ <path d="M5.5 3H3.5C3.22386 3 3 3.22386 3 3.5V10.5C3 10.7761 3.22386 11 3.5 11H10.5C10.7761 11 11 10.7761 11 10.5V8.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
793
+ <path d="M8 2H12V6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
794
+ <path d="M12 2L7 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
795
+ </svg>
796
+ </a>
797
+ ) : modelName ? (
755
798
  <div className="relative shrink-0" ref={modelSwitcherRef}>
756
799
  <Tip label={`Switch model (${providerLabel})`}>
757
800
  <button
@@ -803,7 +846,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
803
846
  </div>
804
847
  )}
805
848
  </div>
806
- )}
849
+ ) : null}
807
850
  {threadContextLabel && (
808
851
  <HeaderChip title={threadContextLabel}>
809
852
  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ // Verify that rpcOnConnectedGateway is exported (needed by setup check-provider route)
5
+ test('rpcOnConnectedGateway is exported from openclaw provider', async () => {
6
+ const mod = await import('./openclaw')
7
+ assert.equal(typeof mod.rpcOnConnectedGateway, 'function')
8
+ })
9
+
10
+ test('wsConnect is exported from openclaw provider', async () => {
11
+ const mod = await import('./openclaw')
12
+ assert.equal(typeof mod.wsConnect, 'function')
13
+ })
14
+
15
+ test('getDeviceId is exported from openclaw provider', async () => {
16
+ const mod = await import('./openclaw')
17
+ assert.equal(typeof mod.getDeviceId, 'function')
18
+ })
19
+
20
+ test('buildOpenClawConnectParams is exported from openclaw provider', async () => {
21
+ const mod = await import('./openclaw')
22
+ assert.equal(typeof mod.buildOpenClawConnectParams, 'function')
23
+ })
@@ -321,7 +321,7 @@ export function buildOpenClawSessionKey(
321
321
  return `agent:${agentId}:swarm:${sessionId}`
322
322
  }
323
323
 
324
- async function rpcOnConnectedGateway(
324
+ export async function rpcOnConnectedGateway(
325
325
  ws: InstanceType<typeof WebSocket>,
326
326
  method: string,
327
327
  params: unknown,
@@ -90,4 +90,39 @@ describe('data-dir resolution', () => {
90
90
  fs.rmSync(tempDir, { recursive: true, force: true })
91
91
  }
92
92
  })
93
+
94
+ it('derives runtime directories from SWARMCLAW_HOME when set', () => {
95
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-data-dir-home-'))
96
+ const fakeHome = path.join(tempDir, 'home')
97
+ const swarmclawHome = path.join(tempDir, 'project', '.swarmclaw')
98
+
99
+ try {
100
+ const env = { ...process.env, HOME: fakeHome, SWARMCLAW_HOME: swarmclawHome }
101
+ delete (env as any).DATA_DIR
102
+ delete (env as any).WORKSPACE_DIR
103
+ delete (env as any).BROWSER_PROFILES_DIR
104
+
105
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
106
+ const modNs = await import('./src/lib/server/data-dir')
107
+ const mod = modNs.default || modNs['module.exports'] || modNs
108
+ console.log(JSON.stringify({
109
+ dataDir: mod.DATA_DIR,
110
+ workspaceDir: mod.WORKSPACE_DIR,
111
+ browserProfilesDir: mod.BROWSER_PROFILES_DIR,
112
+ }))
113
+ `], {
114
+ cwd: repoRoot,
115
+ env,
116
+ encoding: 'utf-8',
117
+ })
118
+
119
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
120
+ const payload = extractLastJson(result.stdout || '')
121
+ assert.equal(payload.dataDir, path.join(swarmclawHome, 'data'))
122
+ assert.equal(payload.workspaceDir, path.join(swarmclawHome, 'workspace'))
123
+ assert.equal(payload.browserProfilesDir, path.join(swarmclawHome, 'browser-profiles'))
124
+ } finally {
125
+ fs.rmSync(tempDir, { recursive: true, force: true })
126
+ }
127
+ })
93
128
  })
@@ -13,9 +13,16 @@ function isBuildBootstrapEnv(env: NodeJS.ProcessEnv = process.env, argv: string[
13
13
 
14
14
  export const IS_BUILD_BOOTSTRAP = isBuildBootstrapEnv()
15
15
 
16
+ function resolveSwarmclawHome(): string | null {
17
+ const configured = process.env.SWARMCLAW_HOME?.trim()
18
+ return configured ? path.resolve(configured) : null
19
+ }
20
+
16
21
  function resolveDataDir(): string {
17
22
  if (process.env.DATA_DIR) return process.env.DATA_DIR
18
23
  if (IS_BUILD_BOOTSTRAP) return path.join(os.tmpdir(), 'swarmclaw-build-data')
24
+ const appHome = resolveSwarmclawHome()
25
+ if (appHome) return path.join(appHome, 'data')
19
26
  return path.join(process.cwd(), 'data')
20
27
  }
21
28
 
@@ -41,6 +48,8 @@ function supportsChildWrites(dir: string): boolean {
41
48
  function resolveWorkspaceDir(): string {
42
49
  if (process.env.WORKSPACE_DIR) return process.env.WORKSPACE_DIR
43
50
  if (IS_BUILD_BOOTSTRAP) return path.join(DATA_DIR, 'workspace')
51
+ const appHome = resolveSwarmclawHome()
52
+ if (appHome) return path.join(appHome, 'workspace')
44
53
  const external = path.join(os.homedir(), '.swarmclaw', 'workspace')
45
54
  if (supportsChildWrites(external)) {
46
55
  return external
@@ -53,6 +62,8 @@ export const WORKSPACE_DIR = resolveWorkspaceDir()
53
62
  function resolveBrowserProfilesDir(): string {
54
63
  if (process.env.BROWSER_PROFILES_DIR) return process.env.BROWSER_PROFILES_DIR
55
64
  if (IS_BUILD_BOOTSTRAP) return path.join(DATA_DIR, 'browser-profiles')
65
+ const appHome = resolveSwarmclawHome()
66
+ if (appHome) return path.join(appHome, 'browser-profiles')
56
67
  const external = path.join(os.homedir(), '.swarmclaw', 'browser-profiles')
57
68
  if (supportsChildWrites(external)) {
58
69
  return external
@@ -1,6 +1,8 @@
1
1
  import { deriveOpenClawWsUrl, normalizeOpenClawEndpoint } from '@/lib/openclaw/openclaw-endpoint'
2
2
  import { wsConnect } from '@/lib/providers/openclaw'
3
- import { decryptKey, loadCredentials } from '../storage'
3
+ import { decryptKey, loadCredentials, loadGatewayProfiles, saveGatewayProfiles } from '../storage'
4
+ import { notify } from '../ws-hub'
5
+ import type { GatewayProfile } from '@/types'
4
6
 
5
7
  export interface OpenClawHealthInput {
6
8
  endpoint?: string | null
@@ -411,3 +413,30 @@ export async function probeOpenClawHealth(input: OpenClawHealthInput): Promise<O
411
413
  hint: http.hint,
412
414
  }
413
415
  }
416
+
417
+ export function persistGatewayHealthResult(
418
+ id: string,
419
+ result: OpenClawHealthResult,
420
+ now = Date.now(),
421
+ ): GatewayProfile | null {
422
+ const gateways = loadGatewayProfiles()
423
+ const gateway = gateways[id]
424
+ if (!gateway) return null
425
+
426
+ gateway.status = result.ok ? 'healthy' : (result.authProvided ? 'degraded' : 'offline')
427
+ gateway.lastCheckedAt = now
428
+ gateway.lastError = result.ok ? null : (result.error || result.hint || 'Gateway health check failed.')
429
+ gateway.lastModelCount = Array.isArray(result.models) ? result.models.length : 0
430
+ gateway.deployment = {
431
+ ...(gateway.deployment || {}),
432
+ lastVerifiedAt: now,
433
+ lastVerifiedOk: result.ok,
434
+ lastVerifiedMessage: result.ok
435
+ ? result.message
436
+ : (result.error || result.hint || 'Gateway health check failed.'),
437
+ }
438
+ gateway.updatedAt = now
439
+ saveGatewayProfiles(gateways)
440
+ notify('gateways')
441
+ return gateway
442
+ }
@@ -113,10 +113,26 @@ describe('normalizeSendFilePaths', () => {
113
113
  fs.rmSync(cwd, { recursive: true, force: true })
114
114
  })
115
115
 
116
- it('resolves browser profile screenshot paths back into the agent home directory', () => {
116
+ it('resolves browser profile screenshot paths into the configured browser profiles directory', () => {
117
117
  const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'send-file-browser-profile-'))
118
118
  const resolved = resolveSendFileSourcePath(cwd, '../../../.swarmclaw/browser-profiles/example/mcp-output/page.png')
119
- assert.match(resolved, new RegExp(`\\.swarmclaw[\\\\/]browser-profiles[\\\\/]example[\\\\/]mcp-output[\\\\/]page\\.png$`))
119
+ assert.match(resolved, new RegExp(`browser-profiles[\\\\/]example[\\\\/]mcp-output[\\\\/]page\\.png$`))
120
120
  fs.rmSync(cwd, { recursive: true, force: true })
121
121
  })
122
+
123
+ it('resolves browser profile screenshot paths from BROWSER_PROFILES_DIR when set', () => {
124
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'send-file-browser-profile-env-'))
125
+ const browserProfilesDir = path.join(cwd, '.swarmclaw', 'browser-profiles')
126
+ const previousDir = process.env.BROWSER_PROFILES_DIR
127
+
128
+ try {
129
+ process.env.BROWSER_PROFILES_DIR = browserProfilesDir
130
+ const resolved = resolveSendFileSourcePath(cwd, '../../../.swarmclaw/browser-profiles/example/mcp-output/page.png')
131
+ assert.equal(resolved, path.join(browserProfilesDir, 'example', 'mcp-output', 'page.png'))
132
+ } finally {
133
+ if (previousDir === undefined) delete process.env.BROWSER_PROFILES_DIR
134
+ else process.env.BROWSER_PROFILES_DIR = previousDir
135
+ fs.rmSync(cwd, { recursive: true, force: true })
136
+ }
137
+ })
122
138
  })
@@ -1,10 +1,9 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import fs from 'fs'
4
- import os from 'os'
5
4
  import path from 'path'
6
5
  import { UPLOAD_DIR } from '../storage'
7
- import { WORKSPACE_DIR } from '../data-dir'
6
+ import { BROWSER_PROFILES_DIR, WORKSPACE_DIR } from '../data-dir'
8
7
  import type { ToolBuildContext } from './context'
9
8
  import { safePath, truncate, listDirRecursive, MAX_FILE } from './context'
10
9
  import type { Plugin, PluginHooks } from '@/types'
@@ -387,19 +386,24 @@ export function findRecentSendFileFallbackPaths(cwd: string, maxAgeMs = 10 * 60
387
386
  return dedup(candidates)
388
387
  }
389
388
 
389
+ function getBrowserProfilesDir(): string {
390
+ return process.env.BROWSER_PROFILES_DIR || BROWSER_PROFILES_DIR
391
+ }
392
+
390
393
  export function resolveSendFileSourcePath(cwd: string, rawPath: string, scope?: 'workspace' | 'machine'): string {
391
394
  const trimmed = rawPath.trim()
392
395
  const uploadMatch = trimmed.match(/^(?:sandbox:)?\/api\/uploads\/(.+)$/)
393
396
  if (uploadMatch) {
394
397
  return path.join(UPLOAD_DIR, path.basename(uploadMatch[1]))
395
398
  }
396
- const browserProfileIdx = trimmed.lastIndexOf('.swarmclaw/browser-profiles/')
397
- if (browserProfileIdx !== -1) {
398
- const relative = trimmed.slice(browserProfileIdx)
399
- return path.join(os.homedir(), relative)
399
+ const browserProfileMatch = trimmed
400
+ .replaceAll('\\', '/')
401
+ .match(/(?:^|\/)\.swarmclaw\/browser-profiles\/(.+)$/)
402
+ if (browserProfileMatch) {
403
+ return path.join(getBrowserProfilesDir(), browserProfileMatch[1])
400
404
  }
401
405
  if (trimmed.startsWith('browser-profiles/')) {
402
- const candidate = path.join(os.homedir(), '.swarmclaw', trimmed)
406
+ const candidate = path.join(getBrowserProfilesDir(), trimmed.slice('browser-profiles/'.length))
403
407
  if (fs.existsSync(candidate)) return candidate
404
408
  }
405
409
  if (trimmed === '/workspace' || trimmed === 'workspace') return cwd
@@ -1,7 +1,9 @@
1
1
  import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
2
4
  import path from 'node:path'
3
5
  import test from 'node:test'
4
- import { discoverSkills } from './skill-discovery'
6
+ import { clearDiscoveredSkillsCache, discoverSkills } from './skill-discovery'
5
7
 
6
8
  test('discoverSkills includes tracked bundled skills from bundled-skills', () => {
7
9
  const skills = discoverSkills({ cwd: path.join(process.cwd(), 'src') })
@@ -14,3 +16,34 @@ test('discoverSkills includes tracked bundled skills from bundled-skills', () =>
14
16
  true,
15
17
  )
16
18
  })
19
+
20
+ test('discoverSkills reads workspace skills from SWARMCLAW_HOME when set', () => {
21
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-skills-home-'))
22
+ const skillDir = path.join(tempHome, 'skills', 'local-skill')
23
+ const previousHome = process.env.SWARMCLAW_HOME
24
+
25
+ try {
26
+ fs.mkdirSync(skillDir, { recursive: true })
27
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---
28
+ name: local-skill
29
+ description: A local test skill.
30
+ ---
31
+
32
+ # Local Skill
33
+ `, 'utf8')
34
+ process.env.SWARMCLAW_HOME = tempHome
35
+ clearDiscoveredSkillsCache()
36
+
37
+ const skills = discoverSkills()
38
+ const localSkill = skills.find((skill) => skill.name === 'local-skill')
39
+
40
+ assert.ok(localSkill)
41
+ assert.equal(localSkill?.source, 'workspace')
42
+ assert.equal(localSkill?.sourcePath, path.join(skillDir, 'SKILL.md'))
43
+ } finally {
44
+ clearDiscoveredSkillsCache()
45
+ if (previousHome === undefined) delete process.env.SWARMCLAW_HOME
46
+ else process.env.SWARMCLAW_HOME = previousHome
47
+ fs.rmSync(tempHome, { recursive: true, force: true })
48
+ }
49
+ })
@@ -29,7 +29,12 @@ export function clearDiscoveredSkillsCache(): void {
29
29
  }
30
30
 
31
31
  function buildCacheKey(cwd?: string): string {
32
- return `${cwd || ''}`
32
+ return `${cwd || ''}|${resolveWorkspaceSkillsDir()}`
33
+ }
34
+
35
+ function resolveWorkspaceSkillsDir(): string {
36
+ const swarmclawHome = process.env.SWARMCLAW_HOME || path.join(os.homedir(), '.swarmclaw')
37
+ return path.join(swarmclawHome, 'skills')
33
38
  }
34
39
 
35
40
  function scanLayer(
@@ -81,7 +86,7 @@ function scanLayer(
81
86
  * Discover skills from three layers:
82
87
  * 1. Bundled: `bundled-skills/` (tracked with the app)
83
88
  * Legacy fallback: `data/skills/`
84
- * 2. Workspace: `~/.swarmclaw/skills/` (user-installed)
89
+ * 2. Workspace: `<swarmclaw-home>/skills/` (user-installed)
85
90
  * 3. Project: `<cwd>/skills/` (project-local)
86
91
  *
87
92
  * Results are cached with a 5-second TTL. Later layers override
@@ -102,8 +107,8 @@ export function discoverSkills(opts?: { cwd?: string }): DiscoveredSkill[] {
102
107
  ...scanLayer(BUNDLED_SKILLS_DIR, 'bundled'),
103
108
  ]
104
109
 
105
- // Layer 2: Workspace skills (~/.swarmclaw/skills/)
106
- const workspaceDir = path.join(os.homedir(), '.swarmclaw', 'skills')
110
+ // Layer 2: Workspace skills (<swarmclaw-home>/skills/)
111
+ const workspaceDir = resolveWorkspaceSkillsDir()
107
112
  const workspace = scanLayer(workspaceDir, 'workspace')
108
113
 
109
114
  // Layer 3: Project-local skills (<cwd>/skills/)
@@ -0,0 +1,42 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { DEFAULT_AGENTS, getDefaultModelForProvider } from './setup-defaults'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // OpenClaw default model is empty (not 'default')
7
+ // ---------------------------------------------------------------------------
8
+
9
+ test('OpenClaw default agent model is empty string', () => {
10
+ assert.equal(DEFAULT_AGENTS.openclaw.model, '')
11
+ })
12
+
13
+ test('getDefaultModelForProvider returns empty for openclaw', () => {
14
+ assert.equal(getDefaultModelForProvider('openclaw'), '')
15
+ })
16
+
17
+ test('OpenClaw default model is falsy so UI does not render a suggested model', () => {
18
+ assert.ok(!DEFAULT_AGENTS.openclaw.model, 'model should be falsy')
19
+ })
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Other providers still have their expected models
23
+ // ---------------------------------------------------------------------------
24
+
25
+ test('getDefaultModelForProvider returns non-empty for openai', () => {
26
+ const model = getDefaultModelForProvider('openai')
27
+ assert.ok(model, 'openai model should be truthy')
28
+ })
29
+
30
+ test('getDefaultModelForProvider returns non-empty for anthropic', () => {
31
+ const model = getDefaultModelForProvider('anthropic')
32
+ assert.ok(model, 'anthropic model should be truthy')
33
+ })
34
+
35
+ test('getDefaultModelForProvider returns non-empty for ollama', () => {
36
+ const model = getDefaultModelForProvider('ollama')
37
+ assert.ok(model, 'ollama model should be truthy')
38
+ })
39
+
40
+ test('custom provider default model is empty (like openclaw)', () => {
41
+ assert.equal(DEFAULT_AGENTS.custom.model, '')
42
+ })
@@ -629,7 +629,7 @@ export const DEFAULT_AGENTS: Record<SetupProvider, DefaultAgentConfig> = {
629
629
  name: 'OpenClaw Operator',
630
630
  description: 'A manager agent for talking to and coordinating OpenClaw instances.',
631
631
  systemPrompt: 'You are an operator focused on reliable execution, clear status updates, and task completion.',
632
- model: 'default',
632
+ model: '',
633
633
  tools: STARTER_AGENT_TOOLS,
634
634
  },
635
635
  custom: {