hermes-agent/tools/skills_sync.py
teknium1 c537ae5f40 fix(skills): stop treating an upstream skill rename as a user deletion
`sync_skills()` keys the bundled manifest by frontmatter name but computes
the destination from the bundled path. When upstream renames or
recategorizes a skill, the manifest key still matches while the new dest
does not exist yet, so the loop fell into its "in manifest but not on
disk" branch and misread the skill as user-deleted: the user's copy was
stranded at the old path forever and never received another update.

Three skills hit this in the July 2026 reorg (computer-use,
evaluating-llms-harness, serving-llms-vllm) — silently frozen at their
pre-rename content on every machine that ran `hermes update`.

Recovery only moves a stale copy when it is byte-identical to the origin
hash recorded the last time sync wrote it, which proves the directory is
ours rather than the user's work. User-modified copies are kept in place
with a warning, hub-installed paths are never touched, and a genuine
deletion (no copy anywhere on disk) is still respected.

- tools/skills_sync.py: add _recover_renamed_skill() plus the
  _index_active_skills() / _read_hub_install_paths() indexes; call it
  before classification and report moves via a new `relocated` key.
- hermes_cli/main.py: surface relocations in both `hermes update` skill
  sync reporting sites.
- tests: 4 cases covering relocate, user-modified preservation,
  hub-installed exemption, and genuine-deletion respect.
2026-07-25 15:40:53 -07:00

