From 436672de0efd8bcc50c6043a16223c102d30d71b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 05:15:54 -0700 Subject: [PATCH] feat(curator): add archive and prune subcommands (#20200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ' 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 --------- Co-authored-by: LeonSGP43 Co-authored-by: elmatadorgh --- hermes_cli/curator.py | 130 +++++++++ scripts/release.py | 1 + .../hermes_cli/test_curator_archive_prune.py | 269 ++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 tests/hermes_cli/test_curator_archive_prune.py diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index df69aa7d5d..50c297217c 100644 --- a/hermes_cli/curator.py +++ b/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/ " diff --git a/scripts/release.py b/scripts/release.py index d44ef0de7d..b8f416928f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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", diff --git a/tests/hermes_cli/test_curator_archive_prune.py b/tests/hermes_cli/test_curator_archive_prune.py new file mode 100644 index 0000000000..1ab28fb177 --- /dev/null +++ b/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