From 085fc5d001adfd33b0f2a0813fddaeb876a98e00 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 17 Jun 2026 17:41:23 +0700 Subject: [PATCH] feat(skills): find & diff user-modified bundled skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 [--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 ` — 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. --- hermes_cli/main.py | 4 + hermes_cli/skills_hub.py | 84 ++++++++++++++++- hermes_cli/subcommands/skills.py | 29 ++++++ tools/skills_sync.py | 156 ++++++++++++++++++++++++++++++- 4 files changed, 271 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0394ef90a2e..376de5bd543 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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"): diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index f1e4f83b2c7..d9ab6366719 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -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 [/]") + c.print("[dim]Resume updates: hermes skills reset (keep your copy, re-baseline)[/]") + c.print("[dim]Revert to stock: hermes skills reset --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 --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 \n") + return + do_diff(args[0], console=c) + elif action == "publish": if not args: c.print("[bold red]Usage:[/] /skills publish [--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[/] Remove a hub-installed skill\n" + " [cyan]list-modified[/] List bundled skills you've edited (kept by update)\n" + " [cyan]diff[/] Diff your copy of a bundled skill vs the stock version\n" " [cyan]reset[/] [--restore] Reset bundled-skill tracking (fix 'user-modified' flag)\n" " [cyan]publish[/] --repo Publish a skill to GitHub via PR\n" " [cyan]snapshot[/] export|import Export/import skill configurations\n" diff --git a/hermes_cli/subcommands/skills.py b/hermes_cli/subcommands/skills.py index 03aa41024cb..589f9842f55 100644 --- a/hermes_cli/subcommands/skills.py +++ b/hermes_cli/subcommands/skills.py @@ -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 ` to see changes and `hermes skills reset " + "` 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", diff --git a/tools/skills_sync.py b/tools/skills_sync.py index d9d6d9d5076..548f0dbb1db 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -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": ""} + ) + 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.