@swarmclawai/swarmclaw 1.0.0 → 1.0.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.
Files changed (34) hide show
  1. package/README.md +15 -2
  2. package/bin/doctor-cmd.js +155 -0
  3. package/bin/doctor-cmd.test.js +50 -0
  4. package/bin/install-root.js +39 -0
  5. package/bin/install-root.test.js +60 -0
  6. package/bin/server-cmd.js +160 -38
  7. package/bin/swarmclaw.js +83 -3
  8. package/bin/update-cmd.js +1 -6
  9. package/bin/update-cmd.test.js +1 -36
  10. package/bin/worker-cmd.js +13 -6
  11. package/package.json +16 -15
  12. package/scripts/postinstall.mjs +17 -13
  13. package/src/app/api/gateways/[id]/health/route.ts +2 -32
  14. package/src/app/api/gateways/health-route.test.ts +1 -1
  15. package/src/app/api/setup/check-provider/helpers.ts +28 -0
  16. package/src/app/api/setup/check-provider/route.test.ts +1 -1
  17. package/src/app/api/setup/check-provider/route.ts +5 -32
  18. package/src/app/api/tasks/import/github/helpers.ts +100 -0
  19. package/src/app/api/tasks/import/github/route.test.ts +1 -1
  20. package/src/app/api/tasks/import/github/route.ts +2 -92
  21. package/src/app/api/webhooks/[id]/helpers.ts +253 -0
  22. package/src/app/api/webhooks/[id]/route.ts +2 -243
  23. package/src/app/api/webhooks/route.test.ts +4 -2
  24. package/src/app/usage/page.tsx +22 -12
  25. package/src/cli/binary.test.js +57 -0
  26. package/src/cli/index.js +13 -1
  27. package/src/cli/server-cmd.test.js +77 -0
  28. package/src/lib/server/data-dir.test.ts +38 -3
  29. package/src/lib/server/data-dir.ts +11 -0
  30. package/src/lib/server/openclaw/health.ts +30 -1
  31. package/src/lib/server/session-tools/file-send.test.ts +18 -2
  32. package/src/lib/server/session-tools/file.ts +11 -7
  33. package/src/lib/server/skills/skill-discovery.test.ts +34 -1
  34. package/src/lib/server/skills/skill-discovery.ts +9 -4
@@ -119,6 +119,51 @@ test('binary server help exits successfully', () => {
119
119
  assert.match(result.stdout, /Usage: swarmclaw server/i)
120
120
  })
121
121
 
