#!/usr/bin/env python3 from __future__ import annotations import argparse import re import shutil import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from zipfile import ZIP_DEFLATED, ZipFile EXCLUDED_DIRS = {"evals", "packages", "upload-md", "scripts"} KEY_VALUE_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)\s*$") @dataclass class SkillRecord: source_dir_name: str source_path: Path declared_name: str description: str package_name: str def parse_frontmatter(skill_md: Path) -> tuple[str | None, str | None, str | None]: content = skill_md.read_text(encoding="utf-8") lines = content.splitlines() if not lines or lines[0].strip() != "---": return None, None, "missing YAML frontmatter opening delimiter '---'" try: end_idx = lines.index("---", 1) except ValueError: return None, None, "missing YAML frontmatter closing delimiter '---'" yaml_lines = lines[1:end_idx] values: dict[str, str] = {} for line in yaml_lines: match = KEY_VALUE_RE.match(line) if not match: continue key = match.group(1).strip() value = match.group(2).strip().strip("'\"") values[key] = value name = values.get("name") description = values.get("description") if not name or not description: return None, None, "frontmatter must include both 'name' and 'description'" return name, description, None def iter_skill_dirs(skills_root: Path) -> list[Path]: return [ path for path in sorted(skills_root.iterdir()) if path.is_dir() and path.name not in EXCLUDED_DIRS and not path.name.startswith(".") ] def ensure_clean_output(output_dir: Path) -> tuple[Path, Path, Path, Path]: per_skill_dir = output_dir / "per-skill" per_skill_skill_dir = per_skill_dir / "skill" per_skill_zip_dir = per_skill_dir / "zip" bundles_dir = output_dir / "bundles" if per_skill_dir.exists(): shutil.rmtree(per_skill_dir) if bundles_dir.exists(): shutil.rmtree(bundles_dir) per_skill_skill_dir.mkdir(parents=True, exist_ok=True) per_skill_zip_dir.mkdir(parents=True, exist_ok=True) bundles_dir.mkdir(parents=True, exist_ok=True) return per_skill_dir, per_skill_skill_dir, per_skill_zip_dir, bundles_dir def add_directory_to_zip(zip_file: ZipFile, source_dir: Path, arc_prefix: str | None = None) -> None: for file_path in sorted(source_dir.rglob("*")): if not file_path.is_file(): continue relative = file_path.relative_to(source_dir).as_posix() if arc_prefix: relative = f"{arc_prefix}/{relative}" zip_file.write(file_path, relative) def create_skill_archives( skill: SkillRecord, per_skill_skill_dir: Path, per_skill_zip_dir: Path, ) -> tuple[Path, Path]: skill_file = per_skill_skill_dir / f"{skill.package_name}.skill" zip_file = per_skill_zip_dir / f"{skill.package_name}.zip" with ZipFile(skill_file, "w", compression=ZIP_DEFLATED) as zf: add_directory_to_zip(zf, skill.source_path) with ZipFile(zip_file, "w", compression=ZIP_DEFLATED) as zf: add_directory_to_zip(zf, skill.source_path) return skill_file, zip_file def validate_archive_contains_skill_md(archive_path: Path) -> tuple[bool, str]: with ZipFile(archive_path, "r") as zf: names = set(zf.namelist()) if "SKILL.md" not in names: return False, f"{archive_path.name}: SKILL.md not found at archive root" content = zf.read("SKILL.md").decode("utf-8", errors="replace") if "name:" not in content or "description:" not in content: return False, f"{archive_path.name}: SKILL.md missing 'name' or 'description'" return True, f"{archive_path.name}: OK" def validate_bundle_contains_skills(bundle_path: Path, source_dir_names: list[str]) -> tuple[bool, str]: with ZipFile(bundle_path, "r") as zf: entries = zf.namelist() missing = [] for dir_name in source_dir_names: prefix = f"{dir_name}/" if not any(item.startswith(prefix) for item in entries): missing.append(dir_name) if missing: return False, f"bundle missing directories: {', '.join(missing)}" return True, "bundle contains all valid skill directories" def build_index( output_file: Path, valid_skills: list[SkillRecord], skipped: list[str], warnings: list[str], validations: list[tuple[bool, str]], bundle_path: Path, ) -> None: def table_safe(value: str) -> str: return value.replace("|", r"\|").replace("\n", " ").strip() def concise_scope(value: str, max_len: int = 120) -> str: cleaned = " ".join(value.split()).strip() if not cleaned: return "N/A" parts = cleaned.split(". ", 1) scope = parts[0].strip() if len(parts) > 1 and not scope.endswith("."): scope = f"{scope}." if len(scope) > max_len: scope = scope[: max_len - 3].rstrip() + "..." return table_safe(scope) ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") all_ok = all(ok for ok, _ in validations) passed_validations = sum(1 for ok, _ in validations if ok) total_validations = len(validations) package_dir = output_file.parent upload_md_dir = package_dir / "upload-md" lines = [ "# Mechanical Skills Packages Index", "", "## Snapshot", "", "| Item | Value |", "| --- | --- |", f"| Generated (UTC) | `{ts}` |", f"| Valid skills packaged | **{len(valid_skills)}** |", "| Per-skill formats | `.skill` and `.zip` (separate folders) |", f"| Bundle | [`{bundle_path.name}`](bundles/{bundle_path.name}) |", f"| Validation status | **{'PASS' if all_ok else 'FAIL'}** ({passed_validations}/{total_validations}) |", "", "## Quick Links", "", "- [Per-skill root](per-skill/)", "- [Per-skill `.skill`](per-skill/skill/)", "- [Per-skill `.zip`](per-skill/zip/)", f"- [Bundle archive](bundles/{bundle_path.name})", "- [Upload markdown exports](upload-md/)", "- [Skill sources](../skill/)", "", "## Skill Catalog", "", "| Skill | Scope | Source | Packages | Markdown |", "| --- | --- | --- | --- | --- |", ] for skill in sorted(valid_skills, key=lambda item: item.package_name): source_rel = f"../skill/{skill.source_dir_name}/SKILL.md" markdown_rel = f"upload-md/{skill.package_name}.md" skill_rel = f"per-skill/skill/{skill.package_name}.skill" zip_rel = f"per-skill/zip/{skill.package_name}.zip" markdown_cell = ( f"[`{skill.package_name}.md`]({markdown_rel})" if (upload_md_dir / f"{skill.package_name}.md").exists() else "N/A" ) source_cell = ( f"[`{skill.source_dir_name}/SKILL.md`]({source_rel})" if (skill.source_path / "SKILL.md").exists() else "N/A" ) lines.append( "| `{name}` | {scope} | {source} | [`.skill`]({skill_pkg}) / [`.zip`]({zip_pkg}) | {markdown} |".format( name=skill.package_name, scope=concise_scope(skill.description), source=source_cell, markdown=markdown_cell, skill_pkg=skill_rel, zip_pkg=zip_rel, ) ) lines.extend(["", "## Validation Checks"]) lines.extend( [f"- [{'x' if ok else ' '}] {message}" for ok, message in validations] ) lines.extend(["", "## Warnings", ""]) if warnings: lines.extend([f"- {msg}" for msg in warnings]) else: lines.append("- None") lines.extend(["", "## Skipped", ""]) if skipped: lines.extend([f"- {msg}" for msg in skipped]) else: lines.append("- None") lines.extend( [ "", "## Output Paths", "", "- [`package/per-skill/`](per-skill/)", "- [`package/per-skill/skill/`](per-skill/skill/)", "- [`package/per-skill/zip/`](per-skill/zip/)", "- [`package/bundles/`](bundles/)", "- [`package/upload-md/`](upload-md/)", ] ) output_file.write_text("\n".join(lines) + "\n", encoding="utf-8") def package_skills(skills_root: Path, output_dir: Path, bundle_name: str, spot_check_count: int) -> int: per_skill_dir, per_skill_skill_dir, per_skill_zip_dir, bundles_dir = ensure_clean_output(output_dir) skipped: list[str] = [] warnings: list[str] = [] valid_skills: list[SkillRecord] = [] used_names: dict[str, int] = {} for skill_dir in iter_skill_dirs(skills_root): skill_md = skill_dir / "SKILL.md" if not skill_md.exists(): skipped.append(f"{skill_dir.name}: missing SKILL.md") continue declared_name, description, error = parse_frontmatter(skill_md) if error: skipped.append(f"{skill_dir.name}: {error}") continue assert declared_name is not None and description is not None package_name = declared_name if declared_name != skill_dir.name: warnings.append( f"{skill_dir.name}: declared name '{declared_name}' differs from directory name" ) if package_name in used_names: used_names[package_name] += 1 new_name = f"{package_name}-{used_names[package_name]}" warnings.append( f"name collision for '{package_name}', renamed package to '{new_name}'" ) package_name = new_name else: used_names[package_name] = 1 valid_skills.append( SkillRecord( source_dir_name=skill_dir.name, source_path=skill_dir, declared_name=declared_name, description=description, package_name=package_name, ) ) for skill in valid_skills: create_skill_archives(skill, per_skill_skill_dir, per_skill_zip_dir) bundle_path = bundles_dir / bundle_name with ZipFile(bundle_path, "w", compression=ZIP_DEFLATED) as zf: for skill in valid_skills: add_directory_to_zip(zf, skill.source_path, arc_prefix=skill.source_dir_name) validations: list[tuple[bool, str]] = [] expected = 2 * len(valid_skills) produced = len(list(per_skill_skill_dir.glob("*.skill"))) + len(list(per_skill_zip_dir.glob("*.zip"))) validations.append( ( expected == produced, f"package count check: expected {expected}, found {produced}", ) ) check_items = valid_skills[: max(0, spot_check_count)] for skill in check_items: skill_archive = per_skill_skill_dir / f"{skill.package_name}.skill" zip_archive = per_skill_zip_dir / f"{skill.package_name}.zip" validations.append(validate_archive_contains_skill_md(skill_archive)) validations.append(validate_archive_contains_skill_md(zip_archive)) validations.append( validate_bundle_contains_skills( bundle_path=bundle_path, source_dir_names=[record.source_dir_name for record in valid_skills], ) ) index_path = output_dir / "PACKAGES_INDEX.md" build_index( output_file=index_path, valid_skills=valid_skills, skipped=skipped, warnings=warnings, validations=validations, bundle_path=bundle_path, ) print(f"Skills root: {skills_root}") print(f"Valid skills: {len(valid_skills)}") print(f"Skipped: {len(skipped)}") print(f"Warnings: {len(warnings)}") print(f"Per-skill output root: {per_skill_dir}") print(f"Per-skill .skill output: {per_skill_skill_dir}") print(f"Per-skill .zip output: {per_skill_zip_dir}") print(f"Bundle output: {bundle_path}") print(f"Index: {index_path}") all_ok = all(ok for ok, _ in validations) print(f"Validation: {'PASS' if all_ok else 'FAIL'}") if not all_ok: for ok, message in validations: if not ok: print(f" - {message}") return 1 return 0 def main() -> int: repo_root = Path(__file__).resolve().parents[1] default_skills_root = repo_root / "skill" parser = argparse.ArgumentParser( description="Create .skill/.zip import packages for each skill and a collection bundle." ) parser.add_argument( "--skills-root", type=Path, default=default_skills_root, help="Path to skill collection root (default: script parent directory).", ) parser.add_argument( "--output-dir", type=Path, default=None, help="Output directory (default: /packages).", ) parser.add_argument( "--bundle-name", type=str, default="mechanical-skills-collection.zip", help="Filename for the collection bundle.", ) parser.add_argument( "--spot-check-count", type=int, default=3, help="How many skills to spot-check for SKILL.md in both archive formats.", ) args = parser.parse_args() skills_root = args.skills_root.resolve() output_dir = (args.output_dir or (skills_root.parent / "package")).resolve() if not skills_root.exists() or not skills_root.is_dir(): print(f"skills root does not exist or is not a directory: {skills_root}") return 2 return package_skills( skills_root=skills_root, output_dir=output_dir, bundle_name=args.bundle_name, spot_check_count=max(0, args.spot_check_count), ) if __name__ == "__main__": sys.exit(main())