NousResearch/hermes-agent
Add skill archive, restore, prune, and stats CLI commands
Cluster 19384. Data as of
Nothing open on GitHub. Fix merged in #20200 on 5 May 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #20200. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #20200. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items introduce new CLI subcommands for managing skills, covering usage statistics, archiving, restoring, and pruning. The work spans both the skills layer and a curator-facing interface.
Open issues0
Open PRs0
Fixes on record
-
#20200 feat(curator): add archive and prune subcommands
files:
hermes_cli/curator.py,scripts/release.py,tests/hermes_cli/test_curator_archive_prune.py,tests/tools/test_skill_usage.py,tools/skill_usage.pyShow the diff
-
hermes_cli/curator.py@@ -245,6 +245,111 @@ def _cmd_restore(args) -> int: return 0 if ok else 1 +def _cmd_archive(args) -> int: + """Manually archive an agent-created skill. Refuses if pinned. + + The auto-curator archives stale skills on its own schedule; this verb is + for the user who wants to archive *now* without waiting for a run. + """ + from tools import skill_usage + if skill_usage.get_record(args.skill).get("pinned"): + print( + f"curator: '{args.skill}' is pinned — unpin first with " + f"`hermes curator unpin {args.skill}`" + ) + return 1 + ok, msg = skill_usage.archive_skill(args.skill) + print(f"curator: {msg}") + return 0 if ok else 1 + + +def _idle_days(record: dict) -> Optional[int]: + """Days since the skill's last activity (view / use / patch). + + Falls back to ``created_at`` so a skill that was authored but never used + can still be pruned — otherwise never-touched skills would be immortal. + Returns None only when both fields are missing or unparseable. + """ + ts = record.get("last_activity_at") or record.get("created_at") + if not ts: + return None + try: + dt = datetime.fromisoformat(str(ts)) + except (TypeError, ValueError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return max(0, (datetime.now(timezone.utc) - dt).days) + + +def _cmd_prune(args) -> int: + """Bulk-archive agent-created skills idle for >= N days. + + Pinned skills are exempt. Already-archived skills are skipped. Default + ``--days 90`` matches a conservative read of the curator's own archive + threshold; adjust with ``--days``. Use ``--dry-run`` to preview. + """ + from tools import skill_usage + days = getattr(args, "days", 90) + if days < 1: + print(f"curator: --days must be >= 1 (got {days})", file=sys.stderr) + return 2 + + dry_run = bool(getattr(args, "dry_run", False)) + skip_confirm = bool(getattr(args, "yes", False)) + + candidates = [] + for r in skill_usage.agent_created_report(): + if r.get("pinned"): + continue + if r.get("state") == skill_usage.STATE_ARCHIVED: + continue + idle = _idle_days(r) + if idle is None or idle < days: + continue + candidates.append((r["name"], idle)) + + if not candidates: + print(f"curator: nothing to prune (no unpinned skills idle >= {days}d)") + return 0 + + candidates.sort(key=lambda c: -c[1]) + print(f"curator: {len(candidates)} skill(s) idle >= {days}d:") + for name, idle in candidates: + print(f" {name:40s} idle {idle}d") + + if dry_run: + print("\n(dry run — no changes made)") + return 0 + + if not skip_confirm: + try: + reply = input(f"\nArchive {len(candidates)} skill(s)? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\ncurator: aborted") + return 1 + if reply not in ("y", "yes"): + print("curator: aborted") + return 1 + + archived = 0 + failures = [] + for name, _ in candidates: + ok, msg = skill_usage.archive_skill(name) + if ok: + archived += 1 + else: + failures.append((name, msg)) + + print(f"\ncurator: archived {archived}/{len(candidates)}") + if failures: + print("failures:") + for name, msg in failures: + print(f" {name}: {msg}") + return 1 + return 0 + + def _cmd_backup(args) -> int: """Take a manual snapshot of the skills tree. Same mechanism as the automatic pre-run snapshot, just user-initiated.""" @@ -383,6 +488,31 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_restore.add_argument("skill", help="Skill name") p_restore.set_defaults(func=_cmd_restore) + p_archive = subs.add_parser( + "archive", + help="Manually archive a skill (move to .archive/, excluded from prompt)", + ) + p_archive.add_argument("skill", help="Skill name") + p_archive.set_defaults(func=_cmd_archive) + + p_prune = subs.add_parser( + "prune", + help="Bulk-archive agent-created skills idle for >= N days (default 90)", + ) + p_prune.add_argument( + "--days", type=int, default=90, + help="Archive skills idle for at least N days (default: 90)", + ) + p_prune.add_argument( + "-y", "--yes", action="store_true", + help="Skip the confirmation prompt", + ) + p_prune.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="Show what would be archived without doing it", + ) + p_prune.set_defaults(func=_cmd_prune) + p_backup = subs.add_parser( "backup", help="Take a manual tar.gz snapshot of ~/.hermes/skills/ " -
scripts/release.py@@ -264,6 +264,7 @@ "[redacted:email]": "teyrebaz33", "[email protected]": "Linux2010", "[email protected]": "elmatadorgh", + "[redacted:email]": "elmatadorgh", "[redacted:email]": "alexzhu0", "[email protected]": "Swift42", "[redacted:email]": "Ruzzgar", -
tests/hermes_cli/test_curator_archive_prune.py@@ -0,0 +1,269 @@ +"""Tests for `hermes curator archive` and `hermes curator prune`. + +Covers: +- archive refuses pinned skills with an `unpin` hint +- archive returns 0/1 based on archive_skill() success +- prune filters pinned and already-archived, applies --days threshold +- prune falls back to created_at when last_activity_at is null +- prune --dry-run makes no state changes +- prune --yes skips confirmation +- prune --days validation +""" + +from __future__ import annotations + +import io +from contextlib import redirect_stdout, redirect_stderr +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + + +def _ns(**kwargs): + return SimpleNamespace(**kwargs) + + +# ─── archive ──────────────────────────────────────────────────────────────── + + +def test_archive_refuses_pinned(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": True}) + called = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: called.append(name) or (True, "should not get here"), + ) + + rc = curator_cli._cmd_archive(_ns(skill="pinned-skill")) + assert rc == 1 + assert called == [] + out = capsys.readouterr().out + assert "pinned" in out.lower() + assert "hermes curator unpin" in out + + +def test_archive_calls_archive_skill(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False}) + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: (True, f"archived to .archive/{name}"), + ) + rc = curator_cli._cmd_archive(_ns(skill="my-skill")) + assert rc == 0 + assert "archived to .archive/my-skill" in capsys.readouterr().out + + +def test_archive_reports_failure(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False}) + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: (False, f"skill '{name}' is bundled or hub-installed; never archive"), + ) + rc = curator_cli._cmd_archive(_ns(skill="hub-slug")) + assert rc == 1 + assert "bundled or hub-installed" in capsys.readouterr().out + + +# ─── prune ────────────────────────────────────────────────────────────────── + + +def _mk_record(name, *, idle_days=0, pinned=False, state="active", created_idle_days=None): + import datetime as _dt + now = _dt.datetime.now(_dt.timezone.utc) + last_activity = (now - _dt.timedelta(days=idle_days)).isoformat() if idle_days else None + created_delta = created_idle_days if created_idle_days is not None else idle_days + created = (now - _dt.timedelta(days=created_delta)).isoformat() + return { + "name": name, + "state": state, + "pinned": pinned, + "last_activity_at": last_activity, + "created_at": created, + "activity_count": 0 if idle_days == 0 and last_activity is None else 1, + } + + +def test_prune_days_validation(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + rc = curator_cli._cmd_prune(_ns(days=0, yes=True, dry_run=False)) + assert rc == 2 + err = capsys.readouterr().err + assert "--days must be >= 1" in err + + +def test_prune_nothing_to_do(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: []) + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False)) + assert rc == 0 + assert "nothing to prune" in capsys.readouterr().out + + +def test_prune_filters_pinned_and_archived(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [ + _mk_record("old-pinned", idle_days=200, pinned=True), + _mk_record("old-archived", idle_days=200, state="archived"), + _mk_record("recent", idle_days=10), + _mk_record("old-active", idle_days=200), + ] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, f"archived {name}"), + ) + + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False)) + assert rc == 0 + assert archived == ["old-active"] + out = capsys.readouterr().out + assert "old-active" in out + assert "old-pinned" not in out + assert "old-archived" not in out + assert "recent" not in out + assert "archived 1/1" in out + + +def test_prune_falls_back_to_created_at_when_never_used(monkeypatch, capsys): + """Never-used skills must be prunable via created_at — otherwise immortal.""" + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("never-used", idle_days=0, created_idle_days=200)] + # Force last_activity_at to None explicitly + rows[0]["last_activity_at"] = None + + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + rc = curator_cli._cmd_prune(_ns(days=90, yes=True, dry_run=False)) + assert rc == 0 + assert archived == ["never-used"] + + +def test_prune_dry_run_makes_no_changes(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("old-skill", idle_days=200)] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=True)) + assert rc == 0 + assert archived == [] + out = capsys.readouterr().out + assert "old-skill" in out + assert "dry run" in out + + +def test_prune_prompts_without_yes(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("old-skill", idle_days=200)] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False)) + assert rc == 1 + assert archived == [] + assert "aborted" in capsys.readouterr().out + + +def test_prune_confirms_with_y(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [_mk_record("old-skill", idle_days=200)] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + skill_usage, "archive_skill", + lambda name: archived.append(name) or (True, "ok"), + ) + monkeypatch.setattr("builtins.input", lambda _prompt: "y") + rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False)) + assert rc == 0 + assert archived == ["old-skill"] + + +def test_prune_reports_partial_failure(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + rows = [ + _mk_record("ok-skill", idle_days=200), + _mk_record("bad-skill", idle_days=200), + ] + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows) + + def fake_archive(name): + if name == "bad-skill": + return False, "disk full" + return True, "ok" + + monkeypatch.setattr(skill_usage, "archive_skill", fake_archive) + rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False)) + assert rc == 1 + out = capsys.readouterr().out + assert "archived 1/2" in out + assert "bad-skill: disk full" in out + + +# ─── argparse wiring ──────────────────────────────────────────────────────── + + +def test_archive_and_prune_registered(): + import argparse + import hermes_cli.curator as curator_cli + + parser = argparse.ArgumentParser(prog="hermes curator") + curator_cli.register_cli(parser) + + args = parser.parse_args(["archive", "my-skill"]) + assert args.skill == "my-skill" + assert args.func.__name__ == "_cmd_archive" + + args = parser.parse_args(["prune", "--days", "45", "--yes", "--dry-run"]) + assert args.days == 45 + assert args.yes is True + assert args.dry_run is True + assert args.func.__name__ == "_cmd_prune" + + +def test_prune_defaults(): + import argparse + import hermes_cli.curator as curator_cli + + parser = argparse.ArgumentParser(prog="hermes curator") + curator_cli.register_cli(parser) + args = parser.parse_args(["prune"]) + assert args.days == 90 + assert args.yes is False + assert args.dry_run is False -
tests/tools/test_skill_usage.py@@ -225,6 +225,52 @@ def test_agent_created_excludes_hub_installed(skills_home): assert "hub-skill" not in names +def test_agent_created_excludes_hub_installed_frontmatter_name(skills_home): + from tools.skill_usage import ( + is_agent_created, + list_agent_created_skill_names, + mark_agent_created, + ) + + skills_dir = skills_home / "skills" + hub_skill = skills_dir / "productivity" / "getnote" + hub_skill.mkdir(parents=True) + (hub_skill / "SKILL.md").write_text( + """--- +name: Get笔记 +description: test skill +--- + +# body +""", + encoding="utf-8", + ) + _write_skill(skills_dir, "my-skill") + mark_agent_created("my-skill") + hub_dir = skills_dir / ".hub" + hub_dir.mkdir() + (hub_dir / "lock.json").write_text( + json.dumps( + { + "version": 1, + "installed": { + "getnote": { + "source": "taps/main", + "install_path": "productivity/getnote", + } + }, + } + ), + encoding="utf-8", + ) + + names = list_agent_created_skill_names() + assert "my-skill" in names + assert "Get笔记" not in names + assert is_agent_created("Get笔记") is False + assert is_agent_created("getnote") is False + + def test_is_agent_created(skills_home): from tools.skill_usage import is_agent_created skills_dir = skills_home / "skills" -
tools/skill_usage.py@@ -143,7 +143,26 @@ def _read_hub_installed_names() -> Set[str]: if isinstance(data, dict): installed = data.get("installed") or {} if isinstance(installed, dict): - return {str(k) for k in installed.keys()} + names = {str(k) for k in installed.keys()} + skills_dir = _skills_dir() + for entry in installed.values(): + if not isinstance(entry, dict): + continue + install_path = entry.get("install_path") + if not isinstance(install_path, str) or not install_path.strip(): + continue + skill_dir = Path(install_path) + if not skill_dir.is_absolute(): + skill_dir = skills_dir / skill_dir + try: + resolved = skill_dir.resolve() + resolved.relative_to(skills_dir.resolve()) + except (OSError, ValueError): + continue + skill_md = resolved / "SKILL.md" + if skill_md.exists(): + names.add(_read_skill_name(skill_md, fallback=resolved.name)) + return names except (OSError, json.JSONDecodeError) as e: logger.debug("Failed to read hub lock file: %s", e) return set()
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#19454 feat(skills): add stats/archive/restore/prune CLI subcommands · merged via another change →
436672d,#20200,#19384
Closed issues · 1
- Closed#19384feat(curator): user-facing CLI for skill usage stats, archive/restore, and prune
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #20200
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 5 May 2026