#!/usr/bin/env python3 """ build-hermes.py — Convert claude-skills sources to Hermes-flavour layout. Reads: hermes/mapping.yaml skills// (auto mode) hermes/skills// (manual mode; `source:` override or //) Writes: dist-hermes/// (auto + manual) dist-hermes/SKIPPED.md (skip + pending log) Exits non-zero on: - skill in skills/ not declared in mapping.skills (forces explicit mapping) - mode=auto but skills// missing - mode=manual but `source:` directory missing - a replace-rule `find:` string not found in source SKILL.md Design: .wiki/concepts/hermes-skills-rollout-design.md """ from __future__ import annotations import argparse import shutil import sys from pathlib import Path from typing import Any import yaml def load_mapping(path: Path) -> dict[str, Any]: with path.open(encoding="utf-8") as f: return yaml.safe_load(f) def apply_replace_rules(text: str, rules: list[dict[str, str]], skill_name: str) -> str: for rule in rules: find = rule["find"] replace = rule["replace"] if find not in text: raise ValueError( f"{skill_name}: replace-rule find string not present in SKILL.md: {find!r}" ) text = text.replace(find, replace) return text def copy_tree(src_dir: Path, dst_dir: Path, transform_skill_md: bool, rules: list[dict[str, str]], skill_name: str) -> None: if dst_dir.exists(): shutil.rmtree(dst_dir) dst_dir.mkdir(parents=True) for src_path in src_dir.rglob("*"): rel = src_path.relative_to(src_dir) dst_path = dst_dir / rel if src_path.is_dir(): dst_path.mkdir(parents=True, exist_ok=True) continue dst_path.parent.mkdir(parents=True, exist_ok=True) if transform_skill_md and src_path.name == "SKILL.md" and rules: text = src_path.read_text(encoding="utf-8") text = apply_replace_rules(text, rules, skill_name) dst_path.write_text(text, encoding="utf-8") else: shutil.copyfile(src_path, dst_path) def write_skill_auto(name: str, entry: dict[str, Any], repo_root: Path, dst_root: Path) -> None: src_dir = repo_root / "skills" / name if not src_dir.is_dir(): raise FileNotFoundError(f"auto skill source missing: {src_dir}") cat = entry["category"] rules = entry.get("replace-rules") or [] copy_tree(src_dir, dst_root / cat / name, transform_skill_md=True, rules=rules, skill_name=name) def write_skill_manual(name: str, entry: dict[str, Any], repo_root: Path, dst_root: Path) -> None: source = entry.get("source") or f"hermes/skills/{name}" src_dir = repo_root / source if not src_dir.is_dir(): raise FileNotFoundError(f"manual skill source missing: {src_dir}") cat = entry["category"] copy_tree(src_dir, dst_root / cat / name, transform_skill_md=False, rules=[], skill_name=name) def render_skipped_md(skipped: list[tuple[str, str]], pending: list[tuple[str, dict, str]]) -> str: lines: list[str] = [ "# Skipped Skills", "", "Auto-generated by `scripts/build-hermes.py` from `hermes/mapping.yaml`.", "Do not edit by hand — edit the mapping and re-run the build.", "", "## Skipped (not applicable to Hermes)", "", ] if skipped: for name, reason in sorted(skipped): lines.append(f"- **{name}** — {reason}") else: lines.append("(none)") lines.extend(["", "## Pending (deferred to follow-up tasks)", ""]) if pending: for name, intended, reason in sorted(pending): tail = "" if intended: im = intended.get("mode", "?") ic = intended.get("category", "?") tail = f" → intended: `mode: {im}, category: {ic}`" lines.append(f"- **{name}** — {reason}{tail}") else: lines.append("(none)") lines.append("") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description="Convert claude-skills sources to Hermes-flavour layout.") parser.add_argument( "--repo-root", type=Path, default=Path(__file__).resolve().parent.parent, help="Repo root (default: parent of scripts/)", ) parser.add_argument("--dist", type=Path, default=None, help="Output dir (default: /dist-hermes)") parser.add_argument("--mapping", type=Path, default=None, help="Mapping file (default: /hermes/mapping.yaml)") args = parser.parse_args() repo_root: Path = args.repo_root.resolve() mapping_path: Path = (args.mapping or (repo_root / "hermes" / "mapping.yaml")).resolve() dst_root: Path = (args.dist or (repo_root / "dist-hermes")).resolve() mapping = load_mapping(mapping_path) skills_map: dict[str, dict[str, Any]] = mapping.get("skills") or {} skills_dir = repo_root / "skills" fs_skills = sorted(p.name for p in skills_dir.iterdir() if p.is_dir()) unmapped = [n for n in fs_skills if n not in skills_map] if unmapped: print(f"ERROR: skills/ has unmapped entries: {unmapped}", file=sys.stderr) print(f"Add explicit `mode:` lines for each in {mapping_path.name}.", file=sys.stderr) return 1 counts = {"auto": 0, "manual": 0, "skip": 0, "pending": 0} skipped: list[tuple[str, str]] = [] pending: list[tuple[str, dict, str]] = [] for name, entry in sorted(skills_map.items()): mode = entry.get("mode") if mode == "auto": write_skill_auto(name, entry, repo_root, dst_root) elif mode == "manual": write_skill_manual(name, entry, repo_root, dst_root) elif mode == "skip": skipped.append((name, entry.get("reason", "(no reason)"))) elif mode == "pending": pending.append((name, entry.get("intended") or {}, entry.get("reason", "(no reason)"))) else: print(f"ERROR: unknown mode for {name}: {mode!r}", file=sys.stderr) return 1 counts[mode] += 1 dst_root.mkdir(parents=True, exist_ok=True) (dst_root / "SKIPPED.md").write_text(render_skipped_md(skipped, pending), encoding="utf-8") total = sum(counts.values()) print(f"build-hermes: {total} skills processed") print(f" auto {counts['auto']:>3}") print(f" manual {counts['manual']:>3}") print(f" skip {counts['skip']:>3}") print(f" pending {counts['pending']:>3}") print(f" out: {dst_root}") return 0 if __name__ == "__main__": sys.exit(main())