@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.
- package/README.md +15 -2
- package/bin/doctor-cmd.js +155 -0
- package/bin/doctor-cmd.test.js +50 -0
- package/bin/install-root.js +39 -0
- package/bin/install-root.test.js +60 -0
- package/bin/server-cmd.js +160 -38
- package/bin/swarmclaw.js +83 -3
- package/bin/update-cmd.js +1 -6
- package/bin/update-cmd.test.js +1 -36
- package/bin/worker-cmd.js +13 -6
- package/package.json +16 -15
- package/scripts/postinstall.mjs +17 -13
- package/src/app/api/gateways/[id]/health/route.ts +2 -32
- package/src/app/api/gateways/health-route.test.ts +1 -1
- package/src/app/api/setup/check-provider/helpers.ts +28 -0
- package/src/app/api/setup/check-provider/route.test.ts +1 -1
- package/src/app/api/setup/check-provider/route.ts +5 -32
- package/src/app/api/tasks/import/github/helpers.ts +100 -0
- package/src/app/api/tasks/import/github/route.test.ts +1 -1
- package/src/app/api/tasks/import/github/route.ts +2 -92
- package/src/app/api/webhooks/[id]/helpers.ts +253 -0
- package/src/app/api/webhooks/[id]/route.ts +2 -243
- package/src/app/api/webhooks/route.test.ts +4 -2
- package/src/app/usage/page.tsx +22 -12
- package/src/cli/binary.test.js +57 -0
- package/src/cli/index.js +13 -1
- package/src/cli/server-cmd.test.js +77 -0
- package/src/lib/server/data-dir.test.ts +38 -3
- package/src/lib/server/data-dir.ts +11 -0
- package/src/lib/server/openclaw/health.ts +30 -1
- package/src/lib/server/session-tools/file-send.test.ts +18 -2
- package/src/lib/server/session-tools/file.ts +11 -7
- package/src/lib/server/skills/skill-discovery.test.ts +34 -1
- package/src/lib/server/skills/skill-discovery.ts +9 -4
package/src/cli/binary.test.js
CHANGED
|
@@ -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
|
|
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
|
|
64
|
-
delete
|
|
65
|
-
delete
|
|
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
|
|
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(
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
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:
|
|
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 (
|
|
106
|
-
const workspaceDir =
|
|
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/)
|