mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
a55a133387
commit
e5cde568b7
5 changed files with 351 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue