Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3779,6 +3779,16 @@ def cmd_skills(args):

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)
Expand Down
20 changes: 19 additions & 1 deletion hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -199,8 +210,15 @@ def discover_and_load(self) -> None:
# 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:
Expand Down
155 changes: 153 additions & 2 deletions hermes_cli/plugins_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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 <name>")


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:
Expand All @@ -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

Expand Down
11 changes: 8 additions & 3 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,17 +399,22 @@ See [MCP Config Reference](./mcp-config-reference.md) and [Use MCP with Hermes](
## `hermes plugins`

```bash
hermes plugins <subcommand>
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 <identifier> [--force]` | Install a plugin from a Git URL or `owner/repo`. |
| `update <name>` | Pull latest changes for an installed plugin. |
| `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |
| `list` (alias: `ls`) | List installed plugins. |
| `enable <name>` | Enable a disabled plugin. |
| `disable <name>` | 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).

Expand Down
21 changes: 19 additions & 2 deletions website/docs/user-guide/features/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,26 @@ The handler receives the argument string (everything after `/greet`) and returns

## Managing plugins

```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
```
/plugins # list loaded plugins in a session
hermes config set display.show_cost true # show cost in status bar

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.
Loading