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:
Teknium 2026-04-17 00:41:31 -07:00 committed by GitHub
parent a55a133387
commit e5cde568b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 351 additions and 1 deletions

View file

@ -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")

View file

@ -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",

View file

@ -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()

View file

@ -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)

View file

@ -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 <name>` 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
```