Files
hermes-agent/hermes_cli/plugins_cmd.py

2225 lines
100 KiB
Python

"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins."""
from __future__ import annotations
import functools
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.cli_output import line_input
from hermes_cli.config import cfg_get
from hermes_cli.plugin_capabilities import _child_dict
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(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) -> None:
"""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 _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
# Highest ``manifest_version`` this installer understands; breaking schema changes bump it.
_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 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."""
return next((p for p in (plugin_dir / "plugin.yaml", plugin_dir / "plugin.yml") if p.exists()), 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_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 _refuse_conflicting_python_deps(tmp_target: Path, plugin_name: str) -> None:
"""Dependency pre-check on the staged tree: a conflict with core or an enabled plugin refuses the
install before anything is moved into place (nothing installed, nothing disabled)."""
from hermes_cli.plugin_python_deps import DependencyConflict, refuse_conflicting_candidate
try:
refuse_conflicting_candidate(tmp_target, home=get_hermes_home())
except DependencyConflict as exc:
raise PluginOperationError(
f"Plugin '{plugin_name}' was not installed: {exc}\n"
"The plugin author should relax that requirement. To install the plugin anyway and manage "
"its Python packages yourself: hermes plugins install <identifier> --no-deps") from exc
except ValueError as exc:
raise PluginOperationError(f"Plugin '{plugin_name}' was not installed: {exc}") from exc
def _install_python_dependencies_quietly(target: Path, warnings: list[str]) -> list[str]:
"""Dashboard variant: install, append a failure to *warnings*, return the applicable specs."""
from hermes_cli.plugin_python_deps import install_for_plugin_dir
outcome = install_for_plugin_dir(target)
if outcome.status in ("failed", "invalid"):
warnings.append(outcome.message)
return list(outcome.specs)
def _install_python_dependencies_for_key(key: str, console) -> None:
"""``plugins enable`` variant: a user plugin enabled after a bare install gets its deps now."""
entry = _find_plugin_entry(key)
if entry is not None and entry[4]:
_install_python_dependencies(Path(entry[4]), console)
def _install_python_dependencies(target: Path, console, *, skip: bool = False) -> None:
"""Install the plugin's declared Python deps (pyproject ``[project].dependencies`` or manifest
``python_dependencies``) into the venv and report; the plugin stays installed on failure."""
from hermes_cli.plugin_python_deps import install_for_plugin_dir
outcome = install_for_plugin_dir(target) if not skip else None
if outcome is None or outcome.status == "none":
return
style = {"installed": "green", "failed": "yellow", "invalid": "yellow"}[outcome.status]
console.print(f"[{style}]{'✓' if outcome.status == 'installed' else '⚠'}[/{style}] {outcome.message}")
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
"""Prompt for unset ``requires_env`` variables and save the answers 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", "")
console.print(f" {name}" + (f" — {desc}" if desc else ""))
if url:
console.print(f" [dim]Get yours at: {url}[/dim]")
try:
value = (masked_secret_prompt if spec.get("secret", False) 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 _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>`.")
# ── Install metadata + git plumbing ─────────────────────────────────────────────────────────
_EXACT_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$")
def _install_metadata_path() -> Path:
return get_hermes_home() / "plugins" / ".install-metadata.json"
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 pinned_revision(name: str, metadata: Optional[dict] = None) -> Optional[str]:
"""Full SHA a ``--ref`` install of *name* is pinned to, else ``None``."""
entry = (metadata if metadata is not None else _read_install_metadata()).get(name)
if isinstance(entry, dict) and entry.get("pinned") is True and isinstance(entry.get("revision"), str):
return entry["revision"]
return None
def _pin_annotation(name: str, metadata: dict) -> Optional[str]:
sha = pinned_revision(name, metadata)
return f"git pinned@{sha[:8]}" if sha else None
def _normalize_exact_revision(ref: str) -> str:
"""Lowercase a full 40-hex commit SHA; anything else is a PluginOperationError."""
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:
"""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 = "",
auth_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, auth_url=auth_url)
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:
return _git_or_raise(
git_exe, repo, "rev-parse", "HEAD", timeout=15,
failure_prefix="Could not determine installed Git revision:\n",
).stdout.strip().lower()
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None:
"""Fetch and detach at one immutable commit, then verify the resulting HEAD."""
for verb, args, failure_prefix in (
("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"),
("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"),
):
try:
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url,
auth_url=source_url if verb == "fetch" else "")
except subprocess.TimeoutExpired as exc:
raise PluginOperationError(f"Git {verb} of commit '{revision}' timed out after 60 seconds.") 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:
_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 '{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", *(["--no-checkout"] if revision else []), git_url, str(tmp_clone)]
try:
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url)
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:
raise PluginOperationError(_clone_failure_message(git_url, _safe_git_error(result, git_url)))
_scrub_cloned_origin(tmp_clone, git_exe, git_url)
if revision:
_checkout_exact_revision(tmp_clone, git_exe, revision, source_url=git_url)
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 _probe_readable(path: Path) -> None:
"""Raise ``OSError`` unless *path* can actually be listed (dir) or opened for reading (file)."""
if path.is_dir():
os.listdir(path)
else:
with open(path, "rb"):
pass
def _ensure_tree_readable(root: Path, plugins_dir: Path) -> None:
"""Refuse to ship a tree Hermes cannot read back. A clone can land unreadable (Windows ACL
inheritance -> WinError 5, a mode-000 file) and discovery would then skip the plugin forever
(#111804); repair ``u+rX`` where the OS supports it, otherwise fail before anything moves."""
paths = [root]
for dirpath, dirnames, filenames in os.walk(root):
paths.extend(Path(dirpath) / name for name in (*dirnames, *filenames))
for path in paths:
try:
_probe_readable(path)
continue
except OSError:
if os.name != "nt": # chmod only toggles the read-only bit on Windows; ACLs need icacls
try:
os.chmod(path, os.stat(path).st_mode | (0o500 if path.is_dir() else 0o400))
except OSError:
pass
try:
_probe_readable(path)
except OSError as exc:
fix = (f'icacls "{plugins_dir}" /grant:r "%USERNAME%":(OI)(CI)F /T' if os.name == "nt"
else f"chmod -R u+rX {plugins_dir}")
raise PluginOperationError(
f"Installed file {path.relative_to(root)} is not readable ({exc.strerror or exc}); "
f"nothing was installed. Fix permissions on {plugins_dir} (e.g. `{fix}`) and retry."
) from exc
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,
reviewed_pin: Optional[str] = None,
python_deps: bool = True,
) -> tuple[Path, dict, str]:
"""Clone a Git plugin and atomically record its source and exact revision.
*reviewed_pin* is the curated-catalog sha for this install; the scan trusts the tree
only when the checked-out revision is exactly that sha. *python_deps* False skips the
dependency conflict gate (``--no-deps``: the user installs them by hand)."""
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:
pins = [e for e in old_metadata.values() if e.get("source") == source and e.get("pinned") is True]
if len(pins) == 1 and isinstance(pins[0].get("revision"), str):
requested_revision = _normalize_exact_revision(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
_ensure_tree_readable(tmp_target, plugins_dir)
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)
# Scan BEFORE anything is moved into place; raises PluginScanBlocked when blocked.
_scan_plugin_tree(tmp_target, identifier, force=force, scan_decision_cb=scan_decision_cb,
reviewed_pin=bool(reviewed_pin) and installed_revision == reviewed_pin)
if python_deps:
_refuse_conflicting_python_deps(tmp_target, plugin_name)
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 = {
**old_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)
return target, installed_manifest, installed_manifest.get("name") or target.name
def cmd_install(
identifier: str,
force: bool = False,
enable: Optional[bool] = None,
ref: Optional[str] = None,
allow_removed: bool = False,
no_deps: bool = False,
) -> None:
"""Install a plugin from the curated catalog (bare name), a Git URL, or owner/repo shorthand.
A catalog hit installs the reviewed pinned SHA (an explicit ``--ref`` wins) and records provenance in
a ``.hermes-catalog.json`` sidecar; URLs/shorthand are flagged as custom (unreviewed) sources. Every
install is checked against the catalog kill list unless *allow_removed*.
*enable* None prompts "Enable now? [y/N]"; True/False skip the prompt.
"""
from hermes_cli import plugins_cmd_catalog as catalog
console = _console()
entry = None
if catalog.looks_like_catalog_name(identifier):
entry = catalog.resolve_catalog_name(identifier, console)
identifier = entry.install_identifier
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan] [dim]pinned @ {entry.sha[:8]}[/dim]")
console.print(catalog.entry_capability_summary(entry))
else:
console.print("[yellow]Warning:[/yellow] custom (unreviewed) source — not from the Hermes catalog.")
if allow_removed:
console.print(
"[bold red]WARNING:[/bold red] [red]--allow-removed set — skipping the catalog kill-list check. "
"This plugin may have been removed for security reasons.[/red]")
try:
git_url, _subdir = _resolve_git_url(identifier)
if not allow_removed:
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
except (ValueError, PluginOperationError) as e:
_fail(console, f"[red]Error:[/red] {e}")
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."""
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:
if entry is not None:
target, installed_manifest, installed_name = catalog.install_catalog_entry(
entry, force=force, ref=ref, allow_removed=True, scan_decision_cb=_interactive_scan_decision,
python_deps=not no_deps)
else:
target, installed_manifest, installed_name = _install_plugin_core(
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision,
python_deps=not no_deps)
except PluginOperationError as e:
_fail(console, f"[red]{'Blocked' if isinstance(e, PluginScanBlocked) else 'Error'}:[/red] {e}")
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)
_install_python_dependencies(target, console, skip=no_deps)
_display_after_install(target, identifier)
if enable is None:
enable = _is_tty() and _ask_yes(f" Enable '{installed_name}' now? [y/N]: ")
if 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]")
# Non-interactive installs and declines leave declared 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)
# Store the new HEAD in the plugin's install-metadata record (if it has one).
git_exe = _resolve_git_executable() if install_record else None
if git_exe:
install_record["revision"] = _git_head_revision(target, git_exe)
metadata[target.name] = install_record
_write_install_metadata(metadata)
return output
def cmd_update(name: str) -> None:
"""Update an installed plugin by pulling latest from its git remote."""
from rich.markup import escape
from hermes_cli import plugins_cmd_catalog as catalog
console = _console()
target = _require_installed_plugin(name, _plugins_dir(), console)
sidecar = catalog.read_catalog_sidecar(target)
if sidecar: # catalog installs re-pin to the reviewed SHA — never `git pull`
catalog.cmd_update_catalog(name, target, sidecar, console)
return
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:
_fail(console, f"[red]Error:[/red] {exc}")
_rescan_after_update(target, name, console)
_post_pull_housekeeping(target, console)
# Re-consent when the new version declares capabilities the granted set lacks or the
# declared set changed; additions stay ungranted until the user says yes (fail closed).
# See #64228.
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 _rescan_after_update(target: Path, name: str, console) -> None:
"""Re-scan after ``git pull``: the tree is already mutated, so a dangerous verdict disables
the plugin rather than leaving it active."""
if not _scan_on_install_enabled():
return
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 True:
return
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.")
def _post_pull_housekeeping(target: Path, console) -> None:
"""After ``git pull``: drop stale ``__pycache__``, copy any new ``.example`` files, and install
dependencies the new revision declares (a version bump commonly adds or moves a package)."""
# Same stale-bytecode class as the main checkout (#6207/#60242): the pull just changed .py files under
# this plugin dir, so drop any __pycache__ compiled from the previous revision.
_clear_plugin_bytecode(target)
_copy_example_files(target, console)
_install_python_dependencies(target, console)
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 = {k: v for k, v in metadata.items() if k != 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:
_fail(console, f"[red]Error:[/red] Could not remove plugin '{name}': {exc}")
console.print()
console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}")
console.print()
# ``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_disabled_set(disabled: set) -> None:
_write_config_value("plugins", "disabled", sorted(disabled))
def _save_enabled_set(enabled: set) -> None:
_write_config_value("plugins", "enabled", sorted(enabled))
def _save_plugin_sets(enabled: set, disabled: set) -> None:
_save_enabled_set(enabled)
_save_disabled_set(disabled)
_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 _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_plugin_sets(enabled, disabled)
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 _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 = _child_dict(_child_dict(_child_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 asked about the privileged ``allow_tool_override`` grant;
tri-state: ``True``/``False`` skip the prompt, ``None`` asks. 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)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
if key in enabled and key not in disabled:
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")
else:
enabled.add(key)
# The loader's disable check matches BOTH the canonical key (``web/firecrawl``) and the
# manifest name (``web-firecrawl``); a stale entry under either form would silently veto
# this enable ("explicit disable wins"), so drop the key, its bare leaf, and the name.
_discard_key_and_leaf(disabled, key)
manifest_name = next((e[0] for e in _discover_all_plugins() if e[5] == key), None)
if manifest_name is not None:
disabled.discard(manifest_name)
_save_plugin_sets(enabled, disabled)
console.print(f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. Takes effect on next session.")
# Built-in tool override is a privileged grant; bundled plugins are trusted.
if source == "bundled":
return
_install_python_dependencies_for_key(key, console)
# 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")
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 ──────────────────────────────────────────────────
# ── 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 []
if not entry[4]:
return []
return _declared_capabilities_from_manifest(_read_manifest(Path(entry[4])), entry[0])
def _run_capability_consent(console, plugin_id: str, declared: list, *, context: str = "install") -> bool:
"""Show the capability consent screen and record the decision; True when granted.
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()``. Consent + audit, NOT a sandbox.
"""
from hermes_cli.plugin_capabilities import CAPABILITY_REGISTRY, pending_capabilities, record_consent
pending = pending_capabilities(plugin_id, declared)
if not pending:
# Refresh the consent hash so a later declaration change is detected.
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]")
for cap in pending:
spec = CAPABILITY_REGISTRY.get(cap)
console.print(f" [bold]{cap}[/bold] — {spec.description if spec else ''}")
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():
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)
# Effective state includes grants live via deprecated allow_* keys.
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:
_fail(console, _unknown_plugin_message(name))
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 not in effective:
mark = "[yellow]not granted[/yellow]"
elif cap in granted:
mark = "[green]granted[/green]"
else:
mark = "[green]granted[/green] [dim](via legacy allow_* key — deprecated)[/dim]"
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:
# Default NO: a blind Enter or a non-interactive stdin denies safely.
allow_tool_override = _ask_yes(
"[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] ",
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:
_fail(console, _unknown_plugin_message(name))
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
# Also drop a stale legacy bare-name entry so it can't keep a nested plugin loading.
_discard_key_and_leaf(enabled, key)
disabled.add(key)
_save_plugin_sets(enabled, 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:
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
for d in sorted(base.iterdir()):
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 as exc:
# Mirrors scan_directory: an unsearchable plugin dir (WinError 5 / mode 000) is skipped, not fatal.
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 _plugin_status(name: str, enabled: set, disabled: set, key: str = "") -> str:
"""User-facing activation state for a plugin name or key."""
names = {name, key}
return "disabled" if names & disabled else "enabled" if names & enabled else "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."""
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)
from hermes_cli import plugins_cmd_catalog as catalog
# Source shows catalog provenance (``catalog:<tier>@<sha8>``) or a ``--ref`` pin
# (``git pinned@<sha8>``) so a team can eyeball that everyone runs the same commit.
pins = _read_install_metadata()
# One kill-list resolution for the whole listing: resolving per row costs a live-catalog
# fetch per installed plugin when the catalog host is slow or unreachable.
resolved_removed = catalog.resolved_removed_entries()
rows = [
(
name,
_plugin_status(name, enabled, disabled, key=key),
str(version),
description,
catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source,
catalog.removed_annotation(name, _dir, removed_entries=resolved_removed),
)
for name, version, description, source, _dir, key in entries
]
if getattr(args, "json", False):
keys = ("name", "status", "version", "description", "source", "removed")
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, _removed 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(
(("Name", "bold"), ("Status", None), ("Version", "dim"), ("Description", None), ("Source", "dim")),
title="Plugins", show_lines=False)
removed_lines = []
for name, status_name, version, description, source, removed in rows:
status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]")
if removed:
name = f"[red]{name} ✗[/red]"
removed_lines.append(f"[red]✗ {name}[/red] was removed from the plugin catalog: {removed}")
table.add_row(name, status, version, description, source)
console.print()
console.print(table)
for line in removed_lines:
console.print(line)
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]]:
"""``[(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]]:
"""``[(name, description), ...]`` for repo-shipped context engines plus the plugin-registered
one (``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")
# (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:
"""Radio picker for one ``_PROVIDER_CATEGORY_SPECS`` row: the built-in default first, then the
discovered choices; a current value not among them is appended as ``(not found)``. Saves and
returns True when the choice changed."""
from hermes_cli.curses_ui import curses_radiolist
title, default_label, default_name, current, discover, save = spec
current = current()
choices = discover()
names = [default_name] + [name for name, _desc in choices]
items = [f"{default_label} (default)"] + [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=f"{title} (select one)", items=items, selected=selected)]
if new_value == current:
return False
save(new_value)
return True
def _provider_categories() -> list:
"""``[(title, current_label, configure_fn), ...]`` rows for the composite UI."""
return [(s[0], s[3]() or s[1], functools.partial(_configure_category_spec, s)) for s 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]")
_fail(console, "[dim]List installed plugins:[/dim] hermes plugins list")
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 []
status = _plugin_status(pname, _get_enabled_set(), _get_disabled_set(), 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()
entries = _discover_all_plugins()
enabled_set = _get_enabled_set()
disabled_set = _get_disabled_set()
# Track by CANONICAL KEY, not manifest name: the loader and enable/disable all gate on the
# key (``web/firecrawl``) while the name may differ (``web-firecrawl``); persisting the bare
# name let plugins.disabled drift so "explicit disable wins" kept a plugin off forever.
plugin_keys = [entry[5] for entry in entries]
# Keys keep every surface aligned. See #40190.
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 when enabled AND not disabled; the legacy bare name counts on either side.
plugin_selected = {
i for i, (name, _v, _desc, _src, _d, key) in enumerate(entries)
if {key, name} & enabled_set and not ({key, name} & disabled_set)
}
categories = _provider_categories()
if not sys.stdin.isatty():
console.print("[dim]Interactive mode requires a terminal.[/dim]")
return
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)``.
Unchecked plugins go to the disabled-list (so they stay off even if something auto-enables
them) under the canonical key ONLY, so the list can't drift from what ``cmd_enable`` clears.
Re-checking also drops any stale legacy bare-leaf disable.
"""
# See #40190.
# Persist by canonical key only — never the bare manifest name — so the disabled-list stays aligned with
# cmd_enable / PluginManager (#40190).
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_plugin_sets(new_enabled, 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 _addnstr, flush_stdin
chosen = set(plugin_selected)
n_plugins, n_categories = len(plugin_keys), len(categories)
total_items = n_plugins + n_categories # navigable rows (headers/separator are skipped)
providers_changed = False
nav = { # key -> new cursor, given (cursor, page_size)
key: move
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),
)
for key in keys
}
def _init_colors():
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
gray = 8 if curses.COLORS > 8 else curses.COLOR_WHITE
for pair, fg in ((1, curses.COLOR_GREEN), (2, curses.COLOR_YELLOW), (3, curses.COLOR_CYAN), (4, gray)):
curses.init_pair(pair, fg, -1)
def _attr(base, pair):
return base | curses.color_pair(pair) if curses.has_colors() else base
def _row(text, idx, cursor, pair):
"""One navigable body row: arrow marker + bold color when *idx* is the cursor."""
arrow = "\u2192" if idx == cursor else " "
return (f" {arrow} {text}", _attr(curses.A_BOLD, pair) if idx == cursor else curses.A_NORMAL)
def _configure_category(ci):
"""Leave curses, run the category's picker, refresh its row, re-enter curses."""
nonlocal providers_changed
curses.endwin()
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 _body_lines(cursor, scroll_offset, visible_rows):
"""Body rows as (text, attr); "" is a blank separator."""
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 " "
lines.append(_row(f"[{check}] {plugin_labels[i]}", i, cursor, 1))
lines.append(("", curses.A_NORMAL))
if n_categories > 0:
lines.append((" Provider Plugins", _attr(curses.A_BOLD, 2)))
lines += [
_row(f" {cat_name:<24} \u25b8 {cat_current}", n_plugins + ci, cursor, 3)
for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories)
]
return lines
def _draw(stdscr):
curses.curs_set(0)
_init_colors()
cursor = scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
_addnstr(stdscr, 0, 0, "Plugins", max_x - 1, _attr(curses.A_BOLD, 2))
_addnstr(
stdscr, 1, 0, " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done",
max_x - 1, curses.A_DIM)
visible_rows = max_y - 4
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
lines = _body_lines(cursor, scroll_offset, visible_rows)
for y, (text, attr) in enumerate(lines[: max(0, max_y - 4)], start=3):
if text:
_addnstr(stdscr, y, 0, text, max_x - 1, 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:
console.print(
f"[green]\u2713[/green] Memory provider: [bold]{_get_current_memory_provider() or 'built-in'}[/bold] "
f"Context engine: [bold]{_get_current_context_engine()}[/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))
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)
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]()
except (ValueError, KeyboardInterrupt, EOFError):
pass
print()
def dashboard_install_plugin(
identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None,
ref: Optional[str] = None,
) -> dict[str, Any]:
"""Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its
pinned SHA (identifier may be empty); *ref* pins a custom source to one full commit SHA (same
contract as ``--ref``); every path enforces the kill list (no GUI bypass)."""
from hermes_cli import plugins_cmd_catalog as catalog
warnings: list[str] = []
entry = None
if catalog_name:
entry = catalog.get_live_catalog_entry(catalog_name)
if entry is None:
return {"ok": False, "error": f"'{catalog_name}' is not in the Hermes plugin catalog."}
identifier = entry.install_identifier
else:
warnings.append("Custom (unreviewed) source — not from the Hermes catalog.")
try:
git_url = _resolve_git_url(identifier)[0]
if git_url.startswith(("http://", "file://")):
warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.")
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
except ValueError:
pass
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
try:
if entry is not None:
target, installed_manifest, installed_name = catalog.install_catalog_entry(
entry, force=force, allow_removed=True)
else:
target, installed_manifest, installed_name = _install_plugin_core(
identifier, force=force, ref=(ref or "").strip() or None)
except PluginScanBlocked as exc:
fields = ("pattern_id", "severity", "category", "file", "line", "description")
return {
"ok": False, "error": str(exc), "scan_blocked": True,
"scan_verdict": getattr(exc.scan_result, "verdict", "dangerous"),
"scan_findings": [
{k: getattr(f, k) for k in fields}
for f in (exc.scan_result.findings if exc.scan_result is not None else ())
],
}
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
if enable:
_set_plugin_enabled(installed_name, enable=True)
deps = _install_python_dependencies_quietly(target, warnings)
ap = target / "after-install.md"
return {
"ok": True, "plugin_name": installed_name, "warnings": warnings,
"python_dependencies": deps,
"missing_env": [s["name"] for s in _missing_env_specs(installed_manifest)],
"after_install_path": str(ap) if ap.exists() else None, "enabled": enable,
}
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
config = load_config()
platform_toolsets = _child_dict(config, "platform_toolsets")
changed = False
for ts_list in platform_toolsets.values():
if isinstance(ts_list, list) and enable != (toolset_key in ts_list):
(ts_list.append if enable else ts_list.remove)(toolset_key)
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)."""
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()
if ((name in en and name not in dis) if enabled else (name not in en and name in dis)):
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."""
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>``."""
from hermes_cli import plugins_cmd_catalog as catalog
target = _user_installed_plugin_dir(name)
if target is None:
return {"ok": False, "error": f"Plugin '{name}' was not found under {_plugins_dir()}."}
sidecar = catalog.read_catalog_sidecar(target)
try:
if sidecar:
sha, changed = catalog.repin_catalog_plugin(target, sidecar)
warnings: list[str] = []
deps = _install_python_dependencies_quietly(target, warnings) if changed else []
return {"ok": True, "name": name, "sha": sha, "unchanged": not changed,
"python_dependencies": deps, "warnings": warnings}
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())
return {"ok": True, "name": name, "output": msg, "unchanged": "Already up to date" in msg}
def _clear_plugin_bytecode(target: Path) -> int:
"""Remove ``__pycache__`` dirs under a just-updated plugin checkout. Plugin dirs sit outside
the repo, so the launch-time bytecode sweep never covers them and stale bytecode after a pull
can ImportError in the next process. Never raises.
See #60242, #6207.
"""
removed = 0
try:
for cache_dir in target.rglob("__pycache__"):
if cache_dir.is_dir():
shutil.rmtree(cache_dir, ignore_errors=True)
removed += 0 if cache_dir.exists() else 1
except OSError:
pass
return removed
def _run_plugin_git(
git_exe: str, target: Path, *args: str, timeout: int = 60, auth_url: str = "",
) -> subprocess.CompletedProcess:
"""Run one git command inside a plugin checkout (non-interactive). *auth_url* names the remote
a network verb talks to so a stored user credential for its host is attached (private repos)."""
env = noninteractive_git_env()
if auth_url:
from hermes_cli.git_credentials import with_git_auth
env = with_git_auth(env, auth_url)
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=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 _autostash_dirty_tree(git_exe: str, target: Path) -> tuple[bool, str]:
"""Stash local edits before a pull. Returns ``(stash_created, error)``; a non-empty error means
the tree is dirty but nothing was saved, so the pull must not run."""
status = _run_plugin_git(git_exe, target, "status", "--porcelain")
if status.returncode != 0 or not status.stdout.strip():
return False, ""
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)
if not post_stash or post_stash == pre_stash:
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")
return True, ""
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
"""``git pull --ff-only`` a plugin checkout, autostashing local edits (users patch installed
plugins in place, and a plain ff-only pull would then refuse forever).
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. Same UX class Factory Droid fixed in v0.188 ("Updating a plugin
marketplace now succeeds when its checkout has local changes"), and the same autostash approach ``hermes
update`` already uses for the main checkout (PR #70161).
"""
git_exe = _resolve_git_executable()
if not git_exe:
return False, "git is not installed or not in PATH."
try:
stash_created, err = _autostash_dirty_tree(git_exe, target)
if err:
return False, err
origin = _run_plugin_git(git_exe, target, "remote", "get-url", "origin", timeout=15)
result = _run_plugin_git(git_exe, target, "pull", "--ff-only", auth_url=origin.stdout.strip())
if result.returncode != 0:
err = _safe_git_error(result) or "git pull failed."
if not stash_created:
return False, err
# 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}"
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()
if any(n == name and src == "bundled" for n, _ver, _d, src, _path, _key in _discover_all_plugins()):
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 _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)
def cmd_compat(args: Any | None = None) -> None:
"""``hermes plugins compat`` — which installed plugins import paths scheduled for removal, and where."""
import sys
from pathlib import Path
from hermes_cli.plugin_compat import (
ALLOW_KEY, COMPAT_REMOVAL, compat_report, removal_in_effect, scan_plugin, summary_lines)
console = _console()
path = getattr(args, "path", None)
if path:
hits = scan_plugin(Path(path).expanduser().resolve())
report = {Path(path).name: hits} if hits else {}
else:
report = compat_report(force=True)
if getattr(args, "json", False):
print(json.dumps({"removal_date": COMPAT_REMOVAL, "in_effect": removal_in_effect(),
"plugins": {k: [h.__dict__ for h in v] for k, v in report.items()}}, indent=2))
sys.exit(1 if report else 0)
if not report:
console.print(f"[green]✓ No enabled plugin imports paths scheduled for removal on {COMPAT_REMOVAL}.[/green]")
return
head, tail = summary_lines(report)
console.print(f"[bold {'red' if removal_in_effect() else 'yellow'}]{head}[/]")
console.print(f"[dim]{tail}[/dim]")
for name, hits in sorted(report.items()):
table = _table(((f"{name} ({len(hits)} import{'s' if len(hits) != 1 else ''})", "bold"), ("old path", "yellow"), ("new path", "green")),
title=None, show_lines=False)
for h in hits:
table.add_row(f"{h.file}:{h.line}", h.old, h.new)
console.print()
console.print(table)
console.print()
console.print(f"[dim]After {COMPAT_REMOVAL} these plugins are not loaded. Update them, or force-load with "
f"plugins.{ALLOW_KEY}: true in config.yaml (the old paths still break once the compat layer is reverted).[/dim]")
sys.exit(1)
# 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),
"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 ----