feat(plugins): make all plugins opt-in by default

Plugins now require explicit consent to load. Discovery still finds every
plugin — user-installed, bundled, and pip — so they all show up in
`hermes plugins` and `/plugins`, but the loader only instantiates
plugins whose name appears in `plugins.enabled` in config.yaml. This
removes the previous ambient-execution risk where a newly-installed or
bundled plugin could register hooks, tools, and commands on first run
without the user opting in.

The three-state model is now explicit:
  enabled     — in plugins.enabled, loads on next session
  disabled    — in plugins.disabled, never loads (wins over enabled)
  not enabled — discovered but never opted in (default for new installs)

`hermes plugins install <repo>` prompts "Enable 'name' now? [y/N]"
(defaults to no). New `--enable` / `--no-enable` flags skip the prompt
for scripted installs. `hermes plugins enable/disable` manage both lists
so a disabled plugin stays explicitly off even if something later adds
it to enabled.

Config migration (schema v20 → v21): existing user plugins already
installed under ~/.hermes/plugins/ (minus anything in plugins.disabled)
are auto-grandfathered into plugins.enabled so upgrades don't silently
break working setups. Bundled plugins are NOT grandfathered — even
existing users have to opt in explicitly.

Also: HERMES_DISABLE_BUNDLED_PLUGINS env var removed (redundant with
opt-in default), cmd_list now shows bundled + user plugins together with
their three-state status, interactive UI tags bundled entries
[bundled], docs updated across plugins.md and built-in-plugins.md.

Validation: 442 plugin/config tests pass. E2E: fresh install discovers
disk-cleanup but does not load it; `hermes plugins enable disk-cleanup`
activates hooks; migration grandfathers existing user plugins correctly
while leaving bundled plugins off.
This commit is contained in:
Teknium 2026-04-20 04:40:17 -07:00 committed by Teknium
parent a25c8c6a56
commit 70111eea24
10 changed files with 578 additions and 167 deletions

View file

@ -827,7 +827,7 @@ DEFAULT_CONFIG = {
}, },
# Config schema version - bump this when adding new required fields # Config schema version - bump this when adding new required fields
"_config_version": 20, "_config_version": 21,
} }
# ============================================================================= # =============================================================================
@ -2484,6 +2484,72 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
else: else:
print(" ✓ Removed unused compression.summary_* keys") print(" ✓ Removed unused compression.summary_* keys")
# ── Version 20 → 21: plugins are now opt-in; grandfather existing user plugins ──
# The loader now requires plugins to appear in ``plugins.enabled`` before
# loading. Existing installs had all discovered plugins loading by default
# (minus anything in ``plugins.disabled``). To avoid silently breaking
# those setups on upgrade, populate ``plugins.enabled`` with the set of
# currently-installed user plugins that aren't already disabled.
#
# Bundled plugins (shipped in the repo itself) are NOT grandfathered —
# they ship off for everyone, including existing users, so any user who
# wants one has to opt in explicitly.
if current_ver < 21:
config = read_raw_config()
plugins_cfg = config.get("plugins")
if not isinstance(plugins_cfg, dict):
plugins_cfg = {}
# Only migrate if the enabled allow-list hasn't been set yet.
if "enabled" not in plugins_cfg:
disabled = plugins_cfg.get("disabled", []) or []
if not isinstance(disabled, list):
disabled = []
disabled_set = set(disabled)
# Scan ``$HERMES_HOME/plugins/`` for currently installed user plugins.
grandfathered: List[str] = []
try:
from hermes_constants import get_hermes_home as _ghome
user_plugins_dir = _ghome() / "plugins"
if user_plugins_dir.is_dir():
for child in sorted(user_plugins_dir.iterdir()):
if not child.is_dir():
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
if not manifest_file.exists():
continue
try:
with open(manifest_file) as _mf:
manifest = yaml.safe_load(_mf) or {}
except Exception:
manifest = {}
name = manifest.get("name") or child.name
if name in disabled_set:
continue
grandfathered.append(name)
except Exception:
grandfathered = []
plugins_cfg["enabled"] = grandfathered
config["plugins"] = plugins_cfg
save_config(config)
results["config_added"].append(
f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)"
)
if not quiet:
if grandfathered:
print(
f" ✓ Plugins now opt-in: grandfathered "
f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
)
else:
print(
" ✓ Plugins now opt-in: no existing plugins to grandfather. "
"Use `hermes plugins enable <name>` to activate."
)
if current_ver < latest_ver and not quiet: if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}") print(f"Config version: {current_ver}{latest_ver}")

View file

