From 586acf53077e61869ea9ade8ae3c3d838d953e92 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:05:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(curator):=20add=20`hermes=20curator=20usag?= =?UTF-8?q?e`=20=E2=80=94=20all-skills=20usage=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the usage_report()/provenance() data layer added in #36701 as a user-facing CLI command. Unlike `hermes curator status` (scoped to curator-managed agent-created candidates), `usage` lists every skill on disk — bundled built-ins and hub-installed included — with per-skill use/view/patch counts and an agent/bundled/hub provenance tag. Flags: --sort {activity,recent,name}, --provenance {agent,bundled,hub} filter, --json for machine-readable output. --- hermes_cli/curator.py | 79 +++++++++++++++++ tests/hermes_cli/test_curator_usage.py | 112 +++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 tests/hermes_cli/test_curator_usage.py diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index ce399448026..c64fd5afef8 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -488,6 +488,66 @@ def _cmd_list_archived(args) -> int: return 0 +def _cmd_usage(args) -> int: + """Show usage telemetry for ALL skills, with provenance. + + Unlike `status` (curator-scoped to agent-created candidates), this lists + every skill on disk — bundled built-ins and hub-installed included — so you + can see how often each is actually used regardless of curation. + """ + import json as _json + from tools import skill_usage + + rows = skill_usage.usage_report() + + prov_filter = getattr(args, "provenance", None) + if prov_filter: + rows = [r for r in rows if r.get("provenance") == prov_filter] + + sort_key = getattr(args, "sort", "activity") + if sort_key == "name": + rows.sort(key=lambda r: r["name"]) + elif sort_key == "recent": + # Most-recently-active first; never-active sinks to the bottom. + rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True) + else: # "activity" (default): most-used first + rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True) + + if getattr(args, "json", False): + print(_json.dumps(rows, indent=2, ensure_ascii=False)) + return 0 + + if not rows: + print("curator: no skills found") + return 0 + + # Provenance tallies for a quick header. + counts = {"agent": 0, "bundled": 0, "hub": 0} + for r in rows: + counts[r.get("provenance", "agent")] = counts.get(r.get("provenance", "agent"), 0) + 1 + print( + f"skills: {len(rows)} total " + f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})" + ) + print() + print( + f" {'skill':40s} {'origin':8s} " + f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity" + ) + for r in rows: + last = _fmt_ts(r.get("last_activity_at")) + print( + f" {r['name'][:40]:40s} " + f"{r.get('provenance', 'agent'):8s} " + f"{r.get('use_count', 0):>4d} " + f"{r.get('view_count', 0):>4d} " + f"{r.get('patch_count', 0):>5d} " + f"{r.get('activity_count', 0):>4d} " + f"{last}" + ) + return 0 + + # --------------------------------------------------------------------------- # argparse wiring (called from hermes_cli.main) # --------------------------------------------------------------------------- @@ -504,6 +564,25 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_status = subs.add_parser("status", help="Show curator status and skill stats") p_status.set_defaults(func=_cmd_status) + p_usage = subs.add_parser( + "usage", + help="Show usage telemetry for ALL skills (built-in, hub, agent) with provenance", + ) + p_usage.add_argument( + "--sort", choices=("activity", "recent", "name"), default="activity", + help="Sort order: activity (most-used first, default), recent " + "(most-recently-active first), or name (alphabetical)", + ) + p_usage.add_argument( + "--provenance", choices=("agent", "bundled", "hub"), default=None, + help="Only show skills of this origin", + ) + p_usage.add_argument( + "--json", action="store_true", + help="Emit the full report as JSON instead of a table", + ) + p_usage.set_defaults(func=_cmd_usage) + p_run = subs.add_parser("run", help="Trigger a curator review now") p_run.add_argument( "--sync", "--synchronous", dest="synchronous", action="store_true", diff --git a/tests/hermes_cli/test_curator_usage.py b/tests/hermes_cli/test_curator_usage.py new file mode 100644 index 00000000000..75e95bfb3e5 --- /dev/null +++ b/tests/hermes_cli/test_curator_usage.py @@ -0,0 +1,112 @@ +"""Tests for `hermes curator usage` — the all-skills usage view. + +Covers: +- Lists every skill regardless of provenance (agent / bundled / hub), unlike + `status` which is scoped to curator-managed candidates. +- --provenance filter, --sort ordering, and --json output. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + + +def _fake_rows(): + return [ + { + "name": "agent-skill", "provenance": "agent", "state": "active", + "use_count": 2, "view_count": 1, "patch_count": 0, + "activity_count": 3, "last_activity_at": "2026-05-01T10:00:00+00:00", + "created_at": "2026-01-01T00:00:00+00:00", "_persisted": True, + }, + { + "name": "bundled-skill", "provenance": "bundled", "state": "active", + "use_count": 9, "view_count": 4, "patch_count": 0, + "activity_count": 13, "last_activity_at": "2026-05-10T10:00:00+00:00", + "created_at": "2026-01-01T00:00:00+00:00", "_persisted": True, + }, + { + "name": "hub-skill", "provenance": "hub", "state": "active", + "use_count": 0, "view_count": 0, "patch_count": 0, + "activity_count": 0, "last_activity_at": None, + "created_at": "2026-01-01T00:00:00+00:00", "_persisted": False, + }, + ] + + +def test_usage_lists_all_provenances(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="activity", provenance=None, json=False) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + # Header tally and all three skills present. + assert "agent=1" in out and "bundled=1" in out and "hub=1" in out + assert "agent-skill" in out + assert "bundled-skill" in out + assert "hub-skill" in out + + +def test_usage_sort_activity_orders_most_used_first(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="activity", provenance=None, json=False) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + # bundled-skill (act=13) must appear before agent-skill (act=3). + assert out.index("bundled-skill") < out.index("agent-skill") + + +def test_usage_provenance_filter(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="activity", provenance="bundled", json=False) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + assert "bundled-skill" in out + assert "agent-skill" not in out + assert "hub-skill" not in out + + +def test_usage_json_output(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", _fake_rows) + args = SimpleNamespace(sort="name", provenance=None, json=True) + assert curator_cli._cmd_usage(args) == 0 + out = capsys.readouterr().out + data = json.loads(out) + assert {r["name"] for r in data} == {"agent-skill", "bundled-skill", "hub-skill"} + assert {r["provenance"] for r in data} == {"agent", "bundled", "hub"} + + +def test_usage_empty(monkeypatch, capsys): + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + monkeypatch.setattr(skill_usage, "usage_report", lambda: []) + args = SimpleNamespace(sort="activity", provenance=None, json=False) + assert curator_cli._cmd_usage(args) == 0 + assert "no skills found" in capsys.readouterr().out + + +def test_usage_command_is_registered(): + """The `usage` subcommand must be wired into the curator argparse tree.""" + import argparse + import hermes_cli.curator as curator_cli + + parser = argparse.ArgumentParser(prog="hermes curator") + curator_cli.register_cli(parser) + args = parser.parse_args(["usage", "--sort", "recent", "--provenance", "hub", "--json"]) + assert args.func is curator_cli._cmd_usage + assert args.sort == "recent" + assert args.provenance == "hub" + assert args.json is True