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

* fix(curator): protect hub skills by frontmatter name

* test(skill_usage): add mark_agent_created to regression test

The cherry-picked test predates #19618/#19621 which rewrote
list_agent_created_skill_names() to require an explicit
created_by: 'agent' provenance marker. Without mark_agent_created(),
my-skill is excluded from the list and the positive assertion fails.

* feat(curator): add archive and prune subcommands

Adds 'hermes curator archive <skill>' and 'hermes curator prune
[--days N] [--yes] [--dry-run]' alongside the existing status, run,
pause, resume, pin, unpin, restore, backup, rollback verbs.

These are the two genuinely new user-facing verbs requested in #19384.
The other verbs proposed there ('stats' and 'restore') already exist
as 'curator status' and 'curator restore', so no duplicate surface is
added — all skill lifecycle commands live under the single 'hermes
curator' namespace.

- archive: manual archive of an agent-created skill. Refuses pinned
  skills with a hint pointing at 'hermes curator unpin'.
- prune: bulk-archive unpinned skills idle for >= N days (default 90).
  Falls back to created_at when last_activity_at is null so never-used
  skills can still be pruned. --dry-run previews, --yes skips prompt.

Adapted from @elmatadorgh's PR #19454 which placed the same verbs
under 'hermes skills' with a separate hermes_cli/skills_config.py
handler and rich table for stats. The 'stats' and 'restore' parts of
that PR duplicated existing surface, so only archive and prune are
kept, rewritten to match hermes_cli/curator.py's existing plain-text
handler style. Tests rewritten from scratch against the new handlers.

Closes #19384

Co-authored-by: elmatadorgh <coktinbaran5@gmail.com>

---------

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: elmatadorgh <coktinbaran5@gmail.com>
This commit is contained in:
Teknium 2026-05-05 05:15:54 -07:00 committed by GitHub
parent 4f76166cf0
commit 436672de0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 400 additions and 0 deletions

View file

@ -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/ "

View file

@ -268,6 +268,7 @@ AUTHOR_MAP = {
"hakanerten02@hotmail.com": "teyrebaz33",
"linux2010@users.noreply.github.com": "Linux2010",
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
"coktinbaran5@gmail.com": "elmatadorgh",
"alexazzjjtt@163.com": "alexzhu0",
"1180176+Swift42@users.noreply.github.com": "Swift42",
"ruzzgarcn@gmail.com": "Ruzzgar",

View file

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