{"repo":"NousResearch/hermes-agent","public_id":"19384","display_label":"Add skill archive, restore, prune, and stats CLI commands","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20200,"merged_at":"2026-05-05T12:15:55+00:00"},"resolution":{"state":"resolved","by":"pr-20200"},"freshness":{"state":"verified","last_verified_at":"2026-05-05T12:15:55+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":19384,"pr_number":20200,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-05T12:15:55+00:00"}],"members":[{"kind":"issue","number":19384,"title":"feat(curator): user-facing CLI for skill usage stats, archive/restore, and prune","url":null,"state":"closed"},{"kind":"pr","number":19454,"title":"feat(skills): add stats/archive/restore/prune CLI subcommands","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"436672de0","number":0},{"kind":"pr","value":"#20200","number":20200},{"kind":"pr","value":"#19384","number":19384}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20200,"title":"feat(curator): add archive and prune subcommands","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"hermes_cli/curator.py","hunks":["@@ -245,6 +245,111 @@ def _cmd_restore(args) -> int:\n     return 0 if ok else 1\n \n \n+def _cmd_archive(args) -> int:\n+    \"\"\"Manually archive an agent-created skill. Refuses if pinned.\n+\n+    The auto-curator archives stale skills on its own schedule; this verb is\n+    for the user who wants to archive *now* without waiting for a run.\n+    \"\"\"\n+    from tools import skill_usage\n+    if skill_usage.get_record(args.skill).get(\"pinned\"):\n+        print(\n+            f\"curator: '{args.skill}' is pinned — unpin first with \"\n+            f\"`hermes curator unpin {args.skill}`\"\n+        )\n+        return 1\n+    ok, msg = skill_usage.archive_skill(args.skill)\n+    print(f\"curator: {msg}\")\n+    return 0 if ok else 1\n+\n+\n+def _idle_days(record: dict) -> Optional[int]:\n+    \"\"\"Days since the skill's last activity (view / use / patch).\n+\n+    Falls back to ``created_at`` so a skill that was authored but never used\n+    can still be pruned — otherwise never-touched skills would be immortal.\n+    Returns None only when both fields are missing or unparseable.\n+    \"\"\"\n+    ts = record.get(\"last_activity_at\") or record.get(\"created_at\")\n+    if not ts:\n+        return None\n+    try:\n+        dt = datetime.fromisoformat(str(ts))\n+    except (TypeError, ValueError):\n+        return None\n+    if dt.tzinfo is None:\n+        dt = dt.replace(tzinfo=timezone.utc)\n+    return max(0, (datetime.now(timezone.utc) - dt).days)\n+\n+\n+def _cmd_prune(args) -> int:\n+    \"\"\"Bulk-archive agent-created skills idle for >= N days.\n+\n+    Pinned skills are exempt. Already-archived skills are skipped. Default\n+    ``--days 90`` matches a conservative read of the curator's own archive\n+    threshold; adjust with ``--days``. Use ``--dry-run`` to preview.\n+    \"\"\"\n+    from tools import skill_usage\n+    days = getattr(args, \"days\", 90)\n+    if days < 1:\n+        print(f\"curator: --days must be >= 1 (got {days})\", file=sys.stderr)\n+        return 2\n+\n+    dry_run = bool(getattr(args, \"dry_run\", False))\n+    skip_confirm = bool(getattr(args, \"yes\", False))\n+\n+    candidates = []\n+    for r in skill_usage.agent_created_report():\n+        if r.get(\"pinned\"):\n+            continue\n+        if r.get(\"state\") == skill_usage.STATE_ARCHIVED:\n+            continue\n+        idle = _idle_days(r)\n+        if idle is None or idle < days:\n+            continue\n+        candidates.append((r[\"name\"], idle))\n+\n+    if not candidates:\n+        print(f\"curator: nothing to prune (no unpinned skills idle >= {days}d)\")\n+        return 0\n+\n+    candidates.sort(key=lambda c: -c[1])\n+    print(f\"curator: {len(candidates)} skill(s) idle >= {days}d:\")\n+    for name, idle in candidates:\n+        print(f\"  {name:40s} idle {idle}d\")\n+\n+    if dry_run:\n+        print(\"\\n(dry run — no changes made)\")\n+        return 0\n+\n+    if not skip_confirm:\n+        try:\n+            reply = input(f\"\\nArchive {len(candidates)} skill(s)? [y/N] \").strip().lower()\n+        except (EOFError, KeyboardInterrupt):\n+            print(\"\\ncurator: aborted\")\n+            return 1\n+        if reply not in (\"y\", \"yes\"):\n+            print(\"curator: aborted\")\n+            return 1\n+\n+    archived = 0\n+    failures = []\n+    for name, _ in candidates:\n+        ok, msg = skill_usage.archive_skill(name)\n+        if ok:\n+            archived += 1\n+        else:\n+            failures.append((name, msg))\n+\n+    print(f\"\\ncurator: archived {archived}/{len(candidates)}\")\n+    if failures:\n+        print(\"failures:\")\n+        for name, msg in failures:\n+            print(f\"  {name}: {msg}\")\n+        return 1\n+    return 0\n+\n+\n def _cmd_backup(args) -> int:\n     \"\"\"Take a manual snapshot of the skills tree. Same mechanism as the\n     automatic pre-run snapshot, just user-initiated.\"\"\"","@@ -383,6 +488,31 @@ def register_cli(parent: argparse.ArgumentParser) -> None:\n     p_restore.add_argument(\"skill\", help=\"Skill name\")\n     p_restore.set_defaults(func=_cmd_restore)\n \n+    p_archive = subs.add_parser(\n+        \"archive\",\n+        help=\"Manually archive a skill (move to .archive/, excluded from prompt)\",\n+    )\n+    p_archive.add_argument(\"skill\", help=\"Skill name\")\n+    p_archive.set_defaults(func=_cmd_archive)\n+\n+    p_prune = subs.add_parser(\n+        \"prune\",\n+        help=\"Bulk-archive agent-created skills idle for >= N days (default 90)\",\n+    )\n+    p_prune.add_argument(\n+        \"--days\", type=int, default=90,\n+        help=\"Archive skills idle for at least N days (default: 90)\",\n+    )\n+    p_prune.add_argument(\n+        \"-y\", \"--yes\", action=\"store_true\",\n+        help=\"Skip the confirmation prompt\",\n+    )\n+    p_prune.add_argument(\n+        \"--dry-run\", dest=\"dry_run\", action=\"store_true\",\n+        help=\"Show what would be archived without doing it\",\n+    )\n+    p_prune.set_defaults(func=_cmd_prune)\n+\n     p_backup = subs.add_parser(\n         \"backup\",\n         help=\"Take a manual tar.gz snapshot of ~/.hermes/skills/ \""]},{"file":"scripts/release.py","hunks":["@@ -264,6 +264,7 @@\n     \"[redacted:email]\": \"teyrebaz33\",\n     \"linux2010@users.noreply.github.com\": \"Linux2010\",\n     \"elmatadorgh@users.noreply.github.com\": \"elmatadorgh\",\n+    \"[redacted:email]\": \"elmatadorgh\",\n     \"[redacted:email]\": \"alexzhu0\",\n     \"1180176+Swift42@users.noreply.github.com\": \"Swift42\",\n     \"[redacted:email]\": \"Ruzzgar\","]},{"file":"tests/hermes_cli/test_curator_archive_prune.py","hunks":["@@ -0,0 +1,269 @@\n+\"\"\"Tests for `hermes curator archive` and `hermes curator prune`.\n+\n+Covers:\n+- archive refuses pinned skills with an `unpin` hint\n+- archive returns 0/1 based on archive_skill() success\n+- prune filters pinned and already-archived, applies --days threshold\n+- prune falls back to created_at when last_activity_at is null\n+- prune --dry-run makes no state changes\n+- prune --yes skips confirmation\n+- prune --days validation\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import io\n+from contextlib import redirect_stdout, redirect_stderr\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n+def _ns(**kwargs):\n+    return SimpleNamespace(**kwargs)\n+\n+\n+# ─── archive ────────────────────────────────────────────────────────────────\n+\n+\n+def test_archive_refuses_pinned(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    monkeypatch.setattr(skill_usage, \"get_record\", lambda name: {\"pinned\": True})\n+    called = []\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: called.append(name) or (True, \"should not get here\"),\n+    )\n+\n+    rc = curator_cli._cmd_archive(_ns(skill=\"pinned-skill\"))\n+    assert rc == 1\n+    assert called == []\n+    out = capsys.readouterr().out\n+    assert \"pinned\" in out.lower()\n+    assert \"hermes curator unpin\" in out\n+\n+\n+def test_archive_calls_archive_skill(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    monkeypatch.setattr(skill_usage, \"get_record\", lambda name: {\"pinned\": False})\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: (True, f\"archived to .archive/{name}\"),\n+    )\n+    rc = curator_cli._cmd_archive(_ns(skill=\"my-skill\"))\n+    assert rc == 0\n+    assert \"archived to .archive/my-skill\" in capsys.readouterr().out\n+\n+\n+def test_archive_reports_failure(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    monkeypatch.setattr(skill_usage, \"get_record\", lambda name: {\"pinned\": False})\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: (False, f\"skill '{name}' is bundled or hub-installed; never archive\"),\n+    )\n+    rc = curator_cli._cmd_archive(_ns(skill=\"hub-slug\"))\n+    assert rc == 1\n+    assert \"bundled or hub-installed\" in capsys.readouterr().out\n+\n+\n+# ─── prune ──────────────────────────────────────────────────────────────────\n+\n+\n+def _mk_record(name, *, idle_days=0, pinned=False, state=\"active\", created_idle_days=None):\n+    import datetime as _dt\n+    now = _dt.datetime.now(_dt.timezone.utc)\n+    last_activity = (now - _dt.timedelta(days=idle_days)).isoformat() if idle_days else None\n+    created_delta = created_idle_days if created_idle_days is not None else idle_days\n+    created = (now - _dt.timedelta(days=created_delta)).isoformat()\n+    return {\n+        \"name\": name,\n+        \"state\": state,\n+        \"pinned\": pinned,\n+        \"last_activity_at\": last_activity,\n+        \"created_at\": created,\n+        \"activity_count\": 0 if idle_days == 0 and last_activity is None else 1,\n+    }\n+\n+\n+def test_prune_days_validation(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    rc = curator_cli._cmd_prune(_ns(days=0, yes=True, dry_run=False))\n+    assert rc == 2\n+    err = capsys.readouterr().err\n+    assert \"--days must be >= 1\" in err\n+\n+\n+def test_prune_nothing_to_do(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: [])\n+    rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))\n+    assert rc == 0\n+    assert \"nothing to prune\" in capsys.readouterr().out\n+\n+\n+def test_prune_filters_pinned_and_archived(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    rows = [\n+        _mk_record(\"old-pinned\", idle_days=200, pinned=True),\n+        _mk_record(\"old-archived\", idle_days=200, state=\"archived\"),\n+        _mk_record(\"recent\", idle_days=10),\n+        _mk_record(\"old-active\", idle_days=200),\n+    ]\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: rows)\n+    archived = []\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: archived.append(name) or (True, f\"archived {name}\"),\n+    )\n+\n+    rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))\n+    assert rc == 0\n+    assert archived == [\"old-active\"]\n+    out = capsys.readouterr().out\n+    assert \"old-active\" in out\n+    assert \"old-pinned\" not in out\n+    assert \"old-archived\" not in out\n+    assert \"recent\" not in out\n+    assert \"archived 1/1\" in out\n+\n+\n+def test_prune_falls_back_to_created_at_when_never_used(monkeypatch, capsys):\n+    \"\"\"Never-used skills must be prunable via created_at — otherwise immortal.\"\"\"\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    rows = [_mk_record(\"never-used\", idle_days=0, created_idle_days=200)]\n+    # Force last_activity_at to None explicitly\n+    rows[0][\"last_activity_at\"] = None\n+\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: rows)\n+    archived = []\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: archived.append(name) or (True, \"ok\"),\n+    )\n+    rc = curator_cli._cmd_prune(_ns(days=90, yes=True, dry_run=False))\n+    assert rc == 0\n+    assert archived == [\"never-used\"]\n+\n+\n+def test_prune_dry_run_makes_no_changes(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    rows = [_mk_record(\"old-skill\", idle_days=200)]\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: rows)\n+    archived = []\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: archived.append(name) or (True, \"ok\"),\n+    )\n+    rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=True))\n+    assert rc == 0\n+    assert archived == []\n+    out = capsys.readouterr().out\n+    assert \"old-skill\" in out\n+    assert \"dry run\" in out\n+\n+\n+def test_prune_prompts_without_yes(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    rows = [_mk_record(\"old-skill\", idle_days=200)]\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: rows)\n+    archived = []\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: archived.append(name) or (True, \"ok\"),\n+    )\n+    monkeypatch.setattr(\"builtins.input\", lambda _prompt: \"n\")\n+    rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))\n+    assert rc == 1\n+    assert archived == []\n+    assert \"aborted\" in capsys.readouterr().out\n+\n+\n+def test_prune_confirms_with_y(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    rows = [_mk_record(\"old-skill\", idle_days=200)]\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: rows)\n+    archived = []\n+    monkeypatch.setattr(\n+        skill_usage, \"archive_skill\",\n+        lambda name: archived.append(name) or (True, \"ok\"),\n+    )\n+    monkeypatch.setattr(\"builtins.input\", lambda _prompt: \"y\")\n+    rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))\n+    assert rc == 0\n+    assert archived == [\"old-skill\"]\n+\n+\n+def test_prune_reports_partial_failure(monkeypatch, capsys):\n+    import hermes_cli.curator as curator_cli\n+    import tools.skill_usage as skill_usage\n+\n+    rows = [\n+        _mk_record(\"ok-skill\", idle_days=200),\n+        _mk_record(\"bad-skill\", idle_days=200),\n+    ]\n+    monkeypatch.setattr(skill_usage, \"agent_created_report\", lambda: rows)\n+\n+    def fake_archive(name):\n+        if name == \"bad-skill\":\n+            return False, \"disk full\"\n+        return True, \"ok\"\n+\n+    monkeypatch.setattr(skill_usage, \"archive_skill\", fake_archive)\n+    rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))\n+    assert rc == 1\n+    out = capsys.readouterr().out\n+    assert \"archived 1/2\" in out\n+    assert \"bad-skill: disk full\" in out\n+\n+\n+# ─── argparse wiring ────────────────────────────────────────────────────────\n+\n+\n+def test_archive_and_prune_registered():\n+    import argparse\n+    import hermes_cli.curator as curator_cli\n+\n+    parser = argparse.ArgumentParser(prog=\"hermes curator\")\n+    curator_cli.register_cli(parser)\n+\n+    args = parser.parse_args([\"archive\", \"my-skill\"])\n+    assert args.skill == \"my-skill\"\n+    assert args.func.__name__ == \"_cmd_archive\"\n+\n+    args = parser.parse_args([\"prune\", \"--days\", \"45\", \"--yes\", \"--dry-run\"])\n+    assert args.days == 45\n+    assert args.yes is True\n+    assert args.dry_run is True\n+    assert args.func.__name__ == \"_cmd_prune\"\n+\n+\n+def test_prune_defaults():\n+    import argparse\n+    import hermes_cli.curator as curator_cli\n+\n+    parser = argparse.ArgumentParser(prog=\"hermes curator\")\n+    curator_cli.register_cli(parser)\n+    args = parser.parse_args([\"prune\"])\n+    assert args.days == 90\n+    assert args.yes is False\n+    assert args.dry_run is False"]},{"file":"tests/tools/test_skill_usage.py","hunks":["@@ -225,6 +225,52 @@ def test_agent_created_excludes_hub_installed(skills_home):\n     assert \"hub-skill\" not in names\n \n \n+def test_agent_created_excludes_hub_installed_frontmatter_name(skills_home):\n+    from tools.skill_usage import (\n+        is_agent_created,\n+        list_agent_created_skill_names,\n+        mark_agent_created,\n+    )\n+\n+    skills_dir = skills_home / \"skills\"\n+    hub_skill = skills_dir / \"productivity\" / \"getnote\"\n+    hub_skill.mkdir(parents=True)\n+    (hub_skill / \"SKILL.md\").write_text(\n+        \"\"\"---\n+name: Get笔记\n+description: test skill\n+---\n+\n+# body\n+\"\"\",\n+        encoding=\"utf-8\",\n+    )\n+    _write_skill(skills_dir, \"my-skill\")\n+    mark_agent_created(\"my-skill\")\n+    hub_dir = skills_dir / \".hub\"\n+    hub_dir.mkdir()\n+    (hub_dir / \"lock.json\").write_text(\n+        json.dumps(\n+            {\n+                \"version\": 1,\n+                \"installed\": {\n+                    \"getnote\": {\n+                        \"source\": \"taps/main\",\n+                        \"install_path\": \"productivity/getnote\",\n+                    }\n+                },\n+            }\n+        ),\n+        encoding=\"utf-8\",\n+    )\n+\n+    names = list_agent_created_skill_names()\n+    assert \"my-skill\" in names\n+    assert \"Get笔记\" not in names\n+    assert is_agent_created(\"Get笔记\") is False\n+    assert is_agent_created(\"getnote\") is False\n+\n+\n def test_is_agent_created(skills_home):\n     from tools.skill_usage import is_agent_created\n     skills_dir = skills_home / \"skills\""]},{"file":"tools/skill_usage.py","hunks":["@@ -143,7 +143,26 @@ def _read_hub_installed_names() -> Set[str]:\n         if isinstance(data, dict):\n             installed = data.get(\"installed\") or {}\n             if isinstance(installed, dict):\n-                return {str(k) for k in installed.keys()}\n+                names = {str(k) for k in installed.keys()}\n+                skills_dir = _skills_dir()\n+                for entry in installed.values():\n+                    if not isinstance(entry, dict):\n+                        continue\n+                    install_path = entry.get(\"install_path\")\n+                    if not isinstance(install_path, str) or not install_path.strip():\n+                        continue\n+                    skill_dir = Path(install_path)\n+                    if not skill_dir.is_absolute():\n+                        skill_dir = skills_dir / skill_dir\n+                    try:\n+                        resolved = skill_dir.resolve()\n+                        resolved.relative_to(skills_dir.resolve())\n+                    except (OSError, ValueError):\n+                        continue\n+                    skill_md = resolved / \"SKILL.md\"\n+                    if skill_md.exists():\n+                        names.add(_read_skill_name(skill_md, fallback=resolved.name))\n+                return names\n     except (OSError, json.JSONDecodeError) as e:\n         logger.debug(\"Failed to read hub lock file: %s\", e)\n     return set()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}