Skip to content

Commit 0103c00

Browse files
committed
feat(clawdhub): add local skills sync
1 parent de907c9 commit 0103c00

File tree

7 files changed

+471
-18
lines changed

7 files changed

+471
-18
lines changed

e2e/clawdhub.e2e.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* @vitest-environment node */
22

33
import { spawnSync } from 'node:child_process'
4-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
55
import { tmpdir } from 'node:os'
66
import { join } from 'node:path'
77
import {
@@ -119,4 +119,48 @@ describe('clawdhub e2e', () => {
119119
await rm(cfg.dir, { recursive: true, force: true })
120120
}
121121
})
122+
123+
it('sync dry-run finds skills from an explicit root', async () => {
124+
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || 'https://clawdhub.com'
125+
const site = process.env.CLAWDHUB_SITE?.trim() || 'https://clawdhub.com'
126+
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
127+
if (!token) {
128+
throw new Error('Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login')
129+
}
130+
131+
const cfg = await makeTempConfig(registry, token)
132+
const root = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-sync-'))
133+
try {
134+
const skillDir = join(root, 'cool-skill')
135+
await mkdir(skillDir, { recursive: true })
136+
await writeFile(join(skillDir, 'SKILL.md'), '# Skill\n', 'utf8')
137+
138+
const result = spawnSync(
139+
'bun',
140+
[
141+
'clawdhub',
142+
'sync',
143+
'--dry-run',
144+
'--all',
145+
'--root',
146+
root,
147+
'--site',
148+
site,
149+
'--registry',
150+
registry,
151+
],
152+
{
153+
cwd: process.cwd(),
154+
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
155+
encoding: 'utf8',
156+
},
157+
)
158+
expect(result.status).toBe(0)
159+
expect(result.stderr).not.toMatch(/error:/i)
160+
expect(result.stdout).toMatch(/Dry run/i)
161+
} finally {
162+
await rm(root, { recursive: true, force: true })
163+
await rm(cfg.dir, { recursive: true, force: true })
164+
}
165+
})
122166
})

packages/clawdhub/src/cli.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getCliBuildLabel, getCliVersion } from './cli/buildInfo.js'
55
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
66
import { cmdPublish } from './cli/commands/publish.js'
77
import { cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
8+
import { cmdSync } from './cli/commands/sync.js'
89
import { configureCommanderHelp, styleEnvBlock, styleTitle } from './cli/helpStyle.js'
910
import { DEFAULT_REGISTRY, DEFAULT_SITE } from './cli/registry.js'
1011
import type { GlobalOpts } from './cli/types.js'
@@ -159,6 +160,33 @@ program
159160
await cmdPublish(opts, folder, options)
160161
})
161162

