Files
hermes-agent/hermes_cli/plugins_cmd.py

2674 lines
102 KiB
Python

"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
After install, if the plugin ships an ``after-install.md`` file it is rendered with Rich Markdown.
Otherwise a default confirmation is shown.
"""
from __future__ import annotations
from hermes_cli.cli_output import line_input
import functools
import importlib.metadata
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
from hermes_cli._subprocess_compat import noninteractive_git_env
from hermes_cli.config import cfg_get
from hermes_cli.secret_prompt import masked_secret_prompt
from utils import atomic_write_text
logger = logging.getLogger(__name__)
@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(root, "Git", sub, "git.exe") for root in roots for sub in ("cmd", "bin")
]
else:
candidates = ["/usr/bin/git", "/usr/local/bin/git", "/bin/git"]
for c in candidates:
if c and os.path.isfile(c):
return c
return 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 (imported lazily; rich is not needed at import time)."""
from rich.console import Console
return Console()
def _is_tty() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
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 _sub_dict(parent: dict, key: str) -> dict:
"""``parent[key]`` as a dict, replacing a missing or non-dict value with ``{}``."""
child = parent.get(key)
if not isinstance(child, dict):
child = {}
parent[key] = child
return child
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 _config_name_set(*keys: str) -> set:
"""Read 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:
"""Read a string config key, coercing empty/missing/failed reads 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()
if section not in config:
config[section] = {}
config[section][key] = value
save_config(config)
def _scan_on_install_enabled() -> bool:
"""Whether install/update-time plugin security scanning is enabled.
On by default (inspired by Claude Cowork's skill & plugin security scanning). Disable via
``plugins.scan_on_install: false`` in config.yaml.
"""
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):
"""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. Raises :class:`PluginScanBlocked`
when the plugin may not be installed. 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 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
# Minimum manifest version this installer understands.
# Plugins may declare ``manifest_version: 1`` in plugin.yaml;
# future breaking changes to the manifest schema bump this.
_SUPPORTED_MANIFEST_VERSION = 1
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 path-traversal sequences or a target outside the plugins directory.
``allow_subdir=True`` permits one forward slash so category-namespaced registry keys like
``observability/langfuse`` can be looked up; ``..`` and backslashes are still rejected.
Install paths keep the default ``False`` because a fresh clone always 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."
)
# Reject obvious traversal characters
bad_chars = ("\\", "..") if allow_subdir else ("/", "\\", "..")
for bad in bad_chars:
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",
}
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a security warning at
install time.
"""
# Already a URL.
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
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...] shorthand
parts = [p for p in identifier.strip("/").split("/") if p]
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
subdir = "/".join(parts[2:]).strip("/")
git_url = f"https://github.com/{owner}/{repo}.git"
return git_url, (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``, rejecting path traversal.
Guards against ``..`` segments, absolute paths, and symlinks that would escape the clone.
Raises ``PluginOperationError`` if the path escapes, doesn't exist, or is not a directory.
"""
clone_root = clone_root.resolve()
candidate = (clone_root / subdir).resolve()
# The resolved candidate must stay within the clone root.
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:
"""Extract the repo name from a Git URL for the plugin directory name."""
# Last path component after stripping trailing slashes and ``.git``; ssh-style
# ``git@host:owner/repo`` has no slash to split on, hence the colon fallback.
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."""
for name in ("plugin.yaml", "plugin.yml"):
candidate = plugin_dir / name
if candidate.exists():
return candidate
return None
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."""
import yaml
with open(manifest_file, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def _read_portable_manifest(plugin_dir: Path) -> dict:
"""Validated Agent Plugins v1 ``plugin.json`` manifest (diagnostics dropped); raises on failure."""
from hermes_cli.agent_plugins import read_agent_plugin_manifest
manifest, _ = read_agent_plugin_manifest(plugin_dir)
return manifest
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:
return _read_portable_manifest(plugin_dir)
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 any .example files to their real names if they don't already exist.
For example, ``config.yaml.example`` becomes ``config.yaml``. Skips files that already exist to
avoid overwriting user config on reinstall.
"""
for example_file in plugin_dir.glob("*.example"):
real_name = example_file.stem # e.g. "config.yaml" from "config.yaml.example"
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 _requires_env_specs(manifest: dict) -> list[dict]:
"""Normalise ``requires_env`` (plain names or ``{name, description, url, secret}`` dicts)
to a list of dicts; entries without a name are dropped."""
env_specs: list[dict] = []
for entry in manifest.get("requires_env") or []:
if isinstance(entry, str):
env_specs.append({"name": entry})
elif isinstance(entry, dict) and entry.get("name"):
env_specs.append(entry)
return env_specs
def _missing_env_specs(manifest: dict) -> list[dict]:
"""Declared ``requires_env`` specs whose variable is unset in ``~/.hermes/.env``."""
env_specs = _requires_env_specs(manifest)
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 _missing_requires_env_names(manifest: dict) -> list[str]:
"""Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``."""
return [s["name"] for s in _missing_env_specs(manifest)]
def _print_python_dependencies(manifest: dict, console) -> None:
"""Surface declared python_dependencies at install time (#64165).
Declaration seam ONLY — Hermes never auto-installs plugin pip dependencies (isolation design
deferred; see #64165 / #15220). We print the declared requirements with a copy-pasteable install
hint.
"""
deps = manifest.get("python_dependencies") or []
if not isinstance(deps, list):
return
deps = [d.strip() for d in deps if isinstance(d, str) and d.strip()]
if not deps:
return
plugin_name = manifest.get("name", "this plugin")
console.print(
f"\n[bold]{plugin_name}[/bold] declares Python dependencies "
"(not installed automatically):"
)
for dep in deps:
console.print(f" - {dep}")
console.print(
"[dim]Install them yourself if needed: "
f"pip install {' '.join(repr(d) for d in deps)}[/dim]\n"
)
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
"""Prompt for required environment variables declared in plugin.yaml.
``requires_env`` accepts either a plain list of names or rich entries with ``name``,
``description``, ``url`` and ``secret``. Already-set variables are skipped; values are saved
to the user's ``.env``.
"""
missing = _missing_env_specs(manifest)
if not missing:
return
from hermes_cli.config import save_env_value
from hermes_constants import display_hermes_home
plugin_name = manifest.get("name", "this plugin")
console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n")
for spec in missing:
name = spec["name"]
desc = spec.get("description", "")
url = spec.get("url", "")
secret = spec.get("secret", False)
label = f" {name}"
if desc:
label += f" — {desc}"
console.print(label)
if url:
console.print(f" [dim]Get yours at: {url}[/dim]")
try:
value = (masked_secret_prompt if secret else line_input)(f" {name}: ").strip()
except (EOFError, KeyboardInterrupt):
console.print(f"\n[dim] Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]")
return
if value:
save_env_value(name, value)
os.environ[name] = value
console.print(f" [green]✓[/green] Saved to {display_hermes_home()}/.env")
else:
console.print(f" [dim] Skipped (set {name} in {display_hermes_home()}/.env later)[/dim]")
console.print()
def _display_after_install(plugin_dir: Path, identifier: str) -> None:
"""Show after-install.md if it exists, otherwise a default message."""
from rich.markdown import Markdown
from rich.panel import Panel
console = _console()
after_install = plugin_dir / "after-install.md"
if after_install.exists():
body, title = Markdown(after_install.read_text(encoding="utf-8")), None
else:
body = f"[green bold]Plugin installed:[/] {identifier}\n[dim]Location:[/] {plugin_dir}"
title = "✓ Installed"
console.print()
console.print(Panel(body, border_style="green", title=title, expand=False))
console.print()
def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
"""Return the plugin path if it exists, or exit with an error (invalid name, or a listing of
installed plugins when missing)."""
try:
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
if not target.exists():
installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)"
console.print(
f"[red]Error:[/red] Plugin '{name}' not found in {plugins_dir}.\n"
f"Installed plugins: {installed}"
)
sys.exit(1)
return target
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
_EXACT_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$")
_INSTALL_METADATA_FILE = ".install-metadata.json"
def _install_metadata_path() -> Path:
return get_hermes_home() / "plugins" / _INSTALL_METADATA_FILE
def _read_install_metadata() -> dict[str, dict[str, object]]:
"""Read profile-local, non-secret plugin source metadata from disk."""
path = _install_metadata_path()
if not path.exists():
return {}
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PluginOperationError(f"Could not read plugin install metadata: {exc}") from exc
if not isinstance(value, dict):
raise PluginOperationError("Plugin install metadata must be a JSON object.")
return value
def _write_install_metadata(metadata: dict[str, dict[str, object]]) -> None:
"""Atomically replace the profile-local plugin install metadata sidecar."""
path = _install_metadata_path()
atomic_write_text(
path,
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
tmp_prefix=f"{path.name}.tmp-",
)
def _normalize_exact_revision(ref: str) -> str:
if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref):
raise PluginOperationError("--ref must be a full 40-character commit SHA.")
return ref.lower()
def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -> str:
"""Return diagnosable Git output without echoing embedded credentials."""
from agent.redact import redact_sensitive_text
error = (result.stderr or result.stdout or "").strip()
if source_url:
error = error.replace(source_url, _scrub_git_url(source_url))
return redact_sensitive_text(error)
def _git_or_raise(
git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = ""
) -> subprocess.CompletedProcess:
"""Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error)."""
result = _run_plugin_git(git_exe, repo, *args, timeout=timeout)
if result.returncode != 0:
raise PluginOperationError(failure_prefix + _safe_git_error(result, source_url))
return result
def _git_head_revision(repo: Path, git_exe: str) -> str:
result = _git_or_raise(
git_exe, repo, "rev-parse", "HEAD", timeout=15,
failure_prefix="Could not determine installed Git revision:\n",
)
return result.stdout.strip().lower()
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str) -> None:
"""Fetch and detach at one immutable commit, then verify the resulting HEAD."""
steps = (
(
("fetch", "--depth", "1", "origin", revision),
f"Git fetch of commit '{revision}' timed out after 60 seconds.",
f"Git commit '{revision}' could not be fetched:\n",
),
(
("checkout", "--detach", revision),
f"Git checkout of commit '{revision}' timed out after 60 seconds.",
f"Git checkout of commit '{revision}' failed:\n",
),
)
for args, timeout_msg, failure_prefix in steps:
try:
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix)
except subprocess.TimeoutExpired as exc:
raise PluginOperationError(timeout_msg) from exc
actual = _git_head_revision(repo, git_exe)
if actual != revision:
raise PluginOperationError(
f"Checked-out revision '{actual}' does not match requested commit '{revision}'."
)
def _scrub_git_url(git_url: str) -> str:
"""Strip credentials and query/fragment data from an HTTP Git URL."""
parsed = urllib.parse.urlsplit(git_url)
if parsed.scheme in {"http", "https"} and parsed.hostname:
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
if parsed.port is not None:
host = f"{host}:{parsed.port}"
return urllib.parse.urlunsplit(
(parsed.scheme, host, parsed.path, "", "")
)
return git_url
def _canonical_source(git_url: str, subdir: Optional[str]) -> str:
scrubbed = _scrub_git_url(git_url)
return f"{scrubbed}#{subdir}" if subdir else scrubbed
def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None:
"""Ensure credentials used for cloning do not survive in ``.git/config``."""
scrubbed = _scrub_git_url(git_url)
if scrubbed == git_url:
return
_git_or_raise(
git_exe, repo, "remote", "set-url", "origin", scrubbed, timeout=15,
failure_prefix="Could not sanitize installed Git remote:\n", source_url=git_url,
)
def _check_manifest_version(manifest: dict, plugin_name: str) -> None:
"""Reject manifests declaring a newer ``manifest_version`` than this installer supports."""
mv = manifest.get("manifest_version")
if mv is None:
return
try:
mv_int = int(mv)
except (ValueError, TypeError):
raise PluginOperationError(
f"Plugin '{plugin_name}' has invalid manifest_version "
f"'{mv}' (expected an integer).",
) from None
if mv_int > _SUPPORTED_MANIFEST_VERSION:
from hermes_cli.config import recommended_update_command
raise PluginOperationError(
f"Plugin '{plugin_name}' requires manifest_version {mv}, "
f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. "
f"Run {recommended_update_command()} to update Hermes.",
) from None
def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str]) -> str:
"""Shallow-clone *git_url* into *tmp_clone* (detached at *revision* when given), scrub any
credentials from the recorded origin, and return the installed HEAD SHA."""
git_exe = _resolve_git_executable()
if not git_exe:
raise PluginOperationError("git is not installed or not in PATH.")
clone_args = ["clone", "--depth", "1"]
if revision:
clone_args.append("--no-checkout")
clone_args.extend([git_url, str(tmp_clone)])
try:
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args)
except FileNotFoundError as e:
raise PluginOperationError("git is not installed or not in PATH.") from e
except subprocess.TimeoutExpired as e:
raise PluginOperationError("Git clone timed out after 60 seconds.") from e
if result.returncode != 0:
err = _safe_git_error(result, git_url)
raise PluginOperationError(f"Git clone failed:\n{err}")
_scrub_cloned_origin(tmp_clone, git_exe, git_url)
if revision:
_checkout_exact_revision(tmp_clone, git_exe, revision)
return _git_head_revision(tmp_clone, git_exe)
def _read_manifest_for_install(plugin_dir: Path) -> dict:
"""Manifest of a freshly cloned tree. Unlike :func:`_read_manifest`, a broken portable
``plugin.json`` is an install error (not a silent ``{}``) and its diagnostics are logged."""
if _native_manifest_file(plugin_dir) is not None or not _has_portable_manifest(plugin_dir):
return _read_manifest(plugin_dir)
try:
from hermes_cli.agent_plugins import read_agent_plugin_manifest
manifest, diagnostics = read_agent_plugin_manifest(plugin_dir)
except Exception as exc:
raise PluginOperationError(
f"Portable plugin manifest validation failed: {exc}"
) from exc
for diagnostic in diagnostics:
logger.warning("Agent Plugin install: %s", diagnostic.message)
return manifest
def _swap_in_plugin(tmp_target: Path, target: Path, backup: Path, old_metadata: dict, new_metadata: dict) -> None:
"""Move the validated clone into place and persist metadata; on any failure restore the
previous tree (if one was replaced) and the previous metadata sidecar, then re-raise."""
replaced_existing = target.exists()
if replaced_existing:
os.replace(target, backup)
try:
os.replace(tmp_target, target)
_write_install_metadata(new_metadata)
except Exception:
if target.exists():
shutil.rmtree(target)
if replaced_existing and backup.exists():
os.replace(backup, target)
if old_metadata:
_write_install_metadata(old_metadata)
else:
_install_metadata_path().unlink(missing_ok=True)
raise
def _install_plugin_core(
identifier: str,
*,
force: bool,
ref: Optional[str] = None,
scan_decision_cb=None,
) -> tuple[Path, dict, str]:
"""Clone a Git plugin and atomically record its source and exact revision."""
requested_revision = _normalize_exact_revision(ref) if ref is not None else None
try:
git_url, subdir = _resolve_git_url(identifier)
except ValueError as e:
raise PluginOperationError(str(e)) from e
plugins_dir = _plugins_dir()
source = _canonical_source(git_url, subdir)
old_metadata = _read_install_metadata()
# Reinstalling the same pinned source retains its pin, even if its plugin
# directory was manually removed. Moving a pin requires an explicit --ref.
if requested_revision is None:
matching_pins = [
entry
for entry in old_metadata.values()
if entry.get("source") == source and entry.get("pinned") is True
]
if len(matching_pins) == 1 and isinstance(matching_pins[0].get("revision"), str):
requested_revision = _normalize_exact_revision(matching_pins[0]["revision"])
with tempfile.TemporaryDirectory(prefix=".install-", dir=plugins_dir) as tmp:
tmp_clone = Path(tmp) / "plugin"
installed_revision = _clone_plugin_repo(tmp_clone, git_url, requested_revision)
tmp_target = (
_resolve_subdir_within(tmp_clone, subdir) if subdir else tmp_clone
)
manifest = _read_manifest_for_install(tmp_target)
plugin_name = manifest.get("name") or (
subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url)
)
try:
target = _sanitize_plugin_name(plugin_name, plugins_dir)
except ValueError as e:
raise PluginOperationError(str(e)) from e
_check_manifest_version(manifest, plugin_name)
# Security scan the clone BEFORE anything is moved into place
# (see ``tools/plugin_guard.py``; inspired by Claude Cowork's skill
# & plugin scanning). ``scan_decision_cb`` is called with the
# ScanResult for caution verdicts and may return True to accept the
# risk interactively. Raises PluginScanBlocked when blocked.
_scan_plugin_tree(
tmp_target,
identifier,
force=force,
scan_decision_cb=scan_decision_cb,
)
if target.exists() and not force:
raise PluginOperationError(
f"Plugin '{plugin_name}' already exists. Use force reinstall "
f"or run `hermes plugins update {plugin_name}`."
)
prior = old_metadata.get(plugin_name)
if (
target.exists()
and requested_revision is None
and isinstance(prior, dict)
and prior.get("pinned") is True
):
raise PluginOperationError(
f"Plugin '{plugin_name}' is pinned. Reinstall it with an explicit "
"--ref <40-character commit SHA> to change its source or revision."
)
new_metadata = dict(old_metadata)
new_metadata[plugin_name] = {
"pinned": requested_revision is not None,
"revision": installed_revision,
"source": source,
}
_swap_in_plugin(tmp_target, target, Path(tmp) / "previous-plugin", old_metadata, new_metadata)
if not _looks_like_plugin_dir(target):
logger.warning(
"%s has no plugin.yaml / __init__.py; may not be a valid plugin",
plugin_name,
)
_copy_example_files(target, _console())
installed_manifest = _read_manifest(target)
installed_name = installed_manifest.get("name") or target.name
return target, installed_manifest, installed_name
def _looks_like_bare_index_name(identifier: str) -> bool:
"""True when *identifier* is a bare plugin name (no slash, not a URL).
Bare names are resolved through the community plugin index; anything with a slash or URL scheme
keeps the existing owner/repo / Git URL semantics.
"""
if "/" in identifier or "\\" in identifier:
return False
return not identifier.startswith(("https://", "http://", "git@", "ssh://", "file://"))
def _resolve_index_name(identifier: str, console) -> tuple[str, Optional[str]]:
"""Resolve a bare plugin name to ``(install_identifier, pinned_ref)``.
Exits with an error when the name is unknown, or lists candidates and exits when the name is
ambiguous. The returned ref is only used when it is an exact 40-character commit SHA (the pin
format the installer accepts); tag refs are surfaced as advisory output instead.
"""
from hermes_cli.plugin_index import SECURITY_FOOTER, load_index, resolve_name
entries, source = load_index()
entry, candidates = resolve_name(entries, identifier)
if entry is None:
if len(candidates) > 1:
console.print(
f"[red]Error:[/red] Plugin name '{identifier}' is ambiguous in the "
f"community index ({source}). Candidates:"
)
for c in candidates:
console.print(f" {c.name} → {c.install_identifier}")
console.print("Re-run with the exact name or the owner/repo identifier.")
else:
console.print(
f"[red]Error:[/red] Plugin '{identifier}' was not found in the "
f"community index ({source}). Use `hermes plugins search <term>` to "
"browse, or install directly with an owner/repo identifier."
)
sys.exit(1)
pinned_ref: Optional[str] = None
if entry.ref and _EXACT_COMMIT_RE.fullmatch(entry.ref):
pinned_ref = entry.ref.lower()
elif entry.ref:
console.print(
f"[dim]Index pins ref '{entry.ref}' (not an exact commit SHA); "
"installing the default branch head instead.[/dim]"
)
console.print(
f"[dim]Resolved '{entry.name}' via community index ({source}) → "
f"{entry.install_identifier}"
+ (f" @ {pinned_ref[:12]}[/dim]" if pinned_ref else "[/dim]")
)
console.print(f"[dim]{SECURITY_FOOTER}[/dim]")
return entry.install_identifier, pinned_ref
def cmd_install(
identifier: str,
force: bool = False,
enable: Optional[bool] = None,
ref: Optional[str] = None,
) -> None:
"""Install a plugin from a Git URL, owner/repo shorthand, or index name.
Bare names (no slash, no URL scheme) are resolved through the community plugin index to
``owner/repo`` plus the index-pinned ref. An explicit ``--ref`` always wins over the index pin.
After install, prompt "Enable now? [y/N]" unless *enable* is provided (True = auto-enable
without prompting, False = install disabled).
"""
console = _console()
if _looks_like_bare_index_name(identifier):
identifier, index_ref = _resolve_index_name(identifier, console)
if ref is None:
ref = index_ref
try:
git_url, _subdir = _resolve_git_url(identifier)
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
if git_url.startswith(("http://", "file://")):
console.print(
"[yellow]Warning:[/yellow] Using insecure/local URL scheme. "
"Consider using https:// or git@ for production installs.",
)
console.print(f"[dim]Cloning {git_url}{f' (subdir: {_subdir})' if _subdir else ''}...[/dim]")
def _interactive_scan_decision(scan_result) -> bool:
"""Prompt the user to accept a caution-verdict plugin (Cowork 'warn')."""
from tools.plugin_guard import format_scan_report
console.print()
console.print("[yellow]⚠ Security scan flagged this plugin:[/yellow]")
console.print(format_scan_report(scan_result))
return _is_tty() and _ask_yes(
" Install anyway? Only continue if you trust the source. [y/N]: "
)
try:
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
ref=ref,
scan_decision_cb=_interactive_scan_decision,
)
except PluginOperationError as e:
label = "Blocked" if isinstance(e, PluginScanBlocked) else "Error"
console.print(f"[red]{label}:[/red] {e}")
sys.exit(1)
if not _looks_like_plugin_dir(target):
console.print(
f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml, "
f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.",
)
_prompt_plugin_env_vars(installed_manifest, console)
_print_python_dependencies(installed_manifest, console)
_display_after_install(target, identifier)
should_enable = enable
if should_enable is None:
should_enable = _is_tty() and _ask_yes(f" Enable '{installed_name}' now? [y/N]: ")
if should_enable:
_set_plugin_enabled(installed_name, enable=True)
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]",
)
# Capability consent (#64228): if the manifest declares capabilities,
# show the list once and record consent. Non-interactive installs (and
# declines) proceed with capabilities ungranted — fail closed.
declared_caps = _declared_capabilities_from_manifest(installed_manifest, installed_name)
if declared_caps:
_run_capability_consent(console, installed_name, declared_caps, context="install")
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
console.print("[dim] hermes gateway restart[/dim]")
console.print()
def _pull_plugin_update(target: Path, pinned_msg, not_git_msg, before_pull=None) -> str:
"""Shared ``update`` core: refuse pinned / non-git checkouts, ``git pull``, record the new
revision. Returns the pull output; raises :class:`PluginOperationError` on any refusal.
*pinned_msg(install_record)* / *not_git_msg()* build the caller-specific error text."""
metadata = _read_install_metadata()
install_record = metadata.get(target.name, {})
if install_record.get("pinned") is True:
raise PluginOperationError(pinned_msg(install_record))
if not (target / ".git").exists():
raise PluginOperationError(not_git_msg())
if before_pull is not None:
before_pull()
ok, output = _git_pull_plugin_dir(target)
if not ok:
raise PluginOperationError(output)
_record_pulled_revision(target, metadata, install_record)
return output
def cmd_update(name: str) -> None:
"""Update an installed plugin by pulling latest from its git remote."""
from rich.markup import escape
console = _console()
plugins_dir = _plugins_dir()
target = _require_installed_plugin(name, plugins_dir, console)
try:
output = _pull_plugin_update(
target,
lambda rec: (
f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run "
f"`hermes plugins install {escape(str(rec.get('source', '<source>')))} --force "
"--ref <40-character commit SHA>`."
),
lambda: (
f"Plugin '{name}' was not installed from git (no .git directory). Cannot update."
),
before_pull=lambda: console.print(f"[dim]Updating {name}...[/dim]"),
)
except PluginOperationError as exc:
console.print(f"[red]Error:[/red] {exc}")
sys.exit(1)
# Re-scan after update — Cowork re-scans skills/plugins on edit, and an
# update can introduce malicious content into a previously clean plugin.
# The pull has already mutated the tree, so a dangerous verdict disables
# the plugin rather than leaving it active.
if _scan_on_install_enabled():
from tools.plugin_guard import format_scan_report, scan_plugin, should_allow_plugin_install
scan_result = scan_plugin(target, source=name)
allowed, reason = should_allow_plugin_install(scan_result)
if allowed is not True:
console.print()
console.print(f"[yellow]⚠ Security scan flagged the updated plugin:[/yellow] {reason}")
console.print(format_scan_report(scan_result))
if scan_result.verdict == "dangerous":
if name in _get_enabled_set() or name not in _get_disabled_set():
_set_plugin_enabled(name, enable=False)
console.print(
f"[red]Plugin '{name}' has been disabled.[/red] Review the "
f"findings, then re-enable with `hermes plugins enable {name}` "
f"if you trust them.",
)
_post_pull_housekeeping(target, console)
# Update-time re-consent (#64228): if the new version declares
# capabilities the granted set lacks, surface the diff and require
# re-consent for the additions. The stored consent hash detects a
# changed declaration; additions stay ungranted until the user says yes
# (non-interactive updates leave them ungranted — fail closed).
updated_manifest = _read_manifest(target)
plugin_id = updated_manifest.get("name") or target.name
declared_caps = _declared_capabilities_from_manifest(updated_manifest, plugin_id)
if declared_caps:
from hermes_cli.plugin_capabilities import declared_set_changed, pending_capabilities
if pending_capabilities(plugin_id, declared_caps) or declared_set_changed(
plugin_id, declared_caps
):
_run_capability_consent(console, plugin_id, declared_caps, context="update")
out = output.strip()
if "Already up to date" in out:
console.print(
f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date."
)
else:
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
console.print(f"[dim]{out}[/dim]")
def _post_pull_housekeeping(target: Path, console) -> None:
"""After ``git pull``: drop ``__pycache__`` compiled from the previous revision (same stale-
bytecode class as the main checkout, #6207/#60242) and copy any new ``.example`` files."""
_clear_plugin_bytecode(target)
_copy_example_files(target, console)
def _record_pulled_revision(target: Path, metadata: dict, install_record: dict) -> None:
"""After a pull, store the new HEAD in the plugin's install-metadata record (if it has one)."""
if not install_record:
return
git_exe = _resolve_git_executable()
if git_exe:
install_record["revision"] = _git_head_revision(target, git_exe)
metadata[target.name] = install_record
_write_install_metadata(metadata)
def _remove_plugin_core(target: Path) -> None:
"""Remove one plugin and its metadata without splitting their state."""
metadata = _read_install_metadata()
if target.name not in metadata:
shutil.rmtree(target)
return
updated = dict(metadata)
updated.pop(target.name)
staging = Path(
tempfile.mkdtemp(prefix=f".{target.name}.remove-", dir=target.parent)
)
backup = staging / "plugin"
os.replace(target, backup)
try:
_write_install_metadata(updated)
except Exception:
try:
os.replace(backup, target)
except OSError as restore_exc:
raise PluginOperationError(
f"Plugin metadata update failed and '{target.name}' could not be "
f"restored automatically; recovery copy remains at {backup}."
) from restore_exc
shutil.rmtree(staging, ignore_errors=True)
raise
shutil.rmtree(staging)
def cmd_remove(name: str) -> None:
"""Remove an installed plugin by name."""
console = _console()
plugins_dir = _plugins_dir()
target = _require_installed_plugin(name, plugins_dir, console)
try:
_remove_plugin_core(target)
except (OSError, PluginOperationError) as exc:
console.print(f"[red]Error:[/red] Could not remove plugin '{name}': {exc}")
sys.exit(1)
console.print()
console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}")
console.print()
def _get_disabled_set() -> set:
"""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``.
"""
return _config_name_set("plugins", "disabled")
def _save_disabled_set(disabled: set) -> None:
"""Write the disabled plugins list to config.yaml."""
_write_config_value("plugins", "disabled", sorted(disabled))
_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})
def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool:
"""Re-enable the bundled basic dashboard-auth plugin in *cfg*.
``hermes setup`` / ``hermes plugins disable basic`` can park the plugin in
``plugins.disabled`` while ``dashboard.basic_auth`` is configured; the bundled provider
still respects the deny-list, so password auth silently fails until the block is removed.
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 _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").
"""
return _config_name_set("plugins", "enabled")
def _save_enabled_set(enabled: set) -> None:
"""Write the enabled plugins list to config.yaml."""
_write_config_value("plugins", "enabled", sorted(enabled))
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 _set_plugin_enabled(name: str, *, enable: bool) -> None:
"""Move *name* between the enabled allow-list and the disabled deny-list and persist both."""
enabled = _get_enabled_set()
disabled = _get_disabled_set()
(enabled.add if enable else enabled.discard)(name)
(disabled.discard if enable else disabled.add)(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
def _resolve_plugin_key(name: str) -> Optional[str]:
"""Resolve a user-supplied plugin identifier to its canonical registry key.
Accepts the bare manifest name, the directory name, or the path-derived key
(``observability/langfuse``) and returns the key the loader gates on; ``None`` when nothing
matches. Single normalization point so ``enable``/``disable`` write the same key that
``PluginManager`` matches against, nested category plugins included.
"""
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`` if no match.
Same normalization as :func:`_resolve_plugin_key` but also returns the plugin's source
(``"bundled"``, ``"user"``, ``"project"``, ...) so the enable path can tell whether a built-
in-override consent prompt is needed.
"""
entries = _discover_all_plugins()
# 1. Exact match on canonical key or manifest name — always unambiguous.
for entry in entries:
if name in (entry[0], entry[5]):
return (entry[5], entry[3])
# 2. Fall back to a bare leaf-name match (e.g. "langfuse" ->
# "observability/langfuse"), but only when it resolves to exactly one
# plugin so we never silently pick the wrong same-named nested plugin.
leaf_matches = [
(entry[5], entry[3]) for entry in entries
if name == entry[5].split("/")[-1]
]
if len(leaf_matches) == 1:
return leaf_matches[0]
return None
def _set_plugin_entry_flag(plugin_id: str, key: str, value: bool) -> None:
"""Write ``plugins.entries.<plugin_id>.<key> = value`` into config.yaml."""
from hermes_cli.config import load_config, save_config
config = load_config()
entry = _sub_dict(_sub_dict(_sub_dict(config, "plugins"), "entries"), plugin_id)
entry[key] = bool(value)
save_config(config)
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 are prompted about the privileged ``allow_tool_override`` capability
(replacing built-in tools). ``allow_tool_override`` is tri-state: ``True`` grants and
``False`` declines without prompting, ``None`` asks interactively. Bundled plugins are
trusted and never prompted.
"""
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:
console.print(
f"[red]Plugin '{plugin}' was removed.[/red] Relay lifecycle is owned "
f"by Hermes core; configure {RELAY_PLUGINS_CONFIG_ENV} instead."
)
sys.exit(1)
_refuse_legacy_relay(name)
# Discover the plugin — check installed (user) AND bundled, including
# nested category plugins — and normalize to its canonical registry key.
resolved = _resolve_plugin_key_and_source(name)
if resolved is None:
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
key, source = resolved
_refuse_legacy_relay(key)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
already_enabled = key in enabled and key not in disabled
if not already_enabled:
enabled.add(key)
# Drop every alias of this plugin from the disabled list so an
# explicit disable under a different form can't keep it off. The
# loader's disable check matches on BOTH the canonical key
# (``web/firecrawl``) AND the manifest name (``web-firecrawl``);
# a stale entry under either form makes "explicit disable wins"
# (plugins.py) silently veto this enable. Discard the key, its
# bare leaf, and the manifest name. (#40190 follow-up.)
_discard_key_and_leaf(disabled, key)
for entry in _discover_all_plugins():
# entry = (name, version, description, source, dir_path, key)
if entry[5] == key:
disabled.discard(entry[0])
break
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. "
"Takes effect on next session."
)
else:
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")
# Built-in tool override is a privileged grant. Bundled plugins ship with
# Hermes core and are trusted; every other source needs operator opt-in.
if source == "bundled":
return
# Capability consent (#64228): when the manifest declares capabilities,
# the consent screen is the canonical grant path — it covers
# tools.override too, so skip the legacy standalone prompt unless the
# operator explicitly passed --allow-tool-override/--no-allow-tool-override.
declared_caps = _declared_capabilities_for_key(key)
if declared_caps:
_run_capability_consent(console, key, declared_caps, context="enable")
if allow_tool_override is not None:
_resolve_tool_override_grant(console, key, allow_tool_override)
return
_resolve_tool_override_grant(console, key, allow_tool_override)
# ── Capability consent flow (#64228) ─────────────────────────────────────────
def _declared_capabilities_from_manifest(manifest: dict, plugin_name: str = "?") -> list:
"""Extract + normalize the ``capabilities:`` declaration from a manifest."""
from hermes_cli.plugin_capabilities import parse_declared_capabilities
return parse_declared_capabilities(
(manifest or {}).get("capabilities"), plugin_name
)
def _declared_capabilities_for_key(key: str) -> list:
"""Read the declared capabilities for an installed/bundled plugin by key."""
entry = _find_plugin_entry(key)
if entry is None:
return []
if entry[3] == "entrypoint":
from hermes_cli.plugins import discover_entrypoint_manifests
for manifest in discover_entrypoint_manifests():
if key in (manifest.key, manifest.name):
return list(manifest.capabilities)
return []
dir_path = entry[4]
if not dir_path:
return []
return _declared_capabilities_from_manifest(_read_manifest(Path(dir_path)), entry[0])
def _print_capability_list(console, capabilities: list) -> None:
"""Render the consent screen body: one line per capability."""
from hermes_cli.plugin_capabilities import CAPABILITY_REGISTRY
for cap in capabilities:
spec = CAPABILITY_REGISTRY.get(cap)
desc = spec.description if spec else ""
console.print(f" [bold]{cap}[/bold] — {desc}")
def _run_capability_consent(
console,
plugin_id: str,
declared: list,
*,
context: str = "install",
) -> bool:
"""Show the capability consent screen and record the decision.
Lists declared capabilities with risk descriptions and asks one Y/n. On consent the pending
capabilities are granted under ``plugins.entries.<id>.granted_capabilities`` with a hash of
the declared set. On decline — or in ANY non-interactive context — they stay ungranted (fail
closed) and the plugin must degrade via ``ctx.has_capability()``. This is consent + audit,
NOT a sandbox: an in-process plugin can run arbitrary Python regardless. Returns True when
granted.
"""
from hermes_cli.plugin_capabilities import (
pending_capabilities,
record_consent,
)
pending = pending_capabilities(plugin_id, declared)
if not pending:
# Everything declared is already granted — refresh the consent hash
# so a later declaration change is detected against the current set.
if declared:
record_consent(plugin_id, [], declared)
return True
verb = "requests" if context == "install" else "now requests"
console.print(
f"\n [yellow]Plugin [bold]{plugin_id}[/bold] {verb} the following "
"capabilities:[/yellow]"
)
_print_capability_list(console, pending)
console.print(
" [dim]Granting trusts the plugin author with these host surfaces. "
"This is consent, not a sandbox — plugins run as regular Python "
"in-process.[/dim]"
)
if not _is_tty():
console.print(
" [yellow]Non-interactive session: capabilities NOT granted "
"(fail closed).[/yellow] Run "
f"`hermes plugins capabilities {plugin_id}` to review and "
f"`hermes plugins enable {plugin_id}` to grant interactively."
)
return False
if _ask_yes(" Grant these capabilities? [y/N] ", console.input):
record_consent(plugin_id, pending, declared)
console.print(
f" [green]✓[/green] Granted: {', '.join(pending)} "
f"([dim]plugins.entries.{plugin_id}.granted_capabilities[/dim])"
)
return True
console.print(
f" [dim]Declined. {plugin_id} stays enabled with these capabilities "
"off; it should degrade gracefully (ctx.has_capability()). Re-run "
f"`hermes plugins enable {plugin_id}` to grant later.[/dim]"
)
return False
def cmd_capabilities(name: Optional[str] = None) -> None:
"""``hermes plugins capabilities [<id>]`` — declared vs granted."""
from hermes_cli.plugin_capabilities import (
CAPABILITY_REGISTRY,
granted_capabilities,
plugin_capability_granted,
)
console = _console()
rows = []
for entry in _discover_all_plugins():
# entry = (name, version, description, source, dir_path, key)
key = entry[5] or entry[0]
if name is not None and name not in (key, entry[0]):
continue
declared = _declared_capabilities_for_key(key)
granted = granted_capabilities(key)
# Legacy grants surface too: report capabilities live via deprecated
# allow_* keys so `capabilities` shows the true effective state.
effective = {
cap for cap in CAPABILITY_REGISTRY
if plugin_capability_granted(key, cap)
}
if not declared and not effective and name is None:
continue
rows.append((key, entry[3], declared, granted, effective))
if name is not None and not rows:
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
if not rows:
console.print("[dim]No plugins declare or hold capabilities.[/dim]")
return
for key, source, declared, granted, effective in sorted(rows):
console.print(f"[bold]{key}[/bold] [dim]({source})[/dim]")
if not declared:
console.print(" declared: [dim](none)[/dim]")
for cap in declared:
if cap in effective:
mark = "[green]granted[/green]"
if cap not in granted:
mark += " [dim](via legacy allow_* key — deprecated)[/dim]"
else:
mark = "[yellow]not granted[/yellow]"
console.print(f" {cap}: {mark}")
for cap in sorted(effective - set(declared)):
console.print(
f" {cap}: [green]granted[/green] "
"[dim](not declared in manifest)[/dim]"
)
def _resolve_tool_override_grant(
console, key: str, allow_tool_override: Optional[bool]
) -> None:
"""Resolve and persist the ``allow_tool_override`` grant for a plugin."""
if allow_tool_override is None:
# Interactive consent. Default to NO so a blind Enter doesn't grant
# a privileged capability, and a non-interactive stdin denies safely.
prompt = (
"[yellow]Allow this plugin to replace built-in tools "
"(e.g. shell_exec, write_file)?[/yellow]\n"
" This is a privileged capability: an override can intercept "
"everything the agent routes through that tool.\n"
" Grant it? [y/N] "
)
allow_tool_override = _ask_yes(prompt, console.input)
_set_plugin_entry_flag(key, "allow_tool_override", allow_tool_override)
if allow_tool_override:
console.print(
f"[green]✓[/green] Granted [bold]{key}[/bold] permission to "
"override built-in tools "
f"([dim]plugins.entries.{key}.allow_tool_override: true[/dim])."
)
else:
console.print(
f"[dim]{key} may not override built-in tools. Re-run "
f"`hermes plugins enable {key} --allow-tool-override` to grant "
"this later.[/dim]"
)
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:
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
if key not in enabled and key in disabled:
console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]")
return
# Drop any legacy bare-name entry from the allow-list too, so a stale
# bare name can't keep a nested plugin loading after an explicit disable.
_discard_key_and_leaf(enabled, key)
disabled.add(key)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
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:
manifest = _read_portable_manifest(d)
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 = {}
manifest = manifest if isinstance(manifest, dict) else {}
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 when *dir_path* is an Agent Plugins v1 package (``plugin.json``
only — a native ``plugin.yaml`` takes precedence, matching the loader)."""
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 that are active-by-default when bundled: backends auto-load,
# platforms register lazily but are available out of the box, model providers
# run through providers/ discovery (see PluginManager.discover_and_load).
_BUNDLED_DEFAULT_ON_KINDS = frozenset({"backend", "platform", "model-provider"})
def _bundled_default_on(dir_path) -> bool:
"""True when a bundled plugin at *dir_path* is active without an explicit
``plugins.enabled`` entry. Standalone/exclusive kinds stay opt-in, and
portable packages (``plugin.json``) have no kind at all."""
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
for d in sorted(base.iterdir()):
if not d.is_dir():
continue
if depth == 0 and skip_names and d.name in skip_names:
continue
info = _read_manifest_info(d, prefix)
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:
"""Return (name, version, description, source, dir_path, key) for every plugin the loader sees.
Matches the ordering/dedup of ``PluginManager.discover_and_load``: bundled, then user, then
project, then entry points; later sources override earlier ones on key collision.
"""
seen: dict = {} # key -> (name, version, description, source, path, key)
# Bundled (<repo>/plugins/<name>/), excluding memory/, context_engine/
# and model-providers/ — model providers load through the dedicated
# provider registry (providers/__init__.py), not the general PluginManager
# opt-in surface, so listing them as toggleable plugins is misleading.
from hermes_cli.plugins import get_bundled_plugins_dir
repo_plugins = get_bundled_plugins_dir()
for base, source, skip in (
(repo_plugins, "bundled", {"memory", "context_engine", "model-providers"}),
(_plugins_dir(), "user", set()),
):
_scan_level(base, source, skip, "", 0, seen)
# Entry-point plugins (installed as Python packages; no plugin directory).
for name, version, description, path in _discover_entrypoint_plugins():
seen[name] = (name, version, description, "entrypoint", path, name)
return list(seen.values())
def _discover_entrypoint_plugins() -> list[tuple[str, str, str, str]]:
"""Return plugin entries advertised through ``hermes_agent.plugins``.
Entry-point plugins are installed as Python packages, so they do not have a plugin directory
under ``~/.hermes/plugins``. Include package metadata here so ``hermes plugins list`` can show
and enable them.
"""
from hermes_cli.plugins import ENTRY_POINTS_GROUP
try:
eps = importlib.metadata.entry_points()
if hasattr(eps, "select"):
group_eps = eps.select(group=ENTRY_POINTS_GROUP)
elif isinstance(eps, dict):
group_eps = eps.get(ENTRY_POINTS_GROUP, [])
else:
group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
except Exception as exc:
logger.debug("Entry-point plugin discovery failed: %s", exc)
return []
entries: list[tuple[str, str, str, str]] = []
for ep in group_eps:
dist = getattr(ep, "dist", None)
metadata = getattr(dist, "metadata", None)
if metadata is None:
entries.append((ep.name, "", "", ep.value))
else:
entries.append((
ep.name,
str(getattr(dist, "version", "") or ""),
str(metadata.get("Summary", "") or ""),
ep.value,
))
return entries
def _plugin_status(name: str, enabled: set, disabled: set, key: str = "") -> str:
"""Return the user-facing activation state for a plugin name or key."""
if name in disabled or key in disabled:
return "disabled"
if name in enabled or key in enabled:
return "enabled"
return "not enabled"
def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set) -> list:
"""Apply ``hermes plugins list`` CLI filters."""
filtered = entries
if getattr(args, "no_bundled", False) or getattr(args, "user", False):
filtered = [entry for entry in filtered if entry[3] != "bundled"]
if getattr(args, "enabled", False):
filtered = [
entry for entry in filtered
if _plugin_status(entry[0], enabled, disabled, key=entry[5]) == "enabled"
]
return filtered
_STATUS_MARKUP = {"disabled": "[red]disabled[/red]", "enabled": "[green]enabled[/green]"}
def cmd_list(args: Any | None = None) -> None:
"""List all plugins (bundled + user) with enabled/disabled state."""
from rich.table import Table
console = _console()
entries = _discover_all_plugins()
if not entries:
console.print("[dim]No plugins installed.[/dim]")
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
return
enabled = _get_enabled_set()
disabled = _get_disabled_set()
entries = _filter_plugin_entries(entries, args, enabled, disabled)
# (name, status, version, description, source) per entry
rows = [
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description, source)
for name, version, description, source, _dir, key in entries
]
if getattr(args, "json", False):
keys = ("name", "status", "version", "description", "source")
print(json.dumps([dict(zip(keys, row)) for row in rows], indent=2))
return
if getattr(args, "plain", False):
for name, status, version, _description, source in rows:
print(f"{status:12} {source:8} {version:8} {name}")
return
if not entries:
console.print("[dim]No plugins matched the selected filters.[/dim]")
return
table = Table(title="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")
for name, status_name, version, description, source in rows:
status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]")
table.add_row(name, status, version, description, source)
console.print()
console.print(table)
console.print()
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
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]")
# ---------------------------------------------------------------------------
# Provider plugin discovery helpers
# ---------------------------------------------------------------------------
def _discover_memory_providers() -> list[tuple[str, str]]:
"""Return [(name, description), ...] for available memory providers."""
try:
from plugins.memory import discover_memory_providers
return [(name, desc) for name, desc, _avail in discover_memory_providers()]
except Exception:
return []
def _discover_context_engines() -> list[tuple[str, str]]:
"""Return [(name, description), ...] for available context engines.
Includes repo-shipped engines from ``plugins/context_engine/`` AND plugin-registered engines
(via ``ctx.register_context_engine``). Repo-shipped descriptions win on a name collision.
"""
engines: dict[str, str] = {}
try:
from plugins.context_engine import discover_context_engines
for name, desc, _avail in discover_context_engines():
engines.setdefault(name, desc)
except Exception:
pass
try:
from hermes_cli.plugins import discover_plugins, get_plugin_context_engine
discover_plugins()
plugin_engine = get_plugin_context_engine()
if plugin_engine and getattr(plugin_engine, "name", None):
engines.setdefault(plugin_engine.name, "installed plugin")
except Exception:
pass
return list(engines.items())
# 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 _configure_provider_category(
title: str, default_label: str, default_name: str, current: str, choices, save
) -> bool:
"""Radio picker: the built-in default first, then *choices*; a current value not among
them is appended as ``(not found)``. Calls *save* and returns True when the choice changed."""
from hermes_cli.curses_ui import curses_radiolist
names = [default_name] + [name for name, _desc in choices]
items = [default_label] + [f"{name} \u2014 {desc}" if desc else name for name, desc in choices]
if current not in names:
names.append(current)
items.append(f"{current} (not found)")
selected = max(i for i, name in enumerate(names) if name == current)
new_value = names[curses_radiolist(title=title, items=items, selected=selected)]
if new_value != current:
save(new_value)
return True
return False
# (title, default label, default name, current-value reader, discovery fn, saver) per provider
# category. Readers/savers are looked up at call time so module-level patching still applies.
_PROVIDER_CATEGORY_SPECS = (
("Memory Provider", "built-in", "", lambda: _get_current_memory_provider(),
lambda: _discover_memory_providers(), lambda v: _save_memory_provider(v)),
("Context Engine", "compressor", "compressor", lambda: _get_current_context_engine(),
lambda: _discover_context_engines(), lambda v: _save_context_engine(v)),
)
def _configure_category_spec(spec) -> bool:
"""Launch the radio picker for one ``_PROVIDER_CATEGORY_SPECS`` row. Returns True if changed."""
title, default_label, default_name, current, discover, save = spec
return _configure_provider_category(
f"{title} (select one)", f"{default_label} (default)", default_name,
current(), discover(), save,
)
def _provider_categories() -> list:
"""``[(title, current_label, configure_fn), ...]`` rows for the composite UI."""
return [
(spec[0], spec[3]() or spec[1], functools.partial(_configure_category_spec, spec))
for spec in _PROVIDER_CATEGORY_SPECS
]
# ---------------------------------------------------------------------------
# Composite plugins UI
# ---------------------------------------------------------------------------
def cmd_show(name: str) -> None:
"""Show details for a single plugin, including declared emits/listens."""
console = _console()
match = _find_plugin_entry(name)
if match is None:
console.print(f"[red]Plugin '{name}' not found.[/red]")
console.print("[dim]List installed plugins:[/dim] hermes plugins list")
sys.exit(1)
pname, version, description, source, dir_path, key = match
manifest = _read_manifest(Path(dir_path)) if dir_path else {}
emits = manifest.get("emits") or []
listens = manifest.get("listens") or []
enabled = _get_enabled_set()
disabled = _get_disabled_set()
status = _plugin_status(pname, enabled, disabled, key=key)
console.print()
console.print(f"[bold]{pname}[/bold]" + (f" [dim]v{version}[/dim]" if version else ""))
if description:
console.print(description)
console.print(f"[dim]Status:[/dim] {status}")
console.print(f"[dim]Source:[/dim] {source}")
console.print(f"[dim]Key:[/dim] {key}")
console.print(
"[dim]Emits:[/dim] " + (", ".join(emits) if emits else "[dim](none)[/dim]")
)
console.print(
"[dim]Listens:[/dim] " + (", ".join(listens) if listens else "[dim](none)[/dim]")
)
console.print()
def cmd_toggle() -> None:
"""Interactive composite UI — general plugins + provider plugin categories."""
console = _console()
# -- General plugins discovery (bundled + user) --
entries = _discover_all_plugins()
enabled_set = _get_enabled_set()
disabled_set = _get_disabled_set()
# Track by CANONICAL KEY (``key``), not the manifest name. The loader
# (PluginManager) and ``cmd_enable``/``cmd_disable`` all gate on the
# canonical key (``web/firecrawl``), while the manifest name may differ
# (``web-firecrawl``). Persisting the bare name here caused the two
# forms to drift: the menu would write ``web-firecrawl`` to
# plugins.disabled, but ``hermes plugins enable web/firecrawl`` cleared
# only the key — so "explicit disable wins" kept a bundled backend off
# forever (pi314's #40190 symptom). Keys keep every surface aligned.
plugin_keys = [entry[5] for entry in entries]
plugin_labels = [
(f"{name} \u2014 {description}" if description else name)
+ (" [bundled]" if source == "bundled" else "")
for name, _version, description, source, _d, _key in entries
]
# Selected (enabled) when in enabled-set AND not in disabled-set. Accept the
# legacy bare name on either side for back-compat with configs written
# before this normalization.
plugin_selected = {
i for i, (name, _v, _desc, _src, _d, key) in enumerate(entries)
if (key in enabled_set or name in enabled_set)
and key not in disabled_set
and name not in disabled_set
}
categories = _provider_categories()
# Non-TTY fallback
if not sys.stdin.isatty():
console.print("[dim]Interactive mode requires a terminal.[/dim]")
return
# Launch the composite curses UI
try:
import curses
_run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected,
disabled_set, categories, console)
except ImportError:
_run_composite_fallback(plugin_keys, plugin_labels, plugin_selected,
disabled_set, categories, console)
def _persist_plugin_selection(plugin_keys, chosen, disabled) -> tuple[bool, set]:
"""Save the composite UI's checkbox state; returns ``(changed, new_enabled)``.
Persist by canonical key. Unchecked plugins are written to the disabled-list so they stay off
even if a future plugin auto-enables itself — but we ONLY ever write the canonical key (never
the bare manifest name), so the disabled-list can't drift out of sync with what ``cmd_enable``
clears or what PluginManager gates on (#40190). Re-checking a plugin also drops any stale
legacy bare-leaf disable so it is fully cleared from the disabled-list.
"""
new_enabled: set = set()
new_disabled: set = set(disabled) # preserve existing disabled state for unseen plugins
for i, key in enumerate(plugin_keys):
if i in chosen:
new_enabled.add(key)
_discard_key_and_leaf(new_disabled, key)
else:
new_disabled.add(key)
changed = new_enabled != _get_enabled_set() or new_disabled != disabled
if changed:
_save_enabled_set(new_enabled)
_save_disabled_set(new_disabled)
return changed, new_enabled
def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected,
disabled, categories, console):
"""Custom curses screen with checkboxes + category action rows."""
from hermes_cli.curses_ui import flush_stdin
chosen = set(plugin_selected)
n_plugins = len(plugin_keys)
# Total rows: plugins + separator + categories
# separator is not navigable
n_categories = len(categories)
total_items = n_plugins + n_categories # navigable items
providers_changed = False
def _init_colors():
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray
def _attr(base, pair):
attr = base
if curses.has_colors():
attr |= curses.color_pair(pair)
return attr
def _put(stdscr, y, x, text, max_x, attr):
try:
stdscr.addnstr(y, x, text, max_x - 1, attr)
except curses.error:
pass
def _configure_category(ci):
"""Leave curses, run the category's picker, refresh its row, re-enter curses."""
curses.endwin()
nonlocal providers_changed
cat_name, _cat_cur, cat_fn = categories[ci]
if cat_fn():
providers_changed = True
categories[ci] = (cat_name, _provider_categories()[ci][1], cat_fn)
stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
stdscr.keypad(True)
_init_colors()
curses.curs_set(0)
return stdscr
def _draw(stdscr):
curses.curs_set(0)
_init_colors()
# key -> new cursor, given (cursor, page_size)
nav = {}
for keys, move in (
((curses.KEY_UP, ord("k")), lambda c, p: (c - 1) % total_items),
((curses.KEY_DOWN, ord("j")), lambda c, p: (c + 1) % total_items),
((curses.KEY_NPAGE, ord("f")), lambda c, p: min(total_items - 1, c + p)),
((curses.KEY_PPAGE, ord("b")), lambda c, p: max(0, c - p)),
((curses.KEY_HOME,), lambda c, p: 0),
((curses.KEY_END,), lambda c, p: total_items - 1),
):
nav.update(dict.fromkeys(keys, move))
cursor = 0
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Header
_put(stdscr, 0, 0, "Plugins", max_x, _attr(curses.A_BOLD, 2))
_put(
stdscr, 1, 0,
" ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done",
max_x, curses.A_DIM,
)
# Navigable indices: plugins 0..n_plugins-1, categories n_plugins..total_items-1;
# section headers / separator are drawn but skipped in scroll math.
visible_rows = max_y - 4
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
# Body rows as (text, attr); "" is a blank separator. Drawn from y=3
# and truncated at the last screen line.
lines = []
if n_plugins > 0:
lines.append((" General Plugins", _attr(curses.A_BOLD, 2)))
for i in range(scroll_offset, min(n_plugins, scroll_offset + max(visible_rows, 0))):
check = "\u2713" if i in chosen else " "
arrow = "\u2192" if i == cursor else " "
attr = _attr(curses.A_BOLD, 1) if i == cursor else curses.A_NORMAL
lines.append((f" {arrow} [{check}] {plugin_labels[i]}", attr))
lines.append(("", curses.A_NORMAL))
if n_categories > 0:
lines.append((" Provider Plugins", _attr(curses.A_BOLD, 2)))
for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories):
cat_idx = n_plugins + ci
arrow = "\u2192" if cat_idx == cursor else " "
attr = _attr(curses.A_BOLD, 3) if cat_idx == cursor else curses.A_NORMAL
lines.append((f" {arrow} {cat_name:<24} \u25b8 {cat_current}", attr))
for y, (text, attr) in enumerate(lines[: max(0, max_y - 4)], start=3):
if text:
_put(stdscr, y, 0, text, max_x, attr)
stdscr.refresh()
key = stdscr.getch()
if key in nav:
if total_items > 0: # (with no rows, every motion leaves cursor at 0)
cursor = nav[key](cursor, max(1, max_y - 5))
elif key == ord(" ") or key in {curses.KEY_ENTER, 10, 13}:
if cursor >= n_plugins:
# Provider category — launch sub-screen (SPACE and ENTER alike)
if cursor - n_plugins < n_categories:
stdscr = _configure_category(cursor - n_plugins)
elif key == ord(" "):
chosen.symmetric_difference_update({cursor})
else:
return # ENTER on a plugin checkbox — confirm and exit
elif key in {27, ord("q")}:
return # plugin changes are saved on exit
curses.wrapper(_draw)
flush_stdin()
changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled)
if changed:
console.print(
f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, "
f"{len(plugin_keys) - len(new_enabled)} disabled."
)
elif n_plugins > 0:
console.print("\n[dim]General plugins unchanged.[/dim]")
if providers_changed:
new_memory = _get_current_memory_provider() or "built-in"
new_context = _get_current_context_engine()
console.print(
f"[green]\u2713[/green] Memory provider: [bold]{new_memory}[/bold] "
f"Context engine: [bold]{new_context}[/bold]"
)
if n_plugins > 0 or providers_changed:
console.print("[dim]Changes take effect on next session.[/dim]")
console.print()
def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected,
disabled, categories, console):
"""Text-based fallback for the composite plugins UI."""
from hermes_cli.colors import Colors, color
print(color("\n Plugins", Colors.YELLOW))
# General plugins
if plugin_keys:
chosen = set(plugin_selected)
print(color("\n General Plugins", Colors.YELLOW))
print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM))
while True:
for i, label in enumerate(plugin_labels):
marker = color("[\u2713]", Colors.GREEN) if i in chosen else "[ ]"
print(f" {marker} {i + 1:>2}. {label}")
print()
try:
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
if not val:
break
idx = int(val) - 1
if 0 <= idx < len(plugin_keys):
chosen.symmetric_difference_update({idx})
except (ValueError, KeyboardInterrupt, EOFError):
return
print()
_persist_plugin_selection(plugin_keys, chosen, disabled)
# Provider categories
if categories:
print(color("\n Provider Plugins", Colors.YELLOW))
for ci, (cat_name, cat_current, cat_fn) in enumerate(categories):
print(f" {ci + 1}. {cat_name} [{cat_current}]")
print()
try:
val = input(color(" Configure # (or Enter to skip): ", Colors.DIM)).strip()
if val:
ci = int(val) - 1
if 0 <= ci < len(categories):
categories[ci][2]() # call the configure function
except (ValueError, KeyboardInterrupt, EOFError):
pass
print()
def dashboard_install_plugin(
identifier: str,
*,
force: bool,
enable: bool,
) -> dict[str, Any]:
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
warnings: list[str] = []
try:
insecure = _resolve_git_url(identifier)[0].startswith(("http://", "file://"))
except ValueError:
insecure = False
if insecure:
warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.")
try:
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
)
except PluginScanBlocked as exc:
fields = ("pattern_id", "severity", "category", "file", "line", "description")
findings = [
{k: getattr(f, k) for k in fields}
for f in (exc.scan_result.findings if exc.scan_result is not None else ())
]
return {
"ok": False,
"error": str(exc),
"scan_blocked": True,
"scan_verdict": getattr(exc.scan_result, "verdict", "dangerous"),
"scan_findings": findings,
}
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
missing_env = _missing_requires_env_names(installed_manifest)
if enable:
_set_plugin_enabled(installed_name, enable=True)
ap = target / "after-install.md"
hint = str(ap) if ap.exists() else None
return {
"ok": True,
"plugin_name": installed_name,
"warnings": warnings,
"missing_env": missing_env,
"after_install_path": hint,
"enabled": enable,
}
def _get_plugin_toolset_key(name: str) -> Optional[str]:
"""Return the toolset key a plugin registers its tools under, or None.
Queries the live tool registry — the plugin must already be loaded. Falls back to reading
``provides_tools`` from plugin.yaml and looking up the toolset from the registry for the first
tool name found.
"""
try:
from tools.registry import registry
except Exception:
return None
def _first_toolset(tool_names) -> Optional[str]:
for tool_name in tool_names:
entry = registry.get_entry(tool_name)
if entry and entry.toolset:
return entry.toolset
return 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
for base in (get_bundled_plugins_dir(), _plugins_dir()):
candidate = base / name
if base.is_dir() and candidate.is_dir():
toolset = _first_toolset(_read_manifest(candidate).get("provides_tools") or [])
if toolset:
return toolset
return None
for lookup in (_from_loaded_plugin, _from_manifest_on_disk):
try:
toolset = lookup()
except Exception:
continue
if toolset:
return toolset
return None
def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
"""Add or remove a plugin's toolset from platform_toolsets for all platforms.
Only acts if the plugin actually provides tools (has a toolset key).
"""
toolset_key = _get_plugin_toolset_key(name)
if not toolset_key:
return
from hermes_cli.config import load_config, save_config
config = load_config()
platform_toolsets = _sub_dict(config, "platform_toolsets")
changed = False
for platform, ts_list in platform_toolsets.items():
if not isinstance(ts_list, list):
continue
if enable:
if toolset_key not in ts_list:
ts_list.append(toolset_key)
changed = True
elif toolset_key in ts_list:
ts_list.remove(toolset_key)
changed = True
# If enabling and no platforms have toolset lists yet, add to "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)."""
if _resolve_plugin_key(name) is None:
return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
en = _get_enabled_set()
dis = _get_disabled_set()
already = (name in en and name not in dis) if enabled else (name not in en and name in dis)
if already:
return {"ok": True, "name": name, "unchanged": True}
_set_plugin_enabled(name, enable=enabled)
_toggle_plugin_toolset(name, enable=enabled)
return {"ok": True, "name": name, "unchanged": False}
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
plugins_dir = _plugins_dir()
try:
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
except ValueError:
return None
return target if target.is_dir() else None
def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
"""``git pull`` inside ``~/.hermes/plugins/<name>``."""
target = _user_installed_plugin_dir(name)
if target is None:
return {
"ok": False,
"error": f"Plugin '{name}' was not found under {_plugins_dir()}.",
}
try:
msg = _pull_plugin_update(
target,
lambda rec: (
f"Plugin '{name}' is pinned to {rec.get('revision')}; "
f"run `hermes plugins install {rec.get('source', '<source>')} --force "
"--ref <40-character commit SHA>` to move it."
),
lambda: f"Plugin '{name}' is not a git checkout; cannot pull updates.",
)
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
_post_pull_housekeeping(target, _console())
unchanged = "Already up to date" in msg
return {"ok": True, "name": name, "output": msg, "unchanged": unchanged}
def _clear_plugin_bytecode(target: Path) -> int:
"""Remove ``__pycache__`` dirs under a just-updated plugin checkout.
Plugin dirs live outside the main repo, so the launch-time checkout fingerprint sweep in
``hermes_cli.main`` never covers them. After a ``git pull`` changes a plugin's ``.py`` files,
stale bytecode here can produce the same ImportError class as #6207/#60242 in whichever process
imports the plugin next. Never raises.
"""
removed = 0
try:
for cache_dir in target.rglob("__pycache__"):
if cache_dir.is_dir():
try:
shutil.rmtree(cache_dir)
removed += 1
except OSError:
pass
except OSError:
pass
return removed
def _run_plugin_git(
git_exe: str, target: Path, *args: str, timeout: int = 60
) -> subprocess.CompletedProcess:
"""Run one git command inside a plugin checkout (non-interactive)."""
return subprocess.run(
[git_exe, *args],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=timeout,
cwd=str(target),
stdin=subprocess.DEVNULL,
env=noninteractive_git_env(),
)
def _stash_ref(git_exe: str, target: Path) -> str:
"""Current ``refs/stash`` commit, or empty string when no stash exists."""
probe = _run_plugin_git(git_exe, target, "rev-parse", "--verify", "refs/stash")
return probe.stdout.strip() if probe.returncode == 0 else ""
def _reapply_stash(git_exe: str, target: Path) -> bool:
"""``stash apply`` the autostash; drop it on a clean apply. False when it applied with
errors or left unmerged paths (the stash entry is kept in that case)."""
restore = _run_plugin_git(git_exe, target, "stash", "apply", "stash@{0}")
unmerged = _run_plugin_git(git_exe, target, "diff", "--name-only", "--diff-filter=U")
if restore.returncode != 0 or unmerged.stdout.strip():
return False
_run_plugin_git(git_exe, target, "stash", "drop", "stash@{0}")
return True
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
"""``git pull --ff-only`` a plugin checkout, autostashing local edits.
Users tweak installed plugins in place (config constants, small patches), and a plain ``pull
--ff-only`` then aborts with "Your local changes ... would be overwritten by merge" — making the
plugin permanently un-updatable until they hand-run git.
"""
git_exe = _resolve_git_executable()
if not git_exe:
return False, "git is not installed or not in PATH."
try:
status = _run_plugin_git(git_exe, target, "status", "--porcelain")
dirty = status.returncode == 0 and bool(status.stdout.strip())
stash_created = False
pre_stash = ""
if dirty:
pre_stash = _stash_ref(git_exe, target)
push = _run_plugin_git(
git_exe, target,
"stash", "push", "--include-untracked",
"-m", "hermes-plugin-update-autostash",
)
post_stash = _stash_ref(git_exe, target)
stash_created = bool(post_stash) and post_stash != pre_stash
if not stash_created:
# Nothing was saved — do not risk the pull clobbering edits.
err = _safe_git_error(push)
return False, (
"Local changes in the plugin checkout could not be "
"stashed; update aborted before touching the checkout."
+ (f"\n{err}" if err else "")
)
if push.returncode != 0:
# Saved-but-couldn't-clean (undeletable untracked files):
# the stash entry is complete; reset tracked mods so the
# pull isn't blocked by a still-dirty tree.
_run_plugin_git(git_exe, target, "reset", "--hard", "HEAD")
result = _run_plugin_git(git_exe, target, "pull", "--ff-only")
if result.returncode != 0:
err = _safe_git_error(result) or "git pull failed."
if stash_created:
# Put the user's edits back before reporting the failure.
if _reapply_stash(git_exe, target):
note = "Local changes were restored."
else:
note = (
"Local changes are preserved in git stash "
"(restore with: git stash pop)."
)
return False, f"{err}\n{note}"
return False, err
pulled = result.stdout.strip()
if not stash_created:
return True, pulled
if _reapply_stash(git_exe, target):
return True, pulled + "\nLocal changes were re-applied on top of the update."
# Conflicted re-apply: leave the plugin importable on the updated
# revision; the user's edits stay safe in the stash entry.
_run_plugin_git(git_exe, target, "reset", "--hard", "HEAD")
return True, pulled + (
"\n⚠ Local changes in this plugin conflicted with the update and "
"were NOT re-applied. They are preserved in git stash — inspect "
"with `git stash show -p stash@{0}` and re-apply with "
f"`git stash pop` inside {target}."
)
except FileNotFoundError:
return False, "git is not installed or not in PATH."
except subprocess.TimeoutExpired:
return False, "Git operation timed out after 60 seconds."
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
plugins_dir = _plugins_dir()
for n, _ver, _d, src, _path, _key in _discover_all_plugins():
if n == name and src == "bundled":
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
target = _user_installed_plugin_dir(name)
if target is None:
return {
"ok": False,
"error": f"Plugin '{name}' was not found under {plugins_dir}.",
}
try:
_remove_plugin_core(target)
except (OSError, PluginOperationError) as exc:
return {"ok": False, "error": f"Could not remove plugin '{name}': {exc}"}
return {"ok": True, "name": name}
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 cmd_search(
term: str = "",
*,
json_output: bool = False,
capability: Optional[str] = None,
refresh: bool = False,
) -> None:
"""Search the community plugin index (fuzzy on name/description/tags)."""
from hermes_cli.plugin_index import (
SECURITY_FOOTER,
load_index,
search_index,
)
console = _console()
entries, source = load_index(refresh=refresh)
results = search_index(entries, term, capability=capability)
if json_output:
print(
json.dumps(
{
"source": source,
"query": term,
"results": [e.to_dict() for e in results],
"note": SECURITY_FOOTER,
},
indent=2,
)
)
return
if not results:
console.print(
f"[yellow]No plugins matched '{term}'[/yellow] "
f"[dim](index source: {source})[/dim]"
)
return
from rich.table import Table
table = Table(title=f"Community plugins ({len(results)} match{'es' if len(results) != 1 else ''})")
table.add_column("Name", style="bold")
table.add_column("Description")
table.add_column("Author")
table.add_column("Tags", style="dim")
for e in results:
desc = e.description
if len(desc) > 70:
desc = desc[:67] + "..."
table.add_row(e.name, desc, e.author, ", ".join(e.tags))
console.print(table)
console.print(f"[dim]Index source: {source}. Install: hermes plugins install <name>[/dim]")
console.print(f"[dim]{SECURITY_FOOTER}[/dim]")
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)."""
if getattr(args, yes_attr, False):
return True
if getattr(args, no_attr, False):
return False
return None
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),
),
"search": lambda args: cmd_search(
getattr(args, "term", "") or "",
json_output=getattr(args, "json", False),
capability=getattr(args, "capability", None),
refresh=getattr(args, "refresh", False),
),
"update": lambda args: cmd_update(args.name),
"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)),
"pack": _action_pack,
"show": lambda args: cmd_show(args.name),
"info": lambda args: cmd_show(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:
_console().print(f"[red]Unknown plugins action: {action}[/red]")
sys.exit(1)
handler(args)