@@ -80,18 +80,29 @@ class Skill:
8080 metadata : SkillMetadata
8181 resources : tuple [Path , ...]
8282 zip_path : Optional [Path ] = None
83+ zip_root_prefix : str = ""
8384 _zip_members : Optional [set [str ]] = field (default = None , init = False )
8485
8586 def __post_init__ (self ) -> None :
8687 """Cache zip members for efficient lookups."""
8788 if self .zip_path is not None :
8889 with zipfile .ZipFile (self .zip_path ) as z :
8990 # Cache file members (exclude directory entries)
90- self ._zip_members = {
91+ # Strip the root prefix if present
92+ all_members = {
9193 name
9294 for name in z .namelist ()
9395 if not name .endswith ("/" )
9496 }
97+ if self .zip_root_prefix :
98+ # Store members without the prefix for easier access
99+ self ._zip_members = {
100+ name [len (self .zip_root_prefix ):]
101+ for name in all_members
102+ if name .startswith (self .zip_root_prefix )
103+ }
104+ else :
105+ self ._zip_members = all_members
95106
96107 @property
97108 def is_zip (self ) -> bool :
@@ -102,7 +113,9 @@ def open_bytes(self, rel_path: str) -> bytes:
102113 """Read file content as bytes."""
103114 if self .is_zip :
104115 with zipfile .ZipFile (self .zip_path ) as z :
105- return z .read (rel_path )
116+ # Add the root prefix if present
117+ zip_member_path = self .zip_root_prefix + rel_path
118+ return z .read (zip_member_path )
106119 else :
107120 return (self .directory / rel_path ).read_bytes ()
108121
@@ -353,19 +366,44 @@ def _try_register_zip_skill(self, zip_path: Path) -> None:
353366 """Try to register a zip file as a skill."""
354367 try :
355368 with zipfile .ZipFile (zip_path ) as z :
356- # Check if SKILL.md exists at root
369+ # Check if SKILL.md exists at root or in single top-level dir
357370 members = {
358371 name for name in z .namelist () if not name .endswith ("/" )
359372 }
360- if SKILL_MARKDOWN not in members :
373+
374+ skill_md_path = None
375+ zip_root_prefix = ""
376+
377+ # First, try SKILL.md at root
378+ if SKILL_MARKDOWN in members :
379+ skill_md_path = SKILL_MARKDOWN
380+ else :
381+ # Try to find SKILL.md in single top-level directory
382+ # Pattern: skill-name.zip contains skill-name/SKILL.md
383+ top_level_dirs = set ()
384+ for name in z .namelist ():
385+ if "/" in name :
386+ top_dir = name .split ("/" , 1 )[0 ]
387+ top_level_dirs .add (top_dir )
388+
389+ # If there's exactly one top-level directory
390+ if len (top_level_dirs ) == 1 :
391+ top_dir = list (top_level_dirs )[0 ]
392+ candidate = f"{ top_dir } /{ SKILL_MARKDOWN } "
393+ if candidate in members :
394+ skill_md_path = candidate
395+ zip_root_prefix = f"{ top_dir } /"
396+
397+ if skill_md_path is None :
361398 LOGGER .debug (
362- "Zip %s missing SKILL.md at root; skipping" ,
399+ "Zip %s missing SKILL.md at root or in "
400+ "single top-level directory; skipping" ,
363401 zip_path ,
364402 )
365403 return
366404
367405 # Parse SKILL.md from zip
368- skill_md_data = z .read (SKILL_MARKDOWN )
406+ skill_md_data = z .read (skill_md_path )
369407 skill_md_text = skill_md_data .decode ("utf-8" )
370408
371409 except zipfile .BadZipFile :
@@ -467,11 +505,17 @@ def _try_register_zip_skill(self, zip_path: Path) -> None:
467505 metadata = metadata ,
468506 resources = (), # Will be populated from zip
469507 zip_path = zip_path .resolve (),
508+ zip_root_prefix = zip_root_prefix ,
470509 )
471510
472511 self ._skills_by_slug [slug ] = skill
473512 self ._skills_by_name [metadata .name ] = skill
474- LOGGER .debug ("Registered zip-based skill '%s' from %s" , slug , zip_path )
513+ LOGGER .debug (
514+ "Registered zip-based skill '%s' from %s (root_prefix='%s')" ,
515+ slug ,
516+ zip_path ,
517+ zip_root_prefix ,
518+ )
475519
476520 def _collect_resources (self , directory : Path ) -> tuple [Path , ...]:
477521 """Collect all files in skill directory except SKILL.md.
0 commit comments