# NousResearch/hermes-agent · Add skill archive, restore, prune, and stats CLI commands

## Open now

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

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #20200. Verify it addresses your case.
- **Recommended:** read PR #20200 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-20200`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #19384: feat(curator): user-facing CLI for skill usage stats, archive/restore, and prune

## Prior & competing PRs

- **#20200: feat(curator): add archive and prune subcommands**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `hermes_cli/curator.py`

    ```diff
    @@ -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."""
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -264,6 +264,7 @@
         "[redacted:email]": "teyrebaz33",
         "linux2010@users.noreply.github.com": "Linux2010",
         "elmatadorgh@users.noreply.github.com": "elmatadorgh",
    +    "[redacted:email]": "elmatadorgh",
         "[redacted:email]": "alexzhu0",
         "1180176+Swift42@users.noreply.github.com": "Swift42",
         "[redacted:email]": "Ruzzgar",
    ```
  - `tests/hermes_cli/test_curator_archive_prune.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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()
    ```
- **#19454: feat(skills): add stats/archive/restore/prune CLI subcommands**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `436672de0`, `#20200`, `#19384`

## Fix status

- Merged to main in PR #20200 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-05`.
