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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #20200 feat(curator): add archive and prune subcommands

    merged to main

    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.py

    Show 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.00 means 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 Lab

At a glance

Merged PRs
#20200
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
5 May 2026

For your agent

The same facts, typed.