Skip to content

Commit 7388727

Browse files
AIintellectronica
authored andcommitted
fix: support zip files with single top-level directory
- Handle common packaging pattern: skill.zip/skill-name/SKILL.md - Add zip_root_prefix field to strip top-level directory - Update resource reading to handle prefixed paths - Add tests for nested directory structure - Fixes issue where zip files with top-level directories weren't loaded
1 parent 6c64ecd commit 7388727

File tree

2 files changed

+112
-7
lines changed

2 files changed

+112
-7
lines changed

src/skillz/_server.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

tests/test_zip_skills.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,64 @@ def test_mixed_directory_and_zip_skills(tmp_path: Path) -> None:
404404
assert zip_skill_obj.is_zip
405405
assert dir_skill_obj.metadata.name == "DirSkill"
406406
assert zip_skill_obj.metadata.name == "ZipSkill"
407+
408+
409+
def test_zip_with_top_level_directory(tmp_path: Path) -> None:
410+
"""Test zip with single top-level directory containing SKILL.md."""
411+
# Create a zip with structure: my-skill.zip/my-skill/SKILL.md
412+
zip_path = tmp_path / "my-skill.zip"
413+
with zipfile.ZipFile(zip_path, "w") as z:
414+
z.writestr(
415+
"my-skill/SKILL.md",
416+
"""---
417+
name: MySkill
418+
description: Test skill in top-level dir
419+
---
420+
Instructions.
421+
""",
422+
)
423+
z.writestr("my-skill/resource.txt", "Hello from nested structure!")
424+
z.writestr("my-skill/scripts/run.py", "print('test')")
425+
426+
registry = SkillRegistry(tmp_path)
427+
registry.load()
428+
429+
# Should be loaded
430+
assert len(registry.skills) == 1
431+
skill = registry.get("myskill")
432+
assert skill.metadata.name == "MySkill"
433+
assert skill.is_zip
434+
assert skill.zip_root_prefix == "my-skill/"
435+
436+
437+
@pytest.mark.asyncio
438+
async def test_zip_with_top_level_directory_resources(
439+
tmp_path: Path,
440+
) -> None:
441+
"""Test reading resources from zip with top-level directory."""
442+
zip_path = tmp_path / "test-skill.zip"
443+
with zipfile.ZipFile(zip_path, "w") as z:
444+
z.writestr(
445+
"test-skill/SKILL.md",
446+
"""---
447+
name: TestSkill
448+
description: Test
449+
---
450+
Content.
451+
""",
452+
)
453+
z.writestr("test-skill/data.txt", "Test data from nested structure")
454+
455+
registry = SkillRegistry(tmp_path)
456+
registry.load()
457+
458+
server = build_server(registry)
459+
tools = await server.get_tools()
460+
fetch_tool = tools["fetch_resource"]
461+
462+
result = await fetch_tool.fn(
463+
resource_uri="resource://skillz/testskill/data.txt"
464+
)
465+
466+
assert result["encoding"] == "utf-8"
467+
assert result["content"] == "Test data from nested structure"

0 commit comments

Comments
 (0)