Files
hermes-agent/hermes_cli/plugins_cmd.py
ethernet 1e76db800c refactor(cli): split plugins_cmd.py into topical siblings
plugins_cmd.py had grown to 2,858 lines, past the ~2,000-line gate. Move
each verb family into a plugins_cmd_<topic>.py sibling: git (install
metadata + git plumbing), install, update (plus adopt / trust-update-url /
check-updates), remove, capabilities, toggle (composite UI) and listing.
The facade keeps the shared primitives, enable/disable selection,
discovery and the dispatch table, and re-exports the names other modules,
tests and the old-updater surface import (978 lines now).

Siblings never import the facade at module level; they read facade names
through _pc() at call time, so monkeypatching plugins_cmd.<name> still
intercepts calls made from a sibling. No behaviour change. Tests that
imported three sibling-only helpers now import them from the defining
module, and two subprocess.run patches target subprocess directly.
2026-09-24 11:50:29 -04:00

970 lines
43 KiB
Python

"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
Facade: shared primitives (errors, console/config helpers, manifest reading, discovery, enable/disable
selection) and the dispatch table live here; each verb family lives in a ``plugins_cmd_<topic>.py``
sibling (install, update, remove, git, capabilities, toggle, listing, catalog)."""
from __future__ import annotations
import functools
import logging
import os
import shutil
import sys
from pathlib import Path
from typing import Any, NoReturn, Optional
from hermes_constants import get_hermes_home
from hermes_cli.config import cfg_get
from hermes_cli.plugin_capabilities import _child_dict
# Tests patch these two on the facade; the install/remove siblings read them through it.
from hermes_cli.secret_prompt import masked_secret_prompt # noqa: F401
from utils import rmtree_readonly # noqa: F401
# Topical siblings. The facade re-exports what other modules, tests and the old updater import from
# ``hermes_cli.plugins_cmd``; sibling bodies read those names back through the facade at call time.
from hermes_cli.plugins_cmd_capabilities import ( # noqa: F401
_declared_capabilities_for_key, _declared_capabilities_from_manifest, _resolve_tool_override_grant,
_run_capability_consent, cmd_capabilities,
)
from hermes_cli.plugins_cmd_git import ( # noqa: F401
_EXACT_COMMIT_RE, _canonical_source, _checkout_exact_revision, _clone_plugin_repo, _git_head_revision,
_git_or_raise, _git_pull_plugin_dir, _git_resolve_commit, _normalize_exact_revision, _pin_annotation,
_read_install_metadata, _run_plugin_git, _safe_git_error, _scrub_cloned_origin, _update_install_record,
_write_install_metadata, pinned_revision,
)
from hermes_cli.plugins_cmd_install import ( # noqa: F401
_check_manifest_version, _consent_python_deps, _display_after_install, _install_plugin_core,
_install_plugin_python_deps, _prompt_plugin_env_vars, _python_dependency_summary,
_read_manifest_for_install, cmd_install, dashboard_install_plugin,
)
from hermes_cli.plugins_cmd_listing import ( # noqa: F401
_filter_plugin_entries, cmd_compat, cmd_list, cmd_show,
)
from hermes_cli.plugins_cmd_remove import ( # noqa: F401
_remove_plugin_core, cmd_remove, dashboard_remove_user_plugin,
)
from hermes_cli.plugins_cmd_toggle import ( # noqa: F401
_discover_context_engines, _persist_plugin_selection, _provider_categories, _run_composite_fallback,
cmd_toggle,
)
from hermes_cli.plugins_cmd_update import ( # noqa: F401
_clear_plugin_bytecode, cmd_adopt, cmd_check_updates, cmd_trust_update_url, cmd_update,
dashboard_update_user_plugin,
)
logger = logging.getLogger(__name__)
_DEFAULT_CLONE_TIMEOUT_SECONDS = 300
_MAX_CLONE_TIMEOUT_SECONDS = 3600
_CLONE_TIMEOUT_HINT = "On a slow connection, raise plugins.clone_timeout_seconds in config.yaml."
@functools.lru_cache(maxsize=1)
def _resolve_git_executable() -> Optional[str]:
"""Resolve a git binary for subprocess use when ``PATH`` may be minimal."""
found = shutil.which("git")
if found:
return found
if os.name == "nt":
roots = [
os.environ.get("ProgramFiles", r"C:\Program Files"),
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
]
local = os.environ.get("LOCALAPPDATA", "")
if local:
roots.append(os.path.join(local, "Programs"))
candidates = [os.path.join(r, "Git", sub, "git.exe") for r in roots for sub in ("cmd", "bin")]
else:
candidates = ["/usr/bin/git", "/usr/local/bin/git", "/bin/git"]
return next((c for c in candidates if c and os.path.isfile(c)), None)
class PluginOperationError(Exception):
"""Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx)."""
class PluginScanBlocked(PluginOperationError):
"""Plugin failed the security scan and was not installed."""
def __init__(self, message: str, scan_result=None):
super().__init__(message)
self.scan_result = scan_result
def _console():
"""A fresh Rich console (rich is imported lazily)."""
from rich.console import Console
return Console()
def _table(columns, **kwargs):
"""A Rich ``Table(**kwargs)`` with ``(header, style)`` *columns* added in order."""
from rich.table import Table
table = Table(**kwargs)
for header, style in columns:
table.add_column(header, style=style)
return table
def _is_tty() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def _fail(console, message: str) -> NoReturn:
"""Print *message* and exit 1."""
console.print(message)
sys.exit(1)
def _ask_yes(prompt: str, reader=input) -> bool:
"""One y/N question; EOF / Ctrl-C count as "no"."""
try:
answer = reader(prompt).strip().lower()
except (EOFError, KeyboardInterrupt):
return False
return answer in {"y", "yes"}
def _config_value(*keys: str, default: Any) -> Any:
"""Read ``keys`` from config.yaml; *default* on a missing key or any load failure."""
try:
from hermes_cli.config import load_config
return cfg_get(load_config(), *keys, default=default)
except Exception:
return default
def _clone_timeout_seconds() -> int:
"""Deadline for plugin clone and pinned fetch, scoped to the active profile."""
value = _config_value("plugins", "clone_timeout_seconds", default=_DEFAULT_CLONE_TIMEOUT_SECONDS)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
logger.warning("plugins.clone_timeout_seconds must be a positive integer; using %ss",
_DEFAULT_CLONE_TIMEOUT_SECONDS)
return _DEFAULT_CLONE_TIMEOUT_SECONDS
if value > _MAX_CLONE_TIMEOUT_SECONDS:
logger.warning("plugins.clone_timeout_seconds exceeds %ss; clamping", _MAX_CLONE_TIMEOUT_SECONDS)
return _MAX_CLONE_TIMEOUT_SECONDS
return value
def _config_name_set(*keys: str) -> set:
"""A list-valued config key as a set (empty on any failure or non-list)."""
value = _config_value(*keys, default=[])
return set(value) if isinstance(value, list) else set()
def _config_str(*keys: str, default: str) -> str:
"""A string config key; empty/missing/failed reads coerce to *default*."""
return _config_value(*keys, default=default) or default
def _write_config_value(section: str, key: str, value: Any) -> None:
"""Persist ``config[section][key] = value`` to config.yaml (creating the section)."""
from hermes_cli.config import load_config, save_config
config = load_config()
config.setdefault(section, {})[key] = value
save_config(config)
def _scan_on_install_enabled() -> bool:
"""Install/update-time security scanning; on by default, off via ``plugins.scan_on_install: false``."""
return bool(_config_value("plugins", "scan_on_install", default=True))
def _scan_plugin_tree(plugin_dir: Path, identifier: str, *, force: bool, scan_decision_cb=None,
reviewed_pin: bool = False):
"""Scan *plugin_dir* and enforce the install policy.
Verdicts: safe → proceed; caution → needs confirmation (``force=True`` or a truthy
``scan_decision_cb(result)``); dangerous → always blocked (:class:`PluginScanBlocked`).
*reviewed_pin* marks a tree checked out at a curated-catalog sha: that exact tree passed
the same scanner at admission with a human reading the caution findings, so caution is
accepted without a prompt (the Desktop has none). Dangerous still blocks — a signature
added after review is exactly the case the backstop exists for.
Returns the ScanResult, or None when scanning is disabled.
"""
if not _scan_on_install_enabled():
return None
from tools.plugin_guard import format_scan_report, scan_plugin, should_allow_plugin_install
result = scan_plugin(plugin_dir, source=identifier)
allowed, reason = should_allow_plugin_install(result, force=force)
if allowed is None and reviewed_pin:
allowed, reason = True, "Caution verdict accepted: reviewed catalog pin"
if allowed is None and scan_decision_cb is not None:
try:
if scan_decision_cb(result):
allowed = True
reason = "Caution verdict accepted by user"
except Exception:
logger.exception("plugin scan decision callback failed")
if allowed is not True:
raise PluginScanBlocked(
f"Security scan blocked plugin install: {reason}\n\n"
f"{format_scan_report(result)}\n"
"Review the findings above. Install only plugins from sources "
"you trust. (Scanning can be configured via "
"plugins.scan_on_install in config.yaml.)",
scan_result=result)
logger.info("plugin scan passed for %s: %s", plugin_dir.name, reason)
return result
def _plugins_dir() -> Path:
"""Return the user plugins directory, creating it if needed."""
plugins = get_hermes_home() / "plugins"
plugins.mkdir(parents=True, exist_ok=True)
return plugins
def _sanitize_plugin_name(name: str, plugins_dir: Path, *, allow_subdir: bool = False) -> Path:
"""Validate a plugin name and return the safe target path inside *plugins_dir*.
Raises ``ValueError`` on traversal or a target outside the plugins directory. ``allow_subdir``
permits forward slashes so category keys like ``observability/langfuse`` can be looked up
(``..`` and backslashes stay rejected); install paths keep ``False`` — a clone lands top-level.
"""
if allow_subdir and name:
name = name.strip("/")
if not name:
raise ValueError("Plugin name must not be empty.")
if name in {".", ".."}:
raise ValueError(f"Invalid plugin name '{name}': must not reference the plugins directory itself.")
for bad in ("\\", "..") if allow_subdir else ("/", "\\", ".."):
if bad in name:
raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.")
target = (plugins_dir / name).resolve()
plugins_resolved = plugins_dir.resolve()
if target == plugins_resolved:
raise ValueError(f"Invalid plugin name '{name}': resolves to the plugins directory itself.")
if plugins_resolved not in target.parents:
raise ValueError(f"Invalid plugin name '{name}': resolves outside the plugins directory.")
return target
_GITHUB_BROWSER_SEGMENTS = {
"actions", "blob", "commit", "commits", "issues", "pull", "pulls", "releases", "tree", "wiki",
}
_URL_SCHEMES = ("https://", "http://", "git@", "ssh://", "file://")
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
``http://`` and ``file://`` are accepted but trigger a security warning at install time.
"""
if identifier.startswith(_URL_SCHEMES):
if identifier.startswith("https://github.com/"):
path = identifier[len("https://github.com/") :]
path = path.split("?", 1)[0].split("#", 1)[0].strip("/")
parts = path.split("/")
if len(parts) >= 3 and all(parts[:2]) and parts[2] in _GITHUB_BROWSER_SEGMENTS:
repo = parts[1].removesuffix(".git")
subdir = None
if parts[2] == "tree" and len(parts) >= 5:
subdir = "/".join(p for p in parts[4:] if p).strip("/") or None
return f"https://github.com/{parts[0]}/{repo}.git", subdir
# Explicit ``#subdir`` fragment — unambiguous for any scheme.
if "#" in identifier:
git_url, _, frag = identifier.partition("#")
return git_url, (frag.strip("/") or None)
# Natural ``.git/`` boundary (GitHub-style URLs).
git_url, marker, subdir = identifier.partition(".git/")
if marker:
return git_url + ".git", (subdir.strip("/") or None)
return identifier, None
# owner/repo[/subdir...] or owner/repo#subdir shorthand (the catalog spells subdirs with ``#``).
identifier, _, fragment = identifier.partition("#")
parts = [p for p in identifier.strip("/").split("/") if p]
if len(parts) >= 2:
subdir = "/".join([*parts[2:], *fragment.split("/")]).strip("/")
return f"https://github.com/{parts[0]}/{parts[1]}.git", (subdir or None)
raise ValueError(
f"Invalid plugin identifier: '{identifier}'. "
"Use a Git URL or 'owner/repo' shorthand (optionally with a subdirectory: "
"'owner/repo/path/to/plugin').")
def _resolve_subdir_within(clone_root: Path, subdir: str) -> Path:
"""Resolve ``subdir`` inside ``clone_root``; ``..``, absolute paths and symlinks may not
escape the clone. Raises ``PluginOperationError`` if it escapes, is missing, or is a file."""
clone_root = clone_root.resolve()
candidate = (clone_root / subdir).resolve()
if candidate != clone_root and clone_root not in candidate.parents:
raise PluginOperationError(f"Plugin subdirectory '{subdir}' escapes the repository.")
if not candidate.exists():
raise PluginOperationError(f"Plugin subdirectory '{subdir}' does not exist in the repository.")
if not candidate.is_dir():
raise PluginOperationError(f"Plugin subdirectory '{subdir}' is not a directory.")
return candidate
def _repo_name_from_url(url: str) -> str:
"""Repo name from a Git URL (last path component; ssh-style ``git@host:repo`` splits on ':')."""
name = url.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1]
if ":" in name:
name = name.rsplit(":", 1)[-1].rsplit("/", 1)[-1]
return name
def _native_manifest_file(plugin_dir: Path) -> Optional[Path]:
"""``plugin.yaml`` (or ``plugin.yml``) under *plugin_dir*, or None when neither exists."""
from pm.plugin_declarations import native_manifest_file
try:
return native_manifest_file(plugin_dir)
except ValueError as exc:
raise PluginOperationError(str(exc)) from exc
def _has_portable_manifest(plugin_dir: Path) -> bool:
"""True when ``plugin.json`` exists (or is a symlink, even dangling) under *plugin_dir*."""
portable_file = plugin_dir / "plugin.json"
return portable_file.exists() or portable_file.is_symlink()
def _load_yaml_manifest(manifest_file: Path):
"""``yaml.safe_load`` of *manifest_file* (``{}`` when empty); raises on any read/parse error."""
from pm.plugin_declarations import read_native_manifest
return read_native_manifest(manifest_file)
def _read_manifest(plugin_dir: Path) -> dict:
"""Read a native or portable manifest, preferring native YAML."""
manifest_file = _native_manifest_file(plugin_dir)
if manifest_file is None:
if not _has_portable_manifest(plugin_dir):
return {}
try:
from hermes_cli.agent_plugins import read_agent_plugin_manifest
return read_agent_plugin_manifest(plugin_dir)[0]
except Exception as e:
logger.warning("Failed to read plugin.json in %s: %s", plugin_dir, e)
return {}
try:
return _load_yaml_manifest(manifest_file)
except Exception as e:
logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e)
return {}
def _looks_like_plugin_dir(target: Path) -> bool:
"""True when *target* has a native/portable manifest or a package ``__init__.py``."""
return (
_native_manifest_file(target) is not None
or (target / "plugin.json").exists()
or (target / "__init__.py").exists())
def _copy_example_files(plugin_dir: Path, console) -> None:
"""Copy ``*.example`` files to their real names (``config.yaml.example`` -> ``config.yaml``),
never overwriting an existing file so reinstall keeps user config."""
for example_file in plugin_dir.glob("*.example"):
real_name = example_file.stem
real_path = plugin_dir / real_name
if real_path.exists():
continue
try:
shutil.copy2(example_file, real_path)
console.print(f"[dim] Created {real_name} from {example_file.name}[/dim]")
except OSError as e:
console.print(f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}")
def _missing_env_specs(manifest: dict) -> list[dict]:
"""``requires_env`` entries (plain names or ``{name, description, url, secret}`` dicts,
normalised to dicts; nameless entries dropped) whose variable is unset in ``~/.hermes/.env``."""
env_specs = [
{"name": entry} if isinstance(entry, str) else entry
for entry in manifest.get("requires_env") or []
if isinstance(entry, str) or (isinstance(entry, dict) and entry.get("name"))
]
if not env_specs:
return []
from hermes_cli.config import get_env_value
return [s for s in env_specs if not get_env_value(s["name"])]
def _clone_failure_message(git_url: str, git_error: str) -> str:
"""Plain-words clone failure: what to check (address, network, private repo), raw git text last.
The text reaches ``_fail`` -> Rich ``console.print``: escape the git output so ``[...]`` in it is
not parsed as markup."""
from rich.markup import escape
return (f"Could not download the plugin from {git_url}. Check the address (browse the catalog "
"with `hermes plugins search`), check your internet connection, or, if the repository "
"is private, sign in first with `gh auth login` (or set GITHUB_TOKEN in your .env).\n"
f"Details: {escape(git_error.strip())}")
def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
"""The plugin path if it exists; else exit 1 (invalid name, or a listing of installed plugins)."""
try:
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
except ValueError as e:
_fail(console, f"[red]Error:[/red] {e}")
if not target.exists():
_fail(console, _unknown_plugin_message(name, downloaded_only=True))
return target
def _unknown_plugin_message(name: str, *, downloaded_only: bool = False) -> str:
"""``No plugin named ...`` with the exact-name rule and the two commands that resolve it."""
scope = (" This command only works on downloaded plugins; bundled ones can only be enabled or disabled."
if downloaded_only else " Bundled plugins can only be enabled or disabled.")
return (f"[red]No plugin named '{name}'.[/red] Run `hermes plugins list` to see the exact names "
f"(nested plugins use their full key, e.g. web/firecrawl).{scope} "
"To add one: `hermes plugins install <owner/repo>`.")
# ``plugins.disabled`` is an explicit deny-list that wins over the ``plugins.enabled`` allow-list.
_get_disabled_set = functools.partial(_config_name_set, "plugins", "disabled")
_get_enabled_set = functools.partial(_config_name_set, "plugins", "enabled")
def _save_enabled_set(enabled: set) -> None:
"""Frozen old-updater import: never resurrect a raw plugin-selection write."""
from hermes_cli._old_updater import stop_for_relaunch
stop_for_relaunch()
def _plugin_selection_version() -> str:
from hermes_cli.runtime_state import _digest
return _digest(get_hermes_home() / "config.yaml") or "missing"
def _admit_and_save_plugin_sets(
enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable", expected_config=None
) -> None:
"""ONE admission authority for proposed enabled/disabled sets (C13):
the candidate union is resolved against the ACTIVE environment and
the config commits inside the same worker-owned PM transaction — a refusal or a config-write failure
publishes nothing: previous config bytes AND previous environment
stay exactly in place. Raises :class:`AdmissionRefused` (UI callers
catch and surface it — admission never auto-disables to fit)."""
from hermes_cli.plugins_admission import AdmissionRefused, admit_plugin_set_change
try:
admit_plugin_set_change(
enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs, expected_config=expected_config
)
except AdmissionRefused as exc:
if console is not None:
console.print(f"[red]✗[/red] {action} refused: {exc}")
console.print(
"[dim]config.yaml and the active environment are unchanged. "
"Run `hermes pm install` to resolve dependencies, then retry.[/dim]"
)
raise
_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})
def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool:
"""Drop the bundled basic dashboard-auth plugin from ``plugins.disabled`` in *cfg*.
``hermes setup`` / ``hermes plugins disable basic`` can park it there while
``dashboard.basic_auth`` is configured, and password auth then silently fails.
Returns True when modified.
"""
plugins_cfg = cfg.get("plugins")
disabled = plugins_cfg.get("disabled") if isinstance(plugins_cfg, dict) else None
if not isinstance(disabled, list) or not (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS):
return False
plugins_cfg["disabled"] = sorted(set(disabled) - _BASIC_AUTH_PLUGIN_KEYS)
return True
def _discard_key_and_leaf(names: set, key: str) -> None:
"""Drop *key* and its bare leaf (``observability/langfuse`` -> ``langfuse``) from *names*, so a
stale legacy bare-name entry can't keep vetoing the canonical key."""
names.discard(key)
names.discard(key.split("/")[-1])
def _plugin_aliases(key: str) -> set:
"""Every spelling a config list may hold for *key*: the key, its bare leaf and the manifest name.
The loader matches BOTH the canonical key (``web/firecrawl``) and the manifest name
(``web-firecrawl``), so a stale entry under any form vetoes an enable ("explicit disable wins")."""
names = {key, key.split("/")[-1]}
names.update(e[0] for e in _discover_all_plugins() if e[5] == key)
return names
def _activate_key(key: str, *, enable: bool, console=None) -> bool:
"""Transactionally persist canonical *key*, purging aliases from the opposing list.
False when the lists already say so (nothing written). PM owns the selection and dependency
publication together, so every CLI/dashboard activation surface goes through the same admission
transaction instead of writing ``config.yaml`` directly."""
enabled, disabled = _get_enabled_set(), _get_disabled_set()
aliases = _plugin_aliases(key)
target, other = (enabled, disabled) if enable else (disabled, enabled)
if key in target and not (aliases & other):
return False
_set_plugin_enabled(key, enable=enable, aliases=aliases, console=console)
return True
def _forget_plugin_config(aliases: set) -> dict[str, Any]:
"""Drop every trace of a removed plugin (its :func:`_plugin_aliases`, taken BEFORE the tree went)
from config.yaml: allow/deny-list entries, ``plugins.entries.<id>`` grants and a ``memory.provider``
selection naming it. A later reinstall under the same name must start from the "Enable now?"
decision, not inherit a stale enable or grant (#54336); a dangling ``memory.provider`` would make
the next agent init re-clone the plugin from the catalog, silently undoing the uninstall.
Returns ``{"cleared_memory_provider": True}`` when the selection was reset."""
from hermes_cli.config import load_config, save_config
config = load_config()
changed = False
result: dict[str, Any] = {}
plugins_cfg = config.get("plugins")
if isinstance(plugins_cfg, dict):
for list_key in ("enabled", "disabled"):
names = plugins_cfg.get(list_key)
if isinstance(names, list) and aliases & set(names):
plugins_cfg[list_key] = sorted(set(names) - aliases)
changed = True
entries = plugins_cfg.get("entries")
if isinstance(entries, dict) and aliases & set(entries):
for alias in aliases & set(entries):
del entries[alias]
changed = True
memory_cfg = config.get("memory")
if isinstance(memory_cfg, dict) and str(memory_cfg.get("provider") or "").strip() in aliases:
memory_cfg["provider"] = ""
changed, result = True, {"cleared_memory_provider": True}
if changed:
save_config(config)
return result
def _set_plugin_enabled(name: str, *, enable: bool, aliases=(), console=None) -> None:
"""Submit the command's delta with the version of the selection it read."""
from pm.plugins_state import read_home_selection
expected_config = _plugin_selection_version()
config = read_home_selection(get_hermes_home()) or {}
plugins = config.get("plugins") or {}
enabled = set(plugins.get("enabled") or ())
disabled = set(plugins.get("disabled") or ())
removed = disabled if enable else enabled
_discard_key_and_leaf(removed, name)
removed.difference_update(aliases)
(enabled if enable else disabled).add(name)
_admit_and_save_plugin_sets(enabled, disabled, console=console,
action=f"{'Enable' if enable else 'Disable'} '{name}'",
expected_config=expected_config)
def _resolve_plugin_key(name: str) -> Optional[str]:
"""Canonical registry key for a manifest name / directory name / path key, or ``None``.
The single normalization point so enable/disable write the key ``PluginManager`` gates on."""
resolved = _resolve_plugin_key_and_source(name)
return resolved[0] if resolved else None
def _find_plugin_entry(name: str) -> Optional[tuple]:
"""First discovered ``(name, version, description, source, dir_path, key)`` entry whose
manifest name or canonical key equals *name*."""
return next((entry for entry in _discover_all_plugins() if name in (entry[0], entry[5])), None)
def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]:
"""Resolve *name* to ``(canonical_key, source)`` or ``None``. Exact key/manifest-name match
first; then a bare leaf match (``langfuse`` -> ``observability/langfuse``) only when unique,
so a same-named nested plugin is never picked silently."""
entries = _discover_all_plugins()
for entry in entries:
if name in (entry[0], entry[5]):
return (entry[5], entry[3])
leaf_matches = [(entry[5], entry[3]) for entry in entries if name == entry[5].split("/")[-1]]
return leaf_matches[0] if len(leaf_matches) == 1 else None
def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
"""Add a plugin to the enabled allow-list (and remove it from disabled).
Non-bundled plugins request consent for declared capabilities. The legacy
``allow_tool_override`` grant changes only with an explicit True/False flag;
None leaves it unchanged. Bundled plugins are trusted.
"""
from hermes_cli.relay_plugin_cutover import LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV
console = _console()
def _refuse_legacy_relay(plugin: str) -> None:
if plugin in LEGACY_RELAY_PLUGIN_KEYS:
_fail(console, (
f"[red]Plugin '{plugin}' was removed.[/red] Relay lifecycle is owned "
f"by Hermes core; configure {RELAY_PLUGINS_CONFIG_ENV} instead."))
_refuse_legacy_relay(name)
resolved = _resolve_plugin_key_and_source(name)
if resolved is None:
_fail(console, _unknown_plugin_message(name))
key, source = resolved
_refuse_legacy_relay(key)
if source != "bundled":
# Activating recalled code is the same act as installing it (`plugins/AGENTS.md`: kill list).
from hermes_cli import plugins_cmd_catalog as catalog
try:
catalog.refuse_if_installed_removed(key, _user_installed_plugin_dir(key.rsplit("/", 1)[-1]))
except PluginOperationError as exc:
_fail(console, f"[red]Error:[/red] {exc}")
if _activate_key(key, enable=True, console=console):
from hermes_cli.plugins_activation import activate_plugin_now, activation_hint
console.print(f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. Takes effect on next session.")
console.print(f"[dim]{activation_hint(activate_plugin_now(key, in_process=False))}[/dim]")
else:
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")
# Built-in tool override is a privileged grant; bundled plugins are trusted.
if source == "bundled":
return
# When the manifest declares capabilities the consent screen is the canonical grant path
# (it covers tools.override too); the legacy prompt then only runs on an explicit flag.
# See #64228.
declared_caps = _declared_capabilities_for_key(key)
if declared_caps:
_run_capability_consent(console, key, declared_caps, context="enable")
# Enabling a plugin is not a request for undeclared privileges. Keep existing
# grants unchanged unless the operator explicitly grants or revokes one.
if allow_tool_override is not None:
_resolve_tool_override_grant(console, key, allow_tool_override)
def cmd_disable(name: str) -> None:
"""Remove a plugin from the enabled allow-list (and add to disabled)."""
console = _console()
key = _resolve_plugin_key(name)
if key is None:
_fail(console, _unknown_plugin_message(name))
if not _activate_key(key, enable=False, console=console):
console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]")
return
console.print(
f"[yellow]\u2298[/yellow] Plugin [bold]{key}[/bold] disabled. Takes effect on next session.")
def _read_manifest_info(d: Path, prefix: str):
"""Read a native or portable manifest and return display metadata."""
manifest_file = _native_manifest_file(d)
if manifest_file is None:
if not _has_portable_manifest(d):
return None
try:
from hermes_cli.agent_plugins import read_agent_plugin_manifest
manifest = read_agent_plugin_manifest(d)[0]
name = manifest["name"]
except Exception:
return None
else:
# Unreadable YAML (or no yaml module) degrades to the directory name, silently.
try:
manifest = _load_yaml_manifest(manifest_file)
except Exception:
manifest = {}
if not isinstance(manifest, dict):
manifest = {}
name = manifest.get("name", d.name)
key = f"{prefix}/{d.name}" if prefix else name
return name, manifest.get("version", ""), manifest.get("description", ""), key
def _is_portable_plugin_dir(dir_path) -> bool:
"""True for an Agent Plugins v1 package (``plugin.json`` only; native ``plugin.yaml`` wins)."""
try:
d = Path(dir_path)
return d.is_dir() and _native_manifest_file(d) is None and _has_portable_manifest(d)
except OSError:
return False
# Manifest kinds active-by-default when bundled (backends auto-load, platforms register lazily,
# model providers go through providers/ discovery). Standalone/exclusive kinds stay opt-in.
_BUNDLED_DEFAULT_ON_KINDS = frozenset({"backend", "platform", "model-provider"})
def _bundled_default_on(dir_path) -> bool:
"""True when a bundled plugin is active without a ``plugins.enabled`` entry (portable
``plugin.json`` packages have no kind, so never)."""
manifest_file = _native_manifest_file(Path(dir_path))
if manifest_file is None:
return False
try:
kind = str(_load_yaml_manifest(manifest_file).get("kind", "standalone")).strip().lower()
return kind in _BUNDLED_DEFAULT_ON_KINDS
except Exception:
return False
def _scan_level(base: Path, source: str, skip_names: set, prefix: str, depth: int, seen: dict) -> None:
"""Recursive directory scan matching PluginManager._scan_directory_level."""
if not base.is_dir():
return
try:
children = sorted(base.iterdir())
except OSError as exc:
logger.warning("Skipping unreadable plugin directory %s: %s", base, exc)
return
for d in children:
try:
if not d.is_dir() or (depth == 0 and skip_names and d.name in skip_names):
continue
info = _read_manifest_info(d, prefix)
except (OSError, PluginOperationError) as exc:
# The PM declaration reader wraps unreadable manifests for install callers;
# listing still skips them rather than hiding every other plugin.
logger.warning("Skipping unreadable plugin directory %s: %s", d, exc)
continue
if info is None:
if depth == 0:
_scan_level(d, source, set(), f"{prefix}/{d.name}" if prefix else d.name, 1, seen)
continue
name, version, description, key = info
if key in seen and source == "bundled":
continue
src_label = "git" if source == "user" and (d / ".git").exists() else source
seen[key] = (name, version, description, src_label, d, key)
def _discover_all_plugins() -> list:
"""``(name, version, description, source, dir_path, key)`` for every plugin the loader sees,
in ``PluginManager.discover_and_load`` order: bundled, user, then entry points — which never
displace a directory plugin of the same key (see ``PluginManager._discover_and_load_inner``)."""
seen: dict = {}
# memory/, context_engine/ and model-providers/ load through dedicated registries, not the
# PluginManager opt-in surface, so listing them as toggleable plugins would mislead.
from hermes_cli.plugins import discover_entrypoint_manifests, get_bundled_plugins_dir
for base, source, skip in (
(get_bundled_plugins_dir(), "bundled", {"memory", "context_engine", "model-providers"}),
(_plugins_dir(), "user", set()),
):
_scan_level(base, source, skip, "", 0, seen)
# Entry-point plugins are installed as Python packages, so they have no plugin directory.
for m in discover_entrypoint_manifests():
seen.setdefault(m.name, (m.name, m.version, m.description, "entrypoint", m.path, m.name))
return list(seen.values())
def _category_active_names() -> set:
"""Provider names switched on through ``<category>.provider`` config rather than
``plugins.enabled`` (the live memory provider), so status never calls them "not enabled"."""
return {n for n in (_get_current_memory_provider(),) if n}
def _plugin_status(name: str, enabled: set, disabled: set, key: str = "", *, source: str = "",
dir_path=None, active: "frozenset | set" = frozenset()) -> str:
"""User-facing activation state for a plugin name or key. Mirrors ``gate_manifest``: an explicit
disable wins, then the allow-list, then the activations that need no list entry — bundled
backends/platforms/model providers (*source* + *dir_path*) and category-selected providers
(*active*, see :func:`_category_active_names`)."""
names = {name, key}
if names & disabled:
return "disabled"
if names & enabled or names & active:
return "enabled"
if source == "bundled" and dir_path is not None and _bundled_default_on(dir_path):
return "enabled"
return "not enabled"
# ── Provider category config accessors ──────────────────────────────────────────────────────
# memory.provider ("" = built-in) and context.engine config accessors.
_get_current_memory_provider = functools.partial(_config_str, "memory", "provider", default="")
_get_current_context_engine = functools.partial(_config_str, "context", "engine", default="compressor")
_save_memory_provider = functools.partial(_write_config_value, "memory", "provider")
_save_context_engine = functools.partial(_write_config_value, "context", "engine")
def _get_plugin_toolset_key(name: str) -> Optional[str]:
"""Toolset key a plugin registers its tools under, or None: from the live registry (plugin
already loaded), else from ``provides_tools`` in plugin.yaml looked up in the registry."""
try:
from tools.registry import registry
except Exception:
return None
def _first_toolset(tool_names) -> Optional[str]:
return next((e.toolset for t in tool_names if (e := registry.get_entry(t)) and e.toolset), None)
def _from_loaded_plugin() -> Optional[str]:
from hermes_cli.plugins import discover_plugins, get_plugin_manager
discover_plugins() # idempotent — ensures plugins are loaded
for _key, loaded in get_plugin_manager()._plugins.items():
if loaded.manifest.name == name or _key == name:
return _first_toolset(loaded.tools_registered)
return None
def _from_manifest_on_disk() -> Optional[str]:
from hermes_cli.plugins import get_bundled_plugins_dir
return next((
toolset for base in (get_bundled_plugins_dir(), _plugins_dir())
if base.is_dir() and (base / name).is_dir()
and (toolset := _first_toolset(_read_manifest(base / name).get("provides_tools") or []))
), None)
for lookup in (_from_loaded_plugin, _from_manifest_on_disk):
try:
if toolset := lookup():
return toolset
except Exception:
continue
return None
def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
"""Add/remove a plugin's toolset in ``platform_toolsets`` for all platforms (no-op when the
plugin provides no tools)."""
toolset_key = _get_plugin_toolset_key(name)
if not toolset_key:
return
from hermes_cli.config import load_config, save_config
from hermes_cli.toolset_validation import parse_platform_toolsets_value
config = load_config()
platform_toolsets = _child_dict(config, "platform_toolsets")
changed = False
for platform, raw in list(platform_toolsets.items()):
# A list-literal string (older `hermes config set`) is the user's real selection; toggling
# it re-saves the entry as a proper list so the string never persists.
ts_list = parse_platform_toolsets_value(raw)
if ts_list is not None and enable != (toolset_key in ts_list):
(ts_list.append if enable else ts_list.remove)(toolset_key)
platform_toolsets[platform] = ts_list
changed = True
# Enabling with no platform lists yet: seed "cli" at minimum.
if enable and not changed and not platform_toolsets:
platform_toolsets["cli"] = [toolset_key]
changed = True
if changed:
save_config(config)
def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]:
"""Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists). *name* may be the
canonical key, the manifest name or a unique bare leaf; the canonical key is what gets written
(the loader never matches a bare leaf, and a stale key in ``disabled`` outranks a manifest-name
entry in ``enabled`` — so writing the raw identifier reported success while the plugin stayed off)."""
key = _resolve_plugin_key(name)
if key is None:
return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
from hermes_cli.plugins_admission import AdmissionRefused
try:
changed = _activate_key(key, enable=enabled)
except AdmissionRefused as exc:
return {
"ok": False,
"error": str(exc),
"name": key,
"unchanged": True,
"restart_required": False,
}
if changed:
_toggle_plugin_toolset(key, enable=enabled)
if changed and enabled:
# Load it now, here and in the running gateway; ``activation`` tells the UI what is live vs
# deferred, and ``restart_required`` only survives when no gateway answered (#87770).
from hermes_cli.plugins_activation import activate_plugin_now
return {"ok": True, "name": key, "unchanged": False, **activate_plugin_now(key)}
# Disable is config-only: there is no un-wire primitive, so a running gateway keeps the plugin's
# handlers until restart and every UI says so — #71595/#54941.
return {"ok": True, "name": key, "unchanged": not changed, "restart_required": changed}
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
try:
target = _sanitize_plugin_name(name, _plugins_dir(), allow_subdir=True)
except ValueError:
return None
return target if target.is_dir() else None
def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
"""Validate one plugin through runtime discovery and registration."""
from hermes_cli.plugin_dev import doctor_plugin
report = doctor_plugin(target)
_console().print(report.format_text())
if ci and not report.ok:
raise SystemExit(1)
def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]:
"""Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given)."""
return True if getattr(args, yes_attr, False) else (False if getattr(args, no_attr, False) else None)
def _catalog():
from hermes_cli import plugins_cmd_catalog
return plugins_cmd_catalog
def _action_pack(args):
from hermes_cli.plugin_packs import pack_command
pack_command(args)
# Tri-state flags: neither --x nor --no-x given == None == interactive prompt.
_PLUGIN_ACTIONS = {
"install": lambda args: cmd_install(
args.identifier,
force=getattr(args, "force", False),
enable=_tri_state_flag(args, "enable", "no_enable"),
ref=getattr(args, "ref", None),
allow_removed=getattr(args, "allow_removed", False),
no_deps=getattr(args, "no_deps", False)),
"search": lambda args: _catalog().cmd_search(
getattr(args, "term", "") or "", json_output=getattr(args, "json", False)),
"browse": lambda args: _catalog().cmd_search(""),
"validate": lambda args: _catalog().cmd_validate(
args.path, as_json=getattr(args, "json", False), install_deps=getattr(args, "install_deps", False)),
"update": lambda args: cmd_update(args.name),
"adopt": lambda args: cmd_adopt(args.name),
"trust-update-url": lambda args: cmd_trust_update_url(args.name),
"check-updates": lambda args: cmd_check_updates(args),
"check": lambda args: cmd_check_updates(args),
"remove": lambda args: cmd_remove(args.name),
"rm": lambda args: cmd_remove(args.name),
"uninstall": lambda args: cmd_remove(args.name),
"enable": lambda args: cmd_enable(
args.name,
allow_tool_override=_tri_state_flag(args, "allow_tool_override", "no_allow_tool_override")),
"disable": lambda args: cmd_disable(args.name),
"capabilities": lambda args: cmd_capabilities(getattr(args, "name", None)),
"list": lambda args: cmd_list(args),
"ls": lambda args: cmd_list(args),
"doctor": lambda args: cmd_plugin_doctor(args.target, ci=getattr(args, "ci", False)),
"compat": lambda args: cmd_compat(args),
"pack": _action_pack,
"show": lambda args: cmd_show(args.name),
"info": lambda args: _catalog().cmd_info(args.name),
None: lambda args: cmd_toggle(),
}
def plugins_command(args) -> None:
"""Dispatch hermes plugins subcommands."""
action = getattr(args, "plugins_action", None)
handler = _PLUGIN_ACTIONS.get(action)
if handler is None:
_fail(_console(), f"[red]Unknown plugins action: {action}[/red]")
handler(args)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import importlib.metadata # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----