122
+ test('binary run alias routes to server help', () => {
123
+ const result = runBinary(['run', '--help'])
124
+ assert.equal(result.status, 0, result.stderr)
125
+ assert.match(result.stdout, /Usage: swarmclaw server/i)
126
+ })
127
+
128
+ test('binary help command shows root help', () => {
129
+ const result = runBinary(['help'])
130
+ assert.equal(result.status, 0, result.stderr)
131
+ assert.match(result.stdout, /SwarmClaw CLI/i)
132
+ assert.match(result.stdout, /swarmclaw help \[command\]/i)
133
+ })
134
+
135
+ test('binary help command shows command help for run alias', () => {
136
+ const result = runBinary(['help', 'run'])
137
+ assert.equal(result.status, 0, result.stderr)
138
+ assert.match(result.stdout, /Usage: swarmclaw server/i)
139
+ })
140
+
141
+ test('binary help command shows command help for doctor alias', () => {
142
+ const result = runBinary(['help', 'doctor'])
143
+ assert.equal(result.status, 0, result.stderr)
144
+ assert.match(result.stdout, /Usage: swarmclaw doctor/i)
145
+ })
146
+
147
+ test('binary status alias routes to local server status', () => {
148
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-binary-status-'))
149
+ const result = runBinary(['status'], {
150
+ env: {
151
+ SWARMCLAW_HOME: homeDir,
152
+ },
153
+ })
154
+
155
+ assert.equal(result.status, 0, result.stderr)
156
+ assert.match(result.stdout, /Server: not running/i)
157
+
158
+ fs.rmSync(homeDir, { recursive: true, force: true })
159
+ })
160
+
161
+ test('binary doctor help exits successfully', () => {
162
+ const result = runBinary(['doctor', '--help'])
163
+ assert.equal(result.status, 0, result.stderr)
164
+ assert.match(result.stdout, /Usage: swarmclaw doctor/i)
165
+ })
166
+
122
167
  test('binary update help exits successfully', () => {
123
168
  const result = runBinary(['update', '--help'])
124
169
  assert.equal(result.status, 0, result.stderr)
@@ -131,6 +176,18 @@ test('binary version output matches package version', () => {
131
176
  assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
132
177
  })
133
178
 
179
+ test('binary bare version alias output matches package version', () => {
180
+ const result = runBinary(['version'])
181
+ assert.equal(result.status, 0, result.stderr)
182
+ assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
183
+ })
184
+
185
+ test('binary -v alias output matches package version', () => {
186
+ const result = runBinary(['-v'])
187
+ assert.equal(result.status, 0, result.stderr)
188
+ assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
189
+ })
190
+
134
191
  test('legacy TS launcher falls back to tsx import when strip-types is unavailable', () => {
135
192
  const cliPath = path.join(APP_ROOT, 'src', 'cli', 'index.ts')
136
193
  const args = buildLegacyTsCliArgs(cliPath, ['runs', 'list'], {
package/src/cli/index.js CHANGED
@@ -1183,6 +1183,9 @@ function renderGeneralHelp() {
1183
1183
  'SwarmClaw CLI',
1184
1184
  '',
1185
1185
  'Usage:',
1186
+ ' swarmclaw',
1187
+ ' swarmclaw help [command]',
1188
+ ' swarmclaw run|start|stop|status|doctor|update|version',
1186
1189
  ' swarmclaw <group> <command> [args] [options]',
1187
1190
  '',
1188
1191
  'Global options:',
@@ -1199,6 +1202,15 @@ function renderGeneralHelp() {
1199
1202
  ' --help Show help',
1200
1203
  ' --version Show package version',
1201
1204
  '',
1205
+ 'Top-level commands:',
1206
+ ' run, start Start the SwarmClaw server',
1207
+ ' stop Stop the detached SwarmClaw server',
1208
+ ' status Show local server status',
1209
+ ' doctor Show local install/build diagnostics',
1210
+ ' help Show root or command help',
1211
+ ' update Update this SwarmClaw installation',
1212
+ ' version Show package version',
1213
+ '',
1202
1214
  'Groups:',
1203
1215
  ]
1204
1216
 
@@ -1210,7 +1222,7 @@ function renderGeneralHelp() {
1210
1222
  }
1211
1223
  }
1212
1224
 
1213
- lines.push('', 'Use "swarmclaw <group> --help" for group commands.')
1225
+ lines.push('', 'Use "swarmclaw help <command>" or "swarmclaw <group> --help" for more detail.')
1214
1226
  return lines.join('\n')
1215
1227
  }
1216
1228
 
@@ -58,3 +58,80 @@ test('findStandaloneServer recursively resolves nested standalone server paths',
58
58
  fs.rmSync(homeDir, { recursive: true, force: true })
59
59
  fs.rmSync(pkgRoot, { recursive: true, force: true })
60
60
  })
61
+
62
+ test('resolvePackageBuildRoot uses a versioned workspace for registry installs', () => {
63
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
64
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
65
+ const serverCmd = loadServerCmdForHome(homeDir)
66
+
67
+ fs.writeFileSync(
68
+ path.join(pkgRoot, 'package.json'),
69
+ JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
70
+ 'utf8',
71
+ )
72
+
73
+ assert.equal(
74
+ serverCmd.resolvePackageBuildRoot(pkgRoot),
75
+ path.join(homeDir, 'builds', 'package-1.0.2'),
76
+ )
77
+
78
+ fs.rmSync(homeDir, { recursive: true, force: true })
79
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
80
+ })
81
+
82
+ test('findStandaloneServer falls back to the external build workspace for registry installs', () => {
83
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
84
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
85
+ const serverCmd = loadServerCmdForHome(homeDir)
86
+
87
+ fs.writeFileSync(
88
+ path.join(pkgRoot, 'package.json'),
89
+ JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
90
+ 'utf8',
91
+ )
92
+
93
+ const nestedServer = path.join(
94
+ serverCmd.resolvePackageBuildRoot(pkgRoot),
95
+ '.next',
96
+ 'standalone',
97
+ 'Users',
98
+ 'wayde',
99
+ 'Dev',
100
+ 'swarmclaw',
101
+ 'server.js',
102
+ )
103
+ fs.mkdirSync(path.dirname(nestedServer), { recursive: true })
104
+ fs.writeFileSync(nestedServer, 'console.log("ok")\n', 'utf8')
105
+
106
+ assert.equal(serverCmd.findStandaloneServer({ pkgRoot }), nestedServer)
107
+
108
+ fs.rmSync(homeDir, { recursive: true, force: true })
109
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
110
+ })
111
+
112
+ test('prepareBuildWorkspace copies the package tree and links node_modules outside node_modules paths', () => {
113
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
114
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
115
+ const externalNodeModules = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-node-modules-'))
116
+ const serverCmd = loadServerCmdForHome(homeDir)
117
+
118
+ fs.writeFileSync(
119
+ path.join(pkgRoot, 'package.json'),
120
+ JSON.stringify({ name: '@swarmclawai/swarmclaw', version: '1.0.2' }),
121
+ 'utf8',
122
+ )
123
+ fs.mkdirSync(path.join(pkgRoot, 'src', 'app'), { recursive: true })
124
+ fs.writeFileSync(path.join(pkgRoot, 'src', 'app', 'page.tsx'), 'export default function Page() { return null }\n', 'utf8')
125
+
126
+ const buildRoot = serverCmd.resolvePackageBuildRoot(pkgRoot)
127
+ serverCmd.prepareBuildWorkspace({ pkgRoot, buildRoot, nodeModulesDir: externalNodeModules })
128
+
129
+ assert.equal(fs.readFileSync(path.join(buildRoot, 'package.json'), 'utf8'), fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'))
130
+ assert.equal(fs.readFileSync(path.join(buildRoot, 'src', 'app', 'page.tsx'), 'utf8'), 'export default function Page() { return null }\n')
131
+ assert.equal(fs.lstatSync(path.join(buildRoot, 'node_modules')).isSymbolicLink(), true)
132
+ assert.equal(fs.realpathSync(path.join(buildRoot, 'node_modules')), fs.realpathSync(externalNodeModules))
133
+
134
+ fs.rmSync(homeDir, { recursive: true, force: true })
135
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
136
+ fs.rmSync(externalNodeModules, { recursive: true, force: true })
137
+ })
@@ -60,9 +60,9 @@ describe('data-dir resolution', () => {
60
60
 
61
61
  try {
62
62
  const env = { ...process.env, HOME: fakeHome, npm_lifecycle_event: 'build:ci' }
63
- delete (env as any).DATA_DIR
64
- delete (env as any).WORKSPACE_DIR
65
- delete (env as any).BROWSER_PROFILES_DIR
63
+ delete env.DATA_DIR
64
+ delete env.WORKSPACE_DIR
65
+ delete env.BROWSER_PROFILES_DIR
66
66
 
67
67
  const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', `
68
68
  const modNs = await import('./src/lib/server/data-dir')
@@ -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.DATA_DIR
102
+ delete env.WORKSPACE_DIR
103
+ delete env.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/)