1+ import { homedir } from 'node:os'
12import { resolve } from 'node:path'
23import { intro , isCancel , multiselect , note , outro , text } from '@clack/prompts'
34import {
89} from '@clawdhub/schema'
910import semver from 'semver'
1011import { 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'
1314import { getRegistry } from '../registry.js'
1415import { findSkillFolders , getFallbackSkillRoots , type SkillFolder } from '../scanSkills.js'
1516import type { GlobalOpts } from '../types.js'
@@ -35,6 +36,7 @@ type Candidate = SkillFolder & {
3536
3637export 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 ( / s k i l l n o t f o u n d / 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 ( / s k i l l n o t f o u n d / i. test ( message ) ) {
118+ matchVersion = null
119+ } else if ( / n o m a t c h i n g r o u t e s f o u n d / i. test ( message ) || / n o t f o u n d / 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
173204async 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
186247async 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+ }
0 commit comments