mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Independent review findings on update-disabled plugins: - Recorded plugins are tried only after the enabled plugins have built, one at a time on top of them; an update that has to disable an enabled plugin re-enables nothing, so a download failure can never swap two plugins. - A resolver or build verdict records a fingerprint of what was tried (core's dependency inputs plus the plugin's) and is not built again until one side changes; a download failure is retried by the next update, and those go first because they fitted beside the rest before. - The update publishes against the config snapshot it decided from, and a rejoin needs the record to still be there, so a user's mid-update choice stands. - Recorded reasons keep their readable start, not the tail of a uv error. - An installed provider that fails to import is reported as such instead of running recovery, and the startup notices dedupe per kind.
1139 lines
52 KiB
Python
1139 lines
52 KiB
Python
"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
|
|
|
|
Facade: shared primitives (errors, console/config helpers, manifest reading, discovery, enable/disable
|
|
selection) and the dispatch table live here; each verb family lives in a ``plugins_cmd_<topic>.py``
|
|
sibling (install, update, remove, git, capabilities, toggle, listing, catalog)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any, NoReturn, Optional
|
|
|
|
from hermes_constants import get_hermes_home, hermes_home_key
|
|
from hermes_cli.config import cfg_get
|
|
from hermes_cli.plugin_capabilities import _child_dict
|
|
# Tests patch these two on the facade; the install/remove siblings read them through it.
|
|
from hermes_cli.secret_prompt import masked_secret_prompt
|
|
from utils import rmtree_readonly
|
|
|
|
# Topical siblings. The facade re-exports what other modules, tests and the old updater import from
|
|
# ``hermes_cli.plugins_cmd``; sibling bodies read those names back through the facade at call time.
|
|
from hermes_cli.plugins_cmd_capabilities import (
|
|
_declared_capabilities_for_key, _declared_capabilities_from_manifest, _resolve_tool_override_grant,
|
|
_run_capability_consent, cmd_capabilities,
|
|
)
|
|
from hermes_cli.plugins_cmd_git import (
|
|
_EXACT_COMMIT_RE, _canonical_source, _checkout_exact_revision, _clone_plugin_repo, _git_head_revision,
|
|
_git_or_raise, _git_pull_plugin_dir, _git_resolve_commit, _normalize_exact_revision, _pin_annotation,
|
|
_read_install_metadata, _run_plugin_git, _safe_git_error, _scrub_cloned_origin, _update_install_record,
|
|
_write_install_metadata, pinned_revision,
|
|
)
|
|
from hermes_cli.plugins_cmd_install import (
|
|
_check_manifest_version, _consent_python_deps, _display_after_install, _install_plugin_core,
|
|
_install_plugin_python_deps, _prompt_plugin_env_vars, _python_dependency_summary,
|
|
_read_manifest_for_install, cmd_install, dashboard_install_plugin,
|
|
)
|
|
from hermes_cli.plugins_cmd_listing import (
|
|
_filter_plugin_entries, cmd_list, cmd_show,
|
|
)
|
|
from hermes_cli.plugins_cmd_remove import (
|
|
_remove_plugin_core, cmd_remove, dashboard_remove_user_plugin,
|
|
)
|
|
from hermes_cli.plugins_cmd_toggle import (
|
|
_discover_context_engines, _persist_plugin_selection, _provider_categories, _run_composite_fallback,
|
|
cmd_toggle,
|
|
)
|
|
from hermes_cli.plugins_cmd_update import (
|
|
_clear_plugin_bytecode, cmd_adopt, cmd_check_updates, cmd_trust_update_url, cmd_update,
|
|
dashboard_update_user_plugin,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_DEFAULT_CLONE_TIMEOUT_SECONDS = 300
|
|
_MAX_CLONE_TIMEOUT_SECONDS = 3600
|
|
_CLONE_TIMEOUT_HINT = "On a slow connection, raise plugins.clone_timeout_seconds in config.yaml."
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _resolve_git_executable() -> Optional[str]:
|
|
"""Resolve a git binary for subprocess use when ``PATH`` may be minimal."""
|
|
found = shutil.which("git")
|
|
if found:
|
|
return found
|
|
if os.name == "nt":
|
|
roots = [
|
|
os.environ.get("ProgramFiles", r"C:\Program Files"),
|
|
os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
|
|
]
|
|
local = os.environ.get("LOCALAPPDATA", "")
|
|
if local:
|
|
roots.append(os.path.join(local, "Programs"))
|
|
candidates = [os.path.join(r, "Git", sub, "git.exe") for r in roots for sub in ("cmd", "bin")]
|
|
else:
|
|
candidates = ["/usr/bin/git", "/usr/local/bin/git", "/bin/git"]
|
|
return next((c for c in candidates if c and os.path.isfile(c)), None)
|
|
|
|
|
|
class PluginOperationError(Exception):
|
|
"""Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx).
|
|
|
|
``failure_class`` names the raise site for the extension-install metric (a closed name from
|
|
``shared_metrics_contract.EXTENSION_PLUGIN_FAILURE_CLASSES``); untagged sites read ``other``.
|
|
"""
|
|
|
|
failure_class = "other"
|
|
|
|
def __init__(self, *args, failure_class: Optional[str] = None):
|
|
super().__init__(*args)
|
|
if failure_class is not None:
|
|
self.failure_class = failure_class
|
|
|
|
|
|
class PluginScanBlocked(PluginOperationError):
|
|
"""Plugin failed the security scan and was not installed."""
|
|
|
|
failure_class = "scan_blocked"
|
|
|
|
def __init__(self, message: str, scan_result=None):
|
|
super().__init__(message)
|
|
self.scan_result = scan_result
|
|
|
|
|
|
def _console():
|
|
"""A fresh Rich console (rich is imported lazily)."""
|
|
from rich.console import Console
|
|
return Console()
|
|
|
|
|
|
def _table(columns, **kwargs):
|
|
"""A Rich ``Table(**kwargs)`` with ``(header, style)`` *columns* added in order."""
|
|
from rich.table import Table
|
|
table = Table(**kwargs)
|
|
for header, style in columns:
|
|
table.add_column(header, style=style)
|
|
return table
|
|
|
|
|
|
def _is_tty() -> bool:
|
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
|
|
|
|
def _fail(console, message: str) -> NoReturn:
|
|
"""Print *message* and exit 1."""
|
|
console.print(message)
|
|
sys.exit(1)
|
|
|
|
|
|
def _ask_yes(prompt: str, reader=input) -> bool:
|
|
"""One y/N question; EOF / Ctrl-C count as "no"."""
|
|
try:
|
|
answer = reader(prompt).strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
return False
|
|
return answer in {"y", "yes"}
|
|
|
|
|
|
def _config_value(*keys: str, default: Any) -> Any:
|
|
"""Read ``keys`` from config.yaml; *default* on a missing key or any load failure."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
return cfg_get(load_config(), *keys, default=default)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _clone_timeout_seconds() -> int:
|
|
"""Deadline for plugin clone and pinned fetch, scoped to the active profile."""
|
|
value = _config_value("plugins", "clone_timeout_seconds", default=_DEFAULT_CLONE_TIMEOUT_SECONDS)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
logger.warning("plugins.clone_timeout_seconds must be a positive integer; using %ss",
|
|
_DEFAULT_CLONE_TIMEOUT_SECONDS)
|
|
return _DEFAULT_CLONE_TIMEOUT_SECONDS
|
|
if value > _MAX_CLONE_TIMEOUT_SECONDS:
|
|
logger.warning("plugins.clone_timeout_seconds exceeds %ss; clamping", _MAX_CLONE_TIMEOUT_SECONDS)
|
|
return _MAX_CLONE_TIMEOUT_SECONDS
|
|
return value
|
|
|
|
|
|
def _config_name_set(*keys: str) -> set:
|
|
"""A list-valued config key as a set (empty on any failure or non-list)."""
|
|
value = _config_value(*keys, default=[])
|
|
return set(value) if isinstance(value, list) else set()
|
|
|
|
|
|
def _config_str(*keys: str, default: str) -> str:
|
|
"""A string config key; empty/missing/failed reads coerce to *default*."""
|
|
return _config_value(*keys, default=default) or default
|
|
|
|
|
|
def _write_config_value(section: str, key: str, value: Any) -> None:
|
|
"""Persist ``config[section][key] = value`` to config.yaml (creating the section)."""
|
|
from hermes_cli.config import load_config, save_config
|
|
config = load_config()
|
|
config.setdefault(section, {})[key] = value
|
|
save_config(config)
|
|
|
|
|
|
def _scan_on_install_enabled() -> bool:
|
|
"""Install/update-time security scanning; on by default, off via ``plugins.scan_on_install: false``."""
|
|
return bool(_config_value("plugins", "scan_on_install", default=True))
|
|
|
|
|
|
def _scan_plugin_tree(plugin_dir: Path, identifier: str, *, force: bool, scan_decision_cb=None,
|
|
reviewed_pin: bool = False):
|
|
"""Scan *plugin_dir* and enforce the install policy.
|
|
|
|
Verdicts: safe → proceed; caution → needs confirmation (``force=True`` or a truthy
|
|
``scan_decision_cb(result)``); dangerous → always blocked (:class:`PluginScanBlocked`).
|
|
*reviewed_pin* marks a tree checked out at a curated-catalog sha: that exact tree passed
|
|
the same scanner at admission with a human reading the caution findings, so caution is
|
|
accepted without a prompt (the Desktop has none). Dangerous still blocks — a signature
|
|
added after review is exactly the case the backstop exists for.
|
|
Returns the ScanResult, or None when scanning is disabled.
|
|
"""
|
|
if not _scan_on_install_enabled():
|
|
return None
|
|
from tools.plugin_guard import format_scan_report, scan_plugin, should_allow_plugin_install
|
|
result = scan_plugin(plugin_dir, source=identifier)
|
|
allowed, reason = should_allow_plugin_install(result, force=force)
|
|
if allowed is None and reviewed_pin:
|
|
allowed, reason = True, "Caution verdict accepted: reviewed catalog pin"
|
|
|
|
if allowed is None and scan_decision_cb is not None:
|
|
try:
|
|
if scan_decision_cb(result):
|
|
allowed = True
|
|
reason = "Caution verdict accepted by user"
|
|
except Exception:
|
|
logger.exception("plugin scan decision callback failed")
|
|
|
|
if allowed is not True:
|
|
raise PluginScanBlocked(
|
|
f"Security scan blocked plugin install: {reason}\n\n"
|
|
f"{format_scan_report(result)}\n"
|
|
"Review the findings above. Install only plugins from sources "
|
|
"you trust. (Scanning can be configured via "
|
|
"plugins.scan_on_install in config.yaml.)",
|
|
scan_result=result)
|
|
logger.info("plugin scan passed for %s: %s", plugin_dir.name, reason)
|
|
return result
|
|
|
|
|
|
def _preserved_files_note(exc: PluginScanBlocked, merged: list[str]) -> str:
|
|
"""Scan-block text for a tree that also holds user files preserved from the installed copy."""
|
|
preserved = set(merged)
|
|
findings = exc.scan_result.findings if exc.scan_result is not None else ()
|
|
hits = sorted({f.file for f in findings if f.file in preserved})
|
|
if hits:
|
|
note = ("These findings come from user files preserved from the installed copy: "
|
|
f"{', '.join(hits)}. Move or remove them and retry the update.")
|
|
else:
|
|
note = "The scanned tree included user files preserved from the installed copy."
|
|
return f"{exc}\n\n{note}"
|
|
|
|
|
|
def _scan_merged_tree(plugin_dir: Path, identifier: str, merged: Optional[list[str]], **kwargs):
|
|
""":func:`_scan_plugin_tree` for a candidate that may hold carried user files (*merged*).
|
|
|
|
A block then names the findings that sit in those files, so user data does not read as a
|
|
malicious upstream revision; the original block stays chained as the cause.
|
|
"""
|
|
try:
|
|
return _scan_plugin_tree(plugin_dir, identifier, **kwargs)
|
|
except PluginScanBlocked as exc:
|
|
if not merged:
|
|
raise
|
|
raise PluginScanBlocked(_preserved_files_note(exc, merged), scan_result=exc.scan_result) from exc
|
|
|
|
|
|
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 ``web/firecrawl`` 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.",
|
|
failure_class="invalid_source")
|
|
if not candidate.exists():
|
|
raise PluginOperationError(f"Plugin subdirectory '{subdir}' does not exist in the repository.",
|
|
failure_class="invalid_source")
|
|
if not candidate.is_dir():
|
|
raise PluginOperationError(f"Plugin subdirectory '{subdir}' is not a directory.",
|
|
failure_class="invalid_source")
|
|
return candidate
|
|
|
|
|
|
def _repo_name_from_url(url: str) -> str:
|
|
"""Repo name from a Git URL (last path component; ssh-style ``git@host:repo`` splits on ':')."""
|
|
name = url.rstrip("/").removesuffix(".git").rsplit("/", 1)[-1]
|
|
if ":" in name:
|
|
name = name.rsplit(":", 1)[-1].rsplit("/", 1)[-1]
|
|
return name
|
|
|
|
|
|
def _native_manifest_file(plugin_dir: Path) -> Optional[Path]:
|
|
"""``plugin.yaml`` (or ``plugin.yml``) under *plugin_dir*, or None when neither exists."""
|
|
from pm.plugin_declarations import native_manifest_file
|
|
|
|
try:
|
|
return native_manifest_file(plugin_dir)
|
|
except ValueError as exc:
|
|
raise PluginOperationError(str(exc), failure_class="manifest_invalid") from exc
|
|
|
|
|
|
def _has_portable_manifest(plugin_dir: Path) -> bool:
|
|
"""True when ``plugin.json`` exists (or is a symlink, even dangling) under *plugin_dir*."""
|
|
portable_file = plugin_dir / "plugin.json"
|
|
return portable_file.exists() or portable_file.is_symlink()
|
|
|
|
|
|
def _load_yaml_manifest(manifest_file: Path):
|
|
"""``yaml.safe_load`` of *manifest_file* (``{}`` when empty); raises on any read/parse error."""
|
|
from pm.plugin_declarations import read_native_manifest
|
|
|
|
return read_native_manifest(manifest_file)
|
|
|
|
|
|
def _read_manifest(plugin_dir: Path) -> dict:
|
|
"""Read a native or portable manifest, preferring native YAML."""
|
|
manifest_file = _native_manifest_file(plugin_dir)
|
|
if manifest_file is None:
|
|
if not _has_portable_manifest(plugin_dir):
|
|
return {}
|
|
try:
|
|
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
|
return read_agent_plugin_manifest(plugin_dir)[0]
|
|
except Exception as e:
|
|
logger.warning("Failed to read plugin.json in %s: %s", plugin_dir, e)
|
|
return {}
|
|
try:
|
|
return _load_yaml_manifest(manifest_file)
|
|
except Exception as e:
|
|
logger.warning("Failed to read plugin.yaml in %s: %s", plugin_dir, e)
|
|
return {}
|
|
|
|
|
|
def _looks_like_plugin_dir(target: Path) -> bool:
|
|
"""True when *target* has a native/portable manifest or a package ``__init__.py``."""
|
|
return (
|
|
_native_manifest_file(target) is not None
|
|
or (target / "plugin.json").exists()
|
|
or (target / "__init__.py").exists())
|
|
|
|
|
|
def _copy_example_files(plugin_dir: Path, console) -> None:
|
|
"""Copy ``*.example`` files to their real names (``config.yaml.example`` -> ``config.yaml``),
|
|
never overwriting an existing file so reinstall keeps user config."""
|
|
for example_file in plugin_dir.glob("*.example"):
|
|
real_name = example_file.stem
|
|
real_path = plugin_dir / real_name
|
|
if real_path.exists():
|
|
continue
|
|
try:
|
|
shutil.copy2(example_file, real_path)
|
|
console.print(f"[dim] Created {real_name} from {example_file.name}[/dim]")
|
|
except OSError as e:
|
|
console.print(f"[yellow]Warning:[/yellow] Failed to copy {example_file.name}: {e}")
|
|
|
|
|
|
def _missing_env_specs(manifest: dict) -> list[dict]:
|
|
"""``requires_env`` entries (plain names or ``{name, description, url, secret}`` dicts,
|
|
normalised to dicts; nameless entries dropped) whose variable is unset in ``~/.hermes/.env``."""
|
|
env_specs = [
|
|
{"name": entry} if isinstance(entry, str) else entry
|
|
for entry in manifest.get("requires_env") or []
|
|
if isinstance(entry, str) or (isinstance(entry, dict) and entry.get("name"))
|
|
]
|
|
if not env_specs:
|
|
return []
|
|
from hermes_cli.config import get_env_value
|
|
return [s for s in env_specs if not get_env_value(s["name"])]
|
|
|
|
|
|
def _clone_failure_message(git_url: str, git_error: str) -> str:
|
|
"""Plain-words clone failure: what to check (address, network, private repo), raw git text last.
|
|
|
|
The text reaches ``_fail`` -> Rich ``console.print``: escape the git output so ``[...]`` in it is
|
|
not parsed as markup."""
|
|
from rich.markup import escape
|
|
return (f"Could not download the plugin from {git_url}. Check the address (browse the catalog "
|
|
"with `hermes plugins search`), check your internet connection, or, if the repository "
|
|
"is private, sign in first with `gh auth login` (or set GITHUB_TOKEN in your .env).\n"
|
|
f"Details: {escape(git_error.strip())}")
|
|
|
|
|
|
def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
|
|
"""The plugin path if it exists; else exit 1 (invalid name, or a listing of installed plugins)."""
|
|
try:
|
|
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
|
|
except ValueError as e:
|
|
_fail(console, f"[red]Error:[/red] {e}")
|
|
if not target.exists():
|
|
target = _resolved_user_plugin_dir(name)
|
|
if target is None:
|
|
_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.")
|
|
from hermes_cli.left_core_migration import moved_plugin_hint
|
|
if moved := moved_plugin_hint(name):
|
|
return f"[red]No plugin named '{name}'.[/red] {moved}"
|
|
return (f"[red]No plugin named '{name}'.[/red] Run `hermes plugins list` to see the exact names "
|
|
f"(nested plugins use their full key, e.g. web/firecrawl).{scope} "
|
|
"To add one: `hermes plugins install <owner/repo>`.")
|
|
|
|
|
|
# ``plugins.disabled`` is an explicit deny-list that wins over the ``plugins.enabled`` allow-list.
|
|
_get_disabled_set = functools.partial(_config_name_set, "plugins", "disabled")
|
|
_get_enabled_set = functools.partial(_config_name_set, "plugins", "enabled")
|
|
|
|
|
|
def _save_enabled_set(enabled: set) -> None:
|
|
"""Frozen old-updater import: never resurrect a raw plugin-selection write."""
|
|
from hermes_cli._old_updater import stop_for_relaunch
|
|
|
|
stop_for_relaunch()
|
|
|
|
|
|
def _plugin_selection_version() -> str:
|
|
from hermes_cli.runtime_state import _digest
|
|
return _digest(get_hermes_home() / "config.yaml") or "missing"
|
|
|
|
|
|
def _admit_and_save_plugin_sets(
|
|
enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable", expected_config=None,
|
|
plugin: Optional[str] = None, settled=(),
|
|
) -> None:
|
|
"""ONE admission authority for proposed enabled/disabled sets (C13):
|
|
the candidate union is resolved against the ACTIVE environment and
|
|
the config commits inside the same worker-owned PM transaction — a refusal or a config-write failure
|
|
publishes nothing: previous config bytes AND previous environment
|
|
stay exactly in place. Raises :class:`AdmissionRefused` (UI callers
|
|
catch and surface it — admission never auto-disables to fit); a resolver
|
|
conflict raises its :class:`DependencyConflict` subclass naming *plugin*."""
|
|
from rich.markup import escape
|
|
|
|
from hermes_cli.plugins_admission import AdmissionRefused, DependencyConflict, admit_plugin_set_change
|
|
|
|
try:
|
|
admit_plugin_set_change(
|
|
enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs, expected_config=expected_config,
|
|
plugin=plugin, settled=settled,
|
|
)
|
|
except DependencyConflict as exc:
|
|
# `hermes pm install` cannot fix a conflict, so the retry hint below would mislead here.
|
|
if console is not None:
|
|
console.print(f"[red]✗[/red] {escape(str(exc))}")
|
|
console.print("[dim]config.yaml and the active environment are unchanged.[/dim]")
|
|
raise
|
|
except AdmissionRefused as exc:
|
|
if console is not None:
|
|
console.print(f"[red]✗[/red] {action} refused: {exc}")
|
|
console.print(
|
|
"[dim]config.yaml and the active environment are unchanged. "
|
|
"Run `hermes pm install` to resolve dependencies, then retry.[/dim]"
|
|
)
|
|
raise
|
|
|
|
|
|
|
|
_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})
|
|
|
|
|
|
def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool:
|
|
"""Drop the bundled basic dashboard-auth plugin from ``plugins.disabled`` in *cfg*.
|
|
|
|
``hermes setup`` / ``hermes plugins disable basic`` can park it there while
|
|
``dashboard.basic_auth`` is configured, and password auth then silently fails.
|
|
Returns True when modified.
|
|
"""
|
|
plugins_cfg = cfg.get("plugins")
|
|
disabled = plugins_cfg.get("disabled") if isinstance(plugins_cfg, dict) else None
|
|
if not isinstance(disabled, list) or not (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS):
|
|
return False
|
|
plugins_cfg["disabled"] = sorted(set(disabled) - _BASIC_AUTH_PLUGIN_KEYS)
|
|
return True
|
|
|
|
|
|
def _discard_key_and_leaf(names: set, key: str) -> None:
|
|
"""Drop *key* and its bare leaf (``web/exa`` -> ``exa``) from *names*, so a
|
|
stale legacy bare-name entry can't keep vetoing the canonical key."""
|
|
names.discard(key)
|
|
names.discard(key.split("/")[-1])
|
|
|
|
|
|
def _plugin_aliases(key: str, entries: Optional[list] = None) -> set:
|
|
"""Every spelling a config list may hold for *key*: the key, its bare leaf and the manifest name.
|
|
The loader matches BOTH the canonical key (``web/firecrawl``) and the manifest name
|
|
(``web-firecrawl``), so a stale entry under any form vetoes an enable ("explicit disable wins").
|
|
Pass *entries* to reuse one :func:`_discover_all_plugins` scan across several keys."""
|
|
from hermes_cli.left_core_migration import legacy_plugin_keys
|
|
names = {key, key.split("/")[-1], *legacy_plugin_keys(key)}
|
|
names.update(e[0] for e in (_discover_all_plugins() if entries is None else entries) if e[5] == key)
|
|
return names
|
|
|
|
|
|
def _activate_key(key: str, *, enable: bool, console=None) -> bool:
|
|
"""Transactionally persist canonical *key*, purging aliases from the opposing list.
|
|
|
|
False when the lists already say so (nothing written). PM owns the selection and dependency
|
|
publication together, so every CLI/dashboard activation surface goes through the same admission
|
|
transaction instead of writing ``config.yaml`` directly."""
|
|
enabled, disabled = _get_enabled_set(), _get_disabled_set()
|
|
aliases = _plugin_aliases(key)
|
|
target, other = (enabled, disabled) if enable else (disabled, enabled)
|
|
# A plugin an update disabled is retried by later updates; disabling it yourself stops that.
|
|
if key in target and not (aliases & other) and not (not enable and _disabled_by_update(key, aliases)):
|
|
return False
|
|
_set_plugin_enabled(key, enable=enable, aliases=aliases, console=console)
|
|
return True
|
|
|
|
|
|
def _disabled_by_update(key: str, aliases) -> bool:
|
|
"""Whether an update disabled this plugin and will retry it (``pm.plugin_eviction`` records it)."""
|
|
from pm.plugins_state import RECORD
|
|
from pm.plugins_state import read_home_selection
|
|
|
|
record = ((read_home_selection(get_hermes_home()) or {}).get("plugins") or {}).get(RECORD)
|
|
return isinstance(record, dict) and bool({key, *aliases} & set(record))
|
|
|
|
|
|
def _forget_plugin_config(aliases: set) -> dict[str, Any]:
|
|
"""Drop every trace of a removed plugin (its :func:`_plugin_aliases`, taken BEFORE the tree went)
|
|
from config.yaml: allow/deny-list entries, ``plugins.entries.<id>`` grants and a ``memory.provider``
|
|
selection naming it. A later reinstall under the same name must start from the "Enable now?"
|
|
decision, not inherit a stale enable or grant (#54336); a dangling ``memory.provider`` would make
|
|
the next agent init re-clone the plugin from the catalog, silently undoing the uninstall.
|
|
Returns ``{"cleared_memory_provider": True}`` when the selection was reset."""
|
|
from hermes_cli.config import load_config, save_config
|
|
config = load_config()
|
|
changed = False
|
|
result: dict[str, Any] = {}
|
|
plugins_cfg = config.get("plugins")
|
|
if isinstance(plugins_cfg, dict):
|
|
for list_key in ("enabled", "disabled"):
|
|
names = plugins_cfg.get(list_key)
|
|
if isinstance(names, list) and aliases & set(names):
|
|
plugins_cfg[list_key] = sorted(set(names) - aliases)
|
|
changed = True
|
|
entries = plugins_cfg.get("entries")
|
|
if isinstance(entries, dict) and aliases & set(entries):
|
|
for alias in aliases & set(entries):
|
|
del entries[alias]
|
|
changed = True
|
|
memory_cfg = config.get("memory")
|
|
if isinstance(memory_cfg, dict) and str(memory_cfg.get("provider") or "").strip() in aliases:
|
|
memory_cfg["provider"] = ""
|
|
changed, result = True, {"cleared_memory_provider": True}
|
|
if changed:
|
|
save_config(config)
|
|
return result
|
|
|
|
|
|
# One lock per Hermes home. The Desktop install card enables several plugins at once, each on its own
|
|
# thread; without it every thread read the same config version and all but the first commit were
|
|
# refused as stale. The version check in PM stays: it still catches an edit from another process.
|
|
_SELECTION_LOCKS: dict[str, threading.Lock] = {}
|
|
_SELECTION_LOCKS_GUARD = threading.Lock()
|
|
|
|
|
|
def _selection_lock() -> threading.Lock:
|
|
with _SELECTION_LOCKS_GUARD:
|
|
return _SELECTION_LOCKS.setdefault(hermes_home_key(), threading.Lock())
|
|
|
|
|
|
def _set_plugin_enabled(name: str, *, enable: bool, aliases=(), console=None) -> None:
|
|
"""Submit the command's delta with the version of the selection it read."""
|
|
from pm.plugins_state import read_home_selection
|
|
|
|
with _selection_lock():
|
|
expected_config = _plugin_selection_version()
|
|
config = read_home_selection(get_hermes_home()) or {}
|
|
plugins = config.get("plugins") or {}
|
|
enabled = set(plugins.get("enabled") or ())
|
|
disabled = set(plugins.get("disabled") or ())
|
|
_apply_activation(enabled, disabled, name, aliases, enable=enable)
|
|
_admit_and_save_plugin_sets(enabled, disabled, console=console,
|
|
action=f"{'Enable' if enable else 'Disable'} '{name}'",
|
|
expected_config=expected_config, plugin=name if enable else None,
|
|
settled=(name, *aliases))
|
|
|
|
|
|
def _apply_activation(enabled: set, disabled: set, key: str, aliases, *, enable: bool) -> None:
|
|
"""Add canonical *key* to the target list and purge it, its bare leaf and *aliases* from the other."""
|
|
removed = disabled if enable else enabled
|
|
_discard_key_and_leaf(removed, key)
|
|
removed.difference_update(aliases)
|
|
(enabled if enable else disabled).add(key)
|
|
|
|
|
|
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 _catalog_installed_dir(name: str) -> Optional[Path]:
|
|
"""Install dir whose installer-owned record names catalog entry *name* (``hermes-memory-wiki`` is
|
|
installed as ``plugins/memory-wiki``), else None (also when two dirs claim it). Read through
|
|
:func:`catalog_install_record` (``.install-metadata.json``), never the tree, so a URL-installed
|
|
repo cannot claim a catalog entry's name."""
|
|
from hermes_cli.plugins_cmd_catalog import catalog_install_record
|
|
try:
|
|
keys = list(_read_install_metadata())
|
|
except PluginOperationError:
|
|
return None
|
|
hits = [target for key in keys if (target := _plugins_dir() / key).is_dir()
|
|
and (record := catalog_install_record(target)) and record["catalog_name"] == name]
|
|
return hits[0] if len(hits) == 1 else None
|
|
|
|
|
|
def _find_plugin_entry(name: str) -> Optional[tuple]:
|
|
"""THE plugin name resolver for every verb (enable/disable/show/capabilities/remove/update, the
|
|
dashboard and the Desktop hub): the discovered ``(name, version, description, source, dir_path, key)``
|
|
entry for a canonical key or manifest name; else a bare leaf (``exa`` ->
|
|
``web/exa``) only when unique; else the catalog name the plugin was installed under.
|
|
Never guesses between two candidates."""
|
|
entries = _discover_all_plugins()
|
|
from hermes_cli.left_core_migration import legacy_plugin_alias
|
|
target = legacy_plugin_alias(name) or name # ``observability/langfuse`` -> its catalog plugin
|
|
exact = next((entry for entry in entries if target in (entry[0], entry[5])), None)
|
|
if exact is not None:
|
|
return exact
|
|
leaf_matches = [entry for entry in entries if name == entry[5].split("/")[-1]]
|
|
if leaf_matches:
|
|
return leaf_matches[0] if len(leaf_matches) == 1 else None
|
|
target = _catalog_installed_dir(name)
|
|
return next((entry for entry in entries if target is not None and entry[4] and Path(str(entry[4])) == target),
|
|
None)
|
|
|
|
|
|
def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]:
|
|
"""Resolve *name* to ``(canonical_key, source)`` or ``None`` through :func:`_find_plugin_entry`."""
|
|
entry = _find_plugin_entry(name)
|
|
return (entry[5], entry[3]) if entry else None
|
|
|
|
|
|
def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
|
|
"""Add a plugin to the enabled allow-list (and remove it from disabled).
|
|
|
|
Non-bundled plugins request consent for declared capabilities. The legacy
|
|
``allow_tool_override`` grant changes only with an explicit True/False flag;
|
|
None leaves it unchanged. Bundled plugins are trusted.
|
|
"""
|
|
from hermes_cli.relay_plugin_cutover import LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV
|
|
console = _console()
|
|
|
|
def _refuse_legacy_relay(plugin: str) -> None:
|
|
if plugin in LEGACY_RELAY_PLUGIN_KEYS:
|
|
_fail(console, (
|
|
f"[red]Plugin '{plugin}' was removed.[/red] Relay lifecycle is owned "
|
|
"by Hermes core; configure a standard user or system Relay plugins.toml, or use "
|
|
f"{RELAY_PLUGINS_CONFIG_ENV} for an explicit user-file override."))
|
|
|
|
_refuse_legacy_relay(name)
|
|
resolved = _resolve_plugin_key_and_source(name)
|
|
if resolved is None:
|
|
_fail(console, _unknown_plugin_message(name))
|
|
key, source = resolved
|
|
_refuse_legacy_relay(key)
|
|
if source != "bundled":
|
|
# Activating recalled code is the same act as installing it (`plugins/AGENTS.md`: kill list).
|
|
from hermes_cli import plugins_cmd_catalog as catalog
|
|
try:
|
|
catalog.refuse_if_installed_removed(key, _user_installed_plugin_dir(key.rsplit("/", 1)[-1]))
|
|
except PluginOperationError as exc:
|
|
_fail(console, f"[red]Error:[/red] {exc}")
|
|
|
|
changed = _activate_key(key, enable=True, console=console)
|
|
if changed:
|
|
from hermes_cli.plugins_activation import report_activation
|
|
console.print(f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled.") # the hint says when it applies
|
|
report_activation(console, key)
|
|
else:
|
|
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")
|
|
if source != "bundled":
|
|
_enable_consent(console, key, allow_tool_override)
|
|
if changed: # after consent: register() sees the grants; same saved-list edit as the Desktop toggle
|
|
_toggle_plugin_toolset(key, enable=True)
|
|
|
|
|
|
def _enable_consent(console, key: str, allow_tool_override: Optional[bool]) -> None:
|
|
"""Capability consent and the legacy tool-override grant for a non-bundled plugin's enable."""
|
|
# When the manifest declares capabilities the consent screen is the canonical grant path
|
|
# (it covers tools.override too); the legacy prompt then only runs on an explicit flag.
|
|
# See #64228.
|
|
declared_caps = _declared_capabilities_for_key(key)
|
|
if declared_caps:
|
|
_run_capability_consent(console, key, declared_caps, context="enable")
|
|
# Enabling a plugin is not a request for undeclared privileges. Keep existing
|
|
# grants unchanged unless the operator explicitly grants or revokes one.
|
|
if allow_tool_override is not None:
|
|
_resolve_tool_override_grant(console, key, allow_tool_override)
|
|
|
|
|
|
def cmd_disable(name: str) -> None:
|
|
"""Remove a plugin from the enabled allow-list (and add to disabled)."""
|
|
console = _console()
|
|
key = _resolve_plugin_key(name)
|
|
if key is None:
|
|
_fail(console, _unknown_plugin_message(name))
|
|
toolset_key = _saved_list_toolset_key(key) # resolve while the plugin still loads
|
|
if not _activate_key(key, enable=False, console=console):
|
|
console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]")
|
|
return
|
|
_toggle_plugin_toolset(key, enable=False, toolset_key=toolset_key)
|
|
from hermes_cli.plugins_activation import gateway_reload_hint, reload_running_gateway
|
|
console.print(f"[yellow]⊘[/yellow] Plugin [bold]{key}[/bold] disabled.")
|
|
console.print(f"[dim]{gateway_reload_hint(reload_running_gateway(), key)}[/dim]")
|
|
|
|
|
|
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 _default_on(dir_path, source: str) -> bool:
|
|
"""True when a plugin is active without a ``plugins.enabled`` entry (portable ``plugin.json``
|
|
packages have no kind, so never). Bundled default-on kinds always; a user model provider only
|
|
where providers/ discovery loads it (``providers._scan_home_layer``): a
|
|
``plugins/model-providers/<name>/`` child whose kind is model-provider, or a flat
|
|
``plugins/<name>/`` child declaring exactly ``kind: model-provider``. Discovery imports any other
|
|
``model-providers/`` child too, but nothing calls its ``register(ctx)``, so it is not on.
|
|
|
|
Entry-point rows store ``module:attr`` in the path slot. That string is not a directory;
|
|
opening it as one is WinError 123 on Windows and aborts the whole plugin list.
|
|
"""
|
|
path = Path(dir_path)
|
|
if not path.is_dir():
|
|
return False
|
|
if source != "bundled":
|
|
from providers import _declares_model_provider_kind
|
|
root = _plugins_dir()
|
|
if path.name.startswith(("_", ".")) or path.parent not in (root, root / "model-providers"):
|
|
return False
|
|
if path.parent == root:
|
|
return _declares_model_provider_kind(path)
|
|
manifest_file = _native_manifest_file(path)
|
|
if manifest_file is None:
|
|
return False
|
|
try:
|
|
kind = str(_load_yaml_manifest(manifest_file).get("kind", "standalone")).strip().lower()
|
|
except Exception:
|
|
return False
|
|
return kind == "model-provider" or (source == "bundled" and kind in _BUNDLED_DEFAULT_ON_KINDS)
|
|
|
|
|
|
def _scan_level(base: Path, source: str, skip_names: set, prefix: str, depth: int, seen: dict) -> None:
|
|
"""Recursive directory scan matching PluginManager._scan_directory_level."""
|
|
if not base.is_dir():
|
|
return
|
|
try:
|
|
children = sorted(base.iterdir())
|
|
except OSError as exc:
|
|
logger.warning("Skipping unreadable plugin directory %s: %s", base, exc)
|
|
return
|
|
for d in children:
|
|
try:
|
|
if not d.is_dir() or (depth == 0 and skip_names and d.name in skip_names):
|
|
continue
|
|
info = _read_manifest_info(d, prefix)
|
|
except (OSError, PluginOperationError) as exc:
|
|
# The PM declaration reader wraps unreadable manifests for install callers;
|
|
# listing still skips them rather than hiding every other plugin.
|
|
logger.warning("Skipping unreadable plugin directory %s: %s", d, exc)
|
|
continue
|
|
if info is None:
|
|
if depth == 0:
|
|
_scan_level(d, source, set(), f"{prefix}/{d.name}" if prefix else d.name, 1, seen)
|
|
continue
|
|
name, version, description, key = info
|
|
if key in seen and source == "bundled":
|
|
continue
|
|
src_label = "git" if source == "user" and (d / ".git").exists() else source
|
|
seen[key] = (name, version, description, src_label, d, key)
|
|
|
|
|
|
def _discover_all_plugins() -> list:
|
|
"""``(name, version, description, source, dir_path, key)`` for every plugin the loader sees,
|
|
in ``PluginManager.discover_and_load`` order: bundled, user, then entry points — which never
|
|
displace a directory plugin of the same key (see ``PluginManager._discover_and_load_inner``)."""
|
|
seen: dict = {}
|
|
# memory/, context_engine/, computer_use/ 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", "computer_use", "model-providers"}),
|
|
(_plugins_dir(), "user", set()),
|
|
):
|
|
_scan_level(base, source, skip, "", 0, seen)
|
|
# Entry-point plugins are installed as Python packages, so they have no plugin directory.
|
|
for m in discover_entrypoint_manifests():
|
|
seen.setdefault(m.name, (m.name, m.version, m.description, "entrypoint", m.path, m.name))
|
|
return list(seen.values())
|
|
|
|
|
|
def _category_active_names() -> set:
|
|
"""Provider names switched on through ``<category>.provider`` config rather than
|
|
``plugins.enabled`` (the live memory provider), so status never calls them "not enabled"."""
|
|
return {n for n in (_get_current_memory_provider(),) if n}
|
|
|
|
|
|
def _plugin_status(name: str, enabled: set, disabled: set, key: str = "", *, source: str = "",
|
|
dir_path=None, active: frozenset | set = frozenset()) -> str:
|
|
"""User-facing activation state for a plugin name or key. Mirrors ``gate_manifest``: an explicit
|
|
disable wins, then the allow-list, then the activations that need no list entry — bundled
|
|
backends/platforms and model providers from any source (*source* + *dir_path*) and category-selected providers
|
|
(*active*, see :func:`_category_active_names`)."""
|
|
names = {name, key}
|
|
if names & disabled:
|
|
return "disabled"
|
|
if names & enabled or names & active:
|
|
return "enabled"
|
|
if dir_path is not None and _default_on(dir_path, source):
|
|
return "enabled"
|
|
return "not enabled"
|
|
|
|
|
|
# ── Provider category config accessors ──────────────────────────────────────────────────────
|
|
|
|
|
|
# memory.provider ("" = built-in) and context.engine config accessors.
|
|
_get_current_memory_provider = functools.partial(_config_str, "memory", "provider", default="")
|
|
_get_current_context_engine = functools.partial(_config_str, "context", "engine", default="compressor")
|
|
_save_memory_provider = functools.partial(_write_config_value, "memory", "provider")
|
|
_save_context_engine = functools.partial(_write_config_value, "context", "engine")
|
|
|
|
|
|
def _get_plugin_toolset_key(name: str) -> Optional[str]:
|
|
"""Toolset key a plugin registers its tools under, or None: from the live registry (plugin
|
|
already loaded), else from ``provides_tools`` in plugin.yaml looked up in the registry."""
|
|
try:
|
|
from tools.registry import registry
|
|
except Exception:
|
|
return None
|
|
|
|
def _first_toolset(tool_names) -> Optional[str]:
|
|
return next((e.toolset for t in tool_names if (e := registry.get_entry(t)) and e.toolset), None)
|
|
|
|
def _from_loaded_plugin() -> Optional[str]:
|
|
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
|
discover_plugins() # idempotent — ensures plugins are loaded
|
|
for _key, loaded in get_plugin_manager()._plugins.items():
|
|
if loaded.manifest.name == name or _key == name:
|
|
return _first_toolset(loaded.tools_registered)
|
|
return None
|
|
|
|
def _from_manifest_on_disk() -> Optional[str]:
|
|
from hermes_cli.plugins import get_bundled_plugins_dir
|
|
return next((
|
|
toolset for base in (get_bundled_plugins_dir(), _plugins_dir())
|
|
if base.is_dir() and (base / name).is_dir()
|
|
and (toolset := _first_toolset(_read_manifest(base / name).get("provides_tools") or []))
|
|
), None)
|
|
|
|
for lookup in (_from_loaded_plugin, _from_manifest_on_disk):
|
|
try:
|
|
if toolset := lookup():
|
|
return toolset
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _saved_list_toolset_key(name: str) -> Optional[str]:
|
|
"""The plugin's toolset key, or None without loading any plugin when no platform has a SAVED
|
|
``platform_toolsets`` list (the only lists :func:`_toggle_plugin_toolset` edits)."""
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.toolset_validation import parse_platform_toolsets_value
|
|
saved = load_config().get("platform_toolsets")
|
|
if not isinstance(saved, dict) or all(parse_platform_toolsets_value(v) is None for v in saved.values()):
|
|
return None
|
|
return _get_plugin_toolset_key(name)
|
|
|
|
|
|
def _toggle_plugin_toolset(name: str, *, enable: bool, toolset_key: Optional[str] = None) -> None:
|
|
"""Add/remove a plugin's toolset in every SAVED ``platform_toolsets`` list (no-op when the plugin
|
|
provides no tools). Platforms with no saved list are left alone: a plugin toolset is already on
|
|
there by default, and seeding ``[<plugin_ts>]`` would replace the core composite and strip every
|
|
built-in tool. The key resolves only while the plugin is loaded, so enable calls this after the
|
|
plugin loads and disable resolves *toolset_key* before the disable is written."""
|
|
toolset_key = toolset_key or _saved_list_toolset_key(name)
|
|
if not toolset_key:
|
|
return
|
|
from hermes_cli.config import load_config, save_config
|
|
from hermes_cli.toolset_validation import parse_platform_toolsets_value
|
|
config = load_config()
|
|
platform_toolsets = _child_dict(config, "platform_toolsets")
|
|
changed = False
|
|
for platform, raw in list(platform_toolsets.items()):
|
|
# A list-literal string (older `hermes config set`) is the user's real selection; toggling
|
|
# it re-saves the entry as a proper list so the string never persists.
|
|
ts_list = parse_platform_toolsets_value(raw)
|
|
if ts_list is not None and enable != (toolset_key in ts_list):
|
|
(ts_list.append if enable else ts_list.remove)(toolset_key)
|
|
platform_toolsets[platform] = ts_list
|
|
changed = True
|
|
if changed:
|
|
save_config(config)
|
|
|
|
|
|
def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]:
|
|
"""Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists). *name* may be the
|
|
canonical key, the manifest name or a unique bare leaf; the canonical key is what gets written
|
|
(the loader never matches a bare leaf, and a stale key in ``disabled`` outranks a manifest-name
|
|
entry in ``enabled`` — so writing the raw identifier reported success while the plugin stayed off)."""
|
|
key = _resolve_plugin_key(name)
|
|
if key is None:
|
|
return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."}
|
|
from hermes_cli.plugins_admission import AdmissionRefused
|
|
|
|
toolset_key = None if enabled else _saved_list_toolset_key(key)
|
|
try:
|
|
changed = _activate_key(key, enable=enabled)
|
|
except AdmissionRefused as exc:
|
|
return {
|
|
"ok": False,
|
|
"error": str(exc),
|
|
"name": key,
|
|
"unchanged": True,
|
|
"restart_required": False,
|
|
}
|
|
if changed and enabled:
|
|
# Load it now, here and in the running gateway; ``activation`` tells the UI what is live vs
|
|
# deferred, and ``restart_required`` only survives when no gateway answered (#87770).
|
|
from hermes_cli.plugins_activation import activate_plugin_now
|
|
result = {"ok": True, "name": key, "unchanged": False, **activate_plugin_now(key)}
|
|
# Only now is the plugin loaded here, so its toolset key resolves: a backend that scanned
|
|
# while the plugin was off held a placeholder and the toolset was never re-added.
|
|
_toggle_plugin_toolset(key, enable=True)
|
|
return result
|
|
if changed:
|
|
_toggle_plugin_toolset(key, enable=False, toolset_key=toolset_key)
|
|
# Disable is config-only: there is no un-wire primitive, so a running gateway keeps the plugin's
|
|
# handlers until restart and every UI says so — #71595/#54941.
|
|
return {"ok": True, "name": key, "unchanged": not changed, "restart_required": changed}
|
|
|
|
|
|
def _user_installed_plugin_dir(name: str) -> Optional[Path]:
|
|
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists, else the user install
|
|
:func:`_find_plugin_entry` resolves *name* to (its manifest or catalog name)."""
|
|
try:
|
|
target = _sanitize_plugin_name(name, _plugins_dir(), allow_subdir=True)
|
|
except ValueError:
|
|
return None
|
|
return target if target.is_dir() else _resolved_user_plugin_dir(name)
|
|
|
|
|
|
def _resolved_user_plugin_dir(name: str) -> Optional[Path]:
|
|
"""Dir of the user install :func:`_find_plugin_entry` resolves *name* to (manifest or catalog name)."""
|
|
entry = _find_plugin_entry(name)
|
|
found = Path(str(entry[4])) if entry and entry[3] in ("user", "git") and entry[4] else None
|
|
return found if found is not None and found.is_dir() else None
|
|
|
|
|
|
def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
|
|
"""Validate one plugin through runtime discovery and registration."""
|
|
from hermes_cli.plugin_dev import doctor_plugin
|
|
report = doctor_plugin(target)
|
|
_console().print(report.format_text())
|
|
if ci and not report.ok:
|
|
raise SystemExit(1)
|
|
|
|
|
|
def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]:
|
|
"""Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given)."""
|
|
return True if getattr(args, yes_attr, False) else (False if getattr(args, no_attr, False) else None)
|
|
|
|
|
|
def _catalog():
|
|
from hermes_cli import plugins_cmd_catalog
|
|
return plugins_cmd_catalog
|
|
|
|
|
|
def _action_update(args):
|
|
if args.name:
|
|
if getattr(args, "yes", False):
|
|
_fail(_console(), "[red]Error:[/red] --yes only applies to `hermes plugins update` with no name.")
|
|
cmd_update(args.name, force=getattr(args, "force", False))
|
|
return
|
|
if getattr(args, "force", False):
|
|
_fail(_console(), "[red]Error:[/red] --force applies to one named plugin: `hermes plugins update <name> --force`.")
|
|
from hermes_cli.plugins_cmd_update_all import cmd_update_all
|
|
cmd_update_all(yes=getattr(args, "yes", False))
|
|
|
|
|
|
def _action_pack(args):
|
|
from hermes_cli.plugin_packs import pack_command
|
|
pack_command(args)
|
|
|
|
|
|
# Tri-state flags: neither --x nor --no-x given == None == interactive prompt.
|
|
_PLUGIN_ACTIONS = {
|
|
"install": lambda args: cmd_install(
|
|
args.identifier,
|
|
force=getattr(args, "force", False),
|
|
enable=_tri_state_flag(args, "enable", "no_enable"),
|
|
ref=getattr(args, "ref", None),
|
|
allow_removed=getattr(args, "allow_removed", False),
|
|
no_deps=getattr(args, "no_deps", False),
|
|
yes_deps=getattr(args, "yes_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": _action_update,
|
|
"adopt": lambda args: cmd_adopt(args.name),
|
|
"trust-update-url": lambda args: cmd_trust_update_url(args.name),
|
|
"check-updates": lambda args: cmd_check_updates(args),
|
|
"check": lambda args: cmd_check_updates(args),
|
|
"remove": lambda args: cmd_remove(args.name),
|
|
"rm": lambda args: cmd_remove(args.name),
|
|
"uninstall": lambda args: cmd_remove(args.name),
|
|
"enable": lambda args: cmd_enable(
|
|
args.name,
|
|
allow_tool_override=_tri_state_flag(args, "allow_tool_override", "no_allow_tool_override")),
|
|
"disable": lambda args: cmd_disable(args.name),
|
|
"capabilities": lambda args: cmd_capabilities(getattr(args, "name", None)),
|
|
"list": lambda args: cmd_list(args),
|
|
"ls": lambda args: cmd_list(args),
|
|
"doctor": lambda args: cmd_plugin_doctor(args.target, ci=getattr(args, "ci", False)),
|
|
"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)
|