@@ -80,11 +80,22 @@ enum Command {
8080
8181#[ derive( Subcommand , Debug ) ]
8282enum SkillsAction {
83- /// Install a skill from a local directory.
83+ /// Install a skill from a local directory or a downloadable tarball .
8484 Install {
8585 /// Path to the skill directory (must contain SKILL.md + manifest.yaml).
86+ /// Mutually exclusive with --url.
87+ #[ arg( long, conflicts_with = "url" , required_unless_present = "url" ) ]
88+ local : Option < std:: path:: PathBuf > ,
89+ /// URL of a .tar.gz containing the skill at its root (i.e. manifest.yaml
90+ /// sits at the top of the archive, not under a subdir).
91+ /// Requires --sha256.
92+ #[ arg( long, requires = "sha256" ) ]
93+ url : Option < String > ,
94+ /// Hex sha256 of the bytes returned by --url. V0.5 always requires
95+ /// integrity verification — no plain HTTP downloads land without a
96+ /// declared checksum.
8697 #[ arg( long) ]
87- local : std :: path :: PathBuf ,
98+ sha256 : Option < String > ,
8899 } ,
89100 /// Remove an installed skill by name.
90101 Uninstall {
@@ -174,7 +185,16 @@ fn main() -> anyhow::Result<()> {
174185 VoiceAction :: Test => run_voice_test ( ) . await ?,
175186 } ,
176187 Command :: Skills { action } => match action {
177- SkillsAction :: Install { local } => run_skills_install ( & local) . await ?,
188+ SkillsAction :: Install { local, url, sha256 } => match ( local, url) {
189+ ( Some ( path) , None ) => run_skills_install_local ( & path) . await ?,
190+ ( None , Some ( url) ) => {
191+ let hash = sha256. ok_or_else ( || {
192+ anyhow:: anyhow!( "--url requires --sha256 for integrity check" )
193+ } ) ?;
194+ run_skills_install_url ( & url, & hash) . await ?;
195+ }
196+ _ => anyhow:: bail!( "specify exactly one of --local or --url" ) ,
197+ } ,
178198 SkillsAction :: Uninstall { name } => run_skills_uninstall ( & name) . await ?,
179199 SkillsAction :: List => run_skills_list ( ) . await ?,
180200 } ,
@@ -283,7 +303,7 @@ async fn build_llm_router() -> anyhow::Result<Arc<dyn LLMProvider>> {
283303 Ok ( Arc :: new ( router) )
284304}
285305
286- async fn run_skills_install ( src : & std:: path:: Path ) -> anyhow:: Result < ( ) > {
306+ async fn run_skills_install_local ( src : & std:: path:: Path ) -> anyhow:: Result < ( ) > {
287307 let store = SkillStore :: default_for_user ( ) ?;
288308 let skill = store. install_local ( src) . await ?;
289309 println ! (
@@ -295,6 +315,87 @@ async fn run_skills_install(src: &std::path::Path) -> anyhow::Result<()> {
295315 Ok ( ( ) )
296316}
297317
318+ async fn run_skills_install_url ( url : & str , expected_sha256 : & str ) -> anyhow:: Result < ( ) > {
319+ let bytes = download_to_bytes ( url) . await ?;
320+ verify_sha256 ( & bytes, expected_sha256) ?;
321+ let staging = tempfile:: tempdir ( ) ?;
322+ extract_tar_gz ( & bytes, staging. path ( ) ) ?;
323+ let skill_root = locate_skill_root ( staging. path ( ) ) ?;
324+ let store = SkillStore :: default_for_user ( ) ?;
325+ let skill = store. install_local ( & skill_root) . await ?;
326+ println ! (
327+ "installed {} v{} at {} (from {url})" ,
328+ skill. name( ) ,
329+ skill. version( ) ,
330+ skill. root. display( )
331+ ) ;
332+ Ok ( ( ) )
333+ }
334+
335+ async fn download_to_bytes ( url : & str ) -> anyhow:: Result < Vec < u8 > > {
336+ let resp = reqwest:: get ( url) . await ?;
337+ let status = resp. status ( ) ;
338+ if !status. is_success ( ) {
339+ anyhow:: bail!( "download {url}: HTTP {status}" ) ;
340+ }
341+ let bytes = resp. bytes ( ) . await ?;
342+ Ok ( bytes. to_vec ( ) )
343+ }
344+
345+ fn verify_sha256 ( bytes : & [ u8 ] , expected_hex : & str ) -> anyhow:: Result < ( ) > {
346+ use sha2:: { Digest , Sha256 } ;
347+ let mut hasher = Sha256 :: new ( ) ;
348+ hasher. update ( bytes) ;
349+ let got = hasher. finalize ( ) ;
350+ let got_hex = hex_encode ( & got) ;
351+ let expected_lc = expected_hex. trim ( ) . to_ascii_lowercase ( ) ;
352+ if got_hex != expected_lc {
353+ anyhow:: bail!( "sha256 mismatch: declared {expected_lc} but downloaded bytes are {got_hex}" ) ;
354+ }
355+ Ok ( ( ) )
356+ }
357+
358+ fn hex_encode ( bytes : & [ u8 ] ) -> String {
359+ let mut out = String :: with_capacity ( bytes. len ( ) * 2 ) ;
360+ for b in bytes {
361+ out. push_str ( & format ! ( "{b:02x}" ) ) ;
362+ }
363+ out
364+ }
365+
366+ fn extract_tar_gz ( bytes : & [ u8 ] , dest : & std:: path:: Path ) -> anyhow:: Result < ( ) > {
367+ let gz = flate2:: read:: GzDecoder :: new ( bytes) ;
368+ let mut archive = tar:: Archive :: new ( gz) ;
369+ archive. set_preserve_permissions ( false ) ;
370+ archive. set_overwrite ( true ) ;
371+ archive. unpack ( dest) ?;
372+ Ok ( ( ) )
373+ }
374+
375+ /// The downloaded tarball may have the skill at its root OR under a single
376+ /// top-level directory (the cargo-package convention). We accept either: if
377+ /// `dest/manifest.yaml` exists, that's the root; else, if there is exactly one
378+ /// subdirectory and it contains manifest.yaml, use that. Anything else is a
379+ /// malformed archive.
380+ fn locate_skill_root ( dest : & std:: path:: Path ) -> anyhow:: Result < std:: path:: PathBuf > {
381+ if dest. join ( "manifest.yaml" ) . is_file ( ) {
382+ return Ok ( dest. to_path_buf ( ) ) ;
383+ }
384+ let mut subdirs: Vec < std:: path:: PathBuf > = Vec :: new ( ) ;
385+ for entry in std:: fs:: read_dir ( dest) ? {
386+ let entry = entry?;
387+ if entry. file_type ( ) ?. is_dir ( ) {
388+ subdirs. push ( entry. path ( ) ) ;
389+ }
390+ }
391+ if subdirs. len ( ) == 1 && subdirs[ 0 ] . join ( "manifest.yaml" ) . is_file ( ) {
392+ return Ok ( subdirs. into_iter ( ) . next ( ) . unwrap ( ) ) ;
393+ }
394+ anyhow:: bail!(
395+ "downloaded tarball doesn't contain manifest.yaml at root or under a single subdir"
396+ )
397+ }
398+
298399async fn run_skills_uninstall ( name : & str ) -> anyhow:: Result < ( ) > {
299400 let store = SkillStore :: default_for_user ( ) ?;
300401 let removed = store. uninstall ( name) . await ?;
@@ -334,3 +435,89 @@ async fn run_voice_test() -> anyhow::Result<()> {
334435 ) ;
335436 Ok ( ( ) )
336437}
438+
439+ #[ cfg( test) ]
440+ mod skill_install_url_tests {
441+ use super :: * ;
442+ use flate2:: write:: GzEncoder ;
443+ use flate2:: Compression ;
444+
445+ fn build_tar_gz ( entries : & [ ( & str , & [ u8 ] ) ] ) -> Vec < u8 > {
446+ let mut gz = GzEncoder :: new ( Vec :: new ( ) , Compression :: default ( ) ) ;
447+ {
448+ let mut tar = tar:: Builder :: new ( & mut gz) ;
449+ for ( name, body) in entries {
450+ let mut header = tar:: Header :: new_gnu ( ) ;
451+ header. set_size ( body. len ( ) as u64 ) ;
452+ header. set_mode ( 0o644 ) ;
453+ header. set_cksum ( ) ;
454+ tar. append_data ( & mut header, name, * body) . unwrap ( ) ;
455+ }
456+ tar. finish ( ) . unwrap ( ) ;
457+ }
458+ gz. finish ( ) . unwrap ( )
459+ }
460+
461+ #[ test]
462+ fn verify_sha256_accepts_matching_hash ( ) {
463+ let bytes = b"morphis skill bytes" ;
464+ // sha256(b"morphis skill bytes"):
465+ use sha2:: { Digest , Sha256 } ;
466+ let mut h = Sha256 :: new ( ) ;
467+ h. update ( bytes) ;
468+ let hex = hex_encode ( & h. finalize ( ) ) ;
469+ assert ! ( verify_sha256( bytes, & hex) . is_ok( ) ) ;
470+ // Case insensitivity + whitespace trim.
471+ let upper = format ! ( " {} " , hex. to_uppercase( ) ) ;
472+ assert ! ( verify_sha256( bytes, & upper) . is_ok( ) ) ;
473+ }
474+
475+ #[ test]
476+ fn verify_sha256_rejects_mismatch ( ) {
477+ let err = verify_sha256 ( b"x" , "deadbeef" ) . unwrap_err ( ) ;
478+ assert ! ( err. to_string( ) . contains( "sha256 mismatch" ) ) ;
479+ }
480+
481+ #[ test]
482+ fn extract_tar_gz_unpacks_a_skill_at_archive_root ( ) {
483+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
484+ let bytes = build_tar_gz ( & [
485+ (
486+ "manifest.yaml" ,
487+ b"name: from-url\n version: 0.1.0\n sandbox: wasmtime\n " ,
488+ ) ,
489+ ( "SKILL.md" , b"# from-url\n from-url body\n " ) ,
490+ ] ) ;
491+ extract_tar_gz ( & bytes, dir. path ( ) ) . unwrap ( ) ;
492+ assert ! ( dir. path( ) . join( "manifest.yaml" ) . is_file( ) ) ;
493+ assert ! ( dir. path( ) . join( "SKILL.md" ) . is_file( ) ) ;
494+ }
495+
496+ #[ test]
497+ fn locate_skill_root_finds_root_manifest ( ) {
498+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
499+ std:: fs:: write ( dir. path ( ) . join ( "manifest.yaml" ) , "name: x" ) . unwrap ( ) ;
500+ let root = locate_skill_root ( dir. path ( ) ) . unwrap ( ) ;
501+ assert_eq ! ( root, dir. path( ) ) ;
502+ }
503+
504+ #[ test]
505+ fn locate_skill_root_finds_single_subdir_manifest ( ) {
506+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
507+ let sub = dir. path ( ) . join ( "inner-skill" ) ;
508+ std:: fs:: create_dir_all ( & sub) . unwrap ( ) ;
509+ std:: fs:: write ( sub. join ( "manifest.yaml" ) , "name: y" ) . unwrap ( ) ;
510+ let root = locate_skill_root ( dir. path ( ) ) . unwrap ( ) ;
511+ assert_eq ! ( root, sub) ;
512+ }
513+
514+ #[ test]
515+ fn locate_skill_root_errors_when_archive_is_malformed ( ) {
516+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
517+ std:: fs:: create_dir_all ( dir. path ( ) . join ( "a" ) ) . unwrap ( ) ;
518+ std:: fs:: create_dir_all ( dir. path ( ) . join ( "b" ) ) . unwrap ( ) ;
519+ // Two subdirs, no root manifest, neither has manifest.yaml — refuse.
520+ let err = locate_skill_root ( dir. path ( ) ) . unwrap_err ( ) ;
521+ assert ! ( err. to_string( ) . contains( "doesn't contain manifest.yaml" ) ) ;
522+ }
523+ }
0 commit comments