feat(skills): add 'hermes skills reset' to un-stick bundled skills (#11468)
When a user edits a bundled skill, sync flags it as user_modified and
skips it forever. The problem: if the user later tries to undo the edit
by copying the current bundled version back into ~/.hermes/skills/, the
manifest still holds the old origin hash from the last successful
sync, so the fresh bundled hash still doesn't match and the skill stays
stuck as user_modified.
Adds an escape hatch for this case.
hermes skills reset <name>
Drops the skill's entry from ~/.hermes/skills/.bundled_manifest and
re-baselines against the user's current copy. Future 'hermes update'
runs accept upstream changes again. Non-destructive.
hermes skills reset <name> --restore
Also deletes the user's copy and re-copies the bundled version.
Use when you want the pristine upstream skill back.
Also available as /skills reset in chat.
- tools/skills_sync.py: new reset_bundled_skill(name, restore=False)
- hermes_cli/skills_hub.py: do_reset() + wired into skills_command and
handle_skills_slash; added to the slash /skills help panel
- hermes_cli/main.py: argparse entry for 'hermes skills reset'
- tests/tools/test_skills_sync.py: 5 new tests covering the stuck-flag
repro, --restore, unknown-skill error, upstream-removed-skill, and
no-op on already-clean state
- website/docs/user-guide/features/skills.md: new 'Bundled skill updates'
section explaining the origin-hash mechanic + reset usage
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 <command> --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 <name> [--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 <skill-path> [--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[/] <name> Remove a hub-installed skill\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"
|
||||
" [cyan]tap[/] list|add|remove Manage skill sources\n",
|
||||
|
||||
Reference in New Issue
Block a user