"""``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 ` 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', '')))} --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.. = 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..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 []`` — 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 (/plugins//), 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 ") 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/`` 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/``.""" 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', '')} --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 [/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)