Adds the conversion infrastructure for the Hermes-rollout (Nous Research): - hermes/mapping.yaml — per-skill schema (mode: auto|manual|skip|pending, category, replace-rules, source override). Every skills/<name>/ has an explicit entry; the build fails on unmapped skills. - scripts/build-hermes.py — Python converter. Reads mapping.yaml; for auto applies replace-rules to SKILL.md and copies the rest verbatim; for manual copies hermes/skills/<source>/ as-is; for skip / pending records to dist-hermes/SKIPPED.md. - dist-hermes/ — pre-converted Hermes-flavour tree (committed; consumed by the recursive installer in [hermes-installer-skill]). Includes 4 universal skills: pulling-before-work, active-platform (with Linux+bash replacement rule), project-discipline, using-markitdown. Plus SKIPPED.md listing 8 skip + 10 pending entries. - README.md — adds "Build for Hermes" section, updates layout, mentions hermes/, dist-hermes/, build-hermes.py. Mapping is the source of truth for the audit table from .wiki/concepts/hermes-skills-rollout-design.md (Q1-Q8). Pending entries preserve the audit decision (intended mode/category) for the follow-up tasks: hermes-flavour-mcp-setups, hermes-installer-skill, hermes-mvp-coverage. Security carry-forward (extraheader-pattern, POSIX-absolute paths, semver bumps) — infrastructure ready (replace-rules + manual mode); concrete content lands in hermes-flavour-mcp-setups. Smoke-tests: idempotent re-run produces no diff; replace-rules verified on active-platform (Linux+bash); strict-mapping fail-fast verified on a synthetic unmapped skill (exit 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
6.5 KiB
Python
178 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
build-hermes.py — Convert claude-skills sources to Hermes-flavour layout.
|
|
|
|
Reads: hermes/mapping.yaml
|
|
skills/<name>/ (auto mode)
|
|
hermes/skills/<source>/ (manual mode; `source:` override or /<name>/)
|
|
|
|
Writes: dist-hermes/<category>/<name>/ (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/<name>/ 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: <repo>/dist-hermes)")
|
|
parser.add_argument("--mapping", type=Path, default=None, help="Mapping file (default: <repo>/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())
|