Files
hermes-agent/tools/skills_sync.py

529 lines
23 KiB
Python

#!/usr/bin/env python3
"""
Skills Sync -- Manifest-based seeding and updating of bundled skills.
Copies bundled skills from the repo's skills/ directory into ~/.hermes/skills/,
tracking each synced skill's origin hash in ~/.hermes/skills/.bundled_manifest
(v2: one "skill_name:origin_hash" line each; v1 plain-name lines auto-migrate).
Update logic: NEW skills are copied and recorded; EXISTING skills update only
when bundled changed AND the user copy still matches the origin hash (a differing
user copy is user-customized -> SKIP); skills the user DELETED are not re-added;
skills REMOVED upstream are cleaned from the manifest.
"""
import hashlib
import logging
import os
import shutil
import stat
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Set, Tuple
# Force stdout/stderr to UTF-8: on GBK-style Windows locales the default encoding
# can't represent the glyphs printed here (✓ ↑ →) and raises mid-run; install.ps1
# also parses this script's stdout as UTF-8 and aborts on a GBK byte stream.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (ValueError, TypeError):
pass
from hermes_constants import get_bundled_skills_dir, get_hermes_home, get_optional_skills_dir
from agent.skill_utils import ESSENTIAL_SKILLS, is_excluded_skill_path
from utils import atomic_write_text
logger = logging.getLogger(__name__)
HERMES_HOME = get_hermes_home()
SKILLS_DIR = HERMES_HOME / "skills"
MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
# Import-time snapshots backing the call-time accessors below. Long-lived
# multi-profile runtimes import this module once and later retarget HERMES_HOME
# via set_hermes_home_override(); frozen constants would then resolve (and for
# reset_bundled_skill() DELETE) against the wrong profile. The accessors honor
# an explicitly patched module global and otherwise re-resolve on every call.
_HERMES_HOME_AT_IMPORT = HERMES_HOME
_SKILLS_DIR_AT_IMPORT = SKILLS_DIR
_MANIFEST_FILE_AT_IMPORT = MANIFEST_FILE
def _live(configured, at_import: Path, fallback) -> Path:
"""The patched module global if it changed since import, else the live value."""
return Path(configured) if Path(configured) != at_import else fallback()
def _hermes_home() -> Path:
return _live(HERMES_HOME, _HERMES_HOME_AT_IMPORT, get_hermes_home)
def _skills_dir() -> Path:
return _live(SKILLS_DIR, _SKILLS_DIR_AT_IMPORT, lambda: _hermes_home() / "skills")
def _manifest_file() -> Path:
return _live(MANIFEST_FILE, _MANIFEST_FILE_AT_IMPORT, lambda: _skills_dir() / ".bundled_manifest")
# Written by `hermes profile create --no-skills` / the installer's `--no-skills`;
# when present in HERMES_HOME, sync_skills() seeds only the essential skills. Mirrors
# hermes_cli.profiles.NO_BUNDLED_SKILLS_MARKER (literal: no CLI import in this module).
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
def _get_bundled_dir() -> Path: # HERMES_BUNDLED_SKILLS env first, then repo-relative
return get_bundled_skills_dir(Path(__file__).parent.parent / "skills")
def _get_optional_dir() -> Path:
return get_optional_skills_dir(Path(__file__).parent.parent / "optional-skills")
def _iter_active_skill_mds(sort: bool = False) -> Iterator[Path]:
"""Yield every non-excluded SKILL.md in the user's skills tree."""
root = _skills_dir()
found = root.rglob("SKILL.md") if root.exists() else iter(())
for skill_md in sorted(found) if sort else found:
if not is_excluded_skill_path(skill_md):
yield skill_md
def _build_external_skill_index() -> Set[str]:
"""Names (directory and frontmatter) of every skill provided by external_dirs,
so sync_skills never shadows an externally-delegated skill."""
try:
from agent.skill_utils import get_external_skills_dirs, _external_dirs_cache_clear
except ImportError:
return set()
_external_dirs_cache_clear() # so a config edit (or a test patch) is seen
external_names: Set[str] = set()
for ext_dir in get_external_skills_dirs():
for skill_md in ext_dir.rglob("SKILL.md"):
if not is_excluded_skill_path(skill_md):
external_names.update({skill_md.parent.name, _read_skill_name(skill_md, "")})
external_names.discard("")
return external_names
def _read_manifest() -> Dict[str, str]:
"""Read the manifest as ``{skill_name: origin_hash}``; v1 plain-name lines get an
empty hash, which triggers migration on the next sync."""
try:
lines = _manifest_file().read_text(encoding="utf-8").splitlines() if _manifest_file().exists() else []
except (OSError, IOError):
return {}
result = {}
for line in map(str.strip, lines):
if line:
name, _, hash_val = line.partition(":")
result[name.strip()] = hash_val.strip()
return result
def _read_suppressed_names() -> set:
"""Built-in skills the curator pruned — must NOT be re-seeded. Delegates to
``tools.skill_usage`` (source of truth), falling back to reading ``.curator_suppressed``
directly if that import is unavailable in a packaged/update context."""
try:
from tools.skill_usage import read_suppressed_names
return read_suppressed_names()
except Exception:
path = _skills_dir() / ".curator_suppressed"
try:
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
except OSError:
return set()
return {line.strip() for line in lines if line.strip() and not line.strip().startswith("#")}
def _write_manifest(entries: Dict[str, str]):
"""Write the manifest atomically in v2 format, preserving an existing file's
permission bits/owner instead of resetting them to mkstemp's 0600."""
_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:
atomic_write_text(_manifest_file(), data, tmp_prefix=".bundled_manifest_", preserve_mode=True)
except Exception as e:
logger.debug("Failed to write skills manifest %s: %s", _manifest_file(), e, exc_info=True)
def _read_skill_name(skill_md: Path, fallback: str) -> str:
"""Read the name field from SKILL.md YAML frontmatter, falling back to *fallback*."""
try:
content = skill_md.read_text(encoding="utf-8", errors="replace")[:4000]
except OSError:
return fallback
in_frontmatter = False
for stripped in map(str.strip, content.split("\n")):
if stripped == "---":
if in_frontmatter:
break
in_frontmatter = True
elif in_frontmatter and stripped.startswith("name:"):
if value := stripped.split(":", 1)[1].strip().strip("\"'"):
return value
return fallback
def _discover_bundled_skills(bundled_dir: Path) -> List[Tuple[str, Path]]:
"""``(skill_name, skill_dir)`` for every SKILL.md under the bundled dir. Exclusions
are evaluated relative to the bundled tree: the install prefix itself may contain
``venv``/``site-packages``, which once made every wheel install discover zero skills."""
if not bundled_dir.exists():
return []
return [
(_read_skill_name(md, md.parent.name), md.parent)
for md in bundled_dir.rglob("SKILL.md")
if not is_excluded_skill_path(md.relative_to(bundled_dir), root=bundled_dir)
]
def _compute_relative_dest(skill_dir: Path, bundled_dir: Path) -> Path:
"""Destination in the skills dir preserving category structure
(bundled/skills/mlops/axolotl -> ~/.hermes/skills/mlops/axolotl)."""
return _skills_dir() / skill_dir.relative_to(bundled_dir)
def _dir_hash(directory: Path) -> str:
"""MD5 over relative paths + contents of every file in a directory."""
hasher = hashlib.md5()
try:
for fpath in sorted(directory.rglob("*")):
if fpath.is_file():
hasher.update(str(fpath.relative_to(directory)).encode("utf-8"))
hasher.update(fpath.read_bytes())
except (OSError, IOError):
pass
return hasher.hexdigest()
def _index_active_skills() -> Dict[str, List[Path]]:
"""Index every skill in the user's tree by frontmatter name (for rename recovery)."""
index: Dict[str, List[Path]] = {}
for md in _iter_active_skill_mds():
index.setdefault(_read_skill_name(md, md.parent.name), []).append(md.parent)
return index
def _rel_skills_posix(path: Path) -> str:
return path.relative_to(_skills_dir()).as_posix()
def _move_dir(src: Path, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
def _copy_dir(src: Path, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dest)
def _recover_renamed_skill(st: "_SyncState", skill_name: str, dest: Path) -> Optional[str]:
"""Move a bundled skill's stale copy to its new canonical path after an upstream
RENAME/RECATEGORIZATION (manifest key still matches, ``dest`` doesn't exist yet;
otherwise the skill is misread as user-deleted and the old dir stranded forever).
Only a copy byte-identical to the origin hash — proof *we* placed it — is moved;
user-edited or hub-installed copies are left. Returns the rel source path on move.
"""
origin_hash = st.manifest.get(skill_name, "")
if not origin_hash:
return None
if st.active_index is None:
st.active_index = _index_active_skills()
st.hub_paths = _read_hub_install_paths()
for candidate in st.active_index.get(skill_name, []):
if candidate == dest or not candidate.is_dir():
continue
try:
rel = _rel_skills_posix(candidate)
except ValueError:
continue
if rel in st.hub_paths: # the hub owns its install paths
continue
if _dir_hash(candidate) != origin_hash:
# Moving a customized copy would edit the user's work; leaving it
# avoids a duplicate-name collision. Warn so they migrate deliberately.
st.say(
f" ⚠ {skill_name}: upstream moved this skill to {_rel_skills_posix(dest)}, but your "
f"modified copy at {rel} was kept — it will not receive updates. "
f"Run `hermes skills reset {skill_name} --restore` to move to the new location."
)
continue
try:
_move_dir(candidate, dest)
except (OSError, IOError):
logger.warning("Could not relocate renamed skill %s -> %s", candidate, dest, exc_info=True)
return None
logger.info("Relocated renamed bundled skill: %s -> %s", candidate, dest)
st.say(f" → {skill_name} (moved {rel} → {_rel_skills_posix(dest)})")
return rel
return None
@dataclass
class _SyncState:
"""Mutable accumulator threaded through one sync_skills() run."""
manifest: Dict[str, str]
quiet: bool
skipped: int = 0
copied: List[str] = field(default_factory=list)
updated: List[str] = field(default_factory=list)
user_modified: List[str] = field(default_factory=list)
suppressed: List[str] = field(default_factory=list)
relocated: List[str] = field(default_factory=list)
shadowed_by_external: List[str] = field(default_factory=list)
# Rename-recovery indexes are expensive on host bind mounts: built lazily,
# only when a tracked skill is actually missing from its canonical path.
active_index: Optional[Dict[str, List[Path]]] = None
hub_paths: Set[str] = field(default_factory=set)
def say(self, msg: str) -> None:
if not self.quiet:
print(msg)
def _recover_orphan_backup(dest: Path) -> None:
"""If an interrupted update left the user's only copy in ``dest.bak`` with
dest gone, move it back so the skill isn't misread as user-deleted."""
orphan = dest.with_suffix(".bak")
if not orphan.exists() or dest.exists():
return
try:
_move_dir(orphan, dest)
logger.info("Recovered orphaned skill backup: %s", orphan)
except (OSError, IOError):
logger.warning("Could not recover orphaned skill backup %s", orphan, exc_info=True)
def _defer_to_external(st: _SyncState, skill_name: str, dest: Path, bundled_hash: str) -> None:
"""An external_dirs source provides this skill; a local copy would be a name collision
the loader refuses to resolve. Defer for ALL manifest states and self-heal a stale local
shadow from an earlier sync — only when byte-identical (a user's own skill differs)."""
st.shadowed_by_external.append(skill_name)
st.skipped += 1
st.say(f" ⇢ {skill_name} (deferred to external_dirs, not written to local tree)")
if dest.exists() and _dir_hash(dest) == bundled_hash:
_rmtree_writable(dest)
st.say(f" ✓ removed stale shadow of {skill_name}")
st.manifest.pop(skill_name, None)
def _install_new_skill(st: _SyncState, skill_name: str, skill_src: Path, dest: Path, bundled_hash: str) -> None:
"""Handle a skill never offered before (not in manifest)."""
try:
if dest.exists():
# Never overwrite a same-named user skill. Baseline the manifest only when
# byte-identical to bundled: recording bundled_hash for a differing copy
# would read as "user-modified" forever and block every bundled update.
st.skipped += 1
if _dir_hash(dest) == bundled_hash:
st.manifest[skill_name] = bundled_hash
else:
st.say(
f" ⚠ {skill_name}: bundled version shipped but you already have a local skill by this name — "
f"yours was kept. Run `hermes skills reset {skill_name}` to replace it with the bundled version."
)
else:
_copy_dir(skill_src, dest)
st.copied.append(skill_name)
st.manifest[skill_name] = bundled_hash
st.say(f" + {skill_name}")
except (OSError, IOError) as e:
st.say(f" ! Failed to copy {skill_name}: {e}")
# Not added to manifest — next sync retries.
def _replace_skill_dir(skill_src: Path, dest: Path) -> None:
"""Replace ``dest`` with a fresh copy of ``skill_src`` via a ``.bak`` sibling,
restoring the original on failure."""
backup = dest.with_suffix(".bak")
if backup.exists(): # a stale .bak would make shutil.move() nest dest INSIDE it; dest is authoritative
_rmtree_writable(backup)
shutil.move(str(dest), str(backup))
try:
shutil.copytree(skill_src, dest)
except (OSError, IOError):
# Clear a partially-written dest so it can't shadow or block the restore.
if backup.exists() and dest.exists():
try:
_rmtree_writable(dest)
except (OSError, IOError):
logger.warning("Could not clear partial copy %s during restore", dest, exc_info=True)
if backup.exists() and not dest.exists():
shutil.move(str(backup), str(dest))
raise
try:
_rmtree_writable(backup)
except (OSError, IOError):
logger.debug("Could not remove backup %s", backup, exc_info=True)
def _update_existing_skill(st: _SyncState, skill_name: str, skill_src: Path, dest: Path, bundled_hash: str) -> None:
"""Handle a skill that is in the manifest AND on disk."""
origin_hash = st.manifest.get(skill_name, "")
if origin_hash and bundled_hash == origin_hash: # bundled unchanged: skip without hashing the user copy
st.skipped += 1
return
user_hash = _dir_hash(dest)
if not origin_hash:
# v1 migration: baseline from the user's copy so future syncs can detect edits.
# Can't tell user-edit from upstream change — be safe and skip.
st.manifest[skill_name] = user_hash
st.skipped += 1
return
if _is_tracked_user_modification(origin_hash, user_hash):
st.user_modified.append(skill_name)
st.say(f" ~ {skill_name} (user-modified, skipping)")
return
# bundled changed and the user copy is pristine -> update
try:
_replace_skill_dir(skill_src, dest)
except (OSError, IOError) as e:
st.say(f" ! Failed to update {skill_name}: {e}")
return
st.manifest[skill_name] = bundled_hash
st.updated.append(skill_name)
st.say(f" ↑ {skill_name} (updated)")
def _seed_category_descriptions(bundled_dir: Path, only_dirs: Optional[Set[Path]]) -> None:
"""Copy category DESCRIPTION.md files not already present; ``only_dirs``
restricts seeding to the essential skills' categories on opted-out profiles."""
for desc_md in bundled_dir.rglob("DESCRIPTION.md"):
dest_desc = _skills_dir() / desc_md.relative_to(bundled_dir)
if (only_dirs is not None and dest_desc.parent not in only_dirs) or dest_desc.exists():
continue
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)
def sync_skills(quiet: bool = False) -> dict:
"""Sync bundled skills into ~/.hermes/skills/ using the manifest. Returns a dict
with keys copied, updated, skipped, user_modified, cleaned, suppressed, relocated,
total_bundled, optional_provenance_backfilled, shadowed_by_external, skipped_opt_out."""
# Opted-out profiles seed ONLY ESSENTIAL_SKILLS: the system prompt always points at
# ``hermes-agent``, so even a Blank Slate profile keeps it.
essential_only = (_hermes_home() / NO_BUNDLED_SKILLS_MARKER).exists()
if essential_only and not quiet:
print(" (profile opted out of bundled skills via .no-bundled-skills — seeding essential skills only)")
bundled_dir = _get_bundled_dir()
if not bundled_dir.exists():
return {"copied": [], "updated": [], "skipped": 0, "user_modified": [], "cleaned": [],
"suppressed": [], "total_bundled": 0, "optional_provenance_backfilled": []}
_skills_dir().mkdir(parents=True, exist_ok=True)
bundled_skills = _discover_bundled_skills(bundled_dir)
if essential_only:
bundled_skills = [(name, src) for name, src in bundled_skills if name in ESSENTIAL_SKILLS]
suppressed = _read_suppressed_names()
external_index = _build_external_skill_index()
st = _SyncState(manifest=_read_manifest(), quiet=quiet)
for skill_name, skill_src in bundled_skills:
# Curator-pruned built-ins must not resurrect on every update; essentials are exempt.
if skill_name in suppressed and skill_name not in ESSENTIAL_SKILLS:
st.suppressed.append(skill_name)
continue
dest = _compute_relative_dest(skill_src, bundled_dir)
bundled_hash = _dir_hash(skill_src)
# Recoveries run BEFORE classification so a missing dest isn't misread as user-deleted.
_recover_orphan_backup(dest)
if not dest.exists() and skill_name in st.manifest and _recover_renamed_skill(st, skill_name, dest):
st.relocated.append(skill_name)
if skill_name in external_index:
_defer_to_external(st, skill_name, dest, bundled_hash)
elif skill_name not in st.manifest:
_install_new_skill(st, skill_name, skill_src, dest, bundled_hash)
elif dest.exists():
_update_existing_skill(st, skill_name, skill_src, dest, bundled_hash)
else:
st.skipped += 1 # in manifest but not on disk — user deleted it
# Clean manifest entries for skills removed upstream. Skipped when opted out: bundled_skills
# is only the essential set there, so cleaning would drop tracking for every other skill.
cleaned = [] if essential_only else sorted(set(st.manifest) - {name for name, _ in bundled_skills})
for name in cleaned:
del st.manifest[name]
_seed_category_descriptions(
bundled_dir,
{_compute_relative_dest(src, bundled_dir).parent for _, src in bundled_skills} if essential_only else None,
)
_write_manifest(st.manifest)
return {
"copied": st.copied, "updated": st.updated, "skipped": st.skipped, "user_modified": st.user_modified,
"cleaned": cleaned, "suppressed": st.suppressed, "relocated": st.relocated,
"total_bundled": len(bundled_skills),
"optional_provenance_backfilled": _backfill_optional_provenance(quiet=quiet),
"shadowed_by_external": st.shadowed_by_external,
"skipped_opt_out": essential_only, # lets callers report "opted out" rather than a normal sync
}
def _rmtree_writable(path: Path) -> None:
"""Remove a directory tree, making read-only entries writable first (Nix/deb/rpm
sources keep r-x dirs; unlinking a child needs a writable parent, so chmod both).
Scope guard: refuses anything not a STRICT child of the active profile's skills
root, so a bad path join / missing HERMES_HOME / malicious manifest entry raises
a loud ValueError instead of wiping ``~/.hermes``. Callers always pass a skill dir
or its ``.bak`` sibling; the skills root itself must never be removed.
"""
target = Path(path).resolve()
skills_root = _skills_dir().resolve()
if skills_root not in target.parents:
raise ValueError(f"refusing to rmtree {target!r}: not strictly under {skills_root!r} (scope guard — see #48200)")
def _on_error(func, fpath, exc_info):
for p in (os.path.dirname(fpath), fpath):
try:
os.chmod(p, stat.S_IRWXU)
except OSError:
pass
func(fpath)
shutil.rmtree(path, onerror=_on_error)
# Re-exported so ``from tools.skills_sync import X`` / ``patch("tools.skills_sync.X")`` keep working.
from tools.skills_sync_bundled_ops import ( # noqa: E402,F401
_is_tracked_user_modification, _read_for_diff, diff_bundled_skill, list_user_modified_bundled_skills,
remove_pristine_bundled_skills, reset_bundled_skill, set_bundled_skills_opt_out,
)
from tools.skills_sync_optional import ( # noqa: E402,F401
_backfill_optional_provenance, _content_hash, _index_installed_skill_dirs_by_name, _move_to_restore_backup,
_optional_skill_index, _read_hub_install_paths, _safe_rel_install_path, _skill_file_list,
restore_official_optional_skill,
)
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"]
names = result["user_modified"]
if names:
shown = ", ".join(names[:5]) + (f", +{len(names) - 5} more" if len(names) > 5 else "")
parts.append(f"{len(names)} user-modified (kept): {shown}")
if result["cleaned"]:
parts.append(f"{len(result['cleaned'])} cleaned from manifest")
if result.get("optional_provenance_backfilled"):
parts.append(f"{len(result['optional_provenance_backfilled'])} official optional backfilled")
print(f"\nDone: {', '.join(parts)}. {result['total_bundled']} total bundled.")