mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

pack_skill.py

Language Python2.6 KB77 lines

scripts/pack_skill.py

  1. #!/usr/bin/env python3
  2. """
  3. Pack skills into installable .skill files.
  4. Each skill lives unpacked in skills/<name>/ so it can be read and reviewed. A
  5. .skill file is the same folder as a zip archive, with the folder itself as the
  6. top-level entry (<name>/SKILL.md, <name>/scripts/..., ...), which is the layout
  7. Claude expects when a skill is uploaded.
  8. Usage:
  9. python3 scripts/pack_skill.py # every skill -> dist/<name>.skill
  10. python3 scripts/pack_skill.py sop-author # just the named skills
  11. Refuses a folder with no SKILL.md, or whose frontmatter `name` does not match
  12. the folder, because Claude keys an installed skill by that name.
  13. """
  14. from __future__ import annotations
  15. import re
  16. import sys
  17. import zipfile
  18. from fnmatch import fnmatch
  19. from pathlib import Path
  20. ROOT = Path(__file__).resolve().parent.parent
  21. SKILLS = ROOT / "skills"
  22. DIST = ROOT / "dist"
  23. # Glob patterns matched against each part of a path inside the skill folder. A
  24. # local .env or config.json holds a live webhook, so it must never be packed.
  25. SKIP = {".DS_Store", "__pycache__", ".env", "config.json", "*.skill"}
  26. def skipped(rel: Path) -> bool:
  27. return any(fnmatch(part, pattern) for part in rel.parts for pattern in SKIP)
  28. def frontmatter_name(skill_md: Path) -> str | None:
  29. text = skill_md.read_text(encoding="utf-8")
  30. m = re.match(r"---\n(.*?)\n---", text, re.S)
  31. if not m:
  32. return None
  33. name = re.search(r"^name:\s*(\S+)\s*$", m.group(1), re.M)
  34. return name.group(1).strip("'\"") if name else None
  35. def pack(folder: Path) -> Path:
  36. skill_md = folder / "SKILL.md"
  37. if not skill_md.is_file():
  38. raise SystemExit(f"{folder.name}: no SKILL.md")
  39. declared = frontmatter_name(skill_md)
  40. if declared != folder.name:
  41. raise SystemExit(f"{folder.name}: frontmatter name is {declared!r}, expected {folder.name!r}")
  42. DIST.mkdir(exist_ok=True)
  43. out = DIST / f"{folder.name}.skill"
  44. with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
  45. for path in sorted(folder.rglob("*")):
  46. rel = path.relative_to(folder)
  47. if path.is_dir() or skipped(rel):
  48. continue
  49. z.write(path, Path(folder.name) / rel)
  50. return out
  51. def main(argv: list[str]) -> None:
  52. names = argv or sorted(p.name for p in SKILLS.iterdir() if p.is_dir())
  53. for name in names:
  54. folder = SKILLS / name
  55. if not folder.is_dir():
  56. raise SystemExit(f"No skill folder named {name!r} in {SKILLS}")
  57. out = pack(folder)
  58. print(f"{out.relative_to(ROOT)} ({out.stat().st_size:,} bytes)")
  59. if __name__ == "__main__":
  60. main(sys.argv[1:])