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

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