Files
hermes-agent/tools/skills_sync.py
teknium1 4d5f29c74c feat: introduce skill management tool for agent-created skills and skills migration to ~/.hermes
- Added a new `skill_manager_tool` to enable agents to create, update, and delete their own skills, enhancing procedural memory capabilities.
- Updated the skills directory structure to support user-created skills in `~/.hermes/skills/`, allowing for better organization and management.
- Enhanced the CLI and documentation to reflect the new skill management functionalities, including detailed instructions on creating and modifying skills.
- Implemented a manifest-based syncing mechanism for bundled skills to ensure user modifications are preserved during updates.
2026-02-19 18:25:53 -08:00

151 lines
4.7 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 os
import shutil
from pathlib import Path
from typing import List, Tuple
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):
pass
_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.")