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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user