163+
program
164+
.command('sync')
165+
.description('Scan local skills and publish new/updated ones')
166+
.option('--root <dir...>', 'Extra scan roots (one or more)')
167+
.option('--all', 'Upload all new/updated skills without prompting')
168+
.option('--dry-run', 'Show what would be uploaded')
169+
.option('--bump <type>', 'Version bump for updates (patch|minor|major)', 'patch')
170+
.option('--changelog <text>', 'Changelog to use for updates (non-interactive)')
171+
.option('--tags <tags>', 'Comma-separated tags', 'latest')
172+
.action(async (options) => {
173+
const opts = resolveGlobalOpts()
174+
const bump = String(options.bump ?? 'patch') as 'patch' | 'minor' | 'major'
175+
if (!['patch', 'minor', 'major'].includes(bump)) fail('--bump must be patch|minor|major')
176+
await cmdSync(
177+
opts,
178+
{
179+
root: options.root,
180+
all: options.all,
181+
dryRun: options.dryRun,
182+
bump,
183+
changelog: options.changelog,
184+
tags: options.tags,
185+
},
186+
isInputAllowed(),
187+
)
188+
})
189+
162190
void program.parseAsync(process.argv).catch((error) => {
163191
const message = error instanceof Error ? error.message : String(error)
164192
fail(message)

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

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { readGlobalConfig } from '../../config.js'
1414
import { apiRequest } from '../../http.js'
1515
import { listTextFiles, sha256Hex } from '../../skills.js'
1616
import { getRegistry } from '../registry.js'
17+
import { sanitizeSlug, titleCase } from '../slug.js'
1718
import type { GlobalOpts } from '../types.js'
1819
import { createSpinner, fail, formatError } from '../ui.js'
1920

@@ -131,20 +132,3 @@ async function uploadFile(uploadUrl: string, bytes: Uint8Array, contentType: str
131132
)
132133
return payload.storageId
133134
}
134-
135-
function sanitizeSlug(value: string) {
136-
const raw = value
137-
.trim()
138-
.toLowerCase()
139-
.replace(/[^a-z0-9-]+/g, '-')
140-
const cleaned = raw.replace(/^-+/, '').replace(/-+$/, '').replace(/--+/g, '-')
141-
return cleaned
142-
}
143-
144-
function titleCase(value: string) {
145-
return value
146-
.trim()
147-
.replace(/[-_]+/g, ' ')
148-
.replace(/\s+/g, ' ')
149-
.replace(/\b\w/g, (char) => char.toUpperCase())
150-
}
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { resolve } from 'node:path'
2+
import { intro, isCancel, multiselect, note, outro, text } from '@clack/prompts'
3+
import {
4+
ApiCliWhoamiResponseSchema,
5+
ApiRoutes,
6+
ApiSkillMetaResponseSchema,
7+
ApiSkillResolveResponseSchema,
8+
} from '@clawdhub/schema'
9+
import semver from 'semver'
10+
import { readGlobalConfig } from '../../config.js'
11+
import { apiRequest } from '../../http.js'
12+
import { hashSkillFiles, listTextFiles } from '../../skills.js'
13+
import { getRegistry } from '../registry.js'
14+
import { findSkillFolders, getFallbackSkillRoots, type SkillFolder } from '../scanSkills.js'
15+
import type { GlobalOpts } from '../types.js'
16+
import { createSpinner, fail, formatError, isInteractive } from '../ui.js'
17+
import { cmdPublish } from './publish.js'
18+
19+
type SyncOptions = {
20+
root?: string[]
21+
all?: boolean
22+
dryRun?: boolean
23+
bump?: 'patch' | 'minor' | 'major'
24+
changelog?: string
25+
tags?: string
26+
}
27+
28+
type Candidate = SkillFolder & {
29+
fingerprint: string
30+
fileCount: number
31+
status: 'synced' | 'new' | 'update'
32+
matchVersion: string | null
33+
latestVersion: string | null
34+
}
35+
36+
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
37+
const allowPrompt = isInteractive() && inputAllowed !== false
38+
39+
const cfg = await readGlobalConfig()
40+
const token = cfg?.token
41+
if (!token) fail('Not logged in. Run: clawdhub login')
42+
43+
const registry = await getRegistryWithAuth(opts, token)
44+
const selectedRoots = buildScanRoots(opts, options.root)
45+
46+
const spinner = createSpinner('Scanning for local skills')
47+
let skills = await scanRoots(selectedRoots)
48+
if (skills.length === 0) {
49+
const fallback = getFallbackSkillRoots(opts.workdir)
50+
skills = await scanRoots(fallback)
51+
spinner.stop()
52+
if (skills.length === 0)
53+
fail('No skills found (checked workdir and known Clawdis/Clawd locations)')
54+
note(`No skills in workdir. Found ${skills.length} in legacy locations.`, fallback.join('\n'))
55+
} else {
56+
spinner.stop()
57+
}
58+
59+
intro('ClawdHub sync')
60+
61+
const candidatesSpinner = createSpinner('Checking registry sync state')
62+
const candidates: Candidate[] = []
63+
try {
64+
for (const skill of skills) {
65+
const filesOnDisk = await listTextFiles(skill.folder)
66+
const hashed = hashSkillFiles(filesOnDisk)
67+
const fingerprint = hashed.fingerprint
68+
69+
const meta = await apiRequest(
70+
registry,
71+
{ method: 'GET', path: `${ApiRoutes.skill}?slug=${encodeURIComponent(skill.slug)}` },
72+
ApiSkillMetaResponseSchema,
73+
).catch(() => null)
74+
75+
const latestVersion = meta?.latestVersion?.version ?? null
76+
if (!latestVersion) {
77+
candidates.push({
78+
...skill,
79+
fingerprint,
80+
fileCount: filesOnDisk.length,
81+
status: 'new',
82+
matchVersion: null,
83+
latestVersion: null,
84+
})
85+
continue
86+
}
87+
88+
const resolved = await apiRequest(
89+
registry,
90+
{
91+
method: 'GET',
92+
path: `${ApiRoutes.skillResolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(fingerprint)}`,
93+
},
94+
ApiSkillResolveResponseSchema,
95+
).catch((error) => {
96+
const message = formatError(error)
97+
if (/skill not found/i.test(message)) return { match: null, latestVersion: null }
98+
throw error
99+
})
100+
101+
const matchVersion = resolved.match?.version ?? null
102+
candidates.push({
103+
...skill,
104+
fingerprint,
105+
fileCount: filesOnDisk.length,
106+
status: matchVersion ? 'synced' : 'update',
107+
matchVersion,
108+
latestVersion,
109+
})
110+
}
111+
} catch (error) {
112+
candidatesSpinner.fail(formatError(error))
113+
throw error
114+
} finally {
115+
candidatesSpinner.stop()
116+
}
117+
118+
const synced = candidates.filter((candidate) => candidate.status === 'synced')
119+
if (synced.length > 0) {
120+
const lines = synced
121+
.map((candidate) => `${candidate.slug} synced (${candidate.matchVersion ?? 'unknown'})`)
122+
.join('\n')
123+
note('Already synced', lines)
124+
}
125+
126+
const actionable = candidates.filter((candidate) => candidate.status !== 'synced')
127+
if (actionable.length === 0) {
128+
outro('Everything is already synced.')
129+
return
130+
}
131+
132+
const selected = await selectToUpload(actionable, {
133+
allowPrompt,
134+
all: Boolean(options.all),
135+
bump: options.bump ?? 'patch',
136+
})
137+
if (selected.length === 0) {
138+
outro('Nothing selected.')
139+
return
140+
}
141+
142+
if (options.dryRun) {
143+
outro(`Dry run: would upload ${selected.length} skill(s).`)
144+
return
145+
}
146+
147+
const bump = options.bump ?? 'patch'
148+
const tags = options.tags ?? 'latest'
149+
150+
for (const skill of selected) {
151+
const { publishVersion, changelog } = await resolvePublishMeta(skill, {
152+
bump,
153+
allowPrompt,
154+
changelogFlag: options.changelog,
155+
})
156+
await cmdPublish(opts, skill.folder, {
157+
slug: skill.slug,
158+
name: skill.displayName,
159+
version: publishVersion,
160+
changelog,
161+
tags,
162+
})
163+
}
164+
165+
outro(`Uploaded ${selected.length} skill(s).`)
166+
}
167+
168+
function buildScanRoots(opts: GlobalOpts, extraRoots: string[] | undefined) {
169+
const roots = [opts.workdir, opts.dir, ...(extraRoots ?? [])]
170+
return Array.from(new Set(roots.map((root) => resolve(root))))
171+
}
172+
173+
async function scanRoots(roots: string[]) {
174+
const all: SkillFolder[] = []
175+
for (const root of roots) {
176+
const found = await findSkillFolders(root)
177+
all.push(...found)
178+
}
179+
const byFolder = new Map<string, SkillFolder>()
180+
for (const folder of all) {
181+
byFolder.set(folder.folder, folder)
182+
}
183+
return Array.from(byFolder.values())
184+
}
185+
186+
async function selectToUpload(
187+
candidates: Candidate[],
188+
params: { allowPrompt: boolean; all: boolean; bump: 'patch' | 'minor' | 'major' },
189+
): Promise<Candidate[]> {
190+
if (params.all || !params.allowPrompt) return candidates
191+
192+
const valueByKey = new Map<string, Candidate>()
193+
const choices = candidates.map((candidate) => {
194+
const key = candidate.folder
195+
valueByKey.set(key, candidate)
196+
const latest = candidate.latestVersion
197+
const next = latest ? semver.inc(latest, params.bump) : null
198+
const status =
199+
candidate.status === 'new' ? 'NEW' : latest && next ? `UPDATE ${latest}${next}` : 'UPDATE'
200+
return {
201+
value: key,
202+
label: `${candidate.slug} ${status}`,
203+
hint: candidate.folder,
204+
}
205+
})
206+
207+
const picked = await multiselect({
208+
message: 'Select skills to upload',
209+
options: choices,
210+
initialValues: choices.map((choice) => choice.value),
211+
required: false,
212+
})
213+
if (isCancel(picked)) fail('Canceled')
214+
const selected = picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as Candidate[]
215+
return selected
216+
}
217+
218+
async function resolvePublishMeta(
219+
skill: Candidate,
220+
params: { bump: 'patch' | 'minor' | 'major'; allowPrompt: boolean; changelogFlag?: string },
221+
) {
222+
if (skill.status === 'new') {
223+
return { publishVersion: '1.0.0', changelog: '' }
224+
}
225+
226+
const latest = skill.latestVersion
227+
if (!latest) fail(`Could not resolve latest version for ${skill.slug}`)
228+
const publishVersion = semver.inc(latest, params.bump)
229+
if (!publishVersion) fail(`Could not bump version for ${skill.slug}`)
230+
231+
const fromFlag = params.changelogFlag?.trim()
232+
if (fromFlag) return { publishVersion, changelog: fromFlag }
233+
234+
if (!params.allowPrompt) {
235+
return { publishVersion, changelog: 'Sync update' }
236+
}
237+
238+
const entered = await text({
239+
message: `Changelog for ${skill.slug}@${publishVersion}`,
240+
placeholder: 'What changed?',
241+
defaultValue: 'Sync update',
242+
})
243+
if (isCancel(entered)) fail('Canceled')
244+
const changelog = String(entered ?? '').trim()
245+
if (!changelog) fail('--changelog required for updates')
246+
return { publishVersion, changelog }
247+
}
248+
249+
async function getRegistryWithAuth(opts: GlobalOpts, token: string) {
250+
const registry = await getRegistry(opts, { cache: true })
251+
await apiRequest(
252+
registry,
253+
{ method: 'GET', path: ApiRoutes.cliWhoami, token },
254+
ApiCliWhoamiResponseSchema,
255+
)
256+
return registry
257+
}

0 commit comments

Comments
 (0)