diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9cbaf90e19..e522c07169 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5600,6 +5600,25 @@ Examples: skills_uninstall = skills_subparsers.add_parser("uninstall", help="Remove a hub-installed skill") skills_uninstall.add_argument("name", help="Skill name to remove") + skills_reset = skills_subparsers.add_parser( + "reset", + help="Reset a bundled skill — clears 'user-modified' tracking so updates work again", + description=( + "Clear a bundled skill's entry from the sync manifest (~/.hermes/skills/.bundled_manifest) " + "so future 'hermes update' runs stop marking it as user-modified. Pass --restore to also " + "replace the current copy with the bundled version." + ), + ) + skills_reset.add_argument("name", help="Skill name to reset (e.g. google-workspace)") + skills_reset.add_argument( + "--restore", action="store_true", + help="Also delete the current copy and re-copy the bundled version", + ) + skills_reset.add_argument( + "--yes", "-y", action="store_true", + help="Skip confirmation prompt when using --restore", + ) + skills_publish = skills_subparsers.add_parser("publish", help="Publish a skill to a registry") skills_publish.add_argument("skill_path", help="Path to skill directory") skills_publish.add_argument("--to", default="github", choices=["github", "clawhub"], help="Target registry") diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index ed922805b7..c27ca25eaa 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -684,6 +684,51 @@ def do_uninstall(name: str, console: Optional[Console] = None, c.print(f"[bold red]Error:[/] {msg}\n") +def do_reset(name: str, restore: bool = False, + console: Optional[Console] = None, + skip_confirm: bool = False, + invalidate_cache: bool = True) -> None: + """Reset a bundled skill's manifest tracking (+ optionally restore from bundled).""" + from tools.skills_sync import reset_bundled_skill + + c = console or _console + + if not skip_confirm and restore: + c.print(f"\n[bold]Restore '{name}' from bundled source?[/]") + c.print("[dim]This will DELETE your current copy and re-copy the bundled version.[/]") + try: + answer = input("Confirm [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + if answer not in ("y", "yes"): + c.print("[dim]Cancelled.[/]\n") + return + + result = reset_bundled_skill(name, restore=restore) + + if not result["ok"]: + c.print(f"[bold red]Error:[/] {result['message']}\n") + return + + c.print(f"[bold green]{result['message']}[/]") + synced = result.get("synced") or {} + if synced.get("copied"): + c.print(f"[dim]Copied: {', '.join(synced['copied'])}[/]") + if synced.get("updated"): + c.print(f"[dim]Updated: {', '.join(synced['updated'])}[/]") + c.print() + + if invalidate_cache: + try: + from agent.prompt_builder import clear_skills_system_prompt_cache + clear_skills_system_prompt_cache(clear_snapshot=True) + except Exception: + pass + else: + c.print("[dim]Change will take effect in your next session.[/]") + c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n") + + def do_tap(action: str, repo: str = "", console: Optional[Console] = None) -> None: """Manage taps (custom GitHub repo sources).""" from tools.skills_hub import TapsManager @@ -1007,6 +1052,9 @@ def skills_command(args) -> None: do_audit(name=getattr(args, "name", None)) elif action == "uninstall": do_uninstall(args.name) + elif action == "reset": + do_reset(args.name, restore=getattr(args, "restore", False), + skip_confirm=getattr(args, "yes", False)) elif action == "publish": do_publish( args.skill_path, @@ -1029,7 +1077,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|publish|snapshot|tap]\n") + _console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|publish|snapshot|tap]\n") _console.print("Run 'hermes skills --help' for details.\n") @@ -1175,6 +1223,19 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: do_uninstall(args[0], console=c, skip_confirm=skip_confirm, invalidate_cache=invalidate_cache) + elif action == "reset": + if not args: + c.print("[bold red]Usage:[/] /skills reset [--restore] [--now]\n") + c.print("[dim]Clears the bundled-skills manifest entry so future updates stop marking it as user-modified.[/]") + c.print("[dim]Pass --restore to also replace the current copy with the bundled version.[/]\n") + return + name = args[0] + restore = "--restore" in args + invalidate_cache = "--now" in args + # Slash commands can't prompt — --restore in slash mode is implicit consent. + do_reset(name, restore=restore, console=c, skip_confirm=True, + invalidate_cache=invalidate_cache) + elif action == "publish": if not args: c.print("[bold red]Usage:[/] /skills publish [--to github] [--repo owner/repo]\n") @@ -1231,6 +1292,7 @@ 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]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" " [cyan]tap[/] list|add|remove Manage skill sources\n", diff --git a/tests/tools/test_skills_sync.py b/tests/tools/test_skills_sync.py index 5d6ce1d544..683f6503b0 100644 --- a/tests/tools/test_skills_sync.py +++ b/tests/tools/test_skills_sync.py @@ -12,6 +12,7 @@ from tools.skills_sync import ( _compute_relative_dest, _dir_hash, sync_skills, + reset_bundled_skill, MANIFEST_FILE, SKILLS_DIR, ) @@ -521,3 +522,133 @@ class TestGetBundledDir: monkeypatch.setenv("HERMES_BUNDLED_SKILLS", "") result = _get_bundled_dir() assert result.name == "skills" + + +class TestResetBundledSkill: + """Covers reset_bundled_skill() — the escape hatch for the 'user-modified' trap.""" + + def _setup_bundled(self, tmp_path): + """Create a minimal bundled skills tree with a single 'google-workspace' skill.""" + bundled = tmp_path / "bundled_skills" + (bundled / "productivity" / "google-workspace").mkdir(parents=True) + (bundled / "productivity" / "google-workspace" / "SKILL.md").write_text( + "---\nname: google-workspace\n---\n# GW v2 (upstream)\n" + ) + return bundled + + def _patches(self, bundled, skills_dir, manifest_file): + from contextlib import ExitStack + stack = ExitStack() + stack.enter_context(patch("tools.skills_sync._get_bundled_dir", return_value=bundled)) + stack.enter_context(patch("tools.skills_sync.SKILLS_DIR", skills_dir)) + stack.enter_context(patch("tools.skills_sync.MANIFEST_FILE", manifest_file)) + return stack + + def test_reset_clears_stuck_user_modified_flag(self, tmp_path): + """The core bug repro: copy-pasted bundled restore doesn't un-stick the flag; reset does.""" + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + + # Simulate the stuck state: user edited the skill on an older bundled version, + # so manifest has an old origin hash that no longer matches anything on disk. + dest = skills_dir / "productivity" / "google-workspace" + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("---\nname: google-workspace\n---\n# GW v2 (upstream)\n") + # Stale origin_hash — from some prior bundled version. User "restored" by pasting + # the current bundled contents, so user_hash == current bundled_hash, but manifest + # still points at the stale hash → treated as user_modified forever. + manifest_file.write_text("google-workspace:STALEHASH000000000000000000000000\n") + + with self._patches(bundled, skills_dir, manifest_file): + # Sanity check: without reset, sync would flag it user_modified + pre = sync_skills(quiet=True) + assert "google-workspace" in pre["user_modified"] + + # Reset (no --restore) should clear the manifest entry and re-baseline + result = reset_bundled_skill("google-workspace", restore=False) + + assert result["ok"] is True + assert result["action"] == "manifest_cleared" + + # After reset, the manifest should hold the *current* bundled hash + manifest_after = _read_manifest() + expected = _dir_hash(bundled / "productivity" / "google-workspace") + assert manifest_after["google-workspace"] == expected + # User's copy was preserved (we didn't delete) + assert dest.exists() + assert "GW v2" in (dest / "SKILL.md").read_text() + + def test_reset_restore_replaces_user_copy(self, tmp_path): + """--restore nukes the user's copy and re-copies the bundled version.""" + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + + dest = skills_dir / "productivity" / "google-workspace" + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("# heavily edited by user\n") + (dest / "my_custom_file.py").write_text("print('user-added')\n") + manifest_file.write_text("google-workspace:STALEHASH000000000000000000000000\n") + + with self._patches(bundled, skills_dir, manifest_file): + result = reset_bundled_skill("google-workspace", restore=True) + + assert result["ok"] is True + assert result["action"] == "restored" + # User's custom file should be gone + assert not (dest / "my_custom_file.py").exists() + # SKILL.md should be the bundled content + assert "GW v2 (upstream)" in (dest / "SKILL.md").read_text() + + def test_reset_nonexistent_skill_errors_gracefully(self, tmp_path): + """Resetting a skill that's neither bundled nor in the manifest returns a clear error.""" + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + skills_dir.mkdir(parents=True) + manifest_file.write_text("") + + with self._patches(bundled, skills_dir, manifest_file): + result = reset_bundled_skill("some-hub-skill", restore=False) + + assert result["ok"] is False + assert result["action"] == "not_in_manifest" + assert "not a tracked bundled skill" in result["message"] + + def test_reset_restore_when_bundled_removed_upstream(self, tmp_path): + """If a skill was removed upstream, --restore should fail with a clear message.""" + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + dest = skills_dir / "productivity" / "ghost-skill" + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("---\nname: ghost-skill\n---\n# Ghost\n") + manifest_file.write_text("ghost-skill:OLDHASH00000000000000000000000000\n") + + with self._patches(bundled, skills_dir, manifest_file): + result = reset_bundled_skill("ghost-skill", restore=True) + + assert result["ok"] is False + assert result["action"] == "bundled_missing" + + def test_reset_no_op_when_already_clean(self, tmp_path): + """If manifest has skill but user copy is in-sync, reset still safely clears + re-baselines.""" + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + + # Simulate a clean state — do a fresh sync first + with self._patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=True) + pre_manifest = _read_manifest() + assert "google-workspace" in pre_manifest + + result = reset_bundled_skill("google-workspace", restore=False) + + assert result["ok"] is True + assert result["action"] == "manifest_cleared" + # Manifest entry still present (re-baselined), user copy still present + post_manifest = _read_manifest() + assert "google-workspace" in post_manifest + assert (skills_dir / "productivity" / "google-workspace" / "SKILL.md").exists() diff --git a/tools/skills_sync.py b/tools/skills_sync.py index 18ce1e3ff1..867566b6c1 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -301,6 +301,104 @@ def sync_skills(quiet: bool = False) -> dict: } +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 = {skill_name: skill_dir for skill_name, skill_dir in 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: drop the manifest entry so next sync treats it as new + if in_manifest: + del manifest[name] + _write_manifest(manifest) + + # Step 2 (optional): delete the user's copy so next sync re-copies bundled + 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 cleared " + f"but cannot restore from bundled (skill was removed upstream)." + ), + "synced": None, + } + # The destination mirrors the bundled path relative to bundled_dir. + dest = _compute_relative_dest(bundled_by_name[name], bundled_dir) + if dest.exists(): + try: + shutil.rmtree(dest) + deleted_user_copy = True + except (OSError, IOError) as e: + return { + "ok": False, + "action": "manifest_cleared", + "message": ( + f"Cleared manifest entry for '{name}' but could not " + f"delete user copy at {dest}: {e}" + ), + "synced": None, + } + + # 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} + + if __name__ == "__main__": print("Syncing bundled skills into ~/.hermes/skills/ ...") result = sync_skills(quiet=False) diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index c0f2d8d836..ff5a5c8ec2 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -278,6 +278,8 @@ hermes skills check # Check installed hub skills f hermes skills update # Reinstall hub skills with upstream changes when needed hermes skills audit # Re-scan all hub skills for security hermes skills uninstall k8s # Remove a hub skill +hermes skills reset google-workspace # Un-stick a bundled skill from "user-modified" (see below) +hermes skills reset google-workspace --restore # Also restore the bundled version, deleting your local edits hermes skills publish skills/my-skill --to github --repo owner/repo hermes skills snapshot export setup.json # Export skill config hermes skills tap add myorg/skills-repo # Add a custom GitHub source @@ -430,6 +432,43 @@ This uses the stored source identifier plus the current upstream bundle content Skills hub operations use the GitHub API, which has a rate limit of 60 requests/hour for unauthenticated users. If you see rate-limit errors during install or search, set `GITHUB_TOKEN` in your `.env` file to increase the limit to 5,000 requests/hour. The error message includes an actionable hint when this happens. ::: +## Bundled skill updates (`hermes skills reset`) + +Hermes ships with a set of bundled skills in `skills/` inside the repo. On install and on every `hermes update`, a sync pass copies those into `~/.hermes/skills/` and records a manifest at `~/.hermes/skills/.bundled_manifest` mapping each skill name to the content hash at the time it was synced (the **origin hash**). + +On each sync, Hermes recomputes the hash of your local copy and compares it to the origin hash: + +- **Unchanged** → safe to pull upstream changes, copy the new bundled version in, record the new origin hash. +- **Changed** → treated as **user-modified** and skipped forever, so your edits never get stomped. + +The protection is good, but it has one sharp edge. If you edit a bundled skill and then later want to abandon your changes and go back to the bundled version by just copy-pasting from `~/.hermes/hermes-agent/skills/`, the manifest still holds the *old* origin hash from whenever the last successful sync ran. Your fresh copy-paste contents (current bundled hash) won't match that stale origin hash, so sync keeps flagging it as user-modified. + +`hermes skills reset` is the escape hatch: + +```bash +# Safe: clears the manifest entry for this skill. Your current copy is preserved, +# but the next sync re-baselines against it so future updates work normally. +hermes skills reset google-workspace + +# Full restore: also deletes your local copy and re-copies the current bundled +# version. Use this when you want the pristine upstream skill back. +hermes skills reset google-workspace --restore + +# Non-interactive (e.g. in scripts or TUI mode) — skip the --restore confirmation. +hermes skills reset google-workspace --restore --yes +``` + +The same command works in chat as a slash command: + +```text +/skills reset google-workspace +/skills reset google-workspace --restore +``` + +:::note Profiles +Each profile has its own `.bundled_manifest` under its own `HERMES_HOME`, so `hermes -p coder skills reset ` only affects that profile. +::: + ### Slash commands (inside chat) All the same commands work with `/skills`: @@ -442,6 +481,7 @@ All the same commands work with `/skills`: /skills install openai/skills/skill-creator --force /skills check /skills update +/skills reset google-workspace /skills list ```