Files
hermes-agent/hermes_cli/plugins_cmd_toggle.py
kshitijk4poor 131d4a4634 fix(plugins): picker tick purges every alias of the disable; report real flips
Follow-up to the ported picker fix (#121623):

- Ticking a row re-enables it even when plugins.disabled holds the manifest
  name (e.g. telegram-platform, written by pickers before #40190). The save
  only dropped the key and its bare leaf, so the gate still matched the
  manifest name and the plugin stayed off. It now purges every alias like
  `hermes plugins enable/disable` (_apply_activation, shared with
  _set_plugin_enabled), with one discovery scan per save.
- The success line counts the rows actually turned on/off instead of treating
  every unticked row as "disabled"; the unused new_enabled return is gone.
- _entry_status shares the per-row status call between the picker
  preselection and `hermes plugins list --enabled`.
2026-09-25 21:19:04 +05:30

338 lines
16 KiB
Python

"""The interactive ``hermes plugins`` composite UI: general-plugin checkboxes (saved through the one
admission authority) and the memory-provider / context-engine category pickers.
Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is
imported late here, never at module level).
"""
from __future__ import annotations
import functools
import logging
import sys
logger = logging.getLogger(__name__)
def _pc():
"""The facade, read at call time: tests patch ``plugins_cmd.<name>`` and sibling calls must see it."""
from hermes_cli import plugins_cmd
return plugins_cmd
def _discover_memory_providers() -> list[tuple[str, str]]:
"""``[(name, description), ...]`` for available memory providers."""
try:
from plugins.memory import discover_memory_providers
return [(name, desc) for name, desc, _avail in discover_memory_providers()]
except Exception:
return []
def _discover_context_engines() -> list[tuple[str, str]]:
"""``[(name, description), ...]`` for repo-shipped context engines plus the plugin-registered
one (``ctx.register_context_engine``); repo-shipped descriptions win on a name collision."""
engines: dict[str, str] = {}
try:
from plugins.context_engine import discover_context_engines
for name, desc, _avail in discover_context_engines():
engines.setdefault(name, desc)
except Exception:
pass
try:
from hermes_cli.plugins import discover_plugins, get_plugin_context_engine
discover_plugins()
plugin_engine = get_plugin_context_engine()
if plugin_engine and getattr(plugin_engine, "name", None):
engines.setdefault(plugin_engine.name, "installed plugin")
except Exception:
pass
return list(engines.items())
# (title, default label, default name, current-value reader, discovery fn, saver) per provider
# category. Readers/savers are looked up at call time so module-level patching still applies.
_PROVIDER_CATEGORY_SPECS = (
("Memory Provider", "built-in", "", lambda: _pc()._get_current_memory_provider(),
lambda: _discover_memory_providers(), lambda v: _pc()._save_memory_provider(v)),
("Context Engine", "compressor", "compressor", lambda: _pc()._get_current_context_engine(),
lambda: _pc()._discover_context_engines(), lambda v: _pc()._save_context_engine(v)),
)
def _configure_category_spec(spec) -> bool:
"""Radio picker for one ``_PROVIDER_CATEGORY_SPECS`` row: the built-in default first, then the
discovered choices; a current value not among them is appended as ``(not found)``. Saves and
returns True when the choice changed."""
from hermes_cli.curses_ui import curses_radiolist
title, default_label, default_name, current, discover, save = spec
current = current()
choices = discover()
names = [default_name] + [name for name, _desc in choices]
items = [f"{default_label} (default)"] + [f"{name} \u2014 {desc}" if desc else name for name, desc in choices]
if current not in names:
names.append(current)
items.append(f"{current} (not found)")
selected = max(i for i, name in enumerate(names) if name == current)
new_value = names[curses_radiolist(title=f"{title} (select one)", items=items, selected=selected)]
if new_value == current:
return False
save(new_value)
return True
def _provider_categories() -> list:
"""``[(title, current_label, configure_fn), ...]`` rows for the composite UI."""
return [(s[0], s[3]() or s[1], functools.partial(_configure_category_spec, s)) for s in _PROVIDER_CATEGORY_SPECS]
def cmd_toggle() -> None:
"""Interactive composite UI — general plugins + provider plugin categories."""
console = _pc()._console()
entries = _pc()._discover_all_plugins()
expected_config = _pc()._plugin_selection_version()
enabled_set = _pc()._get_enabled_set()
disabled_set = _pc()._get_disabled_set()
# Track by CANONICAL KEY, not manifest name: the loader and enable/disable all gate on the
# key (``web/firecrawl``) while the name may differ (``web-firecrawl``); persisting the bare
# name let plugins.disabled drift so "explicit disable wins" kept a plugin off forever.
plugin_keys = [entry[5] for entry in entries]
# Keys keep every surface aligned. See #40190.
plugin_labels = [
(f"{name} \u2014 {description}" if description else name) + (" [bundled]" if source == "bundled" else "")
for name, _version, description, source, _d, _key in entries
]
plugin_selected = _effective_plugin_selection(entries, enabled_set, disabled_set)
categories = _pc()._provider_categories()
if not sys.stdin.isatty():
console.print("[dim]Interactive mode requires a terminal.[/dim]")
return
try:
import curses
_run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config)
except ImportError:
_run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config)
def _effective_plugin_selection(entries, enabled: set, disabled: set) -> set:
"""Row indices ticked on open: the load-time rule, not list membership — bundled platforms,
backends and model providers are active without a ``plugins.enabled`` entry."""
from hermes_cli.plugins_cmd_listing import _entry_status
active = _pc()._category_active_names()
return {i for i, entry in enumerate(entries) if _entry_status(entry, enabled, disabled, active) == "enabled"}
def _persist_plugin_selection(plugin_keys, chosen, disabled, initial, *, expected_config=None) -> tuple[list, list]:
"""Write only the rows flipped this session; returns the ``(turned_on, turned_off)`` keys.
Untouched rows are never persisted, so a never-ticked row is not mistaken for an explicit untick
and list entries for plugins the picker didn't show survive. Canonical key only, with every alias
purged from the opposing list like ``hermes plugins enable/disable`` (#40190).
"""
turned_on = [plugin_keys[i] for i in sorted(chosen - initial)]
turned_off = [plugin_keys[i] for i in sorted(initial - chosen)]
if not (turned_on or turned_off):
return [], []
if expected_config is None:
expected_config = _pc()._plugin_selection_version()
new_enabled, new_disabled = set(_pc()._get_enabled_set()), set(disabled)
entries = _pc()._discover_all_plugins()
for keys, enable in ((turned_on, True), (turned_off, False)):
for key in keys:
_pc()._apply_activation(new_enabled, new_disabled, key, _pc()._plugin_aliases(key, entries), enable=enable)
# C13: the composite UI's candidate goes through the ONE admission authority — refusal raises
# AdmissionRefused BEFORE any config write; the caller surfaces it and the selection stays unsaved.
_pc()._admit_and_save_plugin_sets(new_enabled, new_disabled, action="Save plugin selection", expected_config=expected_config)
logger.info("plugins picker: enabled %s; disabled %s", turned_on or "none", turned_off or "none")
return turned_on, turned_off
def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None):
"""Custom curses screen with checkboxes + category action rows."""
from hermes_cli.curses_ui import _addnstr, flush_stdin
chosen = set(plugin_selected)
n_plugins, n_categories = len(plugin_keys), len(categories)
total_items = n_plugins + n_categories # navigable rows (headers/separator are skipped)
providers_changed = False
nav = { # key -> new cursor, given (cursor, page_size)
key: move
for keys, move in (
((curses.KEY_UP, ord("k")), lambda c, p: (c - 1) % total_items),
((curses.KEY_DOWN, ord("j")), lambda c, p: (c + 1) % total_items),
((curses.KEY_NPAGE, ord("f")), lambda c, p: min(total_items - 1, c + p)),
((curses.KEY_PPAGE, ord("b")), lambda c, p: max(0, c - p)),
((curses.KEY_HOME,), lambda c, p: 0),
((curses.KEY_END,), lambda c, p: total_items - 1),
)
for key in keys
}
def _init_colors():
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
gray = 8 if curses.COLORS > 8 else curses.COLOR_WHITE
for pair, fg in ((1, curses.COLOR_GREEN), (2, curses.COLOR_YELLOW), (3, curses.COLOR_CYAN), (4, gray)):
curses.init_pair(pair, fg, -1)
def _attr(base, pair):
return base | curses.color_pair(pair) if curses.has_colors() else base
def _row(text, idx, cursor, pair):
"""One navigable body row: arrow marker + bold color when *idx* is the cursor."""
arrow = "\u2192" if idx == cursor else " "
return (f" {arrow} {text}", _attr(curses.A_BOLD, pair) if idx == cursor else curses.A_NORMAL)
def _configure_category(ci):
"""Leave curses, run the category's picker, refresh its row, re-enter curses."""
nonlocal providers_changed
curses.endwin()
cat_name, _cat_cur, cat_fn = categories[ci]
if cat_fn():
providers_changed = True
categories[ci] = (cat_name, _pc()._provider_categories()[ci][1], cat_fn)
stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
stdscr.keypad(True)
_init_colors()
curses.curs_set(0)
return stdscr
def _body_lines(cursor, scroll_offset, visible_rows):
"""Body rows as (text, attr); "" is a blank separator."""
lines = []
if n_plugins > 0:
lines.append((" General Plugins", _attr(curses.A_BOLD, 2)))
for i in range(scroll_offset, min(n_plugins, scroll_offset + max(visible_rows, 0))):
check = "\u2713" if i in chosen else " "
lines.append(_row(f"[{check}] {plugin_labels[i]}", i, cursor, 1))
lines.append(("", curses.A_NORMAL))
if n_categories > 0:
lines.append((" Provider Plugins", _attr(curses.A_BOLD, 2)))
lines += [
_row(f" {cat_name:<24} \u25b8 {cat_current}", n_plugins + ci, cursor, 3)
for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories)
]
return lines
def _draw(stdscr):
curses.curs_set(0)
_init_colors()
cursor = scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
_addnstr(stdscr, 0, 0, "Plugins", max_x - 1, _attr(curses.A_BOLD, 2))
_addnstr(
stdscr, 1, 0, " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done",
max_x - 1, curses.A_DIM)
visible_rows = max_y - 4
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
lines = _body_lines(cursor, scroll_offset, visible_rows)
for y, (text, attr) in enumerate(lines[: max(0, max_y - 4)], start=3):
if text:
_addnstr(stdscr, y, 0, text, max_x - 1, attr)
stdscr.refresh()
key = stdscr.getch()
if key in nav:
if total_items > 0: # (with no rows, every motion leaves cursor at 0)
cursor = nav[key](cursor, max(1, max_y - 5))
elif key == ord(" ") or key in {curses.KEY_ENTER, 10, 13}:
if cursor >= n_plugins:
# Provider category — launch sub-screen (SPACE and ENTER alike)
if cursor - n_plugins < n_categories:
stdscr = _configure_category(cursor - n_plugins)
elif key == ord(" "):
chosen.symmetric_difference_update({cursor})
else:
return # ENTER on a plugin checkbox — confirm and exit
elif key in {27, ord("q")}:
return # plugin changes are saved on exit
curses.wrapper(_draw)
flush_stdin()
from hermes_cli.plugins_admission import AdmissionRefused
try:
turned_on, turned_off = _persist_plugin_selection(plugin_keys, chosen, disabled, plugin_selected,
expected_config=expected_config)
except AdmissionRefused as exc:
console.print(f"[red]✗[/red] Plugin selection refused, not saved: {exc}")
console.print(
"[dim]config.yaml and the active environment are unchanged. "
"Run `hermes pm install` to resolve, then retry.[/dim]"
)
return
if turned_on or turned_off:
console.print(
f"\n[green]\u2713[/green] General plugins: {len(turned_on)} turned on, {len(turned_off)} turned off.")
elif n_plugins > 0:
console.print("\n[dim]General plugins unchanged.[/dim]")
if providers_changed:
console.print(
f"[green]\u2713[/green] Memory provider: [bold]{_pc()._get_current_memory_provider() or 'built-in'}[/bold] "
f"Context engine: [bold]{_pc()._get_current_context_engine()}[/bold]")
if n_plugins > 0 or providers_changed:
console.print("[dim]Changes take effect on next session.[/dim]")
console.print()
def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None):
"""Text-based fallback for the composite plugins UI."""
from hermes_cli.colors import Colors, color
print(color("\n Plugins", Colors.YELLOW))
if plugin_keys:
chosen = set(plugin_selected)
print(color("\n General Plugins", Colors.YELLOW))
print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM))
while True:
for i, label in enumerate(plugin_labels):
marker = color("[\u2713]", Colors.GREEN) if i in chosen else "[ ]"
print(f" {marker} {i + 1:>2}. {label}")
print()
try:
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
if not val:
break
idx = int(val) - 1
if 0 <= idx < len(plugin_keys):
chosen.symmetric_difference_update({idx})
except (ValueError, KeyboardInterrupt, EOFError):
return
print()
_save_plugin_selection_fallback(plugin_keys, chosen, disabled, plugin_selected, expected_config=expected_config)
if categories:
print(color("\n Provider Plugins", Colors.YELLOW))
for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories):
print(f" {ci + 1}. {cat_name} [{cat_current}]")
print()
try:
val = input(color(" Configure # (or Enter to skip): ", Colors.DIM)).strip()
if val:
ci = int(val) - 1
if 0 <= ci < len(categories):
categories[ci][2]()
except (ValueError, KeyboardInterrupt, EOFError):
pass
print()
def _save_plugin_selection_fallback(plugin_keys, chosen, disabled, initial, *, expected_config=None) -> None:
"""The text fallback's save: same admission authority, refusal printed."""
from hermes_cli.plugins_admission import AdmissionRefused
try:
_persist_plugin_selection(plugin_keys, chosen, disabled, initial, expected_config=expected_config)
except AdmissionRefused as exc:
print(f" Plugin selection refused, not saved: {exc}")
print(" config.yaml and the active environment are unchanged.")