#!/usr/bin/env python3 """ Skills Sync -- Manifest-based seeding of bundled skills into ~/.hermes/skills/. On fresh install: copies all bundled skills from the repo's skills/ directory into ~/.hermes/skills/ and records every skill name in a manifest file. On update: copies only NEW bundled skills (names not in the manifest) so that user deletions are permanent and user modifications are never overwritten. The manifest lives at ~/.hermes/skills/.bundled_manifest and is a simple newline-delimited list of skill names that have been offered to the user. """ import json import logging import os import shutil from pathlib import Path from typing import List, Tuple logger = logging.getLogger(__name__) HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) SKILLS_DIR = HERMES_HOME / "skills" MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest" def _get_bundled_dir() -> Path: """Locate the bundled skills/ directory in the repo.""" return Path(__file__).parent.parent / "skills" def _read_manifest() -> set: """Read the set of skill names already offered to the user.""" if not MANIFEST_FILE.exists(): return set() try: return set( line.strip() for line in MANIFEST_FILE.read_text(encoding="utf-8").splitlines() if line.strip() ) except (OSError, IOError): return set() def _write_manifest(names: set): """Write the manifest file.""" MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True) MANIFEST_FILE.write_text( "\n".join(sorted(names)) + "\n", encoding="utf-8", ) def _discover_bundled_skills(bundled_dir: Path) -> List[Tuple[str, Path]]: """ Find all SKILL.md files in the bundled directory. Returns list of (skill_name, skill_directory_path) tuples. """ skills = [] if not bundled_dir.exists(): return skills for skill_md in bundled_dir.rglob("SKILL.md"): path_str = str(skill_md) if "/.git/" in path_str or "/.github/" in path_str or "/.hub/" in path_str: continue skill_dir = skill_md.parent skill_name = skill_dir.name skills.append((skill_name, skill_dir)) return skills def _compute_relative_dest(skill_dir: Path, bundled_dir: Path) -> Path: """ Compute the destination path in SKILLS_DIR preserving the category structure. e.g., bundled/skills/mlops/axolotl -> ~/.hermes/skills/mlops/axolotl """ rel = skill_dir.relative_to(bundled_dir) return SKILLS_DIR / rel def sync_skills(quiet: bool = False) -> dict: """ Sync bundled skills into ~/.hermes/skills/ using the manifest. - Skills whose names are already in the manifest are skipped (even if deleted by user). - New skills (not in manifest) are copied to SKILLS_DIR and added to the manifest. Returns: dict with keys: copied (list of names), skipped (int), total_bundled (int) """ bundled_dir = _get_bundled_dir() if not bundled_dir.exists(): return {"copied": [], "skipped": 0, "total_bundled": 0} SKILLS_DIR.mkdir(parents=True, exist_ok=True) manifest = _read_manifest() bundled_skills = _discover_bundled_skills(bundled_dir) copied = [] skipped = 0 for skill_name, skill_src in bundled_skills: if skill_name in manifest: skipped += 1 continue dest = _compute_relative_dest(skill_src, bundled_dir) try: if dest.exists(): # Skill dir exists (maybe user created one with same name) -- don't overwrite skipped += 1 else: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(skill_src, dest) copied.append(skill_name) if not quiet: print(f" + {skill_name}") except (OSError, IOError) as e: if not quiet: print(f" ! Failed to copy {skill_name}: {e}") manifest.add(skill_name) # Also copy DESCRIPTION.md files for categories (if not already present) for desc_md in bundled_dir.rglob("DESCRIPTION.md"): rel = desc_md.relative_to(bundled_dir) dest_desc = SKILLS_DIR / rel if not dest_desc.exists(): try: dest_desc.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(desc_md, dest_desc) except (OSError, IOError) as e: logger.debug("Could not copy %s: %s", desc_md, e) _write_manifest(manifest) return { "copied": copied, "skipped": skipped, "total_bundled": len(bundled_skills), } if __name__ == "__main__": print("Syncing bundled skills into ~/.hermes/skills/ ...") result = sync_skills(quiet=False) print(f"\nDone: {len(result['copied'])} new, {result['skipped']} skipped, " f"{result['total_bundled']} total bundled.")