fix: improve plugins list usability

This commit is contained in:
wysie 2026-05-28 01:18:16 +08:00 committed by Teknium
parent c692000a57
commit f32b66c758
3 changed files with 184 additions and 7 deletions

View file

@ -12910,7 +12910,34 @@ Examples:
)
plugins_remove.add_argument("name", help="Plugin directory name to remove")
plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
plugins_list = plugins_subparsers.add_parser(
"list", aliases=["ls"], help="List installed plugins"
)
plugins_list.add_argument(
"--enabled",
action="store_true",
help="Show only enabled plugins",
)
plugins_list.add_argument(
"--user",
action="store_true",
help="Show only user-installed plugins (including git plugins)",
)
plugins_list.add_argument(
"--no-bundled",
action="store_true",
help="Hide bundled plugins",
)
plugins_list.add_argument(
"--plain",
action="store_true",
help="Print compact plain-text output instead of a Rich table",
)
plugins_list.add_argument(
"--json",
action="store_true",
help="Print machine-readable JSON",
)
plugins_enable = plugins_subparsers.add_parser(
"enable", help="Enable a disabled plugin"

View file

@ -10,6 +10,7 @@ rendered with Rich Markdown. Otherwise a default confirmation is shown.
from __future__ import annotations
import functools
import json
import logging
import os
import shutil
@ -810,7 +811,29 @@ def _discover_all_plugins() -> list:
return list(seen.values())
def cmd_list() -> None:
def _plugin_status(name: str, enabled: set, disabled: set) -> str:
"""Return the user-facing activation state for a plugin name."""
if name in disabled:
return "disabled"
if name in enabled:
return "enabled"
return "not enabled"
def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set) -> list:
"""Apply ``hermes plugins list`` CLI filters."""
filtered = entries
if getattr(args, "no_bundled", False) or getattr(args, "user", False):
filtered = [entry for entry in filtered if entry[3] != "bundled"]
if getattr(args, "enabled", False):
filtered = [
entry for entry in filtered
if _plugin_status(entry[0], enabled, disabled) == "enabled"
]
return filtered
def cmd_list(args: Any | None = None) -> None:
"""List all plugins (bundled + user) with enabled/disabled state."""
from rich.console import Console
from rich.table import Table
@ -824,6 +847,31 @@ def cmd_list() -> None:
enabled = _get_enabled_set()
disabled = _get_disabled_set()
entries = _filter_plugin_entries(entries, args, enabled, disabled)
if getattr(args, "json", False):
payload = [
{
"name": name,
"status": _plugin_status(name, enabled, disabled),
"version": str(version),
"description": description,
"source": source,
}
for name, version, description, source, _dir in entries
]
print(json.dumps(payload, indent=2))
return
if getattr(args, "plain", False):
for name, version, _description, source, _dir in entries:
status = _plugin_status(name, enabled, disabled)
print(f"{status:12} {source:8} {str(version):8} {name}")
return
if not entries:
console.print("[dim]No plugins matched the selected filters.[/dim]")
return
table = Table(title="Plugins", show_lines=False)
table.add_column("Name", style="bold")
@ -833,9 +881,10 @@ def cmd_list() -> None:
table.add_column("Source", style="dim")
for name, version, description, source, _dir in entries:
if name in disabled:
status_name = _plugin_status(name, enabled, disabled)
if status_name == "disabled":
status = "[red]disabled[/red]"
elif name in enabled:
elif status_name == "enabled":
status = "[green]enabled[/green]"
else:
status = "[yellow]not enabled[/yellow]"
@ -844,6 +893,7 @@ def cmd_list() -> None:
console.print()
console.print(table)
console.print()
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
@ -1110,7 +1160,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
stdscr.addnstr(0, 0, "Plugins", max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" \u2191\u2193 navigate SPACE toggle ENTER configure/confirm ESC done",
" ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done",
max_x - 1, curses.A_DIM,
)
except curses.error:
@ -1150,7 +1200,9 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
pass
y += 1
for i in range(n_plugins):
plugin_start = scroll_offset
plugin_stop = min(n_plugins, scroll_offset + max(visible_rows, 0))
for i in range(plugin_start, plugin_stop):
if y >= max_y - 1:
break
check = "\u2713" if i in chosen else " "
@ -1208,6 +1260,16 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
elif key in {curses.KEY_DOWN, ord("j")}:
if total_items > 0:
cursor = (cursor + 1) % total_items
elif key in {curses.KEY_NPAGE, ord("f")}:
if total_items > 0:
cursor = min(total_items - 1, cursor + max(1, max_y - 5))
elif key in {curses.KEY_PPAGE, ord("b")}:
if total_items > 0:
cursor = max(0, cursor - max(1, max_y - 5))
elif key == curses.KEY_HOME:
cursor = 0
elif key == curses.KEY_END:
cursor = max(0, total_items - 1)
elif key == ord(" "):
if cursor < n_plugins:
# Toggle general plugin
@ -1649,7 +1711,7 @@ def plugins_command(args) -> None:
elif action == "disable":
cmd_disable(args.name)
elif action in {"list", "ls"}:
cmd_list()
cmd_list(args)
elif action is None:
cmd_toggle()
else:

View file

@ -0,0 +1,88 @@
import argparse
import json
from hermes_cli import plugins_cmd
def _args(**kwargs):
defaults = {
"enabled": False,
"user": False,
"no_bundled": False,
"plain": False,
"json": False,
}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
def test_filter_plugin_entries_enabled_only():
entries = [
("disk-cleanup", "2.0.0", "Bundled", "bundled", None),
("web-search-plus", "2.2.0", "Search", "git", None),
("old-plugin", "1.0.0", "Old", "user", None),
]
filtered = plugins_cmd._filter_plugin_entries(
entries,
_args(enabled=True),
enabled={"disk-cleanup", "web-search-plus"},
disabled={"old-plugin"},
)
assert [entry[0] for entry in filtered] == ["disk-cleanup", "web-search-plus"]
def test_filter_plugin_entries_no_bundled():
entries = [
("disk-cleanup", "2.0.0", "Bundled", "bundled", None),
("drawthings-grpc", "0.3.0", "Draw Things", "user", None),
("web-search-plus", "2.2.0", "Search", "git", None),
]
filtered = plugins_cmd._filter_plugin_entries(
entries,
_args(no_bundled=True),
enabled=set(),
disabled=set(),
)
assert [entry[0] for entry in filtered] == ["drawthings-grpc", "web-search-plus"]
def test_cmd_list_plain_compact_output(monkeypatch, capsys):
entries = [
("disk-cleanup", "2.0.0", "Bundled", "bundled", None),
("web-search-plus", "2.2.0", "Search", "git", None),
]
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: entries)
monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"web-search-plus"})
monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set())
plugins_cmd.cmd_list(_args(plain=True, no_bundled=True))
out = capsys.readouterr().out
assert "web-search-plus" in out
assert "enabled" in out
assert "disk-cleanup" not in out
assert "Search" not in out # plain mode stays compact, no descriptions
def test_cmd_list_json_output(monkeypatch, capsys):
entries = [("web-search-plus", "2.2.0", "Search", "git", None)]
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: entries)
monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"web-search-plus"})
monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set())
plugins_cmd.cmd_list(_args(json=True))
payload = json.loads(capsys.readouterr().out)
assert payload == [
{
"name": "web-search-plus",
"status": "enabled",
"version": "2.2.0",
"description": "Search",
"source": "git",
}
]