Skip to content

Commit 41dd6de

Browse files
committed
fix(clawdhub): default to sync and robust resolve check
1 parent 2c04427 commit 41dd6de

File tree

4 files changed

+145
-23
lines changed

4 files changed

+145
-23
lines changed

packages/clawdhub/src/cli.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { configureCommanderHelp, styleEnvBlock, styleTitle } from './cli/helpSty
1010
import { DEFAULT_REGISTRY, DEFAULT_SITE } from './cli/registry.js'
1111
import type { GlobalOpts } from './cli/types.js'
1212
import { fail } from './cli/ui.js'
13+
import { readGlobalConfig } from './config.js'
1314

1415
const program = new Command()
1516
.name('clawdhub')
@@ -187,6 +188,17 @@ program
187188
)
188189
})
189190

191+
program.action(async () => {
192+
const opts = resolveGlobalOpts()
193+
const cfg = await readGlobalConfig()
194+
if (cfg?.token) {
195+
await cmdSync(opts, {}, isInputAllowed())
196+
return
197+
}
198+
program.outputHelp()
199+
process.exitCode = 0
200+
})
201+
190202
void program.parseAsync(process.argv).catch((error) => {
191203
const message = error instanceof Error ? error.message : String(error)
192204
fail(message)

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

Lines changed: 99 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { homedir } from 'node:os'
12
import { resolve } from 'node:path'
23
import { intro, isCancel, multiselect, note, outro, text } from '@clack/prompts'
34
import {
@@ -8,8 +9,8 @@ import {
89
} from '@clawdhub/schema'
910
import semver from 'semver'
1011
import { readGlobalConfig } from '../../config.js'
11-
import { apiRequest } from '../../http.js'
12-
import { hashSkillFiles, listTextFiles } from '../../skills.js'
12+
import { apiRequest, downloadZip } from '../../http.js'
13+
import { hashSkillFiles, hashSkillZip, listTextFiles } from '../../skills.js'
1314
import { getRegistry } from '../registry.js'
1415
import { findSkillFolders, getFallbackSkillRoots, type SkillFolder } from '../scanSkills.js'
1516
import type { GlobalOpts } from '../types.js'
@@ -35,6 +36,7 @@ type Candidate = SkillFolder & {
3536

3637
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
3738
const allowPrompt = isInteractive() && inputAllowed !== false
39+
intro('ClawdHub sync')
3840

3941
const cfg = await readGlobalConfig()
4042
const token = cfg?.token
@@ -44,22 +46,34 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
4446
const selectedRoots = buildScanRoots(opts, options.root)
4547

4648
const spinner = createSpinner('Scanning for local skills')
47-
let skills = await scanRoots(selectedRoots)
48-
if (skills.length === 0) {
49+
let scan = await scanRoots(selectedRoots)
50+
if (scan.skills.length === 0) {
4951
const fallback = getFallbackSkillRoots(opts.workdir)
50-
skills = await scanRoots(fallback)
52+
scan = await scanRoots(fallback)
5153
spinner.stop()
52-
if (skills.length === 0)
54+
if (scan.skills.length === 0)
5355
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'))
56+
note(
57+
`No skills in workdir. Found ${scan.skills.length} in legacy locations.`,
58+
formatList(scan.rootsWithSkills, 10),
59+
)
5560
} else {
5661
spinner.stop()
5762
}
63+
let skills = scan.skills
5864

59-
intro('ClawdHub sync')
65+
skills = await maybeSelectLocalSkills(skills, {
66+
allowPrompt,
67+
all: Boolean(options.all),
68+
})
69+
if (skills.length === 0) {
70+
outro('Nothing selected.')
71+
return
72+
}
6073

6174
const candidatesSpinner = createSpinner('Checking registry sync state')
6275
const candidates: Candidate[] = []
76+
let supportsResolve: boolean | null = null
6377
try {
6478
for (const skill of skills) {
6579
const filesOnDisk = await listTextFiles(skill.folder)
@@ -85,20 +99,37 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
8599
continue
86100
}
87101

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-
})
102+
let matchVersion: string | null = null
103+
if (supportsResolve !== false) {
104+
try {
105+
const resolved = await apiRequest(
106+
registry,
107+
{
108+
method: 'GET',
109+
path: `${ApiRoutes.skillResolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(fingerprint)}`,
110+
},
111+
ApiSkillResolveResponseSchema,
112+
)
113+
supportsResolve = true
114+
matchVersion = resolved.match?.version ?? null
115+
} catch (error) {
116+
const message = formatError(error)
117+
if (/skill not found/i.test(message)) {
118+
matchVersion = null
119+
} else if (/no matching routes found/i.test(message) || /not found/i.test(message)) {
120+
supportsResolve = false
121+
} else {
122+
throw error
123+
}
124+
}
125+
}
126+
127+
if (supportsResolve === false) {
128+
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion })
129+
const remote = hashSkillZip(zip).fingerprint
130+
matchVersion = remote === fingerprint ? latestVersion : null
131+
}
100132

101-
const matchVersion = resolved.match?.version ?? null
102133
candidates.push({
103134
...skill,
104135
fingerprint,
@@ -172,15 +203,45 @@ function buildScanRoots(opts: GlobalOpts, extraRoots: string[] | undefined) {
172203

173204
async function scanRoots(roots: string[]) {
174205
const all: SkillFolder[] = []
206+
const rootsWithSkills: string[] = []
175207
for (const root of roots) {
176208
const found = await findSkillFolders(root)
209+
if (found.length > 0) rootsWithSkills.push(root)
177210
all.push(...found)
178211
}
179212
const byFolder = new Map<string, SkillFolder>()
180213
for (const folder of all) {
181214
byFolder.set(folder.folder, folder)
182215
}
183-
return Array.from(byFolder.values())
216+
return { skills: Array.from(byFolder.values()), rootsWithSkills }
217+
}
218+
219+
async function maybeSelectLocalSkills(
220+
skills: SkillFolder[],
221+
params: { allowPrompt: boolean; all: boolean },
222+
): Promise<SkillFolder[]> {
223+
if (params.all || !params.allowPrompt) return skills
224+
if (skills.length <= 30) return skills
225+
226+
const valueByKey = new Map<string, SkillFolder>()
227+
const choices = skills.map((skill) => {
228+
const key = skill.folder
229+
valueByKey.set(key, skill)
230+
return {
231+
value: key,
232+
label: skill.slug,
233+
hint: abbreviatePath(skill.folder),
234+
}
235+
})
236+
237+
const picked = await multiselect({
238+
message: `Found ${skills.length} local skills — select what to sync`,
239+
options: choices,
240+
initialValues: [],
241+
required: false,
242+
})
243+
if (isCancel(picked)) fail('Canceled')
244+
return picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as SkillFolder[]
184245
}
185246

186247
async function selectToUpload(
@@ -207,7 +268,7 @@ async function selectToUpload(
207268
const picked = await multiselect({
208269
message: 'Select skills to upload',
209270
options: choices,
210-
initialValues: choices.map((choice) => choice.value),
271+
initialValues: candidates.length <= 10 ? choices.map((choice) => choice.value) : [],
211272
required: false,
212273
})
213274
if (isCancel(picked)) fail('Canceled')
@@ -255,3 +316,18 @@ async function getRegistryWithAuth(opts: GlobalOpts, token: string) {
255316
)
256317
return registry
257318
}
319+
320+
function formatList(values: string[], max: number) {
321+
if (values.length === 0) return ''
322+
const shown = values.map(abbreviatePath)
323+
if (shown.length <= max) return shown.join('\n')
324+
const head = shown.slice(0, Math.max(1, max - 1))
325+
const rest = values.length - head.length
326+
return [...head, `… +${rest} more`].join('\n')
327+
}
328+
329+
function abbreviatePath(value: string) {
330+
const home = homedir()
331+
if (value.startsWith(home)) return `~${value.slice(home.length)}`
332+
return value
333+
}

packages/clawdhub/src/skills.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
buildSkillFingerprint,
99
extractZipToDir,
1010
hashSkillFiles,
11+
hashSkillZip,
1112
listTextFiles,
1213
readLockfile,
1314
sha256Hex,
@@ -84,4 +85,18 @@ describe('skills', () => {
8485
])
8586
expect(fingerprint).toBe(expected)
8687
})
88+
89+
it('hashes text files inside a downloaded zip deterministically', () => {
90+
const zip = zipSync({
91+
'SKILL.md': strToU8('hello'),
92+
'notes.md': strToU8('world'),
93+
'image.png': strToU8('nope'),
94+
})
95+
const { fingerprint } = hashSkillZip(new Uint8Array(zip))
96+
const expected = buildSkillFingerprint([
97+
{ path: 'SKILL.md', sha256: sha256Hex(strToU8('hello')) },
98+
{ path: 'notes.md', sha256: sha256Hex(strToU8('world')) },
99+
])
100+
expect(fingerprint).toBe(expected)
101+
})
87102
})

packages/clawdhub/src/skills.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ export function hashSkillFiles(files: Array<{ relPath: string; bytes: Uint8Array
6363
return { files: hashed, fingerprint: buildSkillFingerprint(hashed) }
6464
}
6565

66+
export function hashSkillZip(zipBytes: Uint8Array) {
67+
const entries = unzipSync(zipBytes)
68+
const hashed = Object.entries(entries)
69+
.map(([rawPath, bytes]) => {
70+
const safePath = sanitizeZipPath(rawPath)
71+
if (!safePath) return null
72+
const ext = safePath.split('.').at(-1)?.toLowerCase() ?? ''
73+
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext)) return null
74+
return { path: safePath, sha256: sha256Hex(bytes), size: bytes.byteLength }
75+
})
76+
.filter(Boolean) as SkillFileHash[]
77+
78+
return { files: hashed, fingerprint: buildSkillFingerprint(hashed) }
79+
}
80+
6681
export async function readLockfile(workdir: string): Promise<Lockfile> {
6782
const path = join(workdir, '.clawdhub', 'lock.json')
6883
try {
@@ -94,6 +109,10 @@ function sanitizeRelPath(path: string) {
94109
return normalized
95110
}
96111

112+
function sanitizeZipPath(path: string) {
113+
return sanitizeRelPath(path)
114+
}
115+
97116
async function walk(dir: string, onFile: (path: string) => Promise<void>) {
98117
const entries = await readdir(dir, { withFileTypes: true })
99118
for (const entry of entries) {

0 commit comments

Comments
 (0)