Files
hermes-agent/tools/skills_sync_bundled_ops.py
Tekniumandnekwo 93fafc4d6f feat(plugins): public names for the skills tool's discovery and lookup helpers
Plugins that show the agent's skills (catalog views, "open this skill", drift checks against the
installed copy) need the same walk, order, search roots and lookup gate the skills_list / skill_view
tools use, plus the package hash the bundled-skill sync stores. All seven were private.

Rename, with every internal caller moved and no underscore aliases (same shape as the platform
helpers in e6cf2a9):
- tools/skills_tool.py: find_all_skills, sort_skills, skill_search_dirs, locate_skill,
  skill_lookup_path_error
- tools/skills_sync.py: dir_hash
- tools/skill_usage.py: read_skill_name

The names join _PUBLIC in the private-import lint and are documented under "Read the skill catalog"
in the plugin developer guide.

Builds on #129698 by @nekwo, which proposed the same names with legacy aliases kept.

Closes #129696

Co-authored-by: nekwo <beastant1@gmail.com>
2026-10-18 00:47:38 -07:00

261 lines
14 KiB
Python

"""Bundled-skill maintenance ops: reset, diff, list-modified, opt-out, remove-pristine.
Profile-scoped paths and patchable helpers resolve through ``_ss()`` at call time."""
import os
import shutil
from pathlib import Path
from typing import List, Optional, Tuple
from tools.skills_sync_optional import _ignore_runtime_cache, _skill_file_list, _ss
def _bundled_state():
"""``(skills_sync, manifest, bundled_dir, {skill_name: bundled_src})`` — shared op preamble."""
ss = _ss()
bundled_dir = ss._get_bundled_dir()
return ss, ss._read_manifest(), bundled_dir, dict(ss._discover_bundled_skills(bundled_dir))
def reset_bundled_skill(name: str, restore: bool = False) -> dict:
"""Reset a bundled skill's manifest tracking so future syncs work normally. An edited bundled
skill stays ``user_modified`` forever because the manifest holds the OLD origin hash; clearing
the entry breaks that loop. ``restore`` also deletes the user's copy so the next sync re-copies
bundled. Returns ``{ok, action, message, synced}``; action is manifest_cleared / restored /
not_in_manifest / bundled_missing / not_reset."""
ss, manifest, bundled_dir, bundled_by_name = _bundled_state()
in_manifest = name in manifest
is_bundled = name in bundled_by_name
def _fail(action: str, message: str) -> dict:
return {"ok": False, "action": action, "message": message, "synced": None}
if not in_manifest and not is_bundled:
return _fail("not_in_manifest", f"'{name}' is not a tracked bundled skill. Nothing to reset. "
f"(Hub-installed skills use `hermes skills uninstall`.)")
# 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: # delete the user's copy BEFORE the manifest so a failed rmtree can't strand it
if not is_bundled:
return _fail("bundled_missing", f"'{name}' has no bundled source — manifest entry preserved "
f"but cannot restore from bundled (skill was removed upstream).")
dest = ss._compute_relative_dest(bundled_by_name[name], bundled_dir)
if deleted_user_copy := dest.exists():
try:
ss._rmtree_writable(dest)
except OSError as e:
return _fail("not_reset", f"Could not delete user copy at {dest}: {e}. "
f"Manifest entry preserved — nothing was changed.")
if in_manifest:
del manifest[name]
ss._write_manifest(manifest)
synced = ss.sync_skills(quiet=True)
if not restore:
action, message = "manifest_cleared", (f"Cleared manifest entry for '{name}'. Future `hermes update` runs "
f"will re-baseline against your current copy and accept upstream changes.")
else:
action = "restored"
message = (f"Restored '{name}' from bundled source." if deleted_user_copy
else f"Restored '{name}' (no prior user copy, re-copied from bundled).")
return {"ok": True, "action": action, "message": message, "synced": synced}
_OWN_REPO_PREFIXES = ("nousresearch/hermes-agent/skills/", "skills-sh/nousresearch/hermes-agent/")
def bundled_skill_for_install(identifier: str) -> Optional[str]:
"""Bundled skill name an install identifier means, else None: a bare bundled name, or this
repo's own copy of one (``NousResearch/hermes-agent/skills/<cat>/<name>``, which the Skills Hub
site hands out for built-ins, or its ``skills-sh/nousresearch/hermes-agent/<name>`` mirror).
Fetched from GitHub, that copy is scanned as a community skill and refused — every time."""
ident = identifier.strip().strip("/")
lowered = ident.lower()
if "/" in ident and not lowered.startswith(_OWN_REPO_PREFIXES):
return None
name = ident.rsplit("/", 1)[-1]
return name if name in _bundled_state()[3] else None
def ensure_bundled_skill(name: str) -> dict:
"""Make bundled ``name`` active: ``{ok, action, path, message}``, action ``present`` (any active
copy, user edits included, is left alone), ``restored`` (copied from the bundled source and
re-tracked, so an opt-out, curator prune or manual delete no longer hides it), or
``hub_shadowed`` (a hub install owns the name; it is the user's to remove, never overwritten).
A restore into a leftover dir only adds the missing files (a user file is never overwritten) and
the manifest records the bundled hash, so a dest that kept user files reads as user-modified and
``hermes update`` leaves it alone: intended, the user's data wins."""
from tools import skill_usage
ss, manifest, bundled_dir, bundled_by_name = _bundled_state()
src = bundled_by_name[name]
dest = ss._compute_relative_dest(src, bundled_dir)
if skill_usage.is_hub_installed(name):
return {"ok": True, "action": "hub_shadowed", "path": None, "message": (
f"'{name}' is a built-in skill, but a skill installed from the hub uses the same name and "
f"shadows it. To get the built-in back: `hermes skills uninstall {name}`, then "
f"`hermes skills install {name}`.")}
# The rglob walk does not descend a symlinked category the loader follows, so the canonical
# SKILL.md counts as present too.
skill_md = dest / "SKILL.md"
active = next((md.parent for md in ss._iter_active_skill_mds()
if ss.read_skill_name(md, md.parent.name) == name), None) or (
dest if skill_md.is_file() and ss.read_skill_name(skill_md, name) == name else None)
if active is not None or name in ss._build_external_skill_index():
return {"ok": True, "action": "present", "path": active, "message": ""}
try: # the hub's containment check: a symlinked category must not carry the copy out of skills/
from pm.filesystem import is_junction
from tools.skills_hub_install import _resolve_lock_install_path
rel = dest.relative_to(ss._skills_dir()).as_posix()
_resolve_lock_install_path(rel, dest.name)
# A dir with no SKILL.md (a delete or earlier copy that died partway) is no skill; filling in
# its missing files beats failing with EEXIST on every retry. A link inside it would carry the
# copy out of skills/, and a SKILL.md there is another skill: refuse both.
if os.path.lexists(skill_md):
raise ValueError("the directory holds a different skill's SKILL.md")
if dest.exists() and any(p.is_symlink() or is_junction(p) for p in dest.rglob("*")):
raise ValueError("the leftover directory contains a symlink or junction")
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dest, ignore=_ignore_runtime_cache, dirs_exist_ok=True,
copy_function=lambda s, d: d if os.path.lexists(d) else shutil.copy2(s, d))
except (OSError, ValueError) as e:
return {"ok": False, "action": "not_restored", "path": dest,
"message": f"Could not copy the built-in skill '{name}' to {dest}: {e}"}
manifest[name] = ss.dir_hash(src)
ss._write_manifest(manifest)
skill_usage._toggle_suppressed_name(name, add=False) # lift a curator prune the way restore_skill does
skill_usage.set_state(name, skill_usage.STATE_ACTIVE)
return {"ok": True, "action": "restored", "path": dest, "message": ""}
def list_user_modified_bundled_skills() -> list[dict]:
"""Bundled skills ``hermes update`` keeps because the user edited them (same test the sync
loop uses). Name-sorted ``{"name", "dest", "bundled_src"}`` dicts."""
ss = _ss()
if not (manifest := ss._read_manifest()):
return []
bundled_dir = ss._get_bundled_dir()
modified: list[dict] = []
for skill_name, skill_dir in ss._discover_bundled_skills(bundled_dir):
origin_hash = manifest.get(skill_name, "") # empty = untracked/un-baselined v1: next sync handles it
dest = ss._compute_relative_dest(skill_dir, bundled_dir)
if origin_hash and dest.exists() and not ss._matches_origin_hash(dest, origin_hash):
modified.append({"name": skill_name, "dest": dest, "bundled_src": skill_dir})
return sorted(modified, key=lambda e: e["name"])
def _read_for_diff(path: Path) -> tuple[Optional[bytes], Optional[str]]:
"""``(raw_bytes, text)`` for diffing; ``text=None`` for binary, ``(None, None)`` if unreadable."""
try:
data = path.read_bytes()
except OSError:
return None, None
try:
return data, (None if b"\x00" in data else 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 stock. Returns ``{ok, name, found, modified,
message, diffs}``; each diff is ``{"path", "status", "diff"}`` with status modified / added
(only in user copy) / removed (only in bundled) / binary."""
import difflib
ss, _, bundled_dir, bundled_by_name = _bundled_state()
def _fail(found: bool, message: str) -> dict:
return {"ok": False, "name": name, "found": found, "modified": False, "diffs": [], "message": message}
if (bundled_src := bundled_by_name.get(name)) is None:
return _fail(False, f"'{name}' is not a tracked bundled skill (no stock version to "
f"diff against). Hub-installed skills use `hermes skills inspect`.")
dest = ss._compute_relative_dest(bundled_src, bundled_dir)
if not dest.exists():
return _fail(True, 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):
if rel not in stock_files:
diffs.append({"path": rel, "status": "added", "diff": f"+ only in your copy: {rel}"})
continue
if rel not in user_files:
diffs.append({"path": rel, "status": "removed", "diff": f"- only in stock: {rel}"})
continue
user_bytes, user_text = _read_for_diff(dest / rel)
stock_bytes, stock_text = _read_for_diff(bundled_src / rel)
if user_text is None or stock_text is None: # a binary side: report only if bytes differ
if user_bytes != stock_bytes:
diffs.append({"path": rel, "status": "binary", "diff": "<binary file differs>"})
elif user_text != stock_text:
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})
message = (f"'{name}' differs from the stock version in {len(diffs)} file(s)." if diffs
else f"'{name}' matches the stock version.")
return {"ok": True, "name": name, "found": True, "modified": bool(diffs), "diffs": diffs, "message": message}
_OPT_OUT_MESSAGES = { # (enabled, changed) -> message
(True, True): "Opted out of bundled skills. Future install / update / sync runs will not seed bundled skills into this profile.",
(True, False): "Already opted out — marker was already present.",
(False, True): "Opted back in. The next `hermes update` (or `hermes skills opt-in --sync`) will re-seed bundled skills.",
(False, False): "Not opted out — no marker to remove."}
def set_bundled_skills_opt_out(enabled: bool) -> dict:
"""Toggle the .no-bundled-skills marker (on-disk half of ``hermes skills opt-out`` / ``opt-in``;
removing present skills is ``remove_pristine_bundled_skills``)."""
ss = _ss()
marker = ss._hermes_home() / ss.NO_BUNDLED_SKILLS_MARKER
existed = marker.exists()
try:
if enabled:
ss._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")
elif existed:
marker.unlink()
except OSError as e:
return {"ok": False, "changed": False, "marker": str(marker),
"message": f"Could not update opt-out marker at {marker}: {e}"}
changed = enabled != existed
return {"ok": True, "changed": changed, "marker": str(marker), "message": _OPT_OUT_MESSAGES[(enabled, changed)]}
def remove_pristine_bundled_skills(dry_run: bool = False) -> dict:
"""Delete bundled skills that are manifest-tracked, still in the bundled source AND
byte-identical to the origin hash; everything else lands in ``skipped``. Removed skills lose
their manifest entry so a later opt-in re-seed treats them as new.
Returns ``{ok, removed, skipped: [{name, reason}], dry_run, message}``."""
ss, manifest, bundled_dir, bundled_by_name = _bundled_state()
removed: list[str] = []
skipped: list[dict] = []
for name, origin_hash in sorted(manifest.items()):
src = bundled_by_name.get(name)
if src is None:
skipped.append({"name": name, "reason": "no bundled source (removed upstream)"})
continue
dest = ss._compute_relative_dest(src, bundled_dir)
if not dest.exists():
if not dry_run: # already gone from disk; forget the stale manifest entry
manifest.pop(name, None)
continue
if not ss._matches_origin_hash(dest, origin_hash):
skipped.append({"name": name, "reason": "user-modified (kept)"})
continue
if not dry_run:
try:
ss._rmtree_writable(dest)
except OSError as e:
skipped.append({"name": name, "reason": f"delete failed: {e}"})
continue
manifest.pop(name, None)
removed.append(name)
if not dry_run and removed:
ss._write_manifest(manifest)
verb = "Would remove" if dry_run else "Removed"
return {"ok": True, "removed": removed, "skipped": skipped, "dry_run": dry_run,
"message": f"{verb} {len(removed)} pristine bundled skill(s); kept {len(skipped)}."}