diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d80e31f6901d5..2689ae10fd74d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9239,13 +9239,62 @@ def cmd_pairing(args): help="Interactive skill configuration — enable/disable individual skills", ) + # Lifecycle sub-actions backed by tools.skill_usage (the curator sidecar). + skills_stats = skills_subparsers.add_parser( + "stats", + help="Show skill usage statistics (uses, views, last activity, idle days)", + ) + skills_stats.add_argument( + "--days", + type=int, + default=None, + help="Only show usage from the last N days", + ) + + skills_archive = skills_subparsers.add_parser( + "archive", + help="Archive a skill (move to .archive/ — excluded from prompt)", + ) + skills_archive.add_argument("name", help="Skill name to archive") + + skills_restore = skills_subparsers.add_parser( + "restore", + help="Restore an archived skill", + ) + skills_restore.add_argument("name", help="Skill name to restore") + + skills_prune = skills_subparsers.add_parser( + "prune", + help="Bulk archive skills idle for N days (default 90)", + ) + skills_prune.add_argument( + "--days", + type=int, + default=90, + help="Archive skills idle for at least N days (default: 90)", + ) + skills_prune.add_argument( + "--yes", "-y", + action="store_true", + help="Skip the confirmation prompt", + ) + skills_prune.add_argument( + "--dry-run", + action="store_true", + help="Show what would be archived without doing it", + ) + def cmd_skills(args): - # Route 'config' action to skills_config module - if getattr(args, "skills_action", None) == "config": + action = getattr(args, "skills_action", None) + if action == "config": _require_tty("skills config") from hermes_cli.skills_config import skills_command as skills_config_command skills_config_command(args) + elif action in ("stats", "archive", "restore", "prune"): + from hermes_cli.skills_config import skills_overflow_command + + skills_overflow_command(args) else: from hermes_cli.skills_hub import skills_command diff --git a/hermes_cli/skills_config.py b/hermes_cli/skills_config.py index 8eaf64605a8c1..5077bfada33f5 100644 --- a/hermes_cli/skills_config.py +++ b/hermes_cli/skills_config.py @@ -175,3 +175,267 @@ def skills_command(args=None): save_disabled_skills(config, new_disabled, platform) enabled_count = len(skills) - len(new_disabled) print(color(f"✓ Saved: {enabled_count} enabled, {len(new_disabled)} disabled ({platform_label}).", Colors.GREEN)) + + +# ─── Lifecycle Commands (stats / archive / restore / prune) ────────────────── +# +# These four verbs are the user-facing surface for the curator's sidecar +# telemetry (~/.hermes/skills/.usage.json). The curator background task +# (agent/curator.py) consumes the same data to decide auto-transitions; this +# CLI lets users inspect and override those decisions manually. + +def skills_overflow_command(args): + """Dispatch for `hermes skills {stats,archive,restore,prune}`.""" + action = args.skills_action + if action == "stats": + _cmd_stats(getattr(args, "days", None)) + elif action == "archive": + _cmd_archive(args.name) + elif action == "restore": + _cmd_restore(args.name) + elif action == "prune": + _cmd_prune( + getattr(args, "days", 90), + getattr(args, "yes", False), + getattr(args, "dry_run", False), + ) + + +def _parse_iso(ts): + """Parse an ISO timestamp into a tz-aware datetime, or None on failure.""" + if not ts: + return None + import datetime as _dt + try: + dt = _dt.datetime.fromisoformat(str(ts)) + except (TypeError, ValueError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_dt.timezone.utc) + return dt + + +def _format_relative(iso_ts: Optional[str]) -> str: + """Render an ISO timestamp as a relative phrase (e.g. '3d ago').""" + if not iso_ts: + return "never" + import datetime as _dt + dt = _parse_iso(iso_ts) + if dt is None: + return "?" + delta = (_dt.datetime.now(_dt.timezone.utc) - dt).total_seconds() + if delta < 60: + return "just now" + if delta < 3600: + return f"{int(delta / 60)}m ago" + if delta < 86400: + return f"{int(delta / 3600)}h ago" + if delta < 172800: + return "yesterday" + if delta < 604800: + return f"{int(delta / 86400)}d ago" + return dt.strftime("%Y-%m-%d") + + +def _idle_days(record: dict) -> Optional[int]: + """Days since last activity, falling back to created_at when no activity. + + Returns None only if both fields are missing/unparseable — never-tracked + skills shouldn't get pruned by accident. + """ + import datetime as _dt + dt = _parse_iso(record.get("last_activity_at")) or _parse_iso(record.get("created_at")) + if dt is None: + return None + return max(0, (_dt.datetime.now(_dt.timezone.utc) - dt).days) + + +def _activity_sort_key(iso_ts) -> float: + """Convert an ISO timestamp to unix seconds for sorting; 0 if missing.""" + dt = _parse_iso(iso_ts) + return dt.timestamp() if dt else 0.0 + + +def _cmd_stats(since_days: Optional[int]) -> None: + """Print a per-skill usage table, ranked by activity_count desc.""" + from tools.skill_usage import agent_created_report + + rows = agent_created_report() + if not rows: + print(color(" No agent-created skills tracked yet.", Colors.DIM)) + print(" (Bundled and hub-installed skills are excluded from curator stats.)") + return + + if since_days is not None: + import datetime as _dt + cutoff = _dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(days=since_days) + rows = [ + r for r in rows + if (dt := _parse_iso(r.get("last_activity_at"))) is not None and dt >= cutoff + ] + if not rows: + print(color(f" No activity in the last {since_days} day(s).", Colors.DIM)) + return + + rows.sort(key=lambda r: ( + -int(r.get("activity_count") or 0), + -_activity_sort_key(r.get("last_activity_at")), + )) + + title = f"Skill usage (last {since_days} days)" if since_days is not None else "Skill usage" + + try: + from rich.console import Console + from rich.table import Table + except ImportError: + # Plain-text fallback — unlikely (rich is a runtime dep) but defensive. + print(f"\n{title}") + print(f"{'Skill':<30} {'Uses':>5} {'Views':>5} {'Patch':>5} " + f"{'Last activity':<14} {'Idle':>6} {'State':<10} Pin") + print("─" * 90) + for r in rows: + idle = _idle_days(r) + print( + f"{str(r['name'])[:29]:<30} " + f"{(r.get('use_count') or 0):>5} " + f"{(r.get('view_count') or 0):>5} " + f"{(r.get('patch_count') or 0):>5} " + f"{_format_relative(r.get('last_activity_at'))[:13]:<14} " + f"{('—' if idle is None else f'{idle}d'):>6} " + f"{str(r.get('state') or 'active')[:9]:<10} " + f"{'pinned' if r.get('pinned') else ''}" + ) + print(f"\n{len(rows)} skill(s)") + return + + c = Console() + t = Table(title=title) + t.add_column("Skill", style="bold") + t.add_column("Uses", justify="right") + t.add_column("Views", justify="right") + t.add_column("Patches", justify="right") + t.add_column("Last activity") + t.add_column("Idle", justify="right") + t.add_column("State") + t.add_column("Pin") + for r in rows: + idle = _idle_days(r) + t.add_row( + str(r["name"]), + str(r.get("use_count") or 0), + str(r.get("view_count") or 0), + str(r.get("patch_count") or 0), + _format_relative(r.get("last_activity_at")), + "—" if idle is None else f"{idle}d", + str(r.get("state") or "active"), + "📌" if r.get("pinned") else "", + ) + c.print(t) + + +def _cmd_archive(name: str) -> None: + """Archive a single skill. Refuses if the skill is pinned.""" + import sys + from tools.skill_usage import archive_skill, get_record + + if get_record(name).get("pinned"): + print( + color( + f"Refusing: '{name}' is pinned. " + f"Unpin first: hermes curator unpin {name}", + Colors.RED, + ), + file=sys.stderr, + ) + sys.exit(1) + + ok, msg = archive_skill(name) + if ok: + print(color(f"✓ {msg}", Colors.GREEN)) + else: + print(color(f"✗ {msg}", Colors.RED), file=sys.stderr) + sys.exit(1) + + +def _cmd_restore(name: str) -> None: + """Restore a single archived skill.""" + import sys + from tools.skill_usage import restore_skill + + ok, msg = restore_skill(name) + if ok: + print(color(f"✓ {msg}", Colors.GREEN)) + else: + print(color(f"✗ {msg}", Colors.RED), file=sys.stderr) + sys.exit(1) + + +def _cmd_prune(days: int, skip_confirm: bool, dry_run: bool) -> None: + """Bulk-archive skills idle for ≥ N days. Pinned skills are exempt.""" + import sys + if days < 1: + print( + color(f"--days must be ≥ 1 (got {days})", Colors.RED), + file=sys.stderr, + ) + sys.exit(2) + from tools.skill_usage import agent_created_report, archive_skill + + candidates = [] + for r in agent_created_report(): + if r.get("pinned"): + continue + if r.get("state") == "archived": + continue + idle = _idle_days(r) + if idle is None or idle < days: + continue + candidates.append((r["name"], idle, r.get("last_activity_at"))) + + if not candidates: + print(color(f" Nothing to prune (no skills idle ≥ {days} days).", Colors.DIM)) + return + + candidates.sort(key=lambda c: -c[1]) + + print(color( + f"\nSkills idle ≥ {days} days ({len(candidates)} candidate(s)):", + Colors.BOLD, + )) + print(f" {'Skill':<32} {'Idle':>8} Last activity") + print(" " + "─" * 70) + for name, idle, last in candidates: + print(f" {name[:31]:<32} {f'{idle}d':>8} {_format_relative(last)}") + print() + + if dry_run: + print(color(" Dry run — no changes made.", Colors.DIM)) + return + + if not skip_confirm: + try: + answer = input( + color(f"Archive these {len(candidates)} skill(s)? [y/N]: ", Colors.YELLOW) + ).strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + return + if answer not in ("y", "yes"): + print("Aborted.") + return + + archived = 0 + failed = 0 + for name, _idle, _last in candidates: + ok, msg = archive_skill(name) + if ok: + print(color(f" ✓ {name}: {msg}", Colors.GREEN)) + archived += 1 + else: + print(color(f" ✗ {name}: {msg}", Colors.RED)) + failed += 1 + + summary = f"\nArchived {archived}/{len(candidates)}" + if failed: + summary += f" — {failed} failed" + print(color(summary, Colors.GREEN if not failed else Colors.YELLOW)) diff --git a/scripts/release.py b/scripts/release.py index b8fec8e95f60f..e2f0431952737 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -255,6 +255,7 @@ "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_skills_lifecycle.py b/tests/hermes_cli/test_skills_lifecycle.py new file mode 100644 index 0000000000000..b1d0f20abc1f8 --- /dev/null +++ b/tests/hermes_cli/test_skills_lifecycle.py @@ -0,0 +1,360 @@ +"""Unit tests for hermes_cli/skills_config.py lifecycle command handlers +added for issue #19384 (`hermes skills {stats, archive, restore, prune}`). + +These tests mock tools.skill_usage at the module level — never write to the +real ~/.hermes/skills/.usage.json sidecar. Each handler is exercised in +isolation; the sidecar I/O and archive mechanics are covered separately by +tests/tools/test_skill_usage.py. +""" +from datetime import datetime, timedelta, timezone + +import pytest + +# Ensure tools.skill_usage is loaded so monkeypatch.setattr can find attributes. +import tools.skill_usage # noqa: F401 + +from hermes_cli.skills_config import ( + _cmd_archive, + _cmd_prune, + _cmd_restore, + _cmd_stats, +) + + +def _iso_days_ago(days: int) -> str: + return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + + +def _row(name, **overrides): + """Build a row in the shape of agent_created_report() output.""" + base = { + "name": name, + "use_count": 0, + "view_count": 0, + "patch_count": 0, + "last_used_at": None, + "last_viewed_at": None, + "last_patched_at": None, + "created_at": _iso_days_ago(1), + "state": "active", + "pinned": False, + "archived_at": None, + "last_activity_at": None, + "activity_count": 0, + } + base.update(overrides) + return base + + +# ─── _cmd_stats ────────────────────────────────────────────────────────────── + +def test_stats_empty_report(monkeypatch, capsys): + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: []) + _cmd_stats(None) + out = capsys.readouterr().out + assert "No agent-created skills tracked yet" in out + + +def test_stats_renders_rows(monkeypatch, capsys): + rows = [_row("foo-skill", use_count=3, activity_count=3, + last_activity_at=_iso_days_ago(1))] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + _cmd_stats(None) + out = capsys.readouterr().out + assert "foo-skill" in out + + +def test_stats_since_days_filters_old(monkeypatch, capsys): + rows = [ + _row("recent", use_count=2, activity_count=2, + last_activity_at=_iso_days_ago(2)), + _row("ancient", use_count=10, activity_count=10, + last_activity_at=_iso_days_ago(60)), + ] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + _cmd_stats(7) + out = capsys.readouterr().out + assert "recent" in out + assert "ancient" not in out + + +def test_stats_since_days_excludes_no_activity(monkeypatch, capsys): + """A row with last_activity_at=None doesn't count as 'within last N days'.""" + rows = [_row("never-used", activity_count=0, last_activity_at=None)] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + _cmd_stats(7) + out = capsys.readouterr().out + assert "No activity in the last" in out + assert "never-used" not in out + + +def test_stats_sort_by_activity_desc(monkeypatch, capsys): + rows = [ + _row("low", activity_count=1, use_count=1, + last_activity_at=_iso_days_ago(1)), + _row("high", activity_count=99, use_count=99, + last_activity_at=_iso_days_ago(1)), + _row("mid", activity_count=10, use_count=10, + last_activity_at=_iso_days_ago(1)), + ] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + _cmd_stats(None) + out = capsys.readouterr().out + pos_high = out.find("high") + pos_mid = out.find("mid") + pos_low = out.find("low") + assert pos_high != -1 and pos_mid != -1 and pos_low != -1 + assert pos_high < pos_mid < pos_low + + +# ─── _cmd_archive ──────────────────────────────────────────────────────────── + +def test_archive_pinned_refuses_with_hint(monkeypatch, capsys): + monkeypatch.setattr( + "tools.skill_usage.get_record", + lambda n: _row(n, pinned=True), + ) + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: pytest.fail("must not call archive_skill on pinned"), + ) + with pytest.raises(SystemExit) as exc: + _cmd_archive("foo") + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "pinned" in err.lower() + assert "hermes curator unpin" in err + + +def test_archive_success(monkeypatch, capsys): + monkeypatch.setattr( + "tools.skill_usage.get_record", + lambda n: {"pinned": False}, + ) + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: (True, "archived to .archive/foo"), + ) + _cmd_archive("foo") + out = capsys.readouterr().out + assert "archived to .archive/foo" in out + + +def test_archive_failure_exits_nonzero(monkeypatch, capsys): + monkeypatch.setattr( + "tools.skill_usage.get_record", + lambda n: {"pinned": False}, + ) + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: (False, "skill 'foo' not found"), + ) + with pytest.raises(SystemExit) as exc: + _cmd_archive("foo") + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "not found" in err + + +def test_archive_typo_falls_through_to_archive_skill(monkeypatch, capsys): + """get_record returns _empty_record (pinned=False) for unknown names — + the failure mode is archive_skill returning (False, ...), not a phantom + pinned check.""" + archive_calls = [] + + def fake_get_record(name): + # Mirrors what tools.skill_usage._empty_record() returns. + return { + "use_count": 0, "view_count": 0, "patch_count": 0, + "last_used_at": None, "last_viewed_at": None, + "last_patched_at": None, + "created_at": "2026-01-01T00:00:00+00:00", + "state": "active", "pinned": False, "archived_at": None, + } + + def fake_archive(name): + archive_calls.append(name) + return (False, f"skill '{name}' not found") + + monkeypatch.setattr("tools.skill_usage.get_record", fake_get_record) + monkeypatch.setattr("tools.skill_usage.archive_skill", fake_archive) + with pytest.raises(SystemExit) as exc: + _cmd_archive("typo-skill") + assert exc.value.code == 1 + assert archive_calls == ["typo-skill"] + + +# ─── _cmd_restore ──────────────────────────────────────────────────────────── + +def test_restore_success(monkeypatch, capsys): + monkeypatch.setattr( + "tools.skill_usage.restore_skill", + lambda n: (True, "restored to skills/foo"), + ) + _cmd_restore("foo") + out = capsys.readouterr().out + assert "restored to skills/foo" in out + + +def test_restore_failure_exits_nonzero(monkeypatch, capsys): + monkeypatch.setattr( + "tools.skill_usage.restore_skill", + lambda n: (False, "skill 'foo' not found in archive"), + ) + with pytest.raises(SystemExit) as exc: + _cmd_restore("foo") + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "not found in archive" in err + + +# ─── _cmd_prune ────────────────────────────────────────────────────────────── + +def test_prune_zero_days_rejected(capsys): + with pytest.raises(SystemExit) as exc: + _cmd_prune(0, skip_confirm=True, dry_run=False) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "--days" in err + + +def test_prune_negative_days_rejected(capsys): + with pytest.raises(SystemExit) as exc: + _cmd_prune(-1, skip_confirm=True, dry_run=False) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "--days" in err + + +def test_prune_empty_report(monkeypatch, capsys): + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: []) + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: pytest.fail("must not archive when report is empty"), + ) + _cmd_prune(90, skip_confirm=True, dry_run=False) + out = capsys.readouterr().out + assert "Nothing to prune" in out + + +def test_prune_dry_run_does_not_archive(monkeypatch, capsys): + rows = [_row("old-skill", last_activity_at=_iso_days_ago(120))] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: pytest.fail("dry_run must not archive"), + ) + _cmd_prune(90, skip_confirm=True, dry_run=True) + out = capsys.readouterr().out + assert "old-skill" in out + assert "Dry run" in out + + +def test_prune_skip_confirm_archives_each(monkeypatch, capsys): + archived = [] + rows = [ + _row("a", last_activity_at=_iso_days_ago(120)), + _row("b", last_activity_at=_iso_days_ago(180)), + ] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + + def fake_archive(name): + archived.append(name) + return (True, f"archived {name}") + + monkeypatch.setattr("tools.skill_usage.archive_skill", fake_archive) + _cmd_prune(90, skip_confirm=True, dry_run=False) + assert sorted(archived) == ["a", "b"] + + +def test_prune_user_says_no_aborts(monkeypatch, capsys): + rows = [_row("old-skill", last_activity_at=_iso_days_ago(120))] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: pytest.fail("user said no — must not archive"), + ) + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + _cmd_prune(90, skip_confirm=False, dry_run=False) + out = capsys.readouterr().out + assert "Aborted" in out + + +def test_prune_user_says_yes_archives(monkeypatch, capsys): + archived = [] + rows = [_row("old-skill", last_activity_at=_iso_days_ago(120))] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + + def fake_archive(name): + archived.append(name) + return (True, "archived ok") + + monkeypatch.setattr("tools.skill_usage.archive_skill", fake_archive) + monkeypatch.setattr("builtins.input", lambda _prompt: "y") + _cmd_prune(90, skip_confirm=False, dry_run=False) + assert archived == ["old-skill"] + + +def test_prune_excludes_pinned(monkeypatch, capsys): + rows = [ + _row("pinned-old", pinned=True, + last_activity_at=_iso_days_ago(200)), + _row("unpinned-old", pinned=False, + last_activity_at=_iso_days_ago(200)), + ] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: archived.append(n) or (True, "ok"), + ) + _cmd_prune(90, skip_confirm=True, dry_run=False) + assert archived == ["unpinned-old"] + + +def test_prune_excludes_already_archived(monkeypatch, capsys): + rows = [ + _row("already-arch", state="archived", + last_activity_at=_iso_days_ago(200)), + _row("active-old", state="active", + last_activity_at=_iso_days_ago(200)), + ] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: archived.append(n) or (True, "ok"), + ) + _cmd_prune(90, skip_confirm=True, dry_run=False) + assert archived == ["active-old"] + + +def test_prune_excludes_recent(monkeypatch, capsys): + rows = [ + _row("fresh", last_activity_at=_iso_days_ago(10)), + _row("stale", last_activity_at=_iso_days_ago(100)), + ] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: archived.append(n) or (True, "ok"), + ) + _cmd_prune(90, skip_confirm=True, dry_run=False) + assert archived == ["stale"] + + +def test_prune_falls_back_to_created_at_for_never_used(monkeypatch, capsys): + """A skill with no activity but old created_at IS eligible for prune — + otherwise never-used skills become immortal.""" + rows = [_row("ancient-never-used", + last_activity_at=None, + created_at=_iso_days_ago(200))] + monkeypatch.setattr("tools.skill_usage.agent_created_report", lambda: rows) + archived = [] + monkeypatch.setattr( + "tools.skill_usage.archive_skill", + lambda n: archived.append(n) or (True, "ok"), + ) + _cmd_prune(90, skip_confirm=True, dry_run=False) + assert archived == ["ancient-never-used"] diff --git a/tests/hermes_cli/test_skills_subparser.py b/tests/hermes_cli/test_skills_subparser.py index d2b89ed3eaa20..e069754854237 100644 --- a/tests/hermes_cli/test_skills_subparser.py +++ b/tests/hermes_cli/test_skills_subparser.py @@ -1,7 +1,15 @@ -"""Test that skills subparser doesn't conflict (regression test for #898).""" +"""Test that skills subparser doesn't conflict (regression test for #898). + +Also smoke-tests the four lifecycle subparsers added for #19384 +(stats / archive / restore / prune) by replicating the argparse block +in isolation — same pattern used by test_subparser_routing_fallback.py +and test_argparse_flag_propagation.py. +""" import argparse +import pytest + def test_no_duplicate_skills_subparser(): """Ensure 'skills' subparser is only registered once to avoid Python 3.11+ crash. @@ -33,3 +41,93 @@ def test_no_duplicate_skills_subparser(): "See issue #898 for details." ) from e raise + + +# ─── Lifecycle subparser smoke tests (issue #19384) ────────────────────────── + +def _build_skills_lifecycle_parser(): + """Minimal replica of the lifecycle block in hermes_cli/main.py. + + Mirrors stats/archive/restore/prune verbatim so argparse semantics can + be verified without importing the full CLI (which has heavy module-load + side effects). + """ + parser = argparse.ArgumentParser(prog="hermes") + sub = parser.add_subparsers(dest="command") + skills = sub.add_parser("skills") + skills_sub = skills.add_subparsers(dest="skills_action") + + stats = skills_sub.add_parser("stats") + stats.add_argument("--days", type=int, default=None) + + archive = skills_sub.add_parser("archive") + archive.add_argument("name") + + restore = skills_sub.add_parser("restore") + restore.add_argument("name") + + prune = skills_sub.add_parser("prune") + prune.add_argument("--days", type=int, default=90) + prune.add_argument("--yes", "-y", action="store_true") + prune.add_argument("--dry-run", action="store_true") + + return parser + + +def test_stats_subparser_no_days(): + args = _build_skills_lifecycle_parser().parse_args(["skills", "stats"]) + assert args.skills_action == "stats" + assert args.days is None + + +def test_stats_subparser_with_days(): + args = _build_skills_lifecycle_parser().parse_args( + ["skills", "stats", "--days", "7"] + ) + assert args.skills_action == "stats" + assert args.days == 7 + + +def test_archive_subparser_requires_name(): + parser = _build_skills_lifecycle_parser() + with pytest.raises(SystemExit): + parser.parse_args(["skills", "archive"]) + + +def test_archive_subparser_with_name(): + args = _build_skills_lifecycle_parser().parse_args( + ["skills", "archive", "foo-skill"] + ) + assert args.skills_action == "archive" + assert args.name == "foo-skill" + + +def test_restore_subparser_with_name(): + args = _build_skills_lifecycle_parser().parse_args( + ["skills", "restore", "foo-skill"] + ) + assert args.skills_action == "restore" + assert args.name == "foo-skill" + + +def test_prune_subparser_defaults(): + args = _build_skills_lifecycle_parser().parse_args(["skills", "prune"]) + assert args.skills_action == "prune" + assert args.days == 90 + assert args.yes is False + assert args.dry_run is False + + +def test_prune_subparser_full_flags(): + args = _build_skills_lifecycle_parser().parse_args( + ["skills", "prune", "--days", "30", "--yes", "--dry-run"] + ) + assert args.skills_action == "prune" + assert args.days == 30 + assert args.yes is True + assert args.dry_run is True + + +def test_prune_subparser_yes_short_flag(): + args = _build_skills_lifecycle_parser().parse_args(["skills", "prune", "-y"]) + assert args.yes is True