feat(skills): find & diff user-modified bundled skills

`hermes update` keeps (won't overwrite) bundled skills the user edited
locally, but only printed a count — "~ N user-modified (kept)" — with no way
to learn which skills, or see what changed. Reverting already existed
(`hermes skills reset <name> [--restore]`); discovery and inspection did not.

Add two CLI commands (zero model-tool footprint), reusing the manifest
origin-hash that sync already maintains:

- `hermes skills list-modified [--json]` — list the bundled skills whose
  on-disk copy diverges from the last-synced origin hash (the exact test the
  sync loop uses to decide what to skip).
- `hermes skills diff <name>` — unified diff between the user's copy and the
  current bundled (stock) version, so the user can confirm what changed
  before reverting.

Both are mirrored as `/skills list-modified` and `/skills diff`. The
`hermes update` notice now points at `hermes skills list-modified`. Core
helpers `list_user_modified_bundled_skills()` and `diff_bundled_skill()` live
in tools/skills_sync.py alongside the existing reset logic.
This commit is contained in:
xxxigm 2026-06-17 17:41:23 +07:00 • committed by kshitijk4poor
parent edcde6b26f
commit 085fc5d001
4 changed files with 271 additions and 2 deletions

View file

@ -9061,6 +9061,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
if result.get("user_modified"):
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
print(
" → see them: hermes skills list-modified "
"(diff/reset to resume updates)"
)
if result.get("cleaned"):
print(f" − {len(result['cleaned'])} removed from manifest")
if not result["copied"] and not result.get("updated"):

View file

@ -1149,6 +1149,73 @@ def do_reset(name: str, restore: bool = False,
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
def do_list_modified(console: Optional[Console] = None,
as_json: bool = False) -> None:
"""List bundled skills the user has edited (which `hermes update` keeps)."""
from tools.skills_sync import list_user_modified_bundled_skills
c = console or _console
modified = list_user_modified_bundled_skills()
if as_json:
import json
c.print(json.dumps([m["name"] for m in modified]))
return
if not modified:
c.print("[dim]No user-modified bundled skills — everything tracks upstream.[/]\n")
return
c.print(f"\n[bold]{len(modified)} user-modified bundled skill(s)[/] "
"[dim](kept as-is by `hermes update`):[/]")
for entry in modified:
c.print(f" [yellow]~[/] {entry['name']}")
c.print()
c.print("[dim]See changes: hermes skills diff <name>[/]")
c.print("[dim]Resume updates: hermes skills reset <name> (keep your copy, re-baseline)[/]")
c.print("[dim]Revert to stock: hermes skills reset <name> --restore[/]\n")
def do_diff(name: str, console: Optional[Console] = None) -> None:
"""Show how the user's copy of a bundled skill differs from the stock version."""
from tools.skills_sync import diff_bundled_skill
c = console or _console
result = diff_bundled_skill(name)
if not result["ok"]:
c.print(f"[bold red]Error:[/] {result['message']}\n")
return
if not result["modified"]:
c.print(f"[green]{result['message']}[/]\n")
return
c.print(f"\n[bold]{result['message']}[/]\n")
for entry in result["diffs"]:
status = entry["status"]
if status == "modified":
# Render the unified diff with light coloring.
for line in entry["diff"].splitlines():
if line.startswith("+") and not line.startswith("+++"):
c.print(f"[green]{line}[/]")
elif line.startswith("-") and not line.startswith("---"):
c.print(f"[red]{line}[/]")
elif line.startswith("@@"):
c.print(f"[cyan]{line}[/]")
else:
c.print(line, highlight=False)
elif status == "added":
c.print(f"[green]+ only in your copy:[/] {entry['path']}")
elif status == "removed":
c.print(f"[red]- only in stock:[/] {entry['path']}")
else: # binary
c.print(f"[yellow]~ {entry['path']}:[/] binary file differs")
c.print()
c.print(f"[dim]Revert with: hermes skills reset {name} --restore[/]\n")
def do_opt_out(remove: bool = False,
console: Optional[Console] = None,
skip_confirm: bool = False,
@ -1624,6 +1691,10 @@ def skills_command(args) -> None:
elif action == "reset":
do_reset(args.name, restore=getattr(args, "restore", False),
skip_confirm=getattr(args, "yes", False))
elif action == "list-modified":
do_list_modified(as_json=getattr(args, "json", False))
elif action == "diff":
do_diff(args.name)
elif action == "opt-out":
do_opt_out(remove=getattr(args, "remove", False),
skip_confirm=getattr(args, "yes", False))
@ -1654,7 +1725,7 @@ def skills_command(args) -> None:
return
do_tap(tap_action, repo=repo)
else:
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Usage: hermes skills [browse|search|install|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Run 'hermes skills <command> --help' for details.\n")
@ -1826,6 +1897,15 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
do_reset(name, restore=restore, console=c, skip_confirm=True,
invalidate_cache=invalidate_cache)
elif action in {"list-modified", "modified"}:
do_list_modified(console=c, as_json="--json" in args)
elif action == "diff":
if not args:
c.print("[bold red]Usage:[/] /skills diff <name>\n")
return
do_diff(args[0], console=c)
elif action == "publish":
if not args:
c.print("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n")
@ -1883,6 +1963,8 @@ def _print_skills_help(console: Console) -> None:
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
" [cyan]uninstall[/] <name> Remove a hub-installed skill\n"
" [cyan]list-modified[/] List bundled skills you've edited (kept by update)\n"
" [cyan]diff[/] <name> Diff your copy of a bundled skill vs the stock version\n"
" [cyan]reset[/] <name> [--restore] Reset bundled-skill tracking (fix 'user-modified' flag)\n"
" [cyan]publish[/] <path> --repo <r> Publish a skill to GitHub via PR\n"
" [cyan]snapshot[/] export|import Export/import skill configurations\n"

View file

@ -164,6 +164,35 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
help="Skip confirmation prompt when using --restore",
)
skills_list_modified = skills_subparsers.add_parser(
"list-modified",
help="List bundled skills you've edited (which `hermes update` keeps)",
description=(
"Show the bundled skills whose local copy differs from the version last "
"synced, i.e. the ones `hermes update` reports as user-modified and skips. "
"Use `hermes skills diff <name>` to see changes and `hermes skills reset "
"<name>` to resume updates."
),
)
skills_list_modified.add_argument(
"--json",
action="store_true",
help="Output the list as JSON",
)
skills_diff = skills_subparsers.add_parser(
"diff",
help="Show how your copy of a bundled skill differs from the stock version",
description=(
"Print a unified diff between your local copy of a bundled skill and the "
"current bundled (stock) version, so you can confirm what changed before "
"running `hermes skills reset`."
),
)
skills_diff.add_argument(
"name", help="Skill name to diff (e.g. google-workspace)"
)
skills_opt_out = skills_subparsers.add_parser(
"opt-out",
help="Stop bundled skills from being seeded into this profile",

View file

@ -30,7 +30,7 @@ from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
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, Tuple
from typing import Dict, List, Optional, Tuple
from utils import atomic_replace
logger = logging.getLogger(__name__)
@ -785,6 +785,160 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
return {"ok": True, "action": action, "message": message, "synced": synced}
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 _dir_hash(dest) != origin_hash:
modified.append(
{"name": skill_name, "dest": dest, "bundled_src": skill_dir}
)
modified.sort(key=lambda e: e["name"])
return modified
def _read_text_for_diff(path: Path) -> Optional[str]:
"""Return file text for diffing, or ``None`` if the file is binary/unreadable."""
try:
data = path.read_bytes()
except OSError:
return None
if b"\x00" in data:
return None
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return 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),
``user_present`` (bool), ``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,
"user_present": 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,
"user_present": False,
"modified": False,
"diffs": [],
"message": f"No local copy of '{name}' found at {dest}.",
}
user_files = {
p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()
}
stock_files = {
p.relative_to(bundled_src).as_posix()
for p in bundled_src.rglob("*")
if p.is_file()
}
diffs: List[dict] = []
for rel in sorted(user_files | stock_files):
in_user = rel in user_files
in_stock = rel in stock_files
user_text = _read_text_for_diff(dest / rel) if in_user else None
stock_text = _read_text_for_diff(bundled_src / rel) if in_stock else 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.
if (dest / rel).read_bytes() != (bundled_src / rel).read_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,
"user_present": 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.