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.
This commit is contained in:
parent
d070b8698d
commit
4d5f29c74c
18 changed files with 1007 additions and 204 deletions
150
tools/skills_sync.py
Normal file
150
tools/skills_sync.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
#!/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.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue