Skip to content

Commit d5d8e6a

Browse files
committed
feat(cli): auto-scan clawdbot skill roots
1 parent f0772e7 commit d5d8e6a

File tree

7 files changed

+200
-3
lines changed

7 files changed

+200
-3
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ Stores your API token + cached registry URL.
8080

8181
- Scans for local skill folders and publishes new/changed ones.
8282
- Roots can be any folder: a skills directory or a single skill folder with `SKILL.md`.
83+
- Auto-adds Clawdbot skill roots when `~/.clawdbot/clawdbot.json` is present:
84+
- `agent.workspace/skills` (main agent)
85+
- `routing.agents.*.workspace/skills` (per-agent)
86+
- `~/.clawdbot/skills` (shared)
87+
- `skills.load.extraDirs` (shared packs)
88+
- Respects `CLAWDBOT_CONFIG_PATH` and `CLAWDBOT_STATE_DIR`.
8389
- Flags:
8490
- `--root <dir...>` extra scan roots
8591
- `--all` upload without prompting

packages/clawdhub/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"commander": "^14.0.2",
2525
"fflate": "^0.8.2",
2626
"ignore": "^7.0.5",
27+
"json5": "^2.2.3",
2728
"mime": "^4.1.0",
2829
"ora": "^9.0.0",
2930
"p-retry": "^7.1.1",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/* @vitest-environment node */
2+
import { mkdtemp, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join, resolve } from 'node:path'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { resolveClawdbotSkillRoots } from './clawdbotConfig.js'
7+
8+
const originalEnv = { ...process.env }
9+
10+
afterEach(() => {
11+
process.env = { ...originalEnv }
12+
})
13+
14+
describe('resolveClawdbotSkillRoots', () => {
15+
it('reads JSON5 config and resolves per-agent + shared skill roots', async () => {
16+
const base = await mkdtemp(join(tmpdir(), 'clawdhub-clawdbot-'))
17+
const home = join(base, 'home')
18+
const stateDir = join(base, 'state')
19+
const configPath = join(base, 'clawdbot.json')
20+
21+
process.env.HOME = home
22+
process.env.CLAWDBOT_STATE_DIR = stateDir
23+
process.env.CLAWDBOT_CONFIG_PATH = configPath
24+
25+
const config = `{
26+
// JSON5 comments + trailing commas supported
27+
agent: { workspace: '~/clawd-main', },
28+
routing: {
29+
agents: {
30+
work: { name: 'Work Bot', workspace: '~/clawd-work', },
31+
family: { workspace: '~/clawd-family' },
32+
},
33+
},
34+
skills: {
35+
load: { extraDirs: ['~/shared/skills', '/opt/skills',], },
36+
},
37+
}`
38+
await writeFile(configPath, config, 'utf8')
39+
40+
const { roots, labels } = await resolveClawdbotSkillRoots()
41+
42+
const expectedRoots = [
43+
resolve(stateDir, 'skills'),
44+
resolve(home, 'clawd-main', 'skills'),
45+
resolve(home, 'clawd-work', 'skills'),
46+
resolve(home, 'clawd-family', 'skills'),
47+
resolve(home, 'shared', 'skills'),
48+
resolve('/opt/skills'),
49+
]
50+
51+
expect(roots).toEqual(expect.arrayContaining(expectedRoots))
52+
expect(labels[resolve(stateDir, 'skills')]).toBe('Shared skills')
53+
expect(labels[resolve(home, 'clawd-main', 'skills')]).toBe('Agent: main')
54+
expect(labels[resolve(home, 'clawd-work', 'skills')]).toBe('Agent: Work Bot')
55+
expect(labels[resolve(home, 'clawd-family', 'skills')]).toBe('Agent: family')
56+
expect(labels[resolve(home, 'shared', 'skills')]).toBe('Extra: skills')
57+
expect(labels[resolve('/opt/skills')]).toBe('Extra: skills')
58+
})
59+
})
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { homedir } from 'node:os'
3+
import { basename, join, resolve } from 'node:path'
4+
import JSON5 from 'json5'
5+
6+
type ClawdbotConfig = {
7+
agent?: { workspace?: string }
8+
routing?: {
9+
agents?: Record<
10+
string,
11+
{
12+
name?: string
13+
workspace?: string
14+
}
15+
>
16+
}
17+
skills?: {
18+
load?: {
19+
extraDirs?: string[]
20+
}
21+
}
22+
}
23+
24+
export type ClawdbotSkillRoots = {
25+
roots: string[]
26+
labels: Record<string, string>
27+
}
28+
29+
export async function resolveClawdbotSkillRoots(): Promise<ClawdbotSkillRoots> {
30+
const roots: string[] = []
31+
const labels: Record<string, string> = {}
32+
33+
const stateDir = resolveClawdbotStateDir()
34+
const sharedSkills = resolveUserPath(join(stateDir, 'skills'))
35+
pushRoot(roots, labels, sharedSkills, 'Shared skills')
36+
37+
const config = await readClawdbotConfig()
38+
if (!config) return { roots, labels }
39+
40+
const mainWorkspace = resolveUserPath(config.agent?.workspace ?? '')
41+
if (mainWorkspace) {
42+
pushRoot(roots, labels, join(mainWorkspace, 'skills'), 'Agent: main')
43+
}
44+
45+
const agents = config.routing?.agents ?? {}
46+
for (const [agentId, entry] of Object.entries(agents)) {
47+
const workspace = resolveUserPath(entry?.workspace ?? '')
48+
if (!workspace) continue
49+
const name = entry?.name?.trim() || agentId
50+
pushRoot(roots, labels, join(workspace, 'skills'), `Agent: ${name}`)
51+
}
52+
53+
const extraDirs = config.skills?.load?.extraDirs ?? []
54+
for (const dir of extraDirs) {
55+
const resolved = resolveUserPath(String(dir))
56+
if (!resolved) continue
57+
const label = `Extra: ${basename(resolved) || resolved}`
58+
pushRoot(roots, labels, resolved, label)
59+
}
60+
61+
return { roots, labels }
62+
}
63+
64+
function resolveClawdbotStateDir() {
65+
const override = process.env.CLAWDBOT_STATE_DIR?.trim()
66+
if (override) return resolveUserPath(override)
67+
return join(homedir(), '.clawdbot')
68+
}
69+
70+
function resolveClawdbotConfigPath() {
71+
const override = process.env.CLAWDBOT_CONFIG_PATH?.trim()
72+
if (override) return resolveUserPath(override)
73+
return join(resolveClawdbotStateDir(), 'clawdbot.json')
74+
}
75+
76+
function resolveUserPath(input: string) {
77+
const trimmed = input.trim()
78+
if (!trimmed) return ''
79+
if (trimmed.startsWith('~')) {
80+
return resolve(trimmed.replace(/^~(?=$|[\\/])/, homedir()))
81+
}
82+
return resolve(trimmed)
83+
}
84+
85+
async function readClawdbotConfig(): Promise<ClawdbotConfig | null> {
86+
try {
87+
const raw = await readFile(resolveClawdbotConfigPath(), 'utf8')
88+
const parsed = JSON5.parse(raw)
89+
if (!parsed || typeof parsed !== 'object') return null
90+
return parsed as ClawdbotConfig
91+
} catch {
92+
return null
93+
}
94+
}
95+
96+
function pushRoot(roots: string[], labels: Record<string, string>, root: string, label?: string) {
97+
const resolved = resolveUserPath(root)
98+
if (!resolved) return
99+
roots.push(resolved)
100+
if (!label) return
101+
const existing = labels[resolved]
102+
labels[resolved] = existing ? `${existing}, ${label}` : label
103+
}

packages/clawdhub/src/cli/commands/sync.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { relative } from 'node:path'
22
import { intro, outro } from '@clack/prompts'
33
import { readGlobalConfig } from '../../config.js'
44
import { hashSkillFiles, listTextFiles, readSkillOrigin } from '../../skills.js'
5+
import { resolveClawdbotSkillRoots } from '../clawdbotConfig.js'
56
import { getFallbackSkillRoots } from '../scanSkills.js'
67
import type { GlobalOpts } from '../types.js'
78
import { createSpinner, fail, formatError, isInteractive } from '../ui.js'
@@ -24,7 +25,7 @@ import {
2425
printSection,
2526
reportTelemetryIfEnabled,
2627
resolvePublishMeta,
27-
scanRoots,
28+
scanRootsWithLabels,
2829
selectToUpload,
2930
} from './syncHelpers.js'
3031
import type { Candidate, LocalSkill, SyncOptions } from './syncTypes.js'
@@ -39,15 +40,19 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
3940

4041
const registry = await getRegistryWithAuth(opts, token)
4142
const selectedRoots = buildScanRoots(opts, options.root)
43+
const clawdbotRoots = await resolveClawdbotSkillRoots()
44+
const combinedRoots = Array.from(
45+
new Set([...selectedRoots, ...clawdbotRoots.roots].map((root) => root.trim()).filter(Boolean)),
46+
)
4247
const concurrency = normalizeConcurrency(options.concurrency)
4348

4449
const spinner = createSpinner('Scanning for local skills')
45-
const primaryScan = await scanRoots(selectedRoots)
50+
const primaryScan = await scanRootsWithLabels(combinedRoots, clawdbotRoots.labels)
4651
let scan = primaryScan
4752
let telemetryScan = primaryScan
4853
if (primaryScan.skills.length === 0) {
4954
const fallback = getFallbackSkillRoots(opts.workdir)
50-
const fallbackScan = await scanRoots(fallback)
55+
const fallbackScan = await scanRootsWithLabels(fallback)
5156
spinner.stop()
5257
telemetryScan = mergeScan(primaryScan, fallbackScan)
5358
scan = fallbackScan
@@ -59,6 +64,15 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
5964
)
6065
} else {
6166
spinner.stop()
67+
const labeledRoots = primaryScan.rootsWithSkills
68+
.map((root) => {
69+
const label = primaryScan.rootLabels?.[root]
70+
return label ? `${label} (${root})` : root
71+
})
72+
.filter(Boolean)
73+
if (labeledRoots.length > 0) {
74+
printSection('Roots with skills', formatList(labeledRoots, 10))
75+
}
6276
}
6377
const deduped = dedupeSkillsBySlug(scan.skills)
6478
const skills = deduped.skills

packages/clawdhub/src/cli/commands/syncHelpers.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,15 +176,27 @@ export async function checkRegistrySyncState(
176176
}
177177

178178
export async function scanRoots(roots: string[]) {
179+
const result = await scanRootsWithLabels(roots)
180+
return {
181+
roots: result.roots,
182+
skillsByRoot: result.skillsByRoot,
183+
skills: result.skills,
184+
rootsWithSkills: result.rootsWithSkills,
185+
}
186+
}
187+
188+
export async function scanRootsWithLabels(roots: string[], labels?: Record<string, string>) {
179189
const all: SkillFolder[] = []
180190
const rootsWithSkills: string[] = []
181191
const uniqueRoots = await dedupeRoots(roots)
182192
const skillsByRoot: Record<string, SkillFolder[]> = {}
193+
const rootLabels: Record<string, string> = {}
183194
for (const root of uniqueRoots) {
184195
const found = await findSkillFolders(root)
185196
skillsByRoot[root] = found
186197
if (found.length > 0) rootsWithSkills.push(root)
187198
all.push(...found)
199+
if (labels?.[root]) rootLabels[root] = labels[root] as string
188200
}
189201
const byFolder = new Map<string, SkillFolder>()
190202
for (const folder of all) {
@@ -195,6 +207,7 @@ export async function scanRoots(roots: string[]) {
195207
skillsByRoot,
196208
skills: Array.from(byFolder.values()),
197209
rootsWithSkills,
210+
rootLabels,
198211
}
199212
}
200213

0 commit comments

Comments
 (0)