From 0df4d1278e7de807e6a0c07a4894e375bb7f213b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:39:57 -0700 Subject: [PATCH] feat(plugins): add enable/disable commands + interactive toggle UI (#3747) Adds plugin management with three interfaces: hermes plugins # interactive curses checklist (like hermes tools) hermes plugins enable # non-interactive enable hermes plugins disable # non-interactive disable hermes plugins list # table with status column Disabled plugins are stored in config.yaml under plugins.disabled and skipped during discovery. Uses the same curses_checklist component as hermes tools for the interactive UI. Changes: - hermes_cli/plugins.py: _get_disabled_plugins() + skip disabled during discover_and_load() - hermes_cli/plugins_cmd.py: cmd_toggle() interactive UI, cmd_enable(), cmd_disable(), updated cmd_list() with status column - hermes_cli/main.py: enable/disable subparser entries - website/docs/reference/cli-commands.md: updated plugins section - website/docs/user-guide/features/plugins.md: updated managing section --- hermes_cli/main.py | 10 ++ hermes_cli/plugins.py | 20 ++- hermes_cli/plugins_cmd.py | 155 +++++++++++++++++++- website/docs/reference/cli-commands.md | 11 +- website/docs/user-guide/features/plugins.md | 23 ++- 5 files changed, 210 insertions(+), 9 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 59900cfd..e27344bb 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3779,6 +3779,16 @@ For more help on a command: plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins") + plugins_enable = plugins_subparsers.add_parser( + "enable", help="Enable a disabled plugin" + ) + plugins_enable.add_argument("name", help="Plugin name to enable") + + plugins_disable = plugins_subparsers.add_parser( + "disable", help="Disable a plugin without removing it" + ) + plugins_disable.add_argument("name", help="Plugin name to disable") + def cmd_plugins(args): from hermes_cli.plugins_cmd import plugins_command plugins_command(args) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 022c4481..c72bc59e 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -68,6 +68,17 @@ def _env_enabled(name: str) -> bool: return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} +def _get_disabled_plugins() -> set: + """Read the disabled plugins list from config.yaml.""" + try: + from hermes_cli.config import load_config + config = load_config() + disabled = config.get("plugins", {}).get("disabled", []) + return set(disabled) if isinstance(disabled, list) else set() + except Exception: + return set() + + # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @@ -199,8 +210,15 @@ class PluginManager: # 3. Pip / entry-point plugins manifests.extend(self._scan_entry_points()) - # Load each manifest + # Load each manifest (skip user-disabled plugins) + disabled = _get_disabled_plugins() for manifest in manifests: + if manifest.name in disabled: + loaded = LoadedPlugin(manifest=manifest, enabled=False) + loaded.error = "disabled via config" + self._plugins[manifest.name] = loaded + logger.debug("Skipping disabled plugin '%s'", manifest.name) + continue self._load_plugin(manifest) if manifests: diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index e20c1e1b..e53f5c94 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -374,6 +374,73 @@ def cmd_remove(name: str) -> None: _display_removed(name, plugins_dir) +def _get_disabled_set() -> set: + """Read the disabled plugins set from config.yaml.""" + try: + from hermes_cli.config import load_config + config = load_config() + disabled = config.get("plugins", {}).get("disabled", []) + return set(disabled) if isinstance(disabled, list) else set() + except Exception: + return set() + + +def _save_disabled_set(disabled: set) -> None: + """Write the disabled plugins list to config.yaml.""" + from hermes_cli.config import load_config, save_config + config = load_config() + if "plugins" not in config: + config["plugins"] = {} + config["plugins"]["disabled"] = sorted(disabled) + save_config(config) + + +def cmd_enable(name: str) -> None: + """Enable a previously disabled plugin.""" + from rich.console import Console + + console = Console() + plugins_dir = _plugins_dir() + + # Verify the plugin exists + target = plugins_dir / name + if not target.is_dir(): + console.print(f"[red]Plugin '{name}' is not installed.[/red]") + sys.exit(1) + + disabled = _get_disabled_set() + if name not in disabled: + console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]") + return + + disabled.discard(name) + _save_disabled_set(disabled) + console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. Takes effect on next session.") + + +def cmd_disable(name: str) -> None: + """Disable a plugin without removing it.""" + from rich.console import Console + + console = Console() + plugins_dir = _plugins_dir() + + # Verify the plugin exists + target = plugins_dir / name + if not target.is_dir(): + console.print(f"[red]Plugin '{name}' is not installed.[/red]") + sys.exit(1) + + disabled = _get_disabled_set() + if name in disabled: + console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]") + return + + disabled.add(name) + _save_disabled_set(disabled) + console.print(f"[yellow]⊘[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.") + + def cmd_list() -> None: """List installed plugins.""" from rich.console import Console @@ -393,8 +460,11 @@ def cmd_list() -> None: console.print("[dim]Install with:[/dim] hermes plugins install owner/repo") return + disabled = _get_disabled_set() + table = Table(title="Installed Plugins", show_lines=False) table.add_column("Name", style="bold") + table.add_column("Status") table.add_column("Version", style="dim") table.add_column("Description") table.add_column("Source", style="dim") @@ -420,11 +490,86 @@ def cmd_list() -> None: if (d / ".git").exists(): source = "git" - table.add_row(name, str(version), description, source) + is_disabled = name in disabled or d.name in disabled + status = "[red]disabled[/red]" if is_disabled else "[green]enabled[/green]" + table.add_row(name, status, str(version), description, source) console.print() console.print(table) console.print() + console.print("[dim]Interactive toggle:[/dim] hermes plugins") + console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable ") + + +def cmd_toggle() -> None: + """Interactive curses checklist to enable/disable installed plugins.""" + from rich.console import Console + + try: + import yaml + except ImportError: + yaml = None + + console = Console() + plugins_dir = _plugins_dir() + + dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir()) + if not dirs: + console.print("[dim]No plugins installed.[/dim]") + console.print("[dim]Install with:[/dim] hermes plugins install owner/repo") + return + + disabled = _get_disabled_set() + + # Build items list: "name — description" for display + names = [] + labels = [] + selected = set() + + for i, d in enumerate(dirs): + manifest_file = d / "plugin.yaml" + name = d.name + description = "" + + if manifest_file.exists() and yaml: + try: + with open(manifest_file) as f: + manifest = yaml.safe_load(f) or {} + name = manifest.get("name", d.name) + description = manifest.get("description", "") + except Exception: + pass + + names.append(name) + label = f"{name} — {description}" if description else name + labels.append(label) + + if name not in disabled and d.name not in disabled: + selected.add(i) + + from hermes_cli.curses_ui import curses_checklist + + result = curses_checklist( + title="Plugins — toggle enabled/disabled", + items=labels, + selected=selected, + ) + + # Compute new disabled set from deselected items + new_disabled = set() + for i, name in enumerate(names): + if i not in result: + new_disabled.add(name) + + if new_disabled != disabled: + _save_disabled_set(new_disabled) + enabled_count = len(names) - len(new_disabled) + console.print( + f"\n[green]✓[/green] {enabled_count} enabled, {len(new_disabled)} disabled. " + f"Takes effect on next session." + ) + else: + console.print("\n[dim]No changes.[/dim]") def plugins_command(args) -> None: @@ -437,8 +582,14 @@ def plugins_command(args) -> None: cmd_update(args.name) elif action in ("remove", "rm", "uninstall"): cmd_remove(args.name) - elif action in ("list", "ls") or action is None: + elif action == "enable": + cmd_enable(args.name) + elif action == "disable": + cmd_disable(args.name) + elif action in ("list", "ls"): cmd_list() + elif action is None: + cmd_toggle() else: from rich.console import Console diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index bc9e3afc..57b9d35b 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -399,17 +399,22 @@ See [MCP Config Reference](./mcp-config-reference.md) and [Use MCP with Hermes]( ## `hermes plugins` ```bash -hermes plugins +hermes plugins [subcommand] ``` -Manage Hermes Agent plugins. +Manage Hermes Agent plugins. Running `hermes plugins` with no subcommand launches an interactive curses checklist to enable/disable installed plugins. | Subcommand | Description | |------------|-------------| +| *(none)* | Interactive toggle UI — enable/disable plugins with arrow keys and space. | | `install [--force]` | Install a plugin from a Git URL or `owner/repo`. | | `update ` | Pull latest changes for an installed plugin. | | `remove ` (aliases: `rm`, `uninstall`) | Remove an installed plugin. | -| `list` (alias: `ls`) | List installed plugins. | +| `enable ` | Enable a disabled plugin. | +| `disable ` | Disable a plugin without removing it. | +| `list` (alias: `ls`) | List installed plugins with enabled/disabled status. | + +Disabled plugins are stored in `config.yaml` under `plugins.disabled` and skipped during loading. See [Plugins](../user-guide/features/plugins.md) and [Build a Hermes Plugin](../guides/build-a-hermes-plugin.md). diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 3bf822d1..0f2e20f1 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -87,9 +87,26 @@ The handler receives the argument string (everything after `/greet`) and returns ## Managing plugins -``` -/plugins # list loaded plugins in a session -hermes config set display.show_cost true # show cost in status bar +```bash +hermes plugins # interactive toggle UI — enable/disable with checkboxes +hermes plugins list # table view with enabled/disabled status +hermes plugins install user/repo # install from Git +hermes plugins update my-plugin # pull latest +hermes plugins remove my-plugin # uninstall +hermes plugins enable my-plugin # re-enable a disabled plugin +hermes plugins disable my-plugin # disable without removing ``` +Running `hermes plugins` with no arguments launches an interactive curses checklist (same UI as `hermes tools`) where you can toggle plugins on/off with arrow keys and space. + +Disabled plugins remain installed but are skipped during loading. The disabled list is stored in `config.yaml` under `plugins.disabled`: + +```yaml +plugins: + disabled: + - my-noisy-plugin +``` + +In a running session, `/plugins` shows which plugins are currently loaded. + See the **[full guide](/docs/guides/build-a-hermes-plugin)** for handler contracts, schema format, hook behavior, error handling, and common mistakes.