From 0e2a53eab2ac7a937b2ce2a089b07c18f8e30bcf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:20:53 -0700 Subject: [PATCH] feat(skills): show enabled/disabled status in 'skills list' (#16129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'hermes skills list' now shows every skill's enabled/disabled status and accepts --enabled-only to filter down to what will actually load for the active profile: hermes -p dario skills list --enabled-only Previously the command was a flat catalog — it did not apply skills.disabled from config.yaml, so there was no way to see the live skill set for a profile without reading config by hand. Profile switching already works via -p (swaps HERMES_HOME); this just surfaces the result visibly. Changes: - hermes_cli/skills_hub.py: do_list adds a Status column and an enabled_only filter; summary reports enabled/disabled split - hermes_cli/main.py: --enabled-only flag on 'skills list' - /skills list slash command accepts --enabled-only too - tests: 4 new (status column, disabled marking, enabled-only hiding, no platform leakage into get_disabled_skill_names); existing fixtures updated to accept skip_disabled kwarg Reported by @mochizukimr on X. --- hermes_cli/main.py | 6 +++ hermes_cli/skills_hub.py | 74 +++++++++++++++++++++++------ tests/hermes_cli/test_skills_hub.py | 72 +++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a53b8d2c5e..9c4b40de27 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8453,6 +8453,12 @@ Examples: skills_list.add_argument( "--source", default="all", choices=["all", "hub", "builtin", "local"] ) + skills_list.add_argument( + "--enabled-only", + action="store_true", + help="Hide disabled skills. Use with -p to see exactly " + "which skills will load for that profile.", + ) skills_check = skills_subparsers.add_parser( "check", help="Check installed hub skills for updates" diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index bf92fafe10..2e425eee89 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -599,11 +599,24 @@ def inspect_skill(identifier: str) -> Optional[dict]: return out -def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None: - """List installed skills, distinguishing hub, builtin, and local skills.""" +def do_list(source_filter: str = "all", + enabled_only: bool = False, + console: Optional[Console] = None) -> None: + """List installed skills, distinguishing hub, builtin, and local skills. + + Args: + source_filter: ``all`` | ``hub`` | ``builtin`` | ``local``. + enabled_only: If True, hide disabled skills from the output. + + Enabled/disabled state is resolved against the currently active profile's + config — ``hermes -p skills list`` reads that profile's + ``skills.disabled`` list because ``-p`` swaps ``HERMES_HOME`` at process + start. No explicit profile flag needed here. + """ from tools.skills_hub import HubLockFile, ensure_hub_dirs from tools.skills_sync import _read_manifest from tools.skills_tool import _find_all_skills + from agent.skill_utils import get_disabled_skill_names c = console or _console ensure_hub_dirs() @@ -611,17 +624,26 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No hub_installed = {e["name"]: e for e in lock.list_installed()} builtin_names = set(_read_manifest()) - all_skills = _find_all_skills() + # Pull ALL skills (including disabled ones) so we can annotate status. + all_skills = _find_all_skills(skip_disabled=True) + disabled_names = get_disabled_skill_names() - table = Table(title="Installed Skills") + title = "Installed Skills" + if enabled_only: + title += " (enabled only)" + + table = Table(title=title) table.add_column("Name", style="bold cyan") table.add_column("Category", style="dim") table.add_column("Source", style="dim") table.add_column("Trust", style="dim") + table.add_column("Status", style="dim") hub_count = 0 builtin_count = 0 local_count = 0 + enabled_count = 0 + disabled_count = 0 for skill in sorted(all_skills, key=lambda s: (s.get("category") or "", s["name"])): name = skill["name"] @@ -632,29 +654,48 @@ def do_list(source_filter: str = "all", console: Optional[Console] = None) -> No source_type = "hub" source_display = hub_entry.get("source", "hub") trust = hub_entry.get("trust_level", "community") - hub_count += 1 elif name in builtin_names: source_type = "builtin" source_display = "builtin" trust = "builtin" - builtin_count += 1 else: source_type = "local" source_display = "local" trust = "local" - local_count += 1 if source_filter != "all" and source_filter != source_type: continue + is_enabled = name not in disabled_names + if enabled_only and not is_enabled: + continue + + if source_type == "hub": + hub_count += 1 + elif source_type == "builtin": + builtin_count += 1 + else: + local_count += 1 + + if is_enabled: + enabled_count += 1 + status_cell = "[bold green]enabled[/]" + else: + disabled_count += 1 + status_cell = "[dim red]disabled[/]" + trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}.get(trust, "dim") trust_label = "official" if source_display == "official" else trust - table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]") + table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]", status_cell) c.print(table) - c.print( - f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local[/]\n" - ) + summary = f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local" + if enabled_only: + summary += f" — {enabled_count} enabled shown" + else: + summary += f" — {enabled_count} enabled, {disabled_count} disabled" + summary += "[/]\n" + c.print(summary) def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None: @@ -1127,7 +1168,10 @@ def skills_command(args) -> None: elif action == "inspect": do_inspect(args.identifier) elif action == "list": - do_list(source_filter=args.source) + do_list( + source_filter=args.source, + enabled_only=getattr(args, "enabled_only", False), + ) elif action == "check": do_check(name=getattr(args, "name", None)) elif action == "update": @@ -1279,11 +1323,12 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: elif action == "list": source_filter = "all" + enabled_only = "--enabled-only" in args or "--enabled" in args if "--source" in args: idx = args.index("--source") if idx + 1 < len(args): source_filter = args[idx + 1] - do_list(source_filter=source_filter, console=c) + do_list(source_filter=source_filter, enabled_only=enabled_only, console=c) elif action == "check": name = args[0] if args else None @@ -1371,7 +1416,8 @@ def _print_skills_help(console: Console) -> None: " [cyan]search[/] Search registries for skills\n" " [cyan]install[/] Install a skill (with security scan)\n" " [cyan]inspect[/] Preview a skill without installing\n" - " [cyan]list[/] [--source hub|builtin|local] List installed skills\n" + " [cyan]list[/] [--source hub|builtin|local] [--enabled-only]\n" + " List installed skills; --enabled-only filters to the active profile's live set\n" " [cyan]check[/] [name] Check hub skills for upstream updates\n" " [cyan]update[/] [name] Update hub skills with upstream changes\n" " [cyan]audit[/] [name] Re-scan hub skills for security\n" diff --git a/tests/hermes_cli/test_skills_hub.py b/tests/hermes_cli/test_skills_hub.py index bf9fa71a3a..3866730921 100644 --- a/tests/hermes_cli/test_skills_hub.py +++ b/tests/hermes_cli/test_skills_hub.py @@ -56,7 +56,7 @@ def three_source_env(monkeypatch, hub_env): import tools.skills_tool as skills_tool monkeypatch.setattr(hub, "HubLockFile", lambda: _DummyLockFile([_HUB_ENTRY])) - monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: list(_ALL_THREE_SKILLS)) + monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: list(_ALL_THREE_SKILLS)) monkeypatch.setattr(skills_sync, "_read_manifest", lambda: dict(_BUILTIN_MANIFEST)) return hub_env @@ -107,7 +107,7 @@ def test_do_list_initializes_hub_dir(monkeypatch, hub_env): import tools.skills_sync as skills_sync import tools.skills_tool as skills_tool - monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: []) + monkeypatch.setattr(skills_tool, "_find_all_skills", lambda **_kwargs: []) monkeypatch.setattr(skills_sync, "_read_manifest", lambda: {}) hub_dir = hub_env @@ -154,6 +154,74 @@ def test_do_list_filter_builtin(three_source_env): assert "local-skill" not in output +def test_do_list_renders_status_column(three_source_env, monkeypatch): + """Every list row should carry an enabled/disabled status (new in PR that + answered Mr Mochizuki's 'I just want to see what's live' question).""" + from agent import skill_utils + + monkeypatch.setattr(skill_utils, "get_disabled_skill_names", lambda platform=None: set()) + output = _capture() + + assert "Status" in output + assert "enabled" in output.lower() + # Summary counts enabled skills. + assert "3 enabled, 0 disabled" in output + + +def test_do_list_marks_disabled_skills(three_source_env, monkeypatch): + from agent import skill_utils + + # Simulate `skills.disabled: [hub-skill]` in config. + monkeypatch.setattr( + skill_utils, "get_disabled_skill_names", + lambda platform=None: {"hub-skill"}, + ) + output = _capture() + + # Row still appears (no --enabled-only), but marked disabled + assert "hub-skill" in output + assert "disabled" in output.lower() + assert "2 enabled, 1 disabled" in output + + +def test_do_list_enabled_only_hides_disabled(three_source_env, monkeypatch): + from agent import skill_utils + + monkeypatch.setattr( + skill_utils, "get_disabled_skill_names", + lambda platform=None: {"hub-skill"}, + ) + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + do_list(enabled_only=True, console=console) + output = sink.getvalue() + + assert "hub-skill" not in output + assert "builtin-skill" in output + assert "local-skill" in output + assert "enabled only" in output.lower() + assert "2 enabled shown" in output + + +def test_do_list_platform_env_is_ignored(three_source_env, monkeypatch): + """`hermes skills list` reads the active profile's config via + HERMES_HOME (swapped by -p), so it must NOT pass a platform arg to + ``get_disabled_skill_names`` — otherwise per-platform overrides + would silently leak in from HERMES_PLATFORM env.""" + from agent import skill_utils + + seen = {} + + def _fake(platform=None): + seen["platform"] = platform + return set() + + monkeypatch.setattr(skill_utils, "get_disabled_skill_names", _fake) + _capture() + + assert seen["platform"] is None + + def test_do_check_reports_available_updates(monkeypatch): output = _capture_check(monkeypatch, [ {"name": "hub-skill", "source": "skills.sh", "status": "update_available"},