- Updated various modules including cli.py, run_agent.py, gateway, and tools to replace silent exception handling with structured logging. - Improved error messages to provide more context, aiding in debugging and monitoring. - Ensured consistent logging practices throughout the codebase, enhancing traceability and maintainability.
154 lines
4.8 KiB
Python
154 lines
4.8 KiB
Python
#!/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.")
|