pack_skill.py
Language Python2.6 KB77 lines
#!/usr/bin/env python3"""Pack skills into installable .skill files.Each skill lives unpacked in skills/<name>/ so it can be read and reviewed. A.skill file is the same folder as a zip archive, with the folder itself as thetop-level entry (<name>/SKILL.md, <name>/scripts/..., ...), which is the layoutClaude expects when a skill is uploaded.Usage:python3 scripts/pack_skill.py # every skill -> dist/<name>.skillpython3 scripts/pack_skill.py sop-author # just the named skillsRefuses a folder with no SKILL.md, or whose frontmatter `name` does not matchthe folder, because Claude keys an installed skill by that name."""from __future__ import annotationsimport reimport sysimport zipfilefrom fnmatch import fnmatchfrom pathlib import PathROOT = Path(__file__).resolve().parent.parentSKILLS = ROOT / "skills"DIST = ROOT / "dist"# Glob patterns matched against each part of a path inside the skill folder. A# local .env or config.json holds a live webhook, so it must never be packed.SKIP = {".DS_Store", "__pycache__", ".env", "config.json", "*.skill"}def skipped(rel: Path) -> bool:return any(fnmatch(part, pattern) for part in rel.parts for pattern in SKIP)def frontmatter_name(skill_md: Path) -> str | None:text = skill_md.read_text(encoding="utf-8")m = re.match(r"---\n(.*?)\n---", text, re.S)if not m:return Nonename = re.search(r"^name:\s*(\S+)\s*$", m.group(1), re.M)return name.group(1).strip("'\"") if name else Nonedef pack(folder: Path) -> Path:skill_md = folder / "SKILL.md"if not skill_md.is_file():raise SystemExit(f"{folder.name}: no SKILL.md")declared = frontmatter_name(skill_md)if declared != folder.name:raise SystemExit(f"{folder.name}: frontmatter name is {declared!r}, expected {folder.name!r}")DIST.mkdir(exist_ok=True)out = DIST / f"{folder.name}.skill"with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:for path in sorted(folder.rglob("*")):rel = path.relative_to(folder)if path.is_dir() or skipped(rel):continuez.write(path, Path(folder.name) / rel)return outdef main(argv: list[str]) -> None:names = argv or sorted(p.name for p in SKILLS.iterdir() if p.is_dir())for name in names:folder = SKILLS / nameif not folder.is_dir():raise SystemExit(f"No skill folder named {name!r} in {SKILLS}")out = pack(folder)print(f"{out.relative_to(ROOT)} ({out.stat().st_size:,} bytes)")if __name__ == "__main__":main(sys.argv[1:])