1327 lines
53 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/
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 json
import logging
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
# Force stdout/stderr to UTF-8. On non-UTF-8 Windows locales (e.g. CP936/GBK
# on zh-CN), Python's default stream encoding can't represent the checkmark /
# arrow glyphs this script prints (✓ U+2713, ↑ U+2191), raising
# UnicodeEncodeError mid-run. The bootstrap installer (install.ps1) captures
# this script's stdout and parses it as UTF-8; a GBK byte stream then surfaces
# as "stream did not contain valid UTF-8" and aborts the config-templates
# stage even though the script itself exits 0. Reconfigure unconditionally so
# output is valid UTF-8 regardless of the active codepage or caller.
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 is_excluded_skill_path
from typing import Dict, List, Optional, Set, Tuple
from utils import atomic_replace
logger = logging.getLogger(__name__)
HERMES_HOME = get_hermes_home()
SKILLS_DIR = HERMES_HOME / "skills"
MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
# Marker file written by `hermes profile create --no-skills` (named profiles)
# and by the installer's `--no-skills` flag (the default ~/.hermes profile).
# When present in HERMES_HOME, sync_skills() is a no-op so neither the
# installer, `hermes update`, nor a direct sync re-injects bundled skills.
# Delete the file to opt back in. Mirrors
# hermes_cli.profiles.NO_BUNDLED_SKILLS_MARKER (kept as a literal here to
# avoid importing the CLI layer into this low-level sync module).
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
def _get_bundled_dir() -> Path:
"""Locate the bundled skills/ directory.
Checks HERMES_BUNDLED_SKILLS env var first (set by Nix wrapper),
then falls back to the relative path from this source file.
"""
return get_bundled_skills_dir(Path(__file__).parent.parent / "skills")
def _get_optional_dir() -> Path:
"""Locate the official optional-skills/ directory."""
return get_optional_skills_dir(Path(__file__).parent.parent / "optional-skills")
def _build_external_skill_index() -> Set[str]:
"""Index every skill available in external_dirs by name and frontmatter name.
Returns a set of skill names that are already provided by external dirs.
Used to prevent sync_skills from shadowing externally-delegated skills.
"""
try:
from agent.skill_utils import get_external_skills_dirs, _external_dirs_cache_clear
except ImportError:
return set()
# Clear the external dirs cache so a config edit (or a test patch) is seen.
_external_dirs_cache_clear()
external_names: Set[str] = set()
for ext_dir in get_external_skills_dirs():
for skill_md in ext_dir.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
skill_dir = skill_md.parent
# Index by directory name (how _find_skill resolves skills)
external_names.add(skill_dir.name)
# Also index by frontmatter name (alternate identifier)
frontmatter_name = _read_skill_name(skill_md, "")
if frontmatter_name:
external_names.add(frontmatter_name)
return external_names
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:
# v2 format: name:hash
name, _, hash_val = line.partition(":")
result[name.strip()] = hash_val.strip()
else:
# v1 format: plain name — empty hash triggers migration
result[line] = ""
return result
except (OSError, IOError):
return {}
def _read_suppressed_names() -> set:
"""Built-in skills the curator pruned — must NOT be re-seeded on sync.
Delegates to ``tools.skill_usage`` (single source of truth) and falls back
to reading ``~/.hermes/skills/.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"
if not path.exists():
return set()
names = set()
try:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
names.add(line)
except OSError:
pass
return names
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())
atomic_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 _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 line in content.split("\n"):
stripped = line.strip()
if stripped == "---":
if in_frontmatter:
break
in_frontmatter = True
continue
if in_frontmatter and stripped.startswith("name:"):
value = stripped.split(":", 1)[1].strip().strip("\"'")
if value:
return value
return fallback
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"):
if is_excluded_skill_path(skill_md):
continue
skill_dir = skill_md.parent
skill_name = _read_skill_name(skill_md, 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 _safe_rel_install_path(path: Path, base: Path) -> str:
"""Return a normalized relative POSIX path, rejecting traversal/absolute paths."""
rel = path.relative_to(base)
posix = rel.as_posix()
pure = PurePosixPath(posix)
parts = [part for part in pure.parts if part not in {"", "."}]
if pure.is_absolute() or not parts or any(part == ".." for part in parts):
raise ValueError(f"Unsafe optional skill path: {posix}")
return "/".join(parts)
def _skill_file_list(skill_dir: Path) -> List[str]:
"""List files inside a skill directory in lock-file format."""
files: List[str] = []
for fpath in sorted(skill_dir.rglob("*")):
if fpath.is_file():
files.append(fpath.relative_to(skill_dir).as_posix())
return files
def _content_hash(directory: Path) -> str:
"""Return the same hash style the skills hub lock uses, falling back locally."""
try:
from tools.skills_guard import content_hash
return content_hash(directory)
except Exception:
# Hashing is provenance metadata only; keep sync resilient if guard
# dependencies are unavailable in a packaged/update context.
return _dir_hash(directory)
def _optional_skill_index() -> Dict[str, Tuple[str, str, Path]]:
"""Return official optional skills keyed by folder name and frontmatter name.
Values are ``(folder_name, install_path, source_dir)``. Multiple keys may
point to the same skill so callers can accept either the folder slug used
by the hub lock or the user-facing frontmatter name.
"""
optional_dir = _get_optional_dir()
index: Dict[str, Tuple[str, str, Path]] = {}
if not optional_dir.exists():
return index
for skill_md in sorted(optional_dir.rglob("SKILL.md")):
if is_excluded_skill_path(skill_md):
continue
src = skill_md.parent
try:
install_path = _safe_rel_install_path(src, optional_dir)
except ValueError:
continue
folder_name = src.name
frontmatter_name = _read_skill_name(skill_md, folder_name)
value = (folder_name, install_path, src)
index[folder_name] = value
index[frontmatter_name] = value
return index
def _move_to_restore_backup(path: Path, backup_root: Path) -> str:
"""Move an existing skill directory into a restore backup, preserving rel path."""
rel = path.relative_to(SKILLS_DIR)
target = backup_root / rel
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
suffix = 1
while target.with_name(f"{target.name}-{suffix}").exists():
suffix += 1
target = target.with_name(f"{target.name}-{suffix}")
shutil.move(str(path), str(target))
return rel.as_posix()
def restore_official_optional_skill(name: str, *, restore: bool = False) -> dict:
"""Restore one or all official optional skills from repo source.
``restore=False`` only performs exact-match provenance backfill. ``restore=True``
repairs already-mutated/reorganized skills by backing up matching active
copies and copying the official optional source into its canonical path.
"""
index = _optional_skill_index()
if not index:
return {"ok": False, "message": "No official optional skills directory found.", "restored": [], "backfilled": [], "backed_up": []}
targets = sorted(set(index.values()), key=lambda item: item[1]) if name in {"all", "*"} else []
if not targets:
target = index.get(name)
if target is None:
return {"ok": False, "message": f"Official optional skill not found: {name}", "restored": [], "backfilled": [], "backed_up": []}
targets = [target]
restored: List[str] = []
backed_up: List[str] = []
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
backup_root = SKILLS_DIR / ".restore-backups" / f"official-optional-{timestamp}"
for folder_name, install_path, src in targets:
dest = SKILLS_DIR / Path(*install_path.split("/"))
src_hash = _dir_hash(src)
canonical_ok = dest.exists() and _dir_hash(dest) == src_hash
# Find already-active copies of this official skill by frontmatter name
# or folder slug, even if curator moved it into another category.
src_frontmatter = _read_skill_name(src / "SKILL.md", folder_name)
matches: List[Path] = []
if SKILLS_DIR.exists():
for skill_md in sorted(SKILLS_DIR.rglob("SKILL.md")):
if is_excluded_skill_path(skill_md):
continue
candidate = skill_md.parent
try:
candidate.relative_to(SKILLS_DIR)
except ValueError:
continue
candidate_name = _read_skill_name(skill_md, candidate.name)
if candidate == dest:
continue
if candidate.name == folder_name or candidate_name in {folder_name, src_frontmatter}:
matches.append(candidate)
if restore:
for match in matches:
if match.exists():
backed_up.append(_move_to_restore_backup(match, backup_root))
if dest.exists() and not canonical_ok:
backed_up.append(_move_to_restore_backup(dest, backup_root))
if not dest.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dest)
restored.append(folder_name)
elif not canonical_ok:
continue
backfilled = _backfill_optional_provenance(quiet=True)
return {
"ok": True,
"message": "Official optional skill repair complete.",
"restored": restored,
"backfilled": backfilled,
"backed_up": backed_up,
"backup_dir": str(backup_root) if backed_up else "",
}
def _backfill_optional_provenance(quiet: bool = False) -> List[str]:
"""Mark already-present official optional skills as hub-installed.
This covers the migration case where a skill used to be bundled (or was
manually copied into the active skills tree) and later lives under
optional-skills/. If the active copy is byte-identical to the official
optional source, record official hub provenance without copying or
reinstalling anything. Modified/local skills are left alone.
"""
optional_dir = _get_optional_dir()
if not optional_dir.exists():
return []
lock_path = SKILLS_DIR / ".hub" / "lock.json"
try:
data = json.loads(lock_path.read_text(encoding="utf-8")) if lock_path.exists() else {"version": 1, "installed": {}}
except (json.JSONDecodeError, OSError):
data = {"version": 1, "installed": {}}
installed = data.setdefault("installed", {})
existing_paths = {
entry.get("install_path")
for entry in installed.values()
if isinstance(entry, dict)
}
backfilled: List[str] = []
changed = False
for skill_md in sorted(optional_dir.rglob("SKILL.md")):
if is_excluded_skill_path(skill_md):
continue
src = skill_md.parent
try:
install_path = _safe_rel_install_path(src, optional_dir)
except ValueError as e:
logger.debug("Skipping optional skill with unsafe path %s: %s", src, e)
continue
dest = SKILLS_DIR / Path(*install_path.split("/"))
if not dest.exists() or not dest.is_dir():
continue
if _dir_hash(dest) != _dir_hash(src):
continue
lock_name = src.name
if lock_name in installed or install_path in existing_paths:
continue
timestamp = datetime.now(timezone.utc).isoformat()
installed[lock_name] = {
"source": "official",
"identifier": f"official/{install_path}",
"trust_level": "builtin",
"scan_verdict": "backfilled",
"content_hash": _content_hash(dest),
"install_path": install_path,
"files": _skill_file_list(dest),
"metadata": {"backfilled_from": "optional-skills"},
"installed_at": timestamp,
"updated_at": timestamp,
}
existing_paths.add(install_path)
backfilled.append(lock_name)
changed = True
if not quiet:
print(f" = {lock_name} (official optional provenance backfilled)")
if changed:
lock_path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write so a crash mid-write can't silently wipe all provenance
# via the JSONDecodeError fallback above (which resets `installed` to
# an empty dict).
import tempfile
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
fd, tmp_path = tempfile.mkstemp(
dir=str(lock_path.parent),
prefix=".lock_",
suffix=".tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(payload)
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, lock_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
return backfilled
def _read_hub_install_paths() -> Set[str]:
"""Return install paths recorded in the skills-hub lock, as POSIX strings.
Hub-installed skills are owned by the hub (``hermes skills uninstall``),
never by bundled sync. Rename recovery must not move them even when their
content happens to match a bundled origin hash, or the lock's
``install_path`` would point at a directory that no longer exists.
"""
lock_path = SKILLS_DIR / ".hub" / "lock.json"
if not lock_path.exists():
return set()
try:
data = json.loads(lock_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return set()
paths: Set[str] = set()
for entry in (data.get("installed") or {}).values():
if isinstance(entry, dict):
install_path = entry.get("install_path")
if install_path:
paths.add(str(install_path).strip("/"))
return paths
def _index_active_skills() -> Dict[str, List[Path]]:
"""Index every skill in the user's tree by frontmatter name.
Returns ``{skill_name: [skill_dir, ...]}``. Used by rename recovery to
locate a bundled skill that upstream moved to a new category/directory.
"""
index: Dict[str, List[Path]] = {}
if not SKILLS_DIR.exists():
return index
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
skill_dir = skill_md.parent
name = _read_skill_name(skill_md, skill_dir.name)
index.setdefault(name, []).append(skill_dir)
return index
def _recover_renamed_skill(
skill_name: str,
origin_hash: str,
dest: Path,
active_index: Dict[str, List[Path]],
hub_paths: Set[str],
quiet: bool,
) -> Optional[str]:
"""Move a bundled skill's stale copy to its new canonical path.
When upstream RENAMES or RECATEGORIZES a bundled skill, the manifest key
(frontmatter name) still matches but ``dest`` is a brand-new path that does
not exist yet. Without recovery, ``sync_skills()`` falls through to its
"in manifest but not on disk" branch and misreads the skill as
*user-deleted*: the old directory is stranded forever and never receives
another update.
A stale copy is only moved when it is byte-identical to ``origin_hash`` —
the hash recorded the last time sync wrote that skill — which proves the
directory is the copy *we* placed there rather than the user's own work.
Anything else (user-edited, hub-installed) is left untouched.
Returns the relative source path when a move happened, else ``None``.
"""
if not origin_hash:
return None
for candidate in active_index.get(skill_name, []):
if candidate == dest or not candidate.is_dir():
continue
try:
rel = candidate.relative_to(SKILLS_DIR).as_posix()
except ValueError:
continue
# Never relocate a hub-installed skill — the hub owns its path.
if rel in hub_paths:
continue
if _dir_hash(candidate) != origin_hash:
# User customized the copy at the old path. Moving it would edit
# their work; leaving it avoids a duplicate-name collision. Warn
# so they can migrate deliberately.
if not quiet:
print(
f"{skill_name}: upstream moved this skill to "
f"{dest.relative_to(SKILLS_DIR).as_posix()}, but your "
f"modified copy at {rel} was kept — it will not receive "
f"updates. Run `hermes skills reset {skill_name} --restore` "
f"to move to the new location."
)
continue
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(candidate), str(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)
if not quiet:
print(f"{skill_name} (moved {rel}{dest.relative_to(SKILLS_DIR).as_posix()})")
return rel
return None
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)
"""
# Opt-out: a profile (named or the default ~/.hermes) that wrote the
# .no-bundled-skills marker gets zero bundled-skill seeding. Returning the
# empty-result shape with skipped_opt_out lets callers report "opted out"
# instead of "synced 0 / failed". This is the default-profile counterpart
# to seed_profile_skills()'s marker check for named profiles.
if (HERMES_HOME / NO_BUNDLED_SKILLS_MARKER).exists():
if not quiet:
print(" (skipped — profile opted out of bundled skills via .no-bundled-skills)")
return {
"copied": [], "updated": [], "skipped": 0,
"user_modified": [], "cleaned": [], "total_bundled": 0,
"optional_provenance_backfilled": [], "skipped_opt_out": True,
}
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)
manifest = _read_manifest()
bundled_skills = _discover_bundled_skills(bundled_dir)
bundled_names = {name for name, _ in bundled_skills}
suppressed = _read_suppressed_names()
# Index of skills already provided by external_dirs (skip writing them)
external_index = _build_external_skill_index()
shadowed_by_external: List[str] = []
# Rename recovery indexes, built once per sync (see _recover_renamed_skill).
active_index = _index_active_skills()
hub_paths = _read_hub_install_paths()
copied = []
updated = []
user_modified = []
suppressed_skipped: List[str] = []
relocated: List[str] = []
skipped = 0
for skill_name, skill_src in bundled_skills:
# Curator-pruned built-ins: do not re-seed. The suppression list
# (~/.hermes/skills/.curator_suppressed) is written when the curator
# archives a bundled skill with curator.prune_builtins enabled. Without
# this skip, every `hermes update` would resurrect a skill the user
# deliberately pruned. Restoring the skill clears its suppression entry.
if skill_name in suppressed:
suppressed_skipped.append(skill_name)
continue
dest = _compute_relative_dest(skill_src, bundled_dir)
bundled_hash = _dir_hash(skill_src)
# Recover an orphaned backup before classifying. If a previous
# update was interrupted between moving dest aside and copying the
# new version in, the user's only copy sits in ``dest.bak`` while
# dest is gone — without this, the "in manifest but not on disk"
# branch below misreads the skill as user-deleted and it silently
# vanishes from discovery.
_orphan = dest.with_suffix(".bak")
if _orphan.exists() and not dest.exists():
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(_orphan), str(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,
)
# Recover an upstream RENAME / RECATEGORIZATION before classifying.
# The manifest key (frontmatter name) survives a directory move, but
# ``dest`` is a new path that does not exist yet — without this the
# "in manifest but not on disk" branch below misreads the skill as
# user-deleted, stranding the old copy at its stale path forever.
if not dest.exists() and skill_name in manifest:
_moved_from = _recover_renamed_skill(
skill_name,
manifest.get(skill_name, ""),
dest,
active_index,
hub_paths,
quiet,
)
if _moved_from:
relocated.append(skill_name)
if skill_name in external_index:
# An external_dirs source already provides this skill. Writing it
# into the profile-local tree would create a name collision the
# loader refuses to resolve (#28126). Defer to the external copy
# for ALL manifest states (new, previously-synced, user-deleted).
shadowed_by_external.append(skill_name)
skipped += 1
if not quiet:
print(
f"{skill_name} (deferred to external_dirs, "
"not written to local tree)"
)
# Self-healing: a prior sync (before external_dirs was configured,
# or an older buggy sync) may have left a local shadow that now
# collides. We own that shadow only when it is byte-identical to
# the bundled source — a user's own customized skill by the same
# name differs, so never delete or re-baseline it. Drop the stale
# manifest entry so the skill isn't later misread as user-deleted.
if dest.exists() and _dir_hash(dest) == bundled_hash:
_rmtree_writable(dest)
if not quiet:
print(f" ✓ removed stale shadow of {skill_name}")
manifest.pop(skill_name, None)
continue
if skill_name not in manifest:
# ── New skill — never offered before ──
try:
if dest.exists():
# User already has a skill with the same name — don't overwrite.
# Only baseline in the manifest when the on-disk copy is
# byte-identical to bundled (e.g. a reset that re-syncs, or
# a coincidentally identical install); that case is harmless
# to track. If the copy differs (custom skill, hub-installed,
# or user-edited) skip the manifest write: recording
# bundled_hash there would poison update detection by making
# user_hash != origin_hash read as "user-modified" on every
# subsequent sync, permanently blocking bundled updates.
skipped += 1
if _dir_hash(dest) == bundled_hash:
manifest[skill_name] = bundled_hash
elif not quiet:
print(
f"{skill_name}: bundled version shipped but you "
f"already have a local skill by this name — yours "
f"was kept. Run `hermes skills reset {skill_name}` "
f"to replace it with the bundled version."
)
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}")
# Do NOT add to manifest — next sync should retry
elif dest.exists():
# ── Existing skill — in manifest AND on disk ──
origin_hash = manifest.get(skill_name, "")
user_hash = _dir_hash(dest)
if not origin_hash:
# v1 migration: no origin hash recorded. Set baseline from
# user's current copy so future syncs can detect modifications.
manifest[skill_name] = user_hash
if user_hash == bundled_hash:
skipped += 1 # already in sync
else:
# Can't tell if user modified or bundled changed — be safe
skipped += 1
continue
if _is_tracked_user_modification(origin_hash, user_hash):
# User modified this skill — don't overwrite their changes
user_modified.append(skill_name)
if not quiet:
print(f" ~ {skill_name} (user-modified, skipping)")
continue
# User copy matches origin — check if bundled has a newer version
if bundled_hash != origin_hash:
try:
# Move old copy to a backup so we can restore on failure
backup = dest.with_suffix(".bak")
# A stale backup left by an earlier failure would make
# shutil.move() nest dest *inside* it (or fail outright)
# and would poison the restore path below. The current
# dest is the authoritative copy — clear the leftover.
if backup.exists():
_rmtree_writable(backup)
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)")
# Remove backup after successful copy
try:
_rmtree_writable(backup)
except (OSError, IOError):
logger.debug("Could not remove backup %s", backup, exc_info=True)
except (OSError, IOError):
# Restore from backup. A partially-written dest must
# not shadow the user's copy or block the restore —
# clear it first, then move the backup home.
if backup.exists():
if dest.exists():
try:
_rmtree_writable(dest)
except (OSError, IOError):
logger.warning(
"Could not clear partial copy %s during restore",
dest, exc_info=True,
)
if 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 # bundled unchanged, user unchanged
else:
# ── In manifest but not on disk — user deleted it ──
skipped += 1
# Clean stale manifest entries (skills removed from bundled dir)
cleaned = sorted(set(manifest.keys()) - bundled_names)
for name in cleaned:
del manifest[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)
optional_provenance_backfilled = _backfill_optional_provenance(quiet=quiet)
return {
"copied": copied,
"updated": updated,
"skipped": skipped,
"user_modified": user_modified,
"cleaned": cleaned,
"suppressed": suppressed_skipped,
"relocated": relocated,
"total_bundled": len(bundled_skills),
"optional_provenance_backfilled": optional_provenance_backfilled,
"shadowed_by_external": shadowed_by_external,
}
def _rmtree_writable(path: Path) -> None:
"""Remove a directory tree, making read-only entries writable first.
Handles immutable package sources (Nix store, deb/rpm installs) that
preserve read-only permissions on copied files *and* directories
(``r-xr-xr-x``). Removing a child requires write permission on its
parent directory, so the retry handler makes the failing path **and its
parent** writable before re-attempting. See #34860, #34972.
"""
# Defense in depth (#48200): refuse to rmtree anything outside
# ``HERMES_HOME/skills/`` to prevent the catastrophic wipe of
# ``~/.hermes/`` (``.env``, ``MEMORY.md``, ``kanban.db``, custom
# skills, scripts, …) that an earlier incident observed. Five call
# sites in this file invoke this helper; if any one of them ever
# computes a destination outside the skills root — through a bad
# path join, a missing ``HERMES_HOME`` default, a malicious
# bundled-manifest entry, or a mid-flight exception that leaves a
# stale path in scope — this guard turns the resulting
# ``shutil.rmtree(~/.hermes)`` into a loud, recoverable ``ValueError``
# instead of silently destroying the user's install.
target = Path(path).resolve()
skills_root = SKILLS_DIR.resolve()
# Every legitimate caller passes a skill directory or its ``.bak``
# sibling — always a strict child of the skills root. The skills root
# itself must never be removed: a ``dest`` that collapses to
# ``SKILLS_DIR`` (e.g. a relative path resolving to ``.``) would wipe
# every installed skill, and its ``.bak`` sibling lands one level up in
# ``HERMES_HOME``. Require a strict-child relationship so both escape
# into the skills root and out of it are refused.
if skills_root not in target.parents:
raise ValueError(
f"refusing to rmtree {target!r}: not strictly under {skills_root!r} "
f"(scope guard — see #48200)"
)
import stat
def _on_error(func, fpath, exc_info):
# Unlinking a child requires the parent dir to be writable, so chmod
# the parent as well as the failing path, then retry.
for target in (os.path.dirname(fpath), fpath):
try:
os.chmod(target, stat.S_IRWXU)
except OSError:
pass
func(fpath)
shutil.rmtree(path, onerror=_on_error)
def reset_bundled_skill(name: str, restore: bool = False) -> dict:
"""
Reset a bundled skill's manifest tracking so future syncs work normally.
When a user edits a bundled skill, subsequent syncs mark it as
``user_modified`` and skip it forever — even if the user later copies
the bundled version back into place, because the manifest still holds
the *old* origin hash. This function breaks that loop.
Args:
name: The skill name (matches the manifest key / skill frontmatter name).
restore: If True, also delete the user's copy in SKILLS_DIR and let
the next sync re-copy the current bundled version. If False
(default), only clear the manifest entry — the user's
current copy is preserved but future updates work again.
Returns:
dict with keys:
- ok: bool, whether the reset succeeded
- action: one of "manifest_cleared", "restored", "not_in_manifest",
"bundled_missing"
- message: human-readable description
- synced: dict from sync_skills() if a sync was triggered, else None
"""
manifest = _read_manifest()
bundled_dir = _get_bundled_dir()
bundled_skills = _discover_bundled_skills(bundled_dir)
bundled_by_name = dict(bundled_skills)
in_manifest = name in manifest
is_bundled = name in bundled_by_name
if not in_manifest and not is_bundled:
return {
"ok": False,
"action": "not_in_manifest",
"message": (
f"'{name}' is not a tracked bundled skill. Nothing to reset. "
f"(Hub-installed skills use `hermes skills uninstall`.)"
),
"synced": None,
}
# Step 1 (optional): delete the user's copy so next sync re-copies bundled.
# Must happen BEFORE manifest deletion so that a failed rmtree does not
# leave the skill in a manifest-less limbo state (see #34972).
deleted_user_copy = False
if restore:
if not is_bundled:
return {
"ok": False,
"action": "bundled_missing",
"message": (
f"'{name}' has no bundled source — manifest entry preserved "
f"but cannot restore from bundled (skill was removed upstream)."
),
"synced": None,
}
dest = _compute_relative_dest(bundled_by_name[name], bundled_dir)
if dest.exists():
try:
_rmtree_writable(dest)
deleted_user_copy = True
except (OSError, IOError) as e:
return {
"ok": False,
"action": "not_reset",
"message": (
f"Could not delete user copy at {dest}: {e}. "
f"Manifest entry preserved — nothing was changed."
),
"synced": None,
}
# Step 2: drop the manifest entry so next sync treats it as new
if in_manifest:
del manifest[name]
_write_manifest(manifest)
# Step 3: run sync to re-baseline (or re-copy if we deleted)
synced = sync_skills(quiet=True)
if restore and deleted_user_copy:
action = "restored"
message = f"Restored '{name}' from bundled source."
elif restore:
# Nothing on disk to delete, but we re-synced — acts like a fresh install
action = "restored"
message = f"Restored '{name}' (no prior user copy, re-copied from bundled)."
else:
action = "manifest_cleared"
message = (
f"Cleared manifest entry for '{name}'. Future `hermes update` runs "
f"will re-baseline against your current copy and accept upstream changes."
)
return {"ok": True, "action": action, "message": message, "synced": synced}
def _is_tracked_user_modification(origin_hash: str, user_hash: str) -> bool:
"""Whether an on-disk skill counts as a user modification ``hermes update`` keeps.
Shared by the sync loop (which decides what to skip) and
``list_user_modified_bundled_skills`` (which surfaces the names) so the two
can never drift. A skill is a tracked modification only when it has a
recorded origin hash (an un-baselined / v1 entry with an empty hash is not)
and its current content hash differs from that origin.
"""
return bool(origin_hash) and user_hash != origin_hash
def list_user_modified_bundled_skills() -> List[dict]:
"""Return the bundled skills that ``hermes update`` keeps because the user
edited them locally.
A skill counts as user-modified when its on-disk copy no longer matches the
origin hash recorded in the manifest the last time it was synced — the exact
same test the sync loop uses to decide what to skip. This is the discovery
half of that behavior, so a user can find the names the ``~ N user-modified
(kept)`` notice only counts.
Returns a list (sorted by name) of dicts:
``{"name": str, "dest": Path, "bundled_src": Path}``
where ``dest`` is the user's copy and ``bundled_src`` is the current stock
copy (so callers can diff or restore).
"""
manifest = _read_manifest()
if not manifest:
return []
bundled_dir = _get_bundled_dir()
modified: List[dict] = []
for skill_name, skill_dir in _discover_bundled_skills(bundled_dir):
origin_hash = manifest.get(skill_name, "")
# No entry, or a v1 entry not yet baselined (empty hash): not a tracked
# modification — the next sync handles it.
if not origin_hash:
continue
dest = _compute_relative_dest(skill_dir, bundled_dir)
if not dest.exists():
continue
if _is_tracked_user_modification(origin_hash, _dir_hash(dest)):
modified.append(
{"name": skill_name, "dest": dest, "bundled_src": skill_dir}
)
modified.sort(key=lambda e: e["name"])
return modified
def _read_for_diff(path: Path) -> Tuple[Optional[bytes], Optional[str]]:
"""Read a file once for diffing.
Returns ``(raw_bytes, text)`` where ``text`` is ``None`` if the file is
binary; ``(None, None)`` if it could not be read. Returning the raw bytes
lets the caller compare binary files without re-reading them.
"""
try:
data = path.read_bytes()
except OSError:
return None, None
if b"\x00" in data:
return data, None
try:
return data, data.decode("utf-8")
except UnicodeDecodeError:
return data, None
def diff_bundled_skill(name: str) -> dict:
"""Diff a user's copy of a bundled skill against the current stock version.
Lets a user see exactly what diverged before deciding whether to keep their
edits or ``hermes skills reset`` back to upstream.
Returns a dict:
``ok`` (bool), ``name`` (str), ``found`` (bool — bundled source exists),
``modified`` (bool), ``message`` (str),
``diffs``: list of ``{"path": str, "status": str, "diff": str}`` where
status is one of ``modified`` / ``added`` (only in user copy) /
``removed`` (only in bundled) / ``binary``.
"""
import difflib
bundled_dir = _get_bundled_dir()
bundled_by_name = dict(_discover_bundled_skills(bundled_dir))
bundled_src = bundled_by_name.get(name)
if bundled_src is None:
return {
"ok": False,
"name": name,
"found": False,
"modified": False,
"diffs": [],
"message": (
f"'{name}' is not a tracked bundled skill (no stock version to "
f"diff against). Hub-installed skills use `hermes skills inspect`."
),
}
dest = _compute_relative_dest(bundled_src, bundled_dir)
if not dest.exists():
return {
"ok": False,
"name": name,
"found": True,
"modified": False,
"diffs": [],
"message": f"No local copy of '{name}' found at {dest}.",
}
user_files = set(_skill_file_list(dest))
stock_files = set(_skill_file_list(bundled_src))
diffs: List[dict] = []
for rel in sorted(user_files | stock_files):
in_user = rel in user_files
in_stock = rel in stock_files
user_bytes, user_text = (
_read_for_diff(dest / rel) if in_user else (None, None)
)
stock_bytes, stock_text = (
_read_for_diff(bundled_src / rel) if in_stock else (None, None)
)
if in_user and in_stock:
if user_text is None or stock_text is None:
# At least one side is binary — report only if bytes differ
# (reuse the bytes already read above, no second read).
if user_bytes != stock_bytes:
diffs.append(
{"path": rel, "status": "binary", "diff": "<binary file differs>"}
)
continue
if user_text == stock_text:
continue
text = "".join(
difflib.unified_diff(
stock_text.splitlines(keepends=True),
user_text.splitlines(keepends=True),
fromfile=f"stock/{rel}",
tofile=f"yours/{rel}",
)
)
diffs.append({"path": rel, "status": "modified", "diff": text})
elif in_user:
diffs.append(
{"path": rel, "status": "added", "diff": f"+ only in your copy: {rel}"}
)
else:
diffs.append(
{"path": rel, "status": "removed", "diff": f"- only in stock: {rel}"}
)
modified = bool(diffs)
return {
"ok": True,
"name": name,
"found": True,
"modified": modified,
"diffs": diffs,
"message": (
f"'{name}' matches the stock version."
if not modified
else f"'{name}' differs from the stock version in {len(diffs)} file(s)."
),
}
def set_bundled_skills_opt_out(enabled: bool) -> dict:
"""Toggle the .no-bundled-skills opt-out marker for the active profile.
When ``enabled`` is True, writes HERMES_HOME/.no-bundled-skills so the
installer, ``hermes update``, and any direct sync stop seeding bundled
skills. When False, removes the marker so seeding resumes on the next
sync. This is the on-disk-state half of ``hermes skills opt-out`` /
``opt-in``; removal of already-present skills is a separate, explicit
step (see ``remove_pristine_bundled_skills``).
Returns:
dict with keys: ok (bool), changed (bool), marker (str path),
message (str).
"""
marker = HERMES_HOME / NO_BUNDLED_SKILLS_MARKER
existed = marker.exists()
try:
if enabled:
HERMES_HOME.mkdir(parents=True, exist_ok=True)
marker.write_text(
"This profile opted out of bundled-skill seeding "
"(`hermes skills opt-out`).\n"
"Delete this file to re-enable sync on the next `hermes update`.\n",
encoding="utf-8",
)
changed = not existed
message = (
"Opted out of bundled skills. Future install / update / sync "
"runs will not seed bundled skills into this profile."
if changed
else "Already opted out — marker was already present."
)
else:
if existed:
marker.unlink()
changed = existed
message = (
"Opted back in. The next `hermes update` (or `hermes skills "
"opt-in --sync`) will re-seed bundled skills."
if changed
else "Not opted out — no marker to remove."
)
except OSError as e:
return {
"ok": False, "changed": False, "marker": str(marker),
"message": f"Could not update opt-out marker at {marker}: {e}",
}
return {"ok": True, "changed": changed, "marker": str(marker), "message": message}
def is_bundled_skills_opt_out() -> bool:
"""Return True if the active profile carries the opt-out marker."""
return (HERMES_HOME / NO_BUNDLED_SKILLS_MARKER).exists()
def remove_pristine_bundled_skills(dry_run: bool = False) -> dict:
"""Delete bundled skills that are present, manifest-tracked, AND unmodified.
Safety is the whole point of this function. A skill on disk is removed
ONLY when all of these hold:
- it is recorded in the sync manifest (so it is genuinely a bundled
skill, not a hub-installed or hand-written one), AND
- it still exists in the bundled source (so we can hash-compare), AND
- its on-disk copy is byte-identical to the manifest origin hash
(so the user has not edited it).
Anything user-modified, hub-installed, or locally authored is left
untouched and reported under ``skipped``. The manifest entry for each
removed skill is dropped so a later opt-in re-seed treats it as new.
Args:
dry_run: When True, compute what would be removed without deleting.
Returns:
dict with keys: ok (bool), removed (list[str]),
skipped (list[dict]) where each dict is
{name, reason}, dry_run (bool), message (str).
"""
manifest = _read_manifest()
bundled_dir = _get_bundled_dir()
bundled_by_name = dict(_discover_bundled_skills(bundled_dir))
removed: List[str] = []
skipped: List[dict] = []
for name, origin_hash in sorted(manifest.items()):
src = bundled_by_name.get(name)
if src is None:
# Tracked but no longer bundled upstream — leave it; not ours to judge.
skipped.append({"name": name, "reason": "no bundled source (removed upstream)"})
continue
dest = _compute_relative_dest(src, bundled_dir)
if not dest.exists():
# Already gone from disk; just forget the stale manifest entry.
if not dry_run and name in manifest:
del manifest[name]
continue
on_disk = _dir_hash(dest)
if on_disk != origin_hash:
skipped.append({"name": name, "reason": "user-modified (kept)"})
continue
# Pristine bundled copy — safe to remove.
if dry_run:
removed.append(name)
continue
try:
_rmtree_writable(dest)
except (OSError, IOError) as e:
skipped.append({"name": name, "reason": f"delete failed: {e}"})
continue
if name in manifest:
del manifest[name]
removed.append(name)
if not dry_run and removed:
_write_manifest(manifest)
verb = "Would remove" if dry_run else "Removed"
message = f"{verb} {len(removed)} pristine bundled skill(s); kept {len(skipped)}."
return {
"ok": True, "removed": removed, "skipped": skipped,
"dry_run": dry_run, "message": message,
}
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"]:
names = result["user_modified"]
MAX_SHOW = 5
shown = ", ".join(names[:MAX_SHOW])
if len(names) > MAX_SHOW:
shown += f", +{len(names) - MAX_SHOW} more"
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.")