@ -7449,6 +7449,17 @@ Examples:
action="store_true", action="store_true",
help="Remove existing plugin and reinstall", help="Remove existing plugin and reinstall",
) )
_install_enable_group = plugins_install.add_mutually_exclusive_group()
_install_enable_group.add_argument(
"--enable",
action="store_true",
help="Auto-enable the plugin after install (skip confirmation prompt)",
)
_install_enable_group.add_argument(
"--no-enable",
action="store_true",
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
)
plugins_update = plugins_subparsers.add_parser( plugins_update = plugins_subparsers.add_parser(
"update", help="Pull latest changes for an installed plugin" "update", help="Pull latest changes for an installed plugin"

View file

@ -83,7 +83,12 @@ def _env_enabled(name: str) -> bool:
def _get_disabled_plugins() -> set: def _get_disabled_plugins() -> set:
"""Read the disabled plugins list from config.yaml.""" """Read the disabled plugins list from config.yaml.
Kept for backward compat and explicit deny-list semantics. A plugin
name in this set will never load, even if it appears in
``plugins.enabled``.
"""
try: try:
from hermes_cli.config import load_config from hermes_cli.config import load_config
config = load_config() config = load_config()
@ -93,6 +98,36 @@ def _get_disabled_plugins() -> set:
return set() return set()
def _get_enabled_plugins() -> Optional[set]:
"""Read the enabled-plugins allow-list from config.yaml.
Plugins are opt-in by default only plugins whose name appears in
this set are loaded. Returns:
* ``None`` the key is missing or malformed. Callers should treat
this as "nothing enabled yet" (the opt-in default); the first
``migrate_config`` run populates the key with a grandfathered set
of currently-installed user plugins so existing setups don't
break on upgrade.
* ``set()`` an empty list was explicitly set; nothing loads.
* ``set(...)`` the concrete allow-list.
"""
try:
from hermes_cli.config import load_config
config = load_config()
plugins_cfg = config.get("plugins")
if not isinstance(plugins_cfg, dict):
return None
if "enabled" not in plugins_cfg:
return None
enabled = plugins_cfg.get("enabled")
if not isinstance(enabled, list):
return None
return set(enabled)
except Exception:
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Data classes # Data classes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -431,17 +466,17 @@ class PluginManager:
# 1. Bundled plugins (<repo>/plugins/<name>/) # 1. Bundled plugins (<repo>/plugins/<name>/)
# Repo-shipped generic plugins live next to hermes_cli/. Memory and # Repo-shipped generic plugins live next to hermes_cli/. Memory and
# context_engine subdirs are handled by their own discovery paths, so # context_engine subdirs are handled by their own discovery paths, so
# skip those names here. # skip those names here. Bundled plugins are discovered (so they
# Tests can set HERMES_DISABLE_BUNDLED_PLUGINS=1 to get a clean slate. # show up in `hermes plugins`) but only loaded when added to
if not _env_enabled("HERMES_DISABLE_BUNDLED_PLUGINS"): # `plugins.enabled` in config.yaml — opt-in like any other plugin.
repo_plugins = Path(__file__).resolve().parent.parent / "plugins" repo_plugins = Path(__file__).resolve().parent.parent / "plugins"
manifests.extend( manifests.extend(
self._scan_directory( self._scan_directory(
repo_plugins, repo_plugins,
source="bundled", source="bundled",
skip_names={"memory", "context_engine"}, skip_names={"memory", "context_engine"},
)
) )
)
# 2. User plugins (~/.hermes/plugins/) # 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins" user_dir = get_hermes_home() / "plugins"
@ -460,16 +495,34 @@ class PluginManager:
# take precedence over bundled, project plugins take precedence over # take precedence over bundled, project plugins take precedence over
# user. Dedup here so we only load the final winner. # user. Dedup here so we only load the final winner.
disabled = _get_disabled_plugins() disabled = _get_disabled_plugins()
enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled)
winners: Dict[str, PluginManifest] = {} winners: Dict[str, PluginManifest] = {}
for manifest in manifests: for manifest in manifests:
winners[manifest.name] = manifest winners[manifest.name] = manifest
for manifest in winners.values(): for manifest in winners.values():
# Explicit disable always wins.
if manifest.name in disabled: if manifest.name in disabled:
loaded = LoadedPlugin(manifest=manifest, enabled=False) loaded = LoadedPlugin(manifest=manifest, enabled=False)
loaded.error = "disabled via config" loaded.error = "disabled via config"
self._plugins[manifest.name] = loaded self._plugins[manifest.name] = loaded
logger.debug("Skipping disabled plugin '%s'", manifest.name) logger.debug("Skipping disabled plugin '%s'", manifest.name)
continue continue
# Opt-in gate: plugins must be in the enabled allow-list.
# If the allow-list is missing (None), treat as "nothing enabled"
# — users have to explicitly enable plugins to load them.
# Memory and context_engine providers are excluded from this gate
# since they have their own single-select config (memory.provider
# / context.engine), not the enabled list.
if enabled is None or manifest.name not in enabled:
loaded = LoadedPlugin(manifest=manifest, enabled=False)
loaded.error = "not enabled in config (run `hermes plugins enable {}` to activate)".format(
manifest.name
)
self._plugins[manifest.name] = loaded
logger.debug(
"Skipping '%s' (not in plugins.enabled)", manifest.name
)
continue
self._load_plugin(manifest) self._load_plugin(manifest)
if manifests: if manifests:

View file

@ -15,6 +15,7 @@ import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
@ -281,8 +282,16 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def cmd_install(identifier: str, force: bool = False) -> None: def cmd_install(
"""Install a plugin from a Git URL or owner/repo shorthand.""" identifier: str,
force: bool = False,
enable: Optional[bool] = None,
) -> None:
"""Install a plugin from a Git URL or owner/repo shorthand.
After install, prompt "Enable now? [y/N]" unless *enable* is provided
(True = auto-enable without prompting, False = install disabled).
"""
import tempfile import tempfile
from rich.console import Console from rich.console import Console
@ -391,6 +400,40 @@ def cmd_install(identifier: str, force: bool = False) -> None:
_display_after_install(target, identifier) _display_after_install(target, identifier)
# Determine the canonical plugin name for enable-list bookkeeping.
installed_name = installed_manifest.get("name") or target.name
# Decide whether to enable: explicit flag > interactive prompt > default off
should_enable = enable
if should_enable is None:
# Interactive prompt unless stdin isn't a TTY (scripted install).
if sys.stdin.isatty() and sys.stdout.isatty():
try:
answer = input(
f" Enable '{installed_name}' now? [y/N]: "
).strip().lower()
should_enable = answer in ("y", "yes")
except (EOFError, KeyboardInterrupt):
should_enable = False
else:
should_enable = False
if should_enable:
enabled = _get_enabled_set()
disabled = _get_disabled_set()
enabled.add(installed_name)
disabled.discard(installed_name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled."
)
else:
console.print(
f"[dim]Plugin installed but not enabled. "
f"Run `hermes plugins enable {installed_name}` to activate.[/dim]"
)
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]") console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
console.print("[dim] hermes gateway restart[/dim]") console.print("[dim] hermes gateway restart[/dim]")
console.print() console.print()
@ -468,7 +511,11 @@ def cmd_remove(name: str) -> None:
def _get_disabled_set() -> set: def _get_disabled_set() -> set:
"""Read the disabled plugins set from config.yaml.""" """Read the disabled plugins set from config.yaml.
An explicit deny-list. A plugin name here never loads, even if also
listed in ``plugins.enabled``.
"""
try: try:
from hermes_cli.config import load_config from hermes_cli.config import load_config
config = load_config() config = load_config()
@ -488,103 +535,196 @@ def _save_disabled_set(disabled: set) -> None:
save_config(config) save_config(config)
def _get_enabled_set() -> set:
"""Read the enabled plugins allow-list from config.yaml.
Plugins are opt-in: only names here are loaded. Returns ``set()`` if
the key is missing (same behaviour as "nothing enabled yet").
"""
try:
from hermes_cli.config import load_config
config = load_config()
plugins_cfg = config.get("plugins", {})
if not isinstance(plugins_cfg, dict):
return set()
enabled = plugins_cfg.get("enabled", [])
return set(enabled) if isinstance(enabled, list) else set()
except Exception:
return set()
def _save_enabled_set(enabled: set) -> None:
"""Write the enabled 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"]["enabled"] = sorted(enabled)
save_config(config)
def cmd_enable(name: str) -> None: def cmd_enable(name: str) -> None:
"""Enable a previously disabled plugin.""" """Add a plugin to the enabled allow-list (and remove it from disabled)."""
from rich.console import Console from rich.console import Console
console = Console() console = Console()
plugins_dir = _plugins_dir() # Discover the plugin — check installed (user) AND bundled.
if not _plugin_exists(name):
# Verify the plugin exists console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
target = plugins_dir / name
if not target.is_dir():
console.print(f"[red]Plugin '{name}' is not installed.[/red]")
sys.exit(1) sys.exit(1)
enabled = _get_enabled_set()
disabled = _get_disabled_set() disabled = _get_disabled_set()
if name not in disabled:
if name in enabled and name not in disabled:
console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]") console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
return return
enabled.add(name)
disabled.discard(name) disabled.discard(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled) _save_disabled_set(disabled)
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. Takes effect on next session.") console.print(
f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. "
"Takes effect on next session."
)
def cmd_disable(name: str) -> None: def cmd_disable(name: str) -> None:
"""Disable a plugin without removing it.""" """Remove a plugin from the enabled allow-list (and add to disabled)."""
from rich.console import Console from rich.console import Console
console = Console() console = Console()
plugins_dir = _plugins_dir() if not _plugin_exists(name):
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
# 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) sys.exit(1)
enabled = _get_enabled_set()
disabled = _get_disabled_set() disabled = _get_disabled_set()
if name in disabled:
if name not in enabled and name in disabled:
console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]") console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
return return
enabled.discard(name)
disabled.add(name) disabled.add(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled) _save_disabled_set(disabled)
console.print(f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. Takes effect on next session.") console.print(
f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. "
"Takes effect on next session."
)
def cmd_list() -> None: def _plugin_exists(name: str) -> bool:
"""List installed plugins.""" """Return True if a plugin with *name* is installed (user) or bundled."""
from rich.console import Console # Installed: directory name or manifest name match in user plugins dir
from rich.table import Table user_dir = _plugins_dir()
if user_dir.is_dir():
if (user_dir / name).is_dir():
return True
for child in user_dir.iterdir():
if not child.is_dir():
continue
manifest = _read_manifest(child)
if manifest.get("name") == name:
return True
# Bundled: <repo>/plugins/<name>/
from pathlib import Path as _P
import hermes_cli
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
if repo_plugins.is_dir():
candidate = repo_plugins / name
if candidate.is_dir() and (
(candidate / "plugin.yaml").exists()
or (candidate / "plugin.yml").exists()
):
return True
return False
def _discover_all_plugins() -> list:
"""Return a list of (name, version, description, source, dir_path) for
every plugin the loader can see user + bundled + project.
Matches the ordering/dedup of ``PluginManager.discover_and_load``:
bundled first, then user, then project; user overrides bundled on
name collision.
"""
try: try:
import yaml import yaml
except ImportError: except ImportError:
yaml = None yaml = None
console = Console() seen: dict = {} # name -> (name, version, description, source, path)
plugins_dir = _plugins_dir()
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir()) # Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
if not dirs: import hermes_cli
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
if not base.is_dir():
continue
for d in sorted(base.iterdir()):
if not d.is_dir():
continue
if source == "bundled" and d.name in ("memory", "context_engine"):
continue
manifest_file = d / "plugin.yaml"
if not manifest_file.exists():
manifest_file = d / "plugin.yml"
if not manifest_file.exists():
continue
name = d.name
version = ""
description = ""
if yaml:
try:
with open(manifest_file) as f:
manifest = yaml.safe_load(f) or {}
name = manifest.get("name", d.name)
version = manifest.get("version", "")
description = manifest.get("description", "")
except Exception:
pass
# User plugins override bundled on name collision.
if name in seen and source == "bundled":
continue
src_label = source
if source == "user" and (d / ".git").exists():
src_label = "git"
seen[name] = (name, version, description, src_label, d)
return list(seen.values())
def cmd_list() -> None:
"""List all plugins (bundled + user) with enabled/disabled state."""
from rich.console import Console
from rich.table import Table
console = Console()
entries = _discover_all_plugins()
if not entries:
console.print("[dim]No plugins installed.[/dim]") console.print("[dim]No plugins installed.[/dim]")
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo") console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
return return
enabled = _get_enabled_set()
disabled = _get_disabled_set() disabled = _get_disabled_set()
table = Table(title="Installed Plugins", show_lines=False) table = Table(title="Plugins", show_lines=False)
table.add_column("Name", style="bold") table.add_column("Name", style="bold")
table.add_column("Status") table.add_column("Status")
table.add_column("Version", style="dim") table.add_column("Version", style="dim")
table.add_column("Description") table.add_column("Description")
table.add_column("Source", style="dim") table.add_column("Source", style="dim")
for d in dirs: for name, version, description, source, _dir in entries:
manifest_file = d / "plugin.yaml" if name in disabled:
name = d.name status = "[red]disabled[/red]"
version = "" elif name in enabled:
description = "" status = "[green]enabled[/green]"
source = "local" else:
status = "[yellow]not enabled[/yellow]"
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)
version = manifest.get("version", "")
description = manifest.get("description", "")
except Exception:
pass
# Check if it's a git repo (installed via hermes plugins install)
if (d / ".git").exists():
source = "git"
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) table.add_row(name, status, str(version), description, source)
console.print() console.print()
@ -592,6 +732,7 @@ def cmd_list() -> None:
console.print() console.print()
console.print("[dim]Interactive toggle:[/dim] hermes plugins") console.print("[dim]Interactive toggle:[/dim] hermes plugins")
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>") 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]")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -742,41 +883,25 @@ def cmd_toggle() -> None:
"""Interactive composite UI — general plugins + provider plugin categories.""" """Interactive composite UI — general plugins + provider plugin categories."""
from rich.console import Console from rich.console import Console
try:
import yaml
except ImportError:
yaml = None
console = Console() console = Console()
plugins_dir = _plugins_dir()
# -- General plugins discovery -- # -- General plugins discovery (bundled + user) --
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir()) entries = _discover_all_plugins()
disabled = _get_disabled_set() enabled_set = _get_enabled_set()
disabled_set = _get_disabled_set()
plugin_names = [] plugin_names = []
plugin_labels = [] plugin_labels = []
plugin_selected = set() plugin_selected = set()
for i, d in enumerate(dirs): for i, (name, _version, description, source, _d) in enumerate(entries):
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
plugin_names.append(name)
label = f"{name} \u2014 {description}" if description else name label = f"{name} \u2014 {description}" if description else name
if source == "bundled":
label = f"{label} [bundled]"
plugin_names.append(name)
plugin_labels.append(label) plugin_labels.append(label)
# Selected (enabled) when in enabled-set AND not in disabled-set
if name not in disabled and d.name not in disabled: if name in enabled_set and name not in disabled_set:
plugin_selected.add(i) plugin_selected.add(i)
# -- Provider categories -- # -- Provider categories --
@ -804,10 +929,10 @@ def cmd_toggle() -> None:
try: try:
import curses import curses
_run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected, _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
disabled, categories, console) disabled_set, categories, console)
except ImportError: except ImportError:
_run_composite_fallback(plugin_names, plugin_labels, plugin_selected, _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
disabled, categories, console) disabled_set, categories, console)
def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected, def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
@ -1020,18 +1145,29 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected,
curses.wrapper(_draw) curses.wrapper(_draw)
flush_stdin() flush_stdin()
# Persist general plugin changes # Persist general plugin changes. The new allow-list is the set of
new_disabled = set() # plugin names that were checked; anything not checked is explicitly
# disabled (written to disabled-list) so it remains off even if the
# plugin code does something clever like auto-enable in the future.
new_enabled: set = set()
new_disabled: set = set(disabled) # preserve existing disabled state for unseen plugins
for i, name in enumerate(plugin_names): for i, name in enumerate(plugin_names):
if i not in chosen: if i in chosen:
new_enabled.add(name)
new_disabled.discard(name)
else:
new_disabled.add(name) new_disabled.add(name)
if new_disabled != disabled: prev_enabled = _get_enabled_set()
enabled_changed = new_enabled != prev_enabled
disabled_changed = new_disabled != disabled
if enabled_changed or disabled_changed:
_save_enabled_set(new_enabled)
_save_disabled_set(new_disabled) _save_disabled_set(new_disabled)
enabled_count = len(plugin_names) - len(new_disabled)
console.print( console.print(
f"\n[green]\u2713[/green] General plugins: {enabled_count} enabled, " f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, "
f"{len(new_disabled)} disabled." f"{len(plugin_names) - len(new_enabled)} disabled."
) )
elif n_plugins > 0: elif n_plugins > 0:
console.print("\n[dim]General plugins unchanged.[/dim]") console.print("\n[dim]General plugins unchanged.[/dim]")
@ -1078,11 +1214,17 @@ def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected,
return return
print() print()
new_disabled = set() new_enabled: set = set()
new_disabled: set = set(disabled)
for i, name in enumerate(plugin_names): for i, name in enumerate(plugin_names):
if i not in chosen: if i in chosen:
new_enabled.add(name)
new_disabled.discard(name)
else:
new_disabled.add(name) new_disabled.add(name)
if new_disabled != disabled: prev_enabled = _get_enabled_set()
if new_enabled != prev_enabled or new_disabled != disabled:
_save_enabled_set(new_enabled)
_save_disabled_set(new_disabled) _save_disabled_set(new_disabled)
# Provider categories # Provider categories
@ -1108,7 +1250,17 @@ def plugins_command(args) -> None:
action = getattr(args, "plugins_action", None) action = getattr(args, "plugins_action", None)
if action == "install": if action == "install":
cmd_install(args.identifier, force=getattr(args, "force", False)) # Map argparse tri-state: --enable=True, --no-enable=False, neither=None (prompt)
enable_arg = None
if getattr(args, "enable", False):
enable_arg = True
elif getattr(args, "no_enable", False):
enable_arg = False
cmd_install(
args.identifier,
force=getattr(args, "force", False),
enable=enable_arg,
)
elif action == "update": elif action == "update":
cmd_update(args.name) cmd_update(args.name)
elif action in ("remove", "rm", "uninstall"): elif action in ("remove", "rm", "uninstall"):

View file

@ -243,11 +243,6 @@ def _hermetic_environment(tmp_path, monkeypatch):
# 5. Reset plugin singleton so tests don't leak plugins from # 5. Reset plugin singleton so tests don't leak plugins from
# ~/.hermes/plugins/ (which, per step 3, is now empty — but the # ~/.hermes/plugins/ (which, per step 3, is now empty — but the
# singleton might still be cached from a previous test). # singleton might still be cached from a previous test).
# Also disable bundled-plugin discovery by default so the
# repo-shipped <repo>/plugins/<name>/ dirs don't appear in tests
# that assume an empty plugin set. Tests that specifically exercise
# bundled discovery can clear this var explicitly.
monkeypatch.setenv("HERMES_DISABLE_BUNDLED_PLUGINS", "1")
try: try:
import hermes_cli.plugins as _plugins_mod import hermes_cli.plugins as _plugins_mod
monkeypatch.setattr(_plugins_mod, "_plugin_manager", None) monkeypatch.setattr(_plugins_mod, "_plugin_manager", None)

View file

@ -459,7 +459,7 @@ class TestCustomProviderCompatibility:
migrate_config(interactive=False, quiet=True) migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 20 assert raw["_config_version"] == 21
assert raw["providers"]["openai-direct"] == { assert raw["providers"]["openai-direct"] == {
"api": "https://api.openai.com/v1", "api": "https://api.openai.com/v1",
"api_key": "test-key", "api_key": "test-key",
@ -606,7 +606,7 @@ class TestInterimAssistantMessageConfig:
migrate_config(interactive=False, quiet=True) migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 20 assert raw["_config_version"] == 21
assert raw["display"]["tool_progress"] == "off" assert raw["display"]["tool_progress"] == "off"
assert raw["display"]["interim_assistant_messages"] is True assert raw["display"]["interim_assistant_messages"] is True
@ -626,7 +626,7 @@ class TestDiscordChannelPromptsConfig:
migrate_config(interactive=False, quiet=True) migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == 20 assert raw["_config_version"] == 21
assert raw["discord"]["auto_thread"] is True assert raw["discord"]["auto_thread"] is True
assert raw["discord"]["channel_prompts"] == {} assert raw["discord"]["channel_prompts"] == {}

View file

@ -30,8 +30,19 @@ from hermes_cli.plugins import (
def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass", def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
manifest_extra: dict | None = None) -> Path: manifest_extra: dict | None = None,
"""Create a minimal plugin directory with plugin.yaml + __init__.py.""" auto_enable: bool = True) -> Path:
"""Create a minimal plugin directory with plugin.yaml + __init__.py.
If *auto_enable* is True (default), also write the plugin's name into
``<hermes_home>/config.yaml`` under ``plugins.enabled``. Plugins are
opt-in by default, so tests that expect the plugin to actually load
need this. Pass ``auto_enable=False`` for tests that exercise the
unenabled path.
*base* is expected to be ``<hermes_home>/plugins/``; we derive
``<hermes_home>`` from it by walking one level up.
"""
plugin_dir = base / name plugin_dir = base / name
plugin_dir.mkdir(parents=True, exist_ok=True) plugin_dir.mkdir(parents=True, exist_ok=True)
@ -43,6 +54,31 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
(plugin_dir / "__init__.py").write_text( (plugin_dir / "__init__.py").write_text(
f"def register(ctx):\n {register_body}\n" f"def register(ctx):\n {register_body}\n"
) )
if auto_enable:
# Write/merge plugins.enabled in <HERMES_HOME>/config.yaml.
# Config is always read from HERMES_HOME (not from the project
# dir for project plugins), so that's where we opt in.
import os
hermes_home_str = os.environ.get("HERMES_HOME")
if hermes_home_str:
hermes_home = Path(hermes_home_str)
else:
hermes_home = base.parent
hermes_home.mkdir(parents=True, exist_ok=True)
cfg_path = hermes_home / "config.yaml"
cfg: dict = {}
if cfg_path.exists():
try:
cfg = yaml.safe_load(cfg_path.read_text()) or {}
except Exception:
cfg = {}
plugins_cfg = cfg.setdefault("plugins", {})
enabled = plugins_cfg.setdefault("enabled", [])
if isinstance(enabled, list) and name not in enabled:
enabled.append(name)
cfg_path.write_text(yaml.safe_dump(cfg))
return plugin_dir return plugin_dir
@ -102,7 +138,12 @@ class TestPluginDiscovery:
mgr.discover_and_load() mgr.discover_and_load()
mgr.discover_and_load() # second call should no-op mgr.discover_and_load() # second call should no-op
assert len(mgr._plugins) == 1 # Filter out bundled plugins — they're always discovered.
non_bundled = {
n: p for n, p in mgr._plugins.items()
if p.manifest.source != "bundled"
}
assert len(non_bundled) == 1
def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch): def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch):
"""Directories without plugin.yaml are silently skipped.""" """Directories without plugin.yaml are silently skipped."""
@ -113,7 +154,12 @@ class TestPluginDiscovery:
mgr = PluginManager() mgr = PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
assert len(mgr._plugins) == 0 # Filter out bundled plugins — they're always discovered.
non_bundled = {
n: p for n, p in mgr._plugins.items()
if p.manifest.source != "bundled"
}
assert len(non_bundled) == 0
def test_entry_points_scanned(self, tmp_path, monkeypatch): def test_entry_points_scanned(self, tmp_path, monkeypatch):
"""Entry-point based plugins are discovered (mocked).""" """Entry-point based plugins are discovered (mocked)."""
@ -152,7 +198,13 @@ class TestPluginLoading:
plugin_dir = plugins_dir / "bad_plugin" plugin_dir = plugins_dir / "bad_plugin"
plugin_dir.mkdir(parents=True) plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "bad_plugin"})) (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "bad_plugin"}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) # Explicitly enable so the loader tries to import it and hits the
# missing-init error.
hermes_home = tmp_path / "hermes_test"
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"plugins": {"enabled": ["bad_plugin"]}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mgr = PluginManager() mgr = PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
@ -160,6 +212,8 @@ class TestPluginLoading:
assert "bad_plugin" in mgr._plugins assert "bad_plugin" in mgr._plugins
assert not mgr._plugins["bad_plugin"].enabled assert not mgr._plugins["bad_plugin"].enabled
assert mgr._plugins["bad_plugin"].error is not None assert mgr._plugins["bad_plugin"].error is not None
# Should be the missing-init error, not "not enabled".
assert "not enabled" not in mgr._plugins["bad_plugin"].error
def test_load_missing_register_fn(self, tmp_path, monkeypatch): def test_load_missing_register_fn(self, tmp_path, monkeypatch):
"""Plugin without register() function records an error.""" """Plugin without register() function records an error."""
@ -168,7 +222,12 @@ class TestPluginLoading:
plugin_dir.mkdir(parents=True) plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "no_reg"})) (plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "no_reg"}))
(plugin_dir / "__init__.py").write_text("# no register function\n") (plugin_dir / "__init__.py").write_text("# no register function\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) # Explicitly enable it so the loader actually tries to import.
hermes_home = tmp_path / "hermes_test"
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"plugins": {"enabled": ["no_reg"]}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mgr = PluginManager() mgr = PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
@ -404,7 +463,11 @@ class TestPluginContext:
' handler=lambda args, **kw: "echo",\n' ' handler=lambda args, **kw: "echo",\n'
' )\n' ' )\n'
) )
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) hermes_home = tmp_path / "hermes_test"
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"plugins": {"enabled": ["tool_plugin"]}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mgr = PluginManager() mgr = PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
@ -438,7 +501,11 @@ class TestPluginToolVisibility:
' handler=lambda args, **kw: "ok",\n' ' handler=lambda args, **kw: "ok",\n'
' )\n' ' )\n'
) )
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) hermes_home = tmp_path / "hermes_test"
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"plugins": {"enabled": ["vis_plugin"]}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mgr = PluginManager() mgr = PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
@ -749,20 +816,24 @@ class TestPluginCommands:
def test_commands_in_list_plugins_output(self, tmp_path, monkeypatch): def test_commands_in_list_plugins_output(self, tmp_path, monkeypatch):
"""list_plugins() includes command count.""" """list_plugins() includes command count."""
plugins_dir = tmp_path / "hermes_test" / "plugins" plugins_dir = tmp_path / "hermes_test" / "plugins"
# Set HERMES_HOME BEFORE _make_plugin_dir so auto-enable targets
# the right config.yaml.
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
_make_plugin_dir( _make_plugin_dir(
plugins_dir, "cmd-plugin", plugins_dir, "cmd-plugin",
register_body=( register_body=(
'ctx.register_command("mycmd", lambda a: "ok", description="Test")' 'ctx.register_command("mycmd", lambda a: "ok", description="Test")'
), ),
) )
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager() mgr = PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
info = mgr.list_plugins() info = mgr.list_plugins()
assert len(info) == 1 # Filter out bundled plugins — they're always discovered.
assert info[0]["commands"] == 1 cmd_info = [p for p in info if p["name"] == "cmd-plugin"]
assert len(cmd_info) == 1
assert cmd_info[0]["commands"] == 1
def test_handler_receives_raw_args(self): def test_handler_receives_raw_args(self):
"""The handler is called with the raw argument string.""" """The handler is called with the raw argument string."""

View file

@ -366,35 +366,62 @@ class TestSlashCommand:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBundledDiscovery: class TestBundledDiscovery:
def test_disk_cleanup_is_discovered_as_bundled(self, _isolate_env, monkeypatch): def _write_enabled_config(self, hermes_home, names):
# The default hermetic conftest disables bundled plugin discovery. """Write plugins.enabled allow-list to config.yaml."""
# This test specifically exercises it, so clear the suppression. import yaml
monkeypatch.delenv("HERMES_DISABLE_BUNDLED_PLUGINS", raising=False) cfg_path = hermes_home / "config.yaml"
cfg_path.write_text(yaml.safe_dump({"plugins": {"enabled": list(names)}}))
def test_disk_cleanup_discovered_but_not_loaded_by_default(self, _isolate_env):
"""Bundled plugins are discovered but NOT loaded without opt-in."""
from hermes_cli import plugins as pmod from hermes_cli import plugins as pmod
mgr = pmod.PluginManager() mgr = pmod.PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
# Discovered — appears in the registry
assert "disk-cleanup" in mgr._plugins assert "disk-cleanup" in mgr._plugins
loaded = mgr._plugins["disk-cleanup"] loaded = mgr._plugins["disk-cleanup"]
assert loaded.manifest.source == "bundled" assert loaded.manifest.source == "bundled"
# But NOT enabled — no hooks or commands registered
assert not loaded.enabled
assert loaded.error and "not enabled" in loaded.error
def test_disk_cleanup_loads_when_enabled(self, _isolate_env):
"""Adding to plugins.enabled activates the bundled plugin."""
self._write_enabled_config(_isolate_env, ["disk-cleanup"])
from hermes_cli import plugins as pmod
mgr = pmod.PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["disk-cleanup"]
assert loaded.enabled assert loaded.enabled
assert "post_tool_call" in loaded.hooks_registered assert "post_tool_call" in loaded.hooks_registered
assert "on_session_end" in loaded.hooks_registered assert "on_session_end" in loaded.hooks_registered
assert "disk-cleanup" in loaded.commands_registered assert "disk-cleanup" in loaded.commands_registered
def test_memory_and_context_engine_subdirs_skipped(self, _isolate_env, monkeypatch): def test_disabled_beats_enabled(self, _isolate_env):
"""plugins.disabled wins even if the plugin is also in plugins.enabled."""
import yaml
cfg_path = _isolate_env / "config.yaml"
cfg_path.write_text(yaml.safe_dump({
"plugins": {
"enabled": ["disk-cleanup"],
"disabled": ["disk-cleanup"],
}
}))
from hermes_cli import plugins as pmod
mgr = pmod.PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["disk-cleanup"]
assert not loaded.enabled
assert loaded.error == "disabled via config"
def test_memory_and_context_engine_subdirs_skipped(self, _isolate_env):
"""Bundled scan must NOT pick up plugins/memory or plugins/context_engine """Bundled scan must NOT pick up plugins/memory or plugins/context_engine
as top-level plugins they have their own discovery paths.""" as top-level plugins they have their own discovery paths."""
monkeypatch.delenv("HERMES_DISABLE_BUNDLED_PLUGINS", raising=False) self._write_enabled_config(
_isolate_env, ["memory", "context_engine", "disk-cleanup"]
)
from hermes_cli import plugins as pmod from hermes_cli import plugins as pmod
mgr = pmod.PluginManager() mgr = pmod.PluginManager()
mgr.discover_and_load() mgr.discover_and_load()
assert "memory" not in mgr._plugins assert "memory" not in mgr._plugins
assert "context_engine" not in mgr._plugins assert "context_engine" not in mgr._plugins
def test_bundled_scan_suppressed_by_env_var(self, _isolate_env, monkeypatch):
"""HERMES_DISABLE_BUNDLED_PLUGINS=1 suppresses bundled discovery."""
monkeypatch.setenv("HERMES_DISABLE_BUNDLED_PLUGINS", "1")
from hermes_cli import plugins as pmod
mgr = pmod.PluginManager()
mgr.discover_and_load()
assert "disk-cleanup" not in mgr._plugins

View file

@ -24,23 +24,31 @@ On name collision, later sources win — a user plugin named `disk-cleanup` woul
`plugins/memory/` and `plugins/context_engine/` are deliberately excluded from bundled scanning. Those directories use their own discovery paths because memory providers and context engines are single-select providers configured through `hermes memory setup` / `context.engine` in config. `plugins/memory/` and `plugins/context_engine/` are deliberately excluded from bundled scanning. Those directories use their own discovery paths because memory providers and context engines are single-select providers configured through `hermes memory setup` / `context.engine` in config.
Bundled plugins respect the same disable mechanism as any other plugin: ## Bundled plugins are opt-in
Bundled plugins ship disabled. Discovery finds them (they appear in `hermes plugins list` and the interactive `hermes plugins` UI), but none load until you explicitly enable them:
```bash
hermes plugins enable disk-cleanup
```
Or via `~/.hermes/config.yaml`:
```yaml ```yaml
# ~/.hermes/config.yaml
plugins: plugins:
disabled: enabled:
- disk-cleanup - disk-cleanup
``` ```
Or suppress every bundled plugin at once with an env var: This is the same mechanism user-installed plugins use. Bundled plugins are never auto-enabled — not on fresh install, not for existing users upgrading to a newer Hermes. You always opt in explicitly.
To turn a bundled plugin off again:
```bash ```bash
HERMES_DISABLE_BUNDLED_PLUGINS=1 hermes chat hermes plugins disable disk-cleanup
# or: remove it from plugins.enabled in config.yaml
``` ```
The test suite sets `HERMES_DISABLE_BUNDLED_PLUGINS=1` in its hermetic fixture — tests that exercise bundled discovery clear it explicitly.
## Currently shipped ## Currently shipped
### disk-cleanup ### disk-cleanup
@ -87,14 +95,9 @@ Auto-tracks and removes ephemeral files created during sessions — test scripts
**Safety** — cleanup only ever touches paths under `HERMES_HOME` or `/tmp/hermes-*`. Windows mounts (`/mnt/c/...`) are rejected. Well-known top-level state dirs (`logs/`, `memories/`, `sessions/`, `cron/`, `cache/`, `skills/`, `plugins/`, `disk-cleanup/` itself) are never removed even when empty — a fresh install does not get gutted on first session end. **Safety** — cleanup only ever touches paths under `HERMES_HOME` or `/tmp/hermes-*`. Windows mounts (`/mnt/c/...`) are rejected. Well-known top-level state dirs (`logs/`, `memories/`, `sessions/`, `cron/`, `cache/`, `skills/`, `plugins/`, `disk-cleanup/` itself) are never removed even when empty — a fresh install does not get gutted on first session end.
To turn it off without uninstalling: **Enabling:** `hermes plugins enable disk-cleanup` (or check the box in `hermes plugins`).
```yaml **Disabling again:** `hermes plugins disable disk-cleanup`.
# ~/.hermes/config.yaml
plugins:
disabled:
- disk-cleanup
```
## Adding a bundled plugin ## Adding a bundled plugin

View file

@ -100,7 +100,34 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable
| Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) | | Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) |
| pip | `hermes_agent.plugins` entry_points | Distributed packages | | pip | `hermes_agent.plugins` entry_points | Distributed packages |
Later sources override earlier ones on name collision, so a user plugin with the same name as a bundled plugin replaces it. `HERMES_DISABLE_BUNDLED_PLUGINS=1` suppresses the bundled scan entirely. Later sources override earlier ones on name collision, so a user plugin with the same name as a bundled plugin replaces it.
## Plugins are opt-in
**Every plugin — user-installed, bundled, or pip — is disabled by default.** Discovery finds them (so they show up in `hermes plugins` and `/plugins`), but nothing loads until you add the plugin's name to `plugins.enabled` in `~/.hermes/config.yaml`. This stops anything with hooks or tools from running without your explicit consent.
```yaml
plugins:
enabled:
- my-tool-plugin
- disk-cleanup
disabled: # optional deny-list — always wins if a name appears in both
- noisy-plugin
```
Three ways to flip state:
```bash
hermes plugins # interactive toggle (space to check/uncheck)
hermes plugins enable <name> # add to allow-list
hermes plugins disable <name> # remove from allow-list + add to disabled
```
After `hermes plugins install owner/repo`, you're asked `Enable 'name' now? [y/N]` — defaults to no. Skip the prompt for scripted installs with `--enable` or `--no-enable`.
### Migration for existing users
When you upgrade to a version of Hermes that has opt-in plugins (config schema v21+), any user plugins already installed under `~/.hermes/plugins/` that weren't already in `plugins.disabled` are **automatically grandfathered** into `plugins.enabled`. Your existing setup keeps working. Bundled plugins are NOT grandfathered — even existing users have to opt in explicitly.
## Available hooks ## Available hooks
@ -130,13 +157,15 @@ Memory providers and context engines are **provider plugins** — only one of ea
## Managing plugins ## Managing plugins
```bash ```bash
hermes plugins # unified interactive UI hermes plugins # unified interactive UI
hermes plugins list # table view with enabled/disabled status hermes plugins list # table: enabled / disabled / not enabled
hermes plugins install user/repo # install from Git hermes plugins install user/repo # install from Git, then prompt Enable? [y/N]
hermes plugins update my-plugin # pull latest hermes plugins install user/repo --enable # install AND enable (no prompt)
hermes plugins remove my-plugin # uninstall hermes plugins install user/repo --no-enable # install but leave disabled (no prompt)
hermes plugins enable my-plugin # re-enable a disabled plugin hermes plugins update my-plugin # pull latest
hermes plugins disable my-plugin # disable without removing hermes plugins remove my-plugin # uninstall
hermes plugins enable my-plugin # add to allow-list
hermes plugins disable my-plugin # remove from allow-list + add to disabled
``` ```
### Interactive UI ### Interactive UI
@ -150,14 +179,16 @@ Plugins
General Plugins General Plugins
→ [✓] my-tool-plugin — Custom search tool → [✓] my-tool-plugin — Custom search tool
[ ] webhook-notifier — Event hooks [ ] webhook-notifier — Event hooks
[ ] disk-cleanup — Auto-cleanup of ephemeral files [bundled]
Provider Plugins Provider Plugins
Memory Provider ▸ honcho Memory Provider ▸ honcho
Context Engine ▸ compressor Context Engine ▸ compressor
``` ```
- **General Plugins section** — checkboxes, toggle with SPACE - **General Plugins section** — checkboxes, toggle with SPACE. Checked = in `plugins.enabled`, unchecked = in `plugins.disabled` (explicit off).
- **Provider Plugins section** — shows current selection. Press ENTER to drill into a radio picker where you choose one active provider. - **Provider Plugins section** — shows current selection. Press ENTER to drill into a radio picker where you choose one active provider.
- Bundled plugins appear in the same list with a `[bundled]` tag.
Provider plugin selections are saved to `config.yaml`: Provider plugin selections are saved to `config.yaml`:
@ -169,15 +200,17 @@ context:
engine: "compressor" # default built-in compressor engine: "compressor" # default built-in compressor
``` ```
### Disabling general plugins ### Enabled vs. disabled vs. neither
Disabled plugins remain installed but are skipped during loading. The disabled list is stored in `config.yaml` under `plugins.disabled`: Plugins occupy one of three states:
```yaml | State | Meaning | In `plugins.enabled`? | In `plugins.disabled`? |
plugins: |---|---|---|---|
disabled: | `enabled` | Loaded on next session | Yes | No |
- my-noisy-plugin | `disabled` | Explicitly off — won't load even if also in `enabled` | (irrelevant) | Yes |
``` | `not enabled` | Discovered but never opted in | No | No |
The default for a newly-installed or bundled plugin is `not enabled`. `hermes plugins list` shows all three distinct states so you can tell what's been explicitly turned off vs. what's just waiting to be enabled.
In a running session, `/plugins` shows which plugins are currently loaded. In a running session, `/plugins` shows which plugins are currently loaded.