| |
| """ |
| Skills Sync -- Manifest-based seeding and updating of bundled skills. |
| |
| Copies bundled skills from the repo's skills/ directory into ~/.hermes/skills/ |
| and uses a manifest to track which skills have been synced and their origin hash. |
| |
| Manifest format (v2): each line is "skill_name:origin_hash" where origin_hash |
| is the MD5 of the bundled skill at the time it was last synced to the user dir. |
| Old v1 manifests (plain names without hashes) are auto-migrated. |
| |
| Update logic: |
| - NEW skills (not in manifest): copied to user dir, origin hash recorded. |
| - EXISTING skills (in manifest, present in user dir): |
| * If user copy matches origin hash: user hasn't modified it β safe to |
| update from bundled if bundled changed. New origin hash recorded. |
| * If user copy differs from origin hash: user customized it β SKIP. |
| - DELETED by user (in manifest, absent from user dir): respected, not re-added. |
| - REMOVED from bundled (in manifest, gone from repo): cleaned from manifest. |
| |
| The manifest lives at ~/.hermes/skills/.bundled_manifest. |
| """ |
|
|
| import hashlib |
| import logging |
| import os |
| import shutil |
| from pathlib import Path |
| from typing import Dict, 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() -> Dict[str, str]: |
| """ |
| Read the manifest as a dict of {skill_name: origin_hash}. |
| |
| Handles both v1 (plain names) and v2 (name:hash) formats. |
| v1 entries get an empty hash string which triggers migration on next sync. |
| """ |
| if not MANIFEST_FILE.exists(): |
| return {} |
| try: |
| result = {} |
| for line in MANIFEST_FILE.read_text(encoding="utf-8").splitlines(): |
| line = line.strip() |
| if not line: |
| continue |
| if ":" in line: |
| |
| name, _, hash_val = line.partition(":") |
| result[name.strip()] = hash_val.strip() |
| else: |
| |
| result[line] = "" |
| return result |
| except (OSError, IOError): |
| return {} |
|
|
|
|
| def _write_manifest(entries: Dict[str, str]): |
| """Write the manifest file atomically in v2 format (name:hash). |
| |
| Uses a temp file + os.replace() to avoid corruption if the process |
| crashes or is interrupted mid-write. |
| """ |
| import tempfile |
|
|
| MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True) |
| data = "\n".join(f"{name}:{hash_val}" for name, hash_val in sorted(entries.items())) + "\n" |
|
|
| try: |
| fd, tmp_path = tempfile.mkstemp( |
| dir=str(MANIFEST_FILE.parent), |
| prefix=".bundled_manifest_", |
| suffix=".tmp", |
| ) |
| try: |
| with os.fdopen(fd, "w", encoding="utf-8") as f: |
| f.write(data) |
| f.flush() |
| os.fsync(f.fileno()) |
| os.replace(tmp_path, MANIFEST_FILE) |
| except BaseException: |
| try: |
| os.unlink(tmp_path) |
| except OSError: |
| pass |
| raise |
| except Exception as e: |
| logger.debug("Failed to write skills manifest %s: %s", MANIFEST_FILE, e, exc_info=True) |
|
|
|
|
| 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 _dir_hash(directory: Path) -> str: |
| """Compute a hash of all file contents in a directory for change detection.""" |
| hasher = hashlib.md5() |
| try: |
| for fpath in sorted(directory.rglob("*")): |
| if fpath.is_file(): |
| rel = fpath.relative_to(directory) |
| hasher.update(str(rel).encode("utf-8")) |
| hasher.update(fpath.read_bytes()) |
| except (OSError, IOError): |
| pass |
| return hasher.hexdigest() |
|
|
|
|
| def sync_skills(quiet: bool = False) -> dict: |
| """ |
| Sync bundled skills into ~/.hermes/skills/ using the manifest. |
| |
| Returns: |
| dict with keys: copied (list), updated (list), skipped (int), |
| user_modified (list), cleaned (list), total_bundled (int) |
| """ |
| bundled_dir = _get_bundled_dir() |
| if not bundled_dir.exists(): |
| return { |
| "copied": [], "updated": [], "skipped": 0, |
| "user_modified": [], "cleaned": [], "total_bundled": 0, |
| } |
|
|
| SKILLS_DIR.mkdir(parents=True, exist_ok=True) |
| manifest = _read_manifest() |
| bundled_skills = _discover_bundled_skills(bundled_dir) |
| bundled_names = {name for name, _ in bundled_skills} |
|
|
| copied = [] |
| updated = [] |
| user_modified = [] |
| skipped = 0 |
|
|
| for skill_name, skill_src in bundled_skills: |
| dest = _compute_relative_dest(skill_src, bundled_dir) |
| bundled_hash = _dir_hash(skill_src) |
|
|
| if skill_name not in manifest: |
| |
| try: |
| if dest.exists(): |
| |
| skipped += 1 |
| manifest[skill_name] = bundled_hash |
| else: |
| dest.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copytree(skill_src, dest) |
| copied.append(skill_name) |
| manifest[skill_name] = bundled_hash |
| if not quiet: |
| print(f" + {skill_name}") |
| except (OSError, IOError) as e: |
| if not quiet: |
| print(f" ! Failed to copy {skill_name}: {e}") |
| |
|
|
| elif dest.exists(): |
| |
| origin_hash = manifest.get(skill_name, "") |
| user_hash = _dir_hash(dest) |
|
|
| if not origin_hash: |
| |
| |
| manifest[skill_name] = user_hash |
| if user_hash == bundled_hash: |
| skipped += 1 |
| else: |
| |
| skipped += 1 |
| continue |
|
|
| if user_hash != origin_hash: |
| |
| user_modified.append(skill_name) |
| if not quiet: |
| print(f" ~ {skill_name} (user-modified, skipping)") |
| continue |
|
|
| |
| if bundled_hash != origin_hash: |
| try: |
| |
| backup = dest.with_suffix(".bak") |
| shutil.move(str(dest), str(backup)) |
| try: |
| shutil.copytree(skill_src, dest) |
| manifest[skill_name] = bundled_hash |
| updated.append(skill_name) |
| if not quiet: |
| print(f" β {skill_name} (updated)") |
| |
| shutil.rmtree(backup, ignore_errors=True) |
| except (OSError, IOError): |
| |
| if backup.exists() and not dest.exists(): |
| shutil.move(str(backup), str(dest)) |
| raise |
| except (OSError, IOError) as e: |
| if not quiet: |
| print(f" ! Failed to update {skill_name}: {e}") |
| else: |
| skipped += 1 |
|
|
| else: |
| |
| skipped += 1 |
|
|
| |
| cleaned = sorted(set(manifest.keys()) - bundled_names) |
| for name in cleaned: |
| del manifest[name] |
|
|
| |
| 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, |
| "updated": updated, |
| "skipped": skipped, |
| "user_modified": user_modified, |
| "cleaned": cleaned, |
| "total_bundled": len(bundled_skills), |
| } |
|
|
|
|
| if __name__ == "__main__": |
| print("Syncing bundled skills into ~/.hermes/skills/ ...") |
| result = sync_skills(quiet=False) |
| parts = [ |
| f"{len(result['copied'])} new", |
| f"{len(result['updated'])} updated", |
| f"{result['skipped']} unchanged", |
| ] |
| if result["user_modified"]: |
| parts.append(f"{len(result['user_modified'])} user-modified (kept)") |
| if result["cleaned"]: |
| parts.append(f"{len(result['cleaned'])} cleaned from manifest") |
| print(f"\nDone: {', '.join(parts)}. {result['total_bundled']} total bundled.") |
|
|