diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index f8e827f5fc..dd93ef513e 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1,29 +1,57 @@ -"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.""" +"""``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_.py`` +sibling (install, update, remove, git, capabilities, toggle, listing, catalog).""" from __future__ import annotations import functools -import json import logging import os -import re import shutil -import subprocess import sys -import tempfile -import threading -import urllib.parse -from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, NoReturn, Optional +from typing import Any, NoReturn, Optional from hermes_constants import get_hermes_home -from hermes_cli._subprocess_compat import noninteractive_git_env -from hermes_cli.cli_output import line_input from hermes_cli.config import cfg_get from hermes_cli.plugin_capabilities import _child_dict -from hermes_cli.secret_prompt import masked_secret_prompt -from utils import atomic_write_text, rmtree_readonly +# Tests patch these two on the facade; the install/remove siblings read them through it. +from hermes_cli.secret_prompt import masked_secret_prompt # noqa: F401 +from utils import rmtree_readonly # noqa: F401 + +# Topical siblings. The facade re-exports what other modules, tests and the old updater import from +# ``hermes_cli.plugins_cmd``; sibling bodies read those names back through the facade at call time. +from hermes_cli.plugins_cmd_capabilities import ( # noqa: F401 + _declared_capabilities_for_key, _declared_capabilities_from_manifest, _resolve_tool_override_grant, + _run_capability_consent, cmd_capabilities, +) +from hermes_cli.plugins_cmd_git import ( # noqa: F401 + _EXACT_COMMIT_RE, _canonical_source, _checkout_exact_revision, _clone_plugin_repo, _git_head_revision, + _git_or_raise, _git_pull_plugin_dir, _git_resolve_commit, _normalize_exact_revision, _pin_annotation, + _read_install_metadata, _run_plugin_git, _safe_git_error, _scrub_cloned_origin, _update_install_record, + _write_install_metadata, pinned_revision, +) +from hermes_cli.plugins_cmd_install import ( # noqa: F401 + _check_manifest_version, _consent_python_deps, _display_after_install, _install_plugin_core, + _install_plugin_python_deps, _prompt_plugin_env_vars, _python_dependency_summary, + _read_manifest_for_install, cmd_install, dashboard_install_plugin, +) +from hermes_cli.plugins_cmd_listing import ( # noqa: F401 + _filter_plugin_entries, cmd_compat, cmd_list, cmd_show, +) +from hermes_cli.plugins_cmd_remove import ( # noqa: F401 + _remove_plugin_core, cmd_remove, dashboard_remove_user_plugin, +) +from hermes_cli.plugins_cmd_toggle import ( # noqa: F401 + _discover_context_engines, _persist_plugin_selection, _provider_categories, _run_composite_fallback, + cmd_toggle, +) +from hermes_cli.plugins_cmd_update import ( # noqa: F401 + _clear_plugin_bytecode, cmd_adopt, cmd_check_updates, cmd_trust_update_url, cmd_update, + dashboard_update_user_plugin, +) logger = logging.getLogger(__name__) _DEFAULT_CLONE_TIMEOUT_SECONDS = 300 @@ -362,160 +390,6 @@ def _missing_env_specs(manifest: dict) -> list[dict]: return [s for s in env_specs if not get_env_value(s["name"])] -def _install_plugin_python_deps( - manifest: dict, target: Path, console -) -> tuple[bool, Optional[str]]: - """Consent gate for plugin python deps (settled 2026-09-02; C13 rework). - - Node sidecar: y/n prompt → ``npm ci`` into the plugin's OWN - node_modules (separate question, failure never blocks enable). - Python deps: NO resolution here — the resolve runs inside the ONE - admission transaction when the enable commits - (:func:`_admit_and_save_plugin_sets`), so the environment and the - config always change together or not at all. Returns (consented, - reason): consented=True when the user accepted (or no prompt was - needed); a decline/skip returns False and NOTHING is installed. - Never raises — the caller keeps the plugin installed-but-disabled. - """ - from pm.plugin_declarations import read_python_declaration - - try: - declaration = read_python_declaration(target) - deps = declaration.install_requirements - except Exception as exc: - return False, f"invalid Python dependency declaration: {exc}" - has_python = declaration.is_member - has_package_json = (target / "package.json").is_file() - if not has_python and not has_package_json: - return True, None # no declared deps at all - - # Node sidecar (package.json): the npm ci executor — separate consent - # question, same try-then-enable posture. Failure never blocks the - # python path below. - node_reason = None - if has_package_json: - console.print(f"\n[bold]{manifest.get('name', 'this plugin')}[/bold] declares Node dependencies (package.json).") - if sys.stdin.isatty() and sys.stdout.isatty(): - try: - node_answer = input( - " Install them into the plugin's own node_modules now? [y/N]: " - ).strip().lower() - except (EOFError, KeyboardInterrupt): - node_answer = "" - else: - node_answer = "" - if node_answer in {"y", "yes"}: - from pm.workspace import install_node_sidecar - - node_reason = install_node_sidecar(target, explicit=True) - if node_reason: - console.print(f"[yellow]⚠[/yellow] Node deps: {node_reason}") - else: - console.print("[dim]Skipped Node deps — run `hermes plugins install` again to retry.[/dim]\n") - - if not has_python: - return True, None - return _consent_python_deps(manifest.get("name", "this plugin"), deps, console) - - -def _consent_python_deps(plugin_name: str, deps: tuple[str, ...], console) -> tuple[bool, Optional[str]]: - """The y/N gate for Python deps entering the shared environment — install, - reinstall AND an update that declares new ones all pass through here. - Returns (consented, reason); never raises.""" - console.print( - f"\n[bold]{plugin_name}[/bold] declares Python dependencies:" - ) - if deps: - for dep in deps: - console.print(f" - {dep}") - else: - console.print(" - (declared in its pyproject.toml)") - - # A decline or non-interactive invocation leaves the new plugin disabled. - if not (sys.stdin.isatty() and sys.stdout.isatty()): - console.print( - "[dim]Non-interactive install — skipping dependency install. " - "Run `hermes plugins enable` when ready to prepare them.[/dim]\n" - ) - return False, "dependency install skipped (non-interactive)" - try: - answer = input( - " Prepare these with Hermes through PM now? [y/N]: " - ).strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - if answer not in {"y", "yes"}: - console.print( - "[dim]Skipped — run `hermes plugins enable` when ready " - "to prepare them.[/dim]\n" - ) - return False, "dependency install declined" - - # Consent only — the python-deps resolution itself runs inside the ONE - # admission transaction at enable-commit time (C13): env + config move - # together or not at all. - return True, None - - -def _python_dependency_summary(target: Path, warnings: list[str]) -> list[str]: - """Read dashboard dependency details; publication and admission own installation.""" - from pm.plugin_declarations import read_python_declaration - - try: - return list(read_python_declaration(target).install_requirements) - except Exception as exc: - warnings.append(f"Could not read Python dependencies: {exc}") - return [] - - -def _prompt_plugin_env_vars(manifest: dict, console) -> None: - """Prompt for unset ``requires_env`` variables and save the answers to the user's ``.env``.""" - missing = _missing_env_specs(manifest) - if not missing: - return - from hermes_cli.config import save_env_value - from hermes_constants import display_hermes_home - plugin_name = manifest.get("name", "this plugin") - console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n") - for spec in missing: - name = spec["name"] - desc = spec.get("description", "") - url = spec.get("url", "") - console.print(f" {name}" + (f" — {desc}" if desc else "")) - if url: - console.print(f" [dim]Get yours at: {url}[/dim]") - try: - value = (masked_secret_prompt if spec.get("secret", False) else line_input)(f" {name}: ").strip() - except (EOFError, KeyboardInterrupt): - console.print(f"\n[dim] Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]") - return - - if value: - save_env_value(name, value) - os.environ[name] = value - console.print(f" [green]✓[/green] Saved to {display_hermes_home()}/.env") - else: - console.print(f" [dim] Skipped (set {name} in {display_hermes_home()}/.env later)[/dim]") - - console.print() - - -def _display_after_install(plugin_dir: Path, identifier: str) -> None: - """Show after-install.md if it exists, otherwise a default message.""" - from rich.markdown import Markdown - from rich.panel import Panel - console = _console() - after_install = plugin_dir / "after-install.md" - if after_install.exists(): - body, title = Markdown(after_install.read_text(encoding="utf-8-sig")), None - else: - body = f"[green bold]Plugin installed:[/] {identifier}\n[dim]Location:[/] {plugin_dir}" - title = "✓ Installed" - console.print() - console.print(Panel(body, border_style="green", title=title, expand=False)) - console.print() - - def _clone_failure_message(git_url: str, git_error: str) -> str: """Plain-words clone failure: what to check (address, network, private repo), raw git text last. @@ -548,736 +422,6 @@ def _unknown_plugin_message(name: str, *, downloaded_only: bool = False) -> str: "To add one: `hermes plugins install `.") -# ── Install metadata + git plumbing ───────────────────────────────────────────────────────── - -_EXACT_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$") - - -def _install_metadata_path() -> Path: - return get_hermes_home() / "plugins" / ".install-metadata.json" - - -def _read_install_metadata() -> dict[str, dict[str, object]]: - """Read profile-local, non-secret plugin source metadata from disk.""" - path = _install_metadata_path() - if not path.exists(): - return {} - try: - value = json.loads(path.read_text(encoding="utf-8-sig")) - 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-") - - -_INSTALL_METADATA_LOCK_HOLDER = threading.local() - - -@contextmanager -def _install_metadata_lock(): - """Serialize read-modify-write of the sidecar across threads and processes. Installs overlap (the - Desktop install card runs its rows a second apart); each held a snapshot read before its clone, so - the later write dropped the earlier plugin's record.""" - from hermes_cli.auth import _file_lock - - path = _install_metadata_path() - with _file_lock(path.with_name(f"{path.name}.lock"), _INSTALL_METADATA_LOCK_HOLDER, 10.0, - "Timed out waiting for the plugin install metadata lock"): - yield - - -def _update_install_record(name: str, update: Callable[[Optional[dict]], Optional[dict]]) -> None: - """Rewrite one plugin's record in the CURRENT sidecar, under the lock. *update* maps the current - record (None when absent) to the new one (None removes it); every other record is re-read here, - never carried over from a caller's earlier snapshot.""" - with _install_metadata_lock(): - metadata = _read_install_metadata() - record = update(metadata.get(name)) - if record is None: - if name not in metadata: - return - del metadata[name] - else: - metadata[name] = record - _write_install_metadata(metadata) - - -def pinned_revision(name: str, metadata: Optional[dict] = None) -> Optional[str]: - """Full SHA a ``--ref`` install of *name* is pinned to, else ``None``.""" - entry = (metadata if metadata is not None else _read_install_metadata()).get(name) - if isinstance(entry, dict) and entry.get("pinned") is True and isinstance(entry.get("revision"), str): - return entry["revision"] - return None - - -def _pin_annotation(name: str, metadata: dict) -> Optional[str]: - sha = pinned_revision(name, metadata) - return f"git pinned@{sha[:8]}" if sha else None - - -def _normalize_exact_revision(ref: str) -> str: - """Lowercase a full 40-hex commit SHA; anything else is a PluginOperationError.""" - if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref): - raise PluginOperationError("--ref must be a full 40-character commit SHA.") - return ref.lower() - - -def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -> str: - """Diagnosable Git output without echoing embedded credentials.""" - from agent.redact import redact_sensitive_text - error = (result.stderr or result.stdout or "").strip() - if source_url: - error = error.replace(source_url, _scrub_git_url(source_url)) - return redact_sensitive_text(error) - - -def _git_or_raise( - git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = "", - auth_url: str = "", -) -> subprocess.CompletedProcess: - """Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error).""" - result = _run_plugin_git(git_exe, repo, *args, timeout=timeout, auth_url=auth_url) - if result.returncode != 0: - raise PluginOperationError(failure_prefix + _safe_git_error(result, source_url)) - return result - - -def _git_head_revision(repo: Path, git_exe: str) -> str: - return _git_or_raise( - git_exe, repo, "rev-parse", "HEAD", timeout=15, - failure_prefix="Could not determine installed Git revision:\n", - ).stdout.strip().lower() - - -def _git_resolve_commit(repo: Path, git_exe: str, revision: str) -> str: - """The COMMIT a revision names, peeling annotated tags. - - A catalog pin is 40 hex, but that does not make it a commit: a tag object - has a sha of its own, and a pin recorded as `git rev-parse ` names the - tag object, not the commit it points at. Git detaches at the commit, so - comparing HEAD against the tag object's sha refuses a correct checkout - (and the catalog installer then cannot install that entry at all). Peeling - first keeps the guard — HEAD must still BE that commit — while admitting - the pins authors actually publish. Returns `revision` unchanged when it - resolves to nothing, so the mismatch guard below still fires. - """ - try: - result = _run_plugin_git( - git_exe, repo, "rev-parse", "--verify", "--quiet", f"{revision}^{{commit}}", timeout=15, - ) - except (OSError, subprocess.TimeoutExpired): - return revision - resolved = result.stdout.strip().lower() - return resolved if result.returncode == 0 and resolved else revision - - -def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None: - """Fetch and detach at one immutable commit, then verify the resulting HEAD. The checkout is - a network verb too: in a partial (subdirectory) clone it downloads the file contents.""" - timeout = _clone_timeout_seconds() - for verb, args, failure_prefix in ( - ("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"), - ("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"), - ): - try: - _git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url, - auth_url=source_url, timeout=timeout) - except subprocess.TimeoutExpired as exc: - raise PluginOperationError( - f"Git {verb} of commit '{revision}' timed out after {timeout} seconds. {_CLONE_TIMEOUT_HINT}") from exc - actual = _git_head_revision(repo, git_exe) - if actual != _git_resolve_commit(repo, git_exe, revision): - raise PluginOperationError( - f"Checked-out revision '{actual}' does not match requested commit '{revision}'.") - - -def _scrub_git_url(git_url: str) -> str: - """Strip credentials and query/fragment data from an HTTP Git URL.""" - parsed = urllib.parse.urlsplit(git_url) - if parsed.scheme in {"http", "https"} and parsed.hostname: - host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname - if parsed.port is not None: - host = f"{host}:{parsed.port}" - return urllib.parse.urlunsplit((parsed.scheme, host, parsed.path, "", "")) - return git_url - - -def _canonical_source(git_url: str, subdir: Optional[str]) -> str: - scrubbed = _scrub_git_url(git_url) - return f"{scrubbed}#{subdir}" if subdir else scrubbed - - -def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None: - """Ensure credentials used for cloning do not survive in ``.git/config``.""" - scrubbed = _scrub_git_url(git_url) - if scrubbed != git_url: - _git_or_raise( - git_exe, repo, "remote", "set-url", "origin", scrubbed, timeout=15, - failure_prefix="Could not sanitize installed Git remote:\n", source_url=git_url) - - -def _check_manifest_version(manifest: dict, plugin_name: str) -> None: - """Reject manifests declaring a newer ``manifest_version`` than this installer supports.""" - from pm.plugin_declarations import manifest_version_error - - reason = manifest_version_error(manifest, plugin_name) - if reason: - from hermes_cli.config import recommended_update_command - raise PluginOperationError(f"{reason} Run {recommended_update_command()} to update Hermes.") - - -def _restrict_checkout_to_subdir(repo: Path, git_exe: str, subdir: str) -> None: - """Sparse-check-out only *subdir*. Written as the classic ``info/sparse-checkout`` file - rather than ``git sparse-checkout set`` so older Git clients work too.""" - _git_or_raise(git_exe, repo, "config", "core.sparseCheckout", "true", timeout=15, - failure_prefix="Could not enable sparse checkout:\n") - pattern_file = repo / ".git" / "info" / "sparse-checkout" - pattern_file.parent.mkdir(parents=True, exist_ok=True) - escaped = re.sub(r"([\\*?\[])", r"\\\1", subdir.strip("/")) - pattern_file.write_text(f"/{escaped}/\n", encoding="utf-8") - - -def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str], - subdir: Optional[str] = None) -> 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. - - A *subdir* install is a blobless clone with a sparse checkout of that subdirectory: a plugin - living in a monorepo (Hindsight: 170 MB at depth 1, 2 MB for its plugin folder) otherwise - downloads every file in the repository, which times out on slow connections.""" - git_exe = _resolve_git_executable() - if not git_exe: - raise PluginOperationError("git is not installed or not in PATH.") - clone_timeout = _clone_timeout_seconds() - partial = ["--filter=blob:none"] if subdir else [] - no_checkout = ["--no-checkout"] if revision or subdir else [] - clone_args = ["clone", "--depth", "1", *partial, *no_checkout, git_url, str(tmp_clone)] - try: - result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url, - timeout=clone_timeout) - except FileNotFoundError as e: - raise PluginOperationError("git is not installed or not in PATH.") from e - except subprocess.TimeoutExpired as e: - raise PluginOperationError(f"Git clone timed out after {clone_timeout} seconds. {_CLONE_TIMEOUT_HINT}") from e - if result.returncode != 0: - raise PluginOperationError(_clone_failure_message(git_url, _safe_git_error(result, git_url))) - _scrub_cloned_origin(tmp_clone, git_exe, git_url) - if subdir: - _restrict_checkout_to_subdir(tmp_clone, git_exe, subdir) - if revision: - _checkout_exact_revision(tmp_clone, git_exe, revision, source_url=git_url) - elif subdir: - try: - _git_or_raise(git_exe, tmp_clone, "checkout", "HEAD", timeout=clone_timeout, source_url=git_url, - auth_url=git_url, failure_prefix="Git checkout of the plugin subdirectory failed:\n") - except subprocess.TimeoutExpired as e: - raise PluginOperationError( - f"Git checkout timed out after {clone_timeout} seconds. {_CLONE_TIMEOUT_HINT}") from e - return _git_head_revision(tmp_clone, git_exe) - - -def _read_manifest_for_install(plugin_dir: Path) -> dict: - """A candidate's unreadable or malformed manifest must stop publication.""" - native = _native_manifest_file(plugin_dir) - if native is not None: - try: - manifest = _load_yaml_manifest(native) - except Exception as exc: - raise PluginOperationError(f"Could not read plugin manifest {native}: {exc}") from exc - if not isinstance(manifest, dict): - raise PluginOperationError(f"Plugin manifest must be a mapping: {native}") - return manifest - if not _has_portable_manifest(plugin_dir): - return {} - try: - from hermes_cli.agent_plugins import read_agent_plugin_manifest - manifest, diagnostics = read_agent_plugin_manifest(plugin_dir) - except Exception as exc: - raise PluginOperationError(f"Portable plugin manifest validation failed: {exc}") from exc - for diagnostic in diagnostics: - logger.warning("Agent Plugin install: %s", diagnostic.message) - return manifest - - -def _probe_readable(path: Path) -> None: - """Raise ``OSError`` unless *path* can actually be listed (dir) or opened for reading (file).""" - if path.is_dir(): - os.listdir(path) - else: - with open(path, "rb"): - pass - - -def _ensure_tree_readable(root: Path, plugins_dir: Path) -> None: - """Refuse to ship a tree Hermes cannot read back. A clone can land unreadable (Windows ACL - inheritance -> WinError 5, a mode-000 file) and discovery would then skip the plugin forever - (#111804); repair ``u+rX`` where the OS supports it, otherwise fail before anything moves.""" - paths = [root] - for dirpath, dirnames, filenames in os.walk(root): - paths.extend(Path(dirpath) / name for name in (*dirnames, *filenames)) - for path in paths: - try: - _probe_readable(path) - continue - except OSError: - if os.name != "nt": # chmod only toggles the read-only bit on Windows; ACLs need icacls - try: - os.chmod(path, os.stat(path).st_mode | (0o500 if path.is_dir() else 0o400)) - except OSError: - pass - try: - _probe_readable(path) - except OSError as exc: - fix = (f'icacls "{plugins_dir}" /grant:r "%USERNAME%":(OI)(CI)F /T' if os.name == "nt" - else f"chmod -R u+rX {plugins_dir}") - raise PluginOperationError( - f"Installed file {path.relative_to(root)} is not readable ({exc.strerror or exc}); " - f"nothing was installed. Fix permissions on {plugins_dir} (e.g. `{fix}`) and retry." - ) from exc - - -def _refuse_unavailable_portable_plugin(plugin_name: str, tree: Path) -> None: - if not (tree / "plugin.json").is_file(): - return - from hermes_cli.agent_plugins import load_agent_plugin - from hermes_platform.resolver.availability import availability - - try: - package = load_agent_plugin(tree, tree.parent / ".hermes-install-data") - except ValueError as exc: - raise PluginOperationError(f"Plugin '{plugin_name}' is unavailable: {exc}.") from exc - for server_name, server_decl in package.server_declarations.items(): - result = availability(server_decl.declaration) - if result.offerable: - continue - found = f", found version {result.version}" if result.version else "" - raise PluginOperationError( - f"Plugin '{plugin_name}' server '{server_name}' is unavailable: {result.state}{found}." - ) - - -def _install_plugin_core( - identifier: str, - *, - force: bool, - ref: Optional[str] = None, - scan_decision_cb=None, - reviewed_pin: Optional[str] = None, - python_deps: bool = True, - catalog: Optional[dict] = None, - allow_removed: bool = False, - before_swap=None, -) -> tuple[Path, dict, str]: - """Clone a Git plugin and atomically record its source and exact revision. - - *reviewed_pin* is the curated-catalog sha for this install; the scan trusts the tree - only when the checked-out revision is exactly that sha (an annotated-tag pin is peeled to - its commit first — HEAD can only ever be the commit). *python_deps* False refuses active - replacements; it never bypasses PM dependency admission. *catalog* - (``{"name", "repo", "tier", "pin"}``) is recorded on the install-metadata record with the - checked-out sha — provenance lives OUTSIDE the plugin tree, so a repo cannot forge it; - its ``pin`` is kept only when the checkout satisfies it (a ``--ref`` install is off-pin). - *allow_removed* records that the user knowingly bypassed the kill list. - *before_swap(manifest, tree)* runs on the validated clone before anything moves into place - and may raise :class:`PluginOperationError` to abort (re-pin consent).""" - requested_revision = _normalize_exact_revision(ref) if ref is not None else None - try: - git_url, subdir = _resolve_git_url(identifier) - except ValueError as e: - raise PluginOperationError(str(e)) from e - - plugins_dir = _plugins_dir() - source = _canonical_source(git_url, subdir) - old_metadata = _read_install_metadata() - - # Reinstalling the same pinned source retains its pin, even if its plugin - # directory was manually removed. Moving a pin requires an explicit --ref. - if requested_revision is None: - pins = [e for e in old_metadata.values() if e.get("source") == source and e.get("pinned") is True] - if len(pins) == 1 and isinstance(pins[0].get("revision"), str): - requested_revision = _normalize_exact_revision(pins[0]["revision"]) - - with tempfile.TemporaryDirectory(prefix=".install-", dir=plugins_dir) as tmp: - tmp_clone = Path(tmp) / "plugin" - installed_revision = _clone_plugin_repo(tmp_clone, git_url, requested_revision, subdir) - git_exe = _resolve_git_executable() - at_reviewed_pin = bool(reviewed_pin) and installed_revision == ( - _git_resolve_commit(tmp_clone, git_exe, reviewed_pin) if git_exe and reviewed_pin else reviewed_pin) - tmp_target = _resolve_subdir_within(tmp_clone, subdir) if subdir else tmp_clone - _ensure_tree_readable(tmp_target, plugins_dir) - manifest = _read_manifest_for_install(tmp_target) - plugin_name = manifest.get("name") or ( - subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url)) - try: - target = _sanitize_plugin_name(plugin_name, plugins_dir) - except ValueError as e: - raise PluginOperationError(str(e)) from e - _check_manifest_version(manifest, plugin_name) - # Scan BEFORE anything is moved into place; raises PluginScanBlocked when blocked. - _scan_plugin_tree(tmp_target, identifier, force=force, scan_decision_cb=scan_decision_cb, - reviewed_pin=at_reviewed_pin) - if not python_deps: - from pm.workspace import enabled_plugin_dirs - - if target.resolve() in enabled_plugin_dirs(installing=target): - raise PluginOperationError( - "--no-deps cannot replace an active plugin. Retry without --no-deps; " - "PM must prepare its dependencies before publication.") - _refuse_unavailable_portable_plugin(plugin_name, tmp_target) - if before_swap is not None: - before_swap(manifest, tmp_target) - - 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.") - - record: dict[str, object] = { - "pinned": requested_revision is not None, - "revision": installed_revision, - "source": source, - } - # Saved update_url tag (settled: claims vs provenance): the - # manifest's update_url is COPIED into the row at install. Check - # time compares manifest vs tag; a mismatch is needs-fixing and - # only `hermes plugins trust-update-url` moves the tag. - if manifest.get("update_url"): - from hermes_cli.plugins_updates import https_update_url - try: - record["update_url"] = https_update_url(manifest["update_url"]) - except ValueError as exc: - raise PluginOperationError(f"Plugin '{plugin_name}' {exc}") from exc - if catalog: - # ``sha`` = the commit checked out; ``pin`` = the reviewed catalog sha it satisfies (the - # annotated-tag object for a tag pin), empty when installed off-pin via ``--ref``. - record["catalog"] = { - **catalog, - "sha": installed_revision, - "pin": reviewed_pin if at_reviewed_pin else "", - } - from hermes_cli.plugins_cmd_catalog import write_catalog_sidecar_record - write_catalog_sidecar_record(tmp_target, catalog, installed_revision) - if allow_removed: - record["allow_removed"] = True - new_metadata = {**old_metadata, plugin_name: record} - from hermes_cli.plugins_transaction import publish_plugin - - try: - publish_plugin(tmp_target, target, old_metadata, new_metadata, require_consent=True) - except Exception as exc: - raise PluginOperationError(f"Plugin '{plugin_name}' was not published: {exc}") from exc - - if not _looks_like_plugin_dir(target): - logger.warning("%s has no plugin.yaml / __init__.py; may not be a valid plugin", plugin_name) - _copy_example_files(target, _console()) - installed_manifest = _read_manifest(target) - return target, installed_manifest, installed_manifest.get("name") or target.name - - -def cmd_install( - identifier: str, - force: bool = False, - enable: Optional[bool] = None, - ref: Optional[str] = None, - allow_removed: bool = False, - no_deps: bool = False, -) -> None: - """Install a plugin from the curated catalog (bare name), a Git URL, or owner/repo shorthand. - - A catalog hit installs the reviewed pinned SHA and records catalog membership in the shared install - metadata. An explicit different ``--ref`` is a custom pin. URLs/shorthand are custom sources. Every - install is checked against the catalog kill list unless *allow_removed*. - *enable* None prompts "Enable now? [y/N]"; True/False skip the prompt. - """ - from hermes_cli import plugins_cmd_catalog as catalog - console = _console() - entry = None - if catalog.looks_like_catalog_name(identifier): - entry = catalog.resolve_catalog_name(identifier, console) - identifier = entry.install_identifier - console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan] [dim]pinned @ {entry.sha[:8]}[/dim]") - console.print(catalog.entry_capability_summary(entry)) - else: - console.print("[yellow]Warning:[/yellow] custom (unreviewed) source — not from the Hermes catalog.") - if allow_removed: - console.print( - "[bold red]WARNING:[/bold red] [red]--allow-removed set — skipping the catalog kill-list check. " - "This plugin may have been removed for security reasons.[/red]") - - try: - git_url, _subdir = _resolve_git_url(identifier) - if not allow_removed: - catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ())) - except (ValueError, PluginOperationError) as e: - _fail(console, f"[red]Error:[/red] {e}") - if git_url.startswith(("http://", "file://")): - console.print( - "[yellow]Warning:[/yellow] Using insecure/local URL scheme. " - "Consider using https:// or git@ for production installs.") - - console.print(f"[dim]Cloning {git_url}{f' (subdir: {_subdir})' if _subdir else ''}...[/dim]") - - def _interactive_scan_decision(scan_result) -> bool: - """Prompt the user to accept a caution-verdict plugin.""" - from tools.plugin_guard import format_scan_report - console.print() - console.print("[yellow]⚠ Security scan flagged this plugin:[/yellow]") - console.print(format_scan_report(scan_result)) - return _is_tty() and _ask_yes(" Install anyway? Only continue if you trust the source. [y/N]: ") - - try: - if entry is not None: - target, installed_manifest, installed_name = catalog.install_catalog_entry( - entry, force=force, ref=ref, allow_removed=allow_removed, scan_decision_cb=_interactive_scan_decision, - python_deps=not no_deps) - else: - target, installed_manifest, installed_name = _install_plugin_core( - identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision, - python_deps=not no_deps, allow_removed=allow_removed) - except PluginOperationError as e: - _fail(console, f"[red]{'Blocked' if isinstance(e, PluginScanBlocked) else 'Error'}:[/red] {e}") - if not _looks_like_plugin_dir(target): - console.print( - f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml, " - f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.") - _prompt_plugin_env_vars(installed_manifest, console) - - from pm.workspace import enabled_plugin_dirs - - # Active replacements settled consent against the staged tree before PM - # prepared or published it. Do not present a second, ineffective veto. - already_active = target.resolve() in enabled_plugin_dirs() - should_enable = False if no_deps else enable - if no_deps: - console.print("[dim]--no-deps: skipping dependency consent; the plugin stays disabled.[/dim]") - if should_enable is None and not already_active: - should_enable = _is_tty() and _ask_yes(f" Enable '{installed_name}' now? [y/N]: ") - deps_ok, deps_reason = (True, None) - if should_enable and not already_active: - deps_ok, deps_reason = _install_plugin_python_deps(installed_manifest, target, console) - - _display_after_install(target, identifier) - - # ONE admission transaction for the enable (C13): resolve the candidate - # union (enabled members + this target) and commit the config in the - # same step — env and config change together or not at all. No - # duplicate sync here: nothing was resolved before this point. - if should_enable and not deps_ok: - # Consent declined/skipped: nothing was installed or changed, so - # enabling is refused without touching config or environment. - console.print( - f"[red]✗[/red] Cannot enable [bold]{installed_name}[/bold]: " - f"{deps_reason}" - ) - console.print( - "[dim]The plugin stays installed but disabled; re-enable " - "after resolving the conflict.[/dim]" - ) - should_enable = False - - if already_active: - console.print("[dim]Replacement installed; plugin selection was not changed.[/dim]") - elif should_enable: - from hermes_cli.plugins_admission import AdmissionRefused - - try: - _set_plugin_enabled(installed_name, enable=True, console=console) - except AdmissionRefused: - console.print( - "[dim]The plugin stays installed but disabled; re-enable " - "after resolving the conflict.[/dim]" - ) - else: - console.print( - f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.", - ) - else: - console.print( - f"[dim]Plugin installed but not enabled. " - f"Run `hermes plugins enable {installed_name}` to activate.[/dim]") - - # Non-interactive installs and declines leave declared capabilities ungranted (fail closed). - declared_caps = _declared_capabilities_from_manifest(installed_manifest, installed_name) - if declared_caps: - _run_capability_consent(console, installed_name, declared_caps, context="install") - if enable: - # Loads it into the running gateway now (handlers live) or says what needs a restart (#87770). - from hermes_cli.plugins_activation import activate_plugin_now, activation_hint - console.print(f"[dim]{activation_hint(activate_plugin_now(installed_name, in_process=False))}[/dim]") - console.print() - - -def _pull_plugin_update(target: Path, pinned_msg, not_git_msg, before_pull=None, *, interactive: bool = False) -> str: - """Shared ``update`` core: refuse pinned checkouts, ``git pull`` (or re-install from the - recorded source when the tree carries no ``.git`` — subdirectory installs), 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)) - # A URL install whose name/repo later landed on the kill list must not keep pulling or - # re-cloning new code, including subdirectory installs that carry no local .git directory. - from hermes_cli import plugins_cmd_catalog as catalog - catalog.refuse_if_installed_removed(target.name, target) - if not (target / ".git").exists(): - source = install_record.get("source") - if not isinstance(source, str) or not source: - raise PluginOperationError(not_git_msg()) - if before_pull is not None: - before_pull() - return _reclone_plugin_update(source, install_record.get("revision")) - if before_pull is not None: - before_pull() - from hermes_cli.plugins_transaction import update_plugin - - return update_plugin(target, interactive=interactive) - - -def _reclone_plugin_update(source: str, previous_revision: object) -> str: - """Update a plugin whose tree is not a git checkout: a subdirectory install ships only - ``/``, so the ``.git`` stays in the temp clone (#65314). Re-run the install - from the recorded source (same URL, same subdir) and swap the fresh tree in; the metadata - revision is rewritten by the installer. Returns pull-shaped output for the callers.""" - new_target, _manifest, _name = _install_plugin_core(source, force=True) - revision = str(_read_install_metadata().get(new_target.name, {}).get("revision") or "") - previous = previous_revision if isinstance(previous_revision, str) else "" - if revision and revision == previous: - return "Already up to date." - return f"Re-installed from {source}: {previous[:8]}..{revision[:8]}" - - -def cmd_update(name: str, *, interactive: bool = True) -> None: - """Update an installed plugin by pulling latest from its git remote.""" - from rich.markup import escape - from hermes_cli import plugins_cmd_catalog as catalog - console = _console() - target = _require_installed_plugin(name, _plugins_dir(), console) - sidecar = catalog.catalog_install_record(target) - if sidecar: # catalog installs re-pin to the reviewed SHA — never `git pull` - catalog.cmd_update_catalog(name, target, sidecar, console, interactive=interactive) - return - try: - output = _pull_plugin_update( - target, - lambda rec: ( - f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run " - f"`hermes plugins install {escape(str(rec.get('source', '')))} --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]"), - interactive=interactive) - except PluginOperationError as exc: - _fail(console, f"[red]Error:[/red] {exc}") - _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): - if interactive: - _run_capability_consent(console, plugin_id, declared_caps, context="update") - else: - console.print(f"[yellow]Plugin {plugin_id} has new capabilities; review them with `hermes plugins capabilities {plugin_id}`.[/yellow]") - - 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 publication: drop stale bytecode and copy any new example files.""" - # Same stale-bytecode class as the main checkout (#6207/#60242): the pull just changed .py files under - # this plugin dir, so drop any __pycache__ compiled from the previous revision. - _clear_plugin_bytecode(target) - _copy_example_files(target, console) - - -def _remove_plugin_core(target: Path) -> None: - """Remove one plugin and its metadata without splitting their state.""" - if target.name not in _read_install_metadata(): - rmtree_readonly(target) - return - staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.remove-", dir=target.parent)) - backup = staging / "plugin" - os.replace(target, backup) - try: - _update_install_record(target.name, lambda _current: None) - 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 - rmtree_readonly(staging, ignore_errors=True) - raise - rmtree_readonly(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: - result = _remove_user_plugin(plugins_dir, name, target) - except (OSError, PluginOperationError) as exc: - _fail(console, f"[red]Error:[/red] Could not remove plugin '{name}': {exc}") - console.print() - console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}") - if result.get("cleared_memory_provider"): - console.print("[yellow]memory.provider pointed at this plugin and was reset; " - "run `hermes memory setup` to pick another.[/yellow]") - console.print() - - -def _remove_user_plugin(plugins_dir: Path, name: str, target: Path) -> dict[str, Any]: - """Shared ``remove`` tail for the CLI, the dashboard and the ``plugins.manage`` RPC. - - *target* is the resolved directory; when ``plugins_dir/name`` itself is a symlink only the link - goes — the tree it points at may be another installed plugin (a dev alias to a sibling checkout), - and following it deleted that plugin plus its install metadata while the alias stayed dangling. - Config bookkeeping (aliases, toolset) is gathered before the tree disappears. - """ - link = plugins_dir / name.strip("/") - if link.is_symlink(): - link.unlink() - return {"ok": True, "name": name, **_forget_plugin_config({link.name})} - entry = next((e for e in _discover_all_plugins() if Path(str(e[4])) == target), None) - key = entry[5] if entry else target.name - aliases = _plugin_aliases(key) | {target.name} - if _read_manifest(target).get("provides_tools"): - _toggle_plugin_toolset(key, enable=False) - _remove_plugin_core(target) - return {"ok": True, "name": name, **_forget_plugin_config(aliases)} - - # ``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") @@ -1444,15 +588,6 @@ def _resolve_plugin_key_and_source(name: str) -> Optional[tuple]: return leaf_matches[0] if len(leaf_matches) == 1 else None -def _set_plugin_entry_flag(plugin_id: str, key: str, value: bool) -> None: - """Write ``plugins.entries.. = value`` into config.yaml.""" - from hermes_cli.config import load_config, save_config - config = load_config() - entry = _child_dict(_child_dict(_child_dict(config, "plugins"), "entries"), plugin_id) - entry[key] = bool(value) - save_config(config) - - def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: """Add a plugin to the enabled allow-list (and remove it from disabled). @@ -1505,148 +640,6 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: _resolve_tool_override_grant(console, key, allow_tool_override) -# ── Capability consent flow ────────────────────────────────────────────────── - - -# ── Capability consent flow (#64228) ───────────────────────────────────────── -def _declared_capabilities_from_manifest(manifest: dict, plugin_name: str = "?") -> list: - """Extract + normalize the ``capabilities:`` declaration from a manifest.""" - from hermes_cli.plugin_capabilities import parse_declared_capabilities - return parse_declared_capabilities((manifest or {}).get("capabilities"), plugin_name) - - -def _declared_capabilities_for_key(key: str) -> list: - """Read the declared capabilities for an installed/bundled plugin by key.""" - entry = _find_plugin_entry(key) - if entry is None: - return [] - if entry[3] == "entrypoint": - from hermes_cli.plugins import discover_entrypoint_manifests - for manifest in discover_entrypoint_manifests(): - if key in (manifest.key, manifest.name): - return list(manifest.capabilities) - return [] - if not entry[4]: - return [] - return _declared_capabilities_from_manifest(_read_manifest(Path(entry[4])), entry[0]) - - -def _run_capability_consent(console, plugin_id: str, declared: list, *, context: str = "install") -> bool: - """Show the capability consent screen and record the decision; True when granted. - - On consent the pending capabilities are granted under - ``plugins.entries..granted_capabilities`` with a hash of the declared set. On decline — - or in ANY non-interactive context — they stay ungranted (fail closed) and the plugin must - degrade via ``ctx.has_capability()``. Consent + audit, NOT a sandbox. - """ - from hermes_cli.plugin_capabilities import CAPABILITY_REGISTRY, pending_capabilities, record_consent - pending = pending_capabilities(plugin_id, declared) - if not pending: - # Refresh the consent hash so a later declaration change is detected. - if declared: - record_consent(plugin_id, [], declared) - return True - - verb = "requests" if context == "install" else "now requests" - console.print(f"\n [yellow]Plugin [bold]{plugin_id}[/bold] {verb} the following capabilities:[/yellow]") - for cap in pending: - spec = CAPABILITY_REGISTRY.get(cap) - console.print(f" [bold]{cap}[/bold] — {spec.description if spec else ''}") - console.print( - " [dim]Granting trusts the plugin author with these host surfaces. " - "This is consent, not a sandbox — plugins run as regular Python " - "in-process.[/dim]") - - if not _is_tty(): - console.print( - " [yellow]Non-interactive session: capabilities NOT granted " - "(fail closed).[/yellow] Run " - f"`hermes plugins capabilities {plugin_id}` to review and " - f"`hermes plugins enable {plugin_id}` to grant interactively.") - return False - - if _ask_yes(" Grant these capabilities? [y/N] ", console.input): - record_consent(plugin_id, pending, declared) - console.print( - f" [green]✓[/green] Granted: {', '.join(pending)} " - f"([dim]plugins.entries.{plugin_id}.granted_capabilities[/dim])") - return True - - console.print( - f" [dim]Declined. {plugin_id} stays enabled with these capabilities " - "off; it should degrade gracefully (ctx.has_capability()). Re-run " - f"`hermes plugins enable {plugin_id}` to grant later.[/dim]") - return False - - -def cmd_capabilities(name: Optional[str] = None) -> None: - """``hermes plugins capabilities []`` — declared vs granted.""" - from hermes_cli.plugin_capabilities import ( - CAPABILITY_REGISTRY, - granted_capabilities, - plugin_capability_granted, - ) - console = _console() - rows = [] - for entry in _discover_all_plugins(): - key = entry[5] or entry[0] - if name is not None and name not in (key, entry[0]): - continue - declared = _declared_capabilities_for_key(key) - granted = granted_capabilities(key) - # Effective state includes grants live via deprecated allow_* keys. - effective = {cap for cap in CAPABILITY_REGISTRY if plugin_capability_granted(key, cap)} - if not declared and not effective and name is None: - continue - rows.append((key, entry[3], declared, granted, effective)) - - if name is not None and not rows: - _fail(console, _unknown_plugin_message(name)) - if not rows: - console.print("[dim]No plugins declare or hold capabilities.[/dim]") - return - - for key, source, declared, granted, effective in sorted(rows): - console.print(f"[bold]{key}[/bold] [dim]({source})[/dim]") - if not declared: - console.print(" declared: [dim](none)[/dim]") - for cap in declared: - if cap not in effective: - mark = "[yellow]not granted[/yellow]" - elif cap in granted: - mark = "[green]granted[/green]" - else: - mark = "[green]granted[/green] [dim](via legacy allow_* key — deprecated)[/dim]" - console.print(f" {cap}: {mark}") - for cap in sorted(effective - set(declared)): - console.print(f" {cap}: [green]granted[/green] [dim](not declared in manifest)[/dim]") - - -def _resolve_tool_override_grant(console, key: str, allow_tool_override: Optional[bool]) -> None: - """Resolve and persist the ``allow_tool_override`` grant for a plugin.""" - if allow_tool_override is None: - # Default NO: a blind Enter or a non-interactive stdin denies safely. - allow_tool_override = _ask_yes( - "[yellow]Allow this plugin to replace built-in tools " - "(e.g. shell_exec, write_file)?[/yellow]\n" - " This is a privileged capability: an override can intercept " - "everything the agent routes through that tool.\n" - " Grant it? [y/N] ", - console.input, - ) - _set_plugin_entry_flag(key, "allow_tool_override", allow_tool_override) - if allow_tool_override: - console.print( - f"[green]✓[/green] Granted [bold]{key}[/bold] permission to " - "override built-in tools " - f"([dim]plugins.entries.{key}.allow_tool_override: true[/dim]).") - else: - console.print( - f"[dim]{key} may not override built-in tools. Re-run " - f"`hermes plugins enable {key} --allow-tool-override` to grant " - "this later.[/dim]") - - def cmd_disable(name: str) -> None: """Remove a plugin from the enabled allow-list (and add to disabled).""" console = _console() @@ -1783,281 +776,7 @@ def _plugin_status(name: str, enabled: set, disabled: set, key: str = "", *, sou 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): - active = _category_active_names() - filtered = [ - entry for entry in filtered - if _plugin_status(entry[0], enabled, disabled, key=entry[5], source=entry[3], dir_path=entry[4], - active=active) == "enabled" - ] - return filtered - - -def cmd_adopt(name: str) -> None: - """Adopt a self-cloned plugin dir into the provenance sidecar. - - Reads the dir's git origin URL, validates it, writes the sidecar row - — from then on a normal git install (check-updates + update). The - ONLY mutation path for self-cloned dirs (settled: explicit verbs). - """ - from rich.console import Console - - console = Console() - plugins_dir = _plugins_dir() - target = _require_installed_plugin(name, plugins_dir, console) - - from hermes_cli.plugins_provenance import ProvenanceClass, plugins_provenance - - prov = next((p for p in plugins_provenance(plugins_dir) if p.name == target.name), None) - if prov is None: - console.print(f"[red]Error:[/red] Plugin '{name}' not classifiable.") - sys.exit(1) - if prov.klass is not ProvenanceClass.SELF_CLONED: - console.print( - f"[red]Error:[/red] Plugin '{name}' is {prov.klass.value}, not " - "self-cloned — there is nothing to adopt." - ) - sys.exit(1) - if not prov.origin_url: - console.print( - f"[red]Error:[/red] Plugin '{name}' has no readable git origin " - "remote. Add one (git remote add origin ) and retry." - ) - sys.exit(1) - - try: - _resolve_git_url(prov.origin_url) - except ValueError as e: - console.print(f"[red]Error:[/red] The dir's origin url is not installable: {e}") - sys.exit(1) - - metadata = _read_install_metadata() - if target.name in metadata: - console.print(f"[red]Error:[/red] Plugin '{name}' already has a provenance row.") - sys.exit(1) - - git_exe = _resolve_git_executable() - revision = _git_head_revision(target, git_exe) if git_exe else "" - metadata[target.name] = { - "pinned": False, - "revision": revision, - "source": _canonical_source(prov.origin_url, None), - } - _write_install_metadata(metadata) - console.print( - f"[green]✓[/green] Adopted [bold]{name}[/bold] " - f"(source: {prov.origin_url}, revision: {revision[:12] or 'unknown'}). " - "It is now a tracked git install." - ) - - -def cmd_trust_update_url(name: str) -> None: - """The ONLY path that moves a saved update_url tag. - - A needs-fixing mismatch (manifest update_url vs the saved tag) is - resolved here: confirms the manifest's url into the sidecar row, - prints old → new. Refuses when there is nothing to trust. - """ - from rich.console import Console - - console = Console() - plugins_dir = _plugins_dir() - target = _require_installed_plugin(name, plugins_dir, console) - - from hermes_cli.plugins_provenance import read_sidecar_rows - - rows = read_sidecar_rows(plugins_dir) - row = rows.get(target.name) - if not isinstance(row, dict): - console.print( - f"[red]Error:[/red] Plugin '{name}' has no provenance row — " - "nothing to trust. Reinstall it instead." - ) - sys.exit(1) - - saved = row.get("update_url") or None - manifest = _read_manifest(target) - claimed = (manifest or {}).get("update_url") or None - if claimed == saved: - console.print( - f"[yellow]Nothing to trust:[/yellow] '{name}' has no " - "update_url mismatch." - ) - return - if claimed is not None: - from hermes_cli.plugins_updates import https_update_url - try: - claimed = https_update_url(claimed) - except ValueError as exc: - console.print(f"[red]Error:[/red] Plugin '{name}' {exc}. Not trusted.") - sys.exit(1) - - row["update_url"] = claimed - rows[target.name] = row - _write_install_metadata(rows) - console.print( - f"[green]✓[/green] Trusted [bold]{name}[/bold] update_url:\n" - f" old: {saved or '(none)'}\n" - f" new: {claimed or '(none)'}" - ) - - -def cmd_check_updates(args: Any | None = None) -> None: - """Read-only: is any installed plugin outdated? NEVER mutates.""" - from rich.console import Console - from rich.table import Table - - console = Console() - plugins_dir = _plugins_dir() - - from hermes_cli.plugins_updates import run_checks - - results = run_checks(plugins_dir) - - if getattr(args, "json", False): - print(json.dumps([r.to_json() for r in results], indent=2)) - return - - table = Table(title="Plugin updates", show_lines=False) - table.add_column("Name", style="bold") - table.add_column("Class", style="dim") - table.add_column("Current") - table.add_column("Latest") - table.add_column("Status") - for r in results: - if r.needs_fixing: - status = f"[red]needs fixing[/red]\n[dim]{r.needs_fixing}[/dim]" - elif r.update_available is True: - status = "[green]update available[/green]" - elif r.update_available is False: - status = "[dim]up to date[/dim]" - else: - status = f"[yellow]unknown[/yellow]\n[dim]{r.reason}[/dim]" - table.add_row( - r.name, r.klass, (r.current or "-")[:12], r.latest or "-", status - ) - console.print() - console.print(table) - console.print() - console.print("[dim]Check-only. Apply with: hermes plugins update [/dim]") - - -_STATUS_MARKUP = {"disabled": "[red]disabled[/red]", "enabled": "[green]enabled[/green]"} - - -def cmd_list(args: Any | None = None) -> None: - """List all plugins (bundled + user) with enabled/disabled state.""" - console = _console() - entries = _discover_all_plugins() - if not entries: - console.print("[dim]No plugins installed.[/dim]") - console.print("[dim]Install with:[/dim] hermes plugins install owner/repo") - return - - enabled = _get_enabled_set() - disabled = _get_disabled_set() - entries = _filter_plugin_entries(entries, args, enabled, disabled) - from hermes_cli import plugins_cmd_catalog as catalog - # Source shows catalog provenance (``catalog:@``) or a ``--ref`` pin - # (``git pinned@``) so a team can eyeball that everyone runs the same commit. - pins = _read_install_metadata() - # One kill-list resolution for the whole listing: resolving per row costs a live-catalog - # fetch per installed plugin when the catalog host is slow or unreachable. - removed_entries = catalog.resolved_removed_entries() - active = _category_active_names() - rows = [ - (name, _plugin_status(name, enabled, disabled, key=key, source=source, dir_path=_dir, active=active), - str(version), description, - catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source, - catalog.removed_annotation(name, _dir, removed_entries)) - for name, version, description, source, _dir, key in entries - ] - - if getattr(args, "json", False): - keys = ("name", "status", "version", "description", "source", "removed") - print(json.dumps([dict(zip(keys, row)) for row in rows], indent=2)) - return - - if getattr(args, "plain", False): - for name, status, version, _description, source, _removed in rows: - print(f"{status:12} {source:8} {version:8} {name}") - return - - if not entries: - console.print("[dim]No plugins matched the selected filters.[/dim]") - return - - table = _table( - (("Name", "bold"), ("Status", None), ("Version", "dim"), ("Description", None), ("Source", "dim")), - title="Plugins", show_lines=False) - # provenance class per user-installed dir (bundled entries show '-') - from hermes_cli.plugins_provenance import plugins_provenance - - prov_classes = { - p.name: p.klass.value for p in plugins_provenance(_plugins_dir()) - } - - removed_lines = [] - for name, status_name, version, description, source, removed in rows: - klass = prov_classes.get(name) - status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]") - if removed: - name = f"[red]{name} ✗[/red]" - removed_lines.append(f"[red]✗ {name}[/red] was removed from the plugin catalog: {removed}") - table.add_row(name, status, version, description, source) - # class line rides the Source column for user plugins - if source in {"user", "git"}: - if klass and klass != "git": - table.add_row( - "", "", "", f"[dim]provenance: {klass}[/dim]", "" - ) - console.print() - console.print(table) - for line in removed_lines: - console.print(line) - console.print() - console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled") - console.print("[dim]Interactive toggle:[/dim] hermes plugins") - console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable ") - console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]") - - -# ── Provider plugin discovery helpers ─────────────────────────────────────────────────────── - - -def _discover_memory_providers() -> list[tuple[str, str]]: - """``[(name, description), ...]`` for available memory providers.""" - try: - from plugins.memory import discover_memory_providers - return [(name, desc) for name, desc, _avail in discover_memory_providers()] - except Exception: - return [] - - -def _discover_context_engines() -> list[tuple[str, str]]: - """``[(name, description), ...]`` for repo-shipped context engines plus the plugin-registered - one (``ctx.register_context_engine``); repo-shipped descriptions win on a name collision.""" - engines: dict[str, str] = {} - try: - from plugins.context_engine import discover_context_engines - for name, desc, _avail in discover_context_engines(): - engines.setdefault(name, desc) - except Exception: - pass - try: - from hermes_cli.plugins import discover_plugins, get_plugin_context_engine - discover_plugins() - plugin_engine = get_plugin_context_engine() - if plugin_engine and getattr(plugin_engine, "name", None): - engines.setdefault(plugin_engine.name, "installed plugin") - except Exception: - pass - return list(engines.items()) +# ── Provider category config accessors ────────────────────────────────────────────────────── # memory.provider ("" = built-in) and context.engine config accessors. @@ -2067,393 +786,6 @@ _save_memory_provider = functools.partial(_write_config_value, "memory", "provid _save_context_engine = functools.partial(_write_config_value, "context", "engine") -# (title, default label, default name, current-value reader, discovery fn, saver) per provider -# category. Readers/savers are looked up at call time so module-level patching still applies. -_PROVIDER_CATEGORY_SPECS = ( - ("Memory Provider", "built-in", "", lambda: _get_current_memory_provider(), - lambda: _discover_memory_providers(), lambda v: _save_memory_provider(v)), - ("Context Engine", "compressor", "compressor", lambda: _get_current_context_engine(), - lambda: _discover_context_engines(), lambda v: _save_context_engine(v)), -) - - -def _configure_category_spec(spec) -> bool: - """Radio picker for one ``_PROVIDER_CATEGORY_SPECS`` row: the built-in default first, then the - discovered choices; a current value not among them is appended as ``(not found)``. Saves and - returns True when the choice changed.""" - from hermes_cli.curses_ui import curses_radiolist - title, default_label, default_name, current, discover, save = spec - current = current() - choices = discover() - names = [default_name] + [name for name, _desc in choices] - items = [f"{default_label} (default)"] + [f"{name} \u2014 {desc}" if desc else name for name, desc in choices] - if current not in names: - names.append(current) - items.append(f"{current} (not found)") - selected = max(i for i, name in enumerate(names) if name == current) - new_value = names[curses_radiolist(title=f"{title} (select one)", items=items, selected=selected)] - if new_value == current: - return False - save(new_value) - return True - - -def _provider_categories() -> list: - """``[(title, current_label, configure_fn), ...]`` rows for the composite UI.""" - return [(s[0], s[3]() or s[1], functools.partial(_configure_category_spec, s)) for s in _PROVIDER_CATEGORY_SPECS] - - -# ── Composite plugins UI ──────────────────────────────────────────────────────────────────── - - -def cmd_show(name: str) -> None: - """Show details for a single plugin, including declared emits/listens.""" - console = _console() - match = _find_plugin_entry(name) - if match is None: - console.print(f"[red]Plugin '{name}' not found.[/red]") - _fail(console, "[dim]List installed plugins:[/dim] hermes plugins list") - - pname, version, description, source, dir_path, key = match - manifest = _read_manifest(Path(dir_path)) if dir_path else {} - emits = manifest.get("emits") or [] - listens = manifest.get("listens") or [] - status = _plugin_status(pname, _get_enabled_set(), _get_disabled_set(), key=key) - console.print() - console.print(f"[bold]{pname}[/bold]" + (f" [dim]v{version}[/dim]" if version else "")) - if description: - console.print(description) - console.print(f"[dim]Status:[/dim] {status}") - console.print(f"[dim]Source:[/dim] {source}") - console.print(f"[dim]Key:[/dim] {key}") - console.print("[dim]Emits:[/dim] " + (", ".join(emits) if emits else "[dim](none)[/dim]")) - console.print("[dim]Listens:[/dim] " + (", ".join(listens) if listens else "[dim](none)[/dim]")) - console.print() - - -def cmd_toggle() -> None: - """Interactive composite UI — general plugins + provider plugin categories.""" - console = _console() - entries = _discover_all_plugins() - expected_config = _plugin_selection_version() - enabled_set = _get_enabled_set() - disabled_set = _get_disabled_set() - - # Track by CANONICAL KEY, not manifest name: the loader and enable/disable all gate on the - # key (``web/firecrawl``) while the name may differ (``web-firecrawl``); persisting the bare - # name let plugins.disabled drift so "explicit disable wins" kept a plugin off forever. - plugin_keys = [entry[5] for entry in entries] - # Keys keep every surface aligned. See #40190. - plugin_labels = [ - (f"{name} \u2014 {description}" if description else name) + (" [bundled]" if source == "bundled" else "") - for name, _version, description, source, _d, _key in entries - ] - # Selected when enabled AND not disabled; the legacy bare name counts on either side. - plugin_selected = { - i for i, (name, _v, _desc, _src, _d, key) in enumerate(entries) - if {key, name} & enabled_set and not ({key, name} & disabled_set) - } - categories = _provider_categories() - - if not sys.stdin.isatty(): - console.print("[dim]Interactive mode requires a terminal.[/dim]") - return - try: - import curses - _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config) - except ImportError: - _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config) - - -def _persist_plugin_selection(plugin_keys, chosen, disabled, *, expected_config=None) -> tuple[bool, set]: - """Save the composite UI's checkbox state; returns ``(changed, new_enabled)``. - - Unchecked plugins go to the disabled-list (so they stay off even if something auto-enables - them) under the canonical key ONLY, so the list can't drift from what ``cmd_enable`` clears. - Re-checking also drops any stale legacy bare-leaf disable. - """ - # See #40190. - # Persist by canonical key only — never the bare manifest name — so the disabled-list stays aligned with - # cmd_enable / PluginManager (#40190). - if expected_config is None: - expected_config = _plugin_selection_version() - 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: - # C13: the composite UI's candidate goes through the ONE admission - # authority — refusal raises AdmissionRefused BEFORE any config - # write; the caller surfaces it and the selection stays unsaved. - _admit_and_save_plugin_sets(new_enabled, new_disabled, action="Save plugin selection", expected_config=expected_config) - return changed, new_enabled - - -def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None): - """Custom curses screen with checkboxes + category action rows.""" - from hermes_cli.curses_ui import _addnstr, flush_stdin - chosen = set(plugin_selected) - n_plugins, n_categories = len(plugin_keys), len(categories) - total_items = n_plugins + n_categories # navigable rows (headers/separator are skipped) - providers_changed = False - nav = { # key -> new cursor, given (cursor, page_size) - key: move - for keys, move in ( - ((curses.KEY_UP, ord("k")), lambda c, p: (c - 1) % total_items), - ((curses.KEY_DOWN, ord("j")), lambda c, p: (c + 1) % total_items), - ((curses.KEY_NPAGE, ord("f")), lambda c, p: min(total_items - 1, c + p)), - ((curses.KEY_PPAGE, ord("b")), lambda c, p: max(0, c - p)), - ((curses.KEY_HOME,), lambda c, p: 0), - ((curses.KEY_END,), lambda c, p: total_items - 1), - ) - for key in keys - } - - def _init_colors(): - if curses.has_colors(): - curses.start_color() - curses.use_default_colors() - gray = 8 if curses.COLORS > 8 else curses.COLOR_WHITE - for pair, fg in ((1, curses.COLOR_GREEN), (2, curses.COLOR_YELLOW), (3, curses.COLOR_CYAN), (4, gray)): - curses.init_pair(pair, fg, -1) - - def _attr(base, pair): - return base | curses.color_pair(pair) if curses.has_colors() else base - - def _row(text, idx, cursor, pair): - """One navigable body row: arrow marker + bold color when *idx* is the cursor.""" - arrow = "\u2192" if idx == cursor else " " - return (f" {arrow} {text}", _attr(curses.A_BOLD, pair) if idx == cursor else curses.A_NORMAL) - - def _configure_category(ci): - """Leave curses, run the category's picker, refresh its row, re-enter curses.""" - nonlocal providers_changed - curses.endwin() - cat_name, _cat_cur, cat_fn = categories[ci] - if cat_fn(): - providers_changed = True - categories[ci] = (cat_name, _provider_categories()[ci][1], cat_fn) - stdscr = curses.initscr() - curses.noecho() - curses.cbreak() - stdscr.keypad(True) - _init_colors() - curses.curs_set(0) - return stdscr - - def _body_lines(cursor, scroll_offset, visible_rows): - """Body rows as (text, attr); "" is a blank separator.""" - lines = [] - if n_plugins > 0: - lines.append((" General Plugins", _attr(curses.A_BOLD, 2))) - for i in range(scroll_offset, min(n_plugins, scroll_offset + max(visible_rows, 0))): - check = "\u2713" if i in chosen else " " - lines.append(_row(f"[{check}] {plugin_labels[i]}", i, cursor, 1)) - lines.append(("", curses.A_NORMAL)) - if n_categories > 0: - lines.append((" Provider Plugins", _attr(curses.A_BOLD, 2))) - lines += [ - _row(f" {cat_name:<24} \u25b8 {cat_current}", n_plugins + ci, cursor, 3) - for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories) - ] - return lines - - def _draw(stdscr): - curses.curs_set(0) - _init_colors() - cursor = scroll_offset = 0 - while True: - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - _addnstr(stdscr, 0, 0, "Plugins", max_x - 1, _attr(curses.A_BOLD, 2)) - _addnstr( - stdscr, 1, 0, " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done", - max_x - 1, curses.A_DIM) - visible_rows = max_y - 4 - if cursor < scroll_offset: - scroll_offset = cursor - elif cursor >= scroll_offset + visible_rows: - scroll_offset = cursor - visible_rows + 1 - lines = _body_lines(cursor, scroll_offset, visible_rows) - for y, (text, attr) in enumerate(lines[: max(0, max_y - 4)], start=3): - if text: - _addnstr(stdscr, y, 0, text, max_x - 1, attr) - stdscr.refresh() - key = stdscr.getch() - - if key in nav: - if total_items > 0: # (with no rows, every motion leaves cursor at 0) - cursor = nav[key](cursor, max(1, max_y - 5)) - elif key == ord(" ") or key in {curses.KEY_ENTER, 10, 13}: - if cursor >= n_plugins: - # Provider category — launch sub-screen (SPACE and ENTER alike) - if cursor - n_plugins < n_categories: - stdscr = _configure_category(cursor - n_plugins) - elif key == ord(" "): - chosen.symmetric_difference_update({cursor}) - else: - return # ENTER on a plugin checkbox — confirm and exit - elif key in {27, ord("q")}: - return # plugin changes are saved on exit - - curses.wrapper(_draw) - flush_stdin() - - from hermes_cli.plugins_admission import AdmissionRefused - - try: - changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled, expected_config=expected_config) - except AdmissionRefused as exc: - console.print(f"[red]✗[/red] Plugin selection refused, not saved: {exc}") - console.print( - "[dim]config.yaml and the active environment are unchanged. " - "Run `hermes pm install` to resolve, then retry.[/dim]" - ) - return - if changed: - console.print( - f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, " - f"{len(plugin_keys) - len(new_enabled)} disabled.") - elif n_plugins > 0: - console.print("\n[dim]General plugins unchanged.[/dim]") - if providers_changed: - console.print( - f"[green]\u2713[/green] Memory provider: [bold]{_get_current_memory_provider() or 'built-in'}[/bold] " - f"Context engine: [bold]{_get_current_context_engine()}[/bold]") - if n_plugins > 0 or providers_changed: - console.print("[dim]Changes take effect on next session.[/dim]") - console.print() - - -def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None): - """Text-based fallback for the composite plugins UI.""" - from hermes_cli.colors import Colors, color - print(color("\n Plugins", Colors.YELLOW)) - if plugin_keys: - chosen = set(plugin_selected) - print(color("\n General Plugins", Colors.YELLOW)) - print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM)) - while True: - for i, label in enumerate(plugin_labels): - marker = color("[\u2713]", Colors.GREEN) if i in chosen else "[ ]" - print(f" {marker} {i + 1:>2}. {label}") - print() - try: - val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip() - if not val: - break - idx = int(val) - 1 - if 0 <= idx < len(plugin_keys): - chosen.symmetric_difference_update({idx}) - except (ValueError, KeyboardInterrupt, EOFError): - return - print() - _save_plugin_selection_fallback(plugin_keys, chosen, disabled, expected_config=expected_config) - - if categories: - print(color("\n Provider Plugins", Colors.YELLOW)) - for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories): - print(f" {ci + 1}. {cat_name} [{cat_current}]") - print() - try: - val = input(color(" Configure # (or Enter to skip): ", Colors.DIM)).strip() - if val: - ci = int(val) - 1 - if 0 <= ci < len(categories): - categories[ci][2]() - except (ValueError, KeyboardInterrupt, EOFError): - pass - print() - - -def _save_plugin_selection_fallback(plugin_keys, chosen, disabled, *, expected_config=None) -> None: - """The text fallback's save: same admission authority, refusal printed.""" - from hermes_cli.plugins_admission import AdmissionRefused - - try: - _persist_plugin_selection(plugin_keys, chosen, disabled, expected_config=expected_config) - except AdmissionRefused as exc: - print(f" Plugin selection refused, not saved: {exc}") - print(" config.yaml and the active environment are unchanged.") - - -def dashboard_install_plugin( - identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None, - ref: Optional[str] = None, -) -> dict[str, Any]: - """Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its - pinned SHA (identifier may be empty); *ref* pins a custom source to one full commit SHA (same - contract as ``--ref``); every path enforces the kill list (no GUI bypass).""" - from hermes_cli import plugins_cmd_catalog as catalog - warnings: list[str] = [] - entry = None - if catalog_name: - entry = catalog.get_live_catalog_entry(catalog_name) - if entry is None: - return {"ok": False, "error": f"'{catalog_name}' is not in the Hermes plugin catalog."} - identifier = entry.install_identifier - else: - warnings.append("Custom (unreviewed) source — not from the Hermes catalog.") - try: - git_url = _resolve_git_url(identifier)[0] - if git_url.startswith(("http://", "file://")): - warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.") - catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ())) - except ValueError: - pass - except PluginOperationError as exc: - return {"ok": False, "error": str(exc)} - try: - if entry is not None: - target, installed_manifest, installed_name = catalog.install_catalog_entry( - entry, force=force, allow_removed=False) - else: - target, installed_manifest, installed_name = _install_plugin_core( - identifier, force=force, ref=(ref or "").strip() or None) - except PluginScanBlocked as exc: - fields = ("pattern_id", "severity", "category", "file", "line", "description") - return { - "ok": False, "error": str(exc), "scan_blocked": True, - "scan_verdict": getattr(exc.scan_result, "verdict", "dangerous"), - "scan_findings": [ - {k: getattr(f, k) for k in fields} - for f in (exc.scan_result.findings if exc.scan_result is not None else ()) - ], - } - except PluginOperationError as exc: - return {"ok": False, "error": str(exc)} - - if enable: - from hermes_cli.plugins_admission import AdmissionRefused - - try: - _set_plugin_enabled(installed_name, enable=True) - except AdmissionRefused as exc: - return { - "ok": False, "error": f"enable refused: {exc}", - "plugin_name": installed_name, "enabled": False, - } - deps = _python_dependency_summary(target, warnings) - ap = target / "after-install.md" - # Deps first, then load: the plugin activates in this process (TUI/Desktop server subscribers see it) - # and in the running gateway; ``activation`` says what is live now vs next session (#87770). - from hermes_cli.plugins_activation import activate_plugin_now - activated = activate_plugin_now(installed_name) if enable else { - "gateway_reloaded": False, "activation": None, "restart_required": False} - return { - "ok": True, "plugin_name": installed_name, "warnings": warnings, - "python_dependencies": deps, - "missing_env": [s["name"] for s in _missing_env_specs(installed_manifest)], - "after_install_path": str(ap) if ap.exists() else None, "enabled": enable, **activated, - } - - 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.""" @@ -2558,191 +890,6 @@ def _user_installed_plugin_dir(name: str) -> Optional[Path]: return target if target.is_dir() else None -def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False) -> dict[str, Any]: - """``git pull`` inside ``~/.hermes/plugins/``; catalog installs re-pin instead. A re-pin that - widens the plugin returns ``{"ok": False, "consent_required": True, "delta": {...}}`` with nothing - changed — the surface shows the delta and retries with *accept_capabilities*.""" - from hermes_cli import plugins_cmd_catalog as catalog - target = _user_installed_plugin_dir(name) - if target is None: - return {"ok": False, "error": f"Plugin '{name}' was not found under {_plugins_dir()}."} - sidecar = catalog.catalog_install_record(target) - try: - if sidecar: - result = catalog.repin_catalog_plugin( - target, sidecar, consent_cb=(lambda _delta: True) if accept_capabilities else None) - warnings = list(result.warnings) - new_target = target.parent / result.installed_name - deps = _python_dependency_summary(new_target, warnings) if result.changed else [] - from hermes_cli.plugins_activation import activate_plugin_now - activated = activate_plugin_now(result.installed_name) if result.changed else {} - return {"ok": True, "name": result.installed_name, "sha": result.sha, "unchanged": not result.changed, - "python_dependencies": deps, "warnings": warnings, **activated} - 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 catalog.RepinConsentRequired as exc: - return {"ok": False, "consent_required": True, "error": str(exc), "name": exc.name, "sha": exc.sha, - "delta": exc.delta, "delta_lines": catalog.surface_delta_lines(exc.delta)} - except PluginOperationError as exc: - return {"ok": False, "error": str(exc)} - _post_pull_housekeeping(target, _console()) - return {"ok": True, "name": name, "output": msg, "unchanged": "Already up to date" in msg} - - -def _clear_plugin_bytecode(target: Path) -> int: - """Remove ``__pycache__`` dirs under a just-updated plugin checkout. Plugin dirs sit outside - the repo, so the launch-time bytecode sweep never covers them and stale bytecode after a pull - can ImportError in the next process. Never raises. - - See #60242, #6207. - """ - removed = 0 - try: - for cache_dir in target.rglob("__pycache__"): - if cache_dir.is_dir(): - shutil.rmtree(cache_dir, ignore_errors=True) - removed += 0 if cache_dir.exists() else 1 - except OSError: - pass - return removed - - -def _run_plugin_git( - git_exe: str, target: Path, *args: str, timeout: int = 60, auth_url: str = "", -) -> subprocess.CompletedProcess: - """Run one git command inside a plugin checkout (non-interactive). *auth_url* names the remote - a network verb talks to; it runs anonymously first and a stored user credential for that host - is attached only when the remote refuses anonymous access (private repos).""" - from hermes_cli.git_credentials import run_git_with_credential_fallback - return run_git_with_credential_fallback( - [git_exe, *args], auth_url, env=noninteractive_git_env(), capture_output=True, text=True, - encoding='utf-8', errors='replace', timeout=timeout, cwd=str(target)) - - -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, stash_sha: str) -> bool: - """``stash apply`` the autostash commit *stash_sha*; drop it on a clean apply. False when it - applied with errors or left unmerged paths (the stash entry is kept in that case). - - Git is addressed by the stash's commit sha, never a ``stash@{N}`` selector: on native Windows - the MSYS runtime re-parses git.exe's argv and strips the braces, so ``stash@{0}`` reaches git - as ``stash@0`` and both the apply and the drop fail (#87542).""" - restore = _run_plugin_git(git_exe, target, "stash", "apply", stash_sha) - unmerged = _run_plugin_git(git_exe, target, "diff", "--name-only", "--diff-filter=U") - if restore.returncode != 0 or unmerged.stdout.strip(): - return False - # `stash drop` only takes a selector; a bare `drop` targets the newest entry, so drop - # positionally only while the newest entry is still our autostash. - if _stash_ref(git_exe, target) == stash_sha: - _run_plugin_git(git_exe, target, "stash", "drop") - return True - - -def _autostash_dirty_tree(git_exe: str, target: Path) -> tuple[str, str]: - """Stash local edits before a pull. Returns ``(stash_sha, error)``; *stash_sha* is empty when - the tree was clean, and a non-empty error means the tree is dirty but nothing was saved, so - the pull must not run.""" - status = _run_plugin_git(git_exe, target, "status", "--porcelain", "-z") - if status.returncode != 0 or not status.stdout.strip(): - return "", "" - # `git add -N` entries make `git stash push` fail outright (see update_cmd_stash), so promote them - # to real staged adds first; the checkout's own local edits are otherwise unstashable. - from hermes_cli.update_cmd_stash import _intent_to_add_paths - - intent_to_add = _intent_to_add_paths(status.stdout) - if intent_to_add: - _run_plugin_git(git_exe, target, "add", "--", *intent_to_add) - pre_stash = _stash_ref(git_exe, target) - push = _run_plugin_git( - git_exe, target, "stash", "push", "--include-untracked", "-m", "hermes-plugin-update-autostash") - post_stash = _stash_ref(git_exe, target) - if not post_stash or post_stash == pre_stash: - err = _safe_git_error(push) - return "", ( - "Local changes in the plugin checkout could not be " - "stashed; update aborted before touching the checkout." - + (f"\n{err}" if err else "")) - if push.returncode != 0: - # Saved-but-couldn't-clean (undeletable untracked files): the stash entry is complete; - # reset tracked mods so the pull isn't blocked by a still-dirty tree. - _run_plugin_git(git_exe, target, "reset", "--hard", "HEAD") - return post_stash, "" - - -def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]: - """``git pull --ff-only`` a plugin checkout, autostashing local edits (users patch installed - plugins in place, and a plain ff-only pull would then refuse forever). - - Users tweak installed plugins in place (config constants, small patches), and a plain ``pull --ff-only`` - then aborts with "Your local changes ... would be overwritten by merge" — making the plugin permanently - un-updatable until they hand-run git. Same UX class Factory Droid fixed in v0.188 ("Updating a plugin - marketplace now succeeds when its checkout has local changes"), and the same autostash approach ``hermes - update`` already uses for the main checkout (PR #70161). - """ - git_exe = _resolve_git_executable() - if not git_exe: - return False, "git is not installed or not in PATH." - try: - stash_sha, err = _autostash_dirty_tree(git_exe, target) - if err: - return False, err - origin = _run_plugin_git(git_exe, target, "remote", "get-url", "origin", timeout=15) - result = _run_plugin_git(git_exe, target, "pull", "--ff-only", auth_url=origin.stdout.strip()) - if result.returncode != 0: - err = _safe_git_error(result) or "git pull failed." - if not stash_sha: - return False, err - # Put the user's edits back before reporting the failure. - if _reapply_stash(git_exe, target, stash_sha): - note = "Local changes were restored." - else: - note = "Local changes are preserved in git stash (restore with: git stash pop)." - return False, f"{err}\n{note}" - - pulled = result.stdout.strip() - if not stash_sha: - return True, pulled - if _reapply_stash(git_exe, target, stash_sha): - 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` and re-apply with " - f"`git stash pop` inside {target}.") - except FileNotFoundError: - return False, "git is not installed or not in PATH." - except subprocess.TimeoutExpired: - return False, "Git operation timed out after 60 seconds." - - -def dashboard_remove_user_plugin(name: str) -> dict[str, Any]: - """Delete a plugin tree under ``~/.hermes/plugins/`` only.""" - plugins_dir = _plugins_dir() - if any(n == name and src == "bundled" for n, _ver, _d, src, _path, _key in _discover_all_plugins()): - return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."} - target = _user_installed_plugin_dir(name) - if target is None: - return {"ok": False, "error": f"Plugin '{name}' was not found under {plugins_dir}."} - try: - return _remove_user_plugin(plugins_dir, name, target) - except (OSError, PluginOperationError) as exc: - return {"ok": False, "error": f"Could not remove plugin '{name}': {exc}"} - - 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 @@ -2767,42 +914,6 @@ def _action_pack(args): pack_command(args) -def cmd_compat(args: Any | None = None) -> None: - """``hermes plugins compat`` — which installed plugins import paths scheduled for removal, and where.""" - import sys - from pathlib import Path - from hermes_cli.plugin_compat import ( - ALLOW_KEY, COMPAT_REMOVAL, compat_report, removal_in_effect, scan_plugin, summary_lines) - console = _console() - path = getattr(args, "path", None) - if path: - hits = scan_plugin(Path(path).expanduser().resolve()) - report = {Path(path).name: hits} if hits else {} - else: - report = compat_report(force=True) - if getattr(args, "json", False): - print(json.dumps({"removal_date": COMPAT_REMOVAL, "in_effect": removal_in_effect(), - "plugins": {k: [h.__dict__ for h in v] for k, v in report.items()}}, indent=2)) - sys.exit(1 if report else 0) - if not report: - console.print(f"[green]✓ No enabled plugin imports paths scheduled for removal on {COMPAT_REMOVAL}.[/green]") - return - head, tail = summary_lines(report) - console.print(f"[bold {'red' if removal_in_effect() else 'yellow'}]{head}[/]") - console.print(f"[dim]{tail}[/dim]") - for name, hits in sorted(report.items()): - table = _table(((f"{name} ({len(hits)} import{'s' if len(hits) != 1 else ''})", "bold"), ("old path", "yellow"), ("new path", "green")), - title=None, show_lines=False) - for h in hits: - table.add_row(f"{h.file}:{h.line}", h.old, h.new) - console.print() - console.print(table) - console.print() - console.print(f"[dim]After {COMPAT_REMOVAL} these plugins are not loaded. Update them, or force-load with " - f"plugins.{ALLOW_KEY}: true in config.yaml (the old paths still break once the compat layer is reverted).[/dim]") - sys.exit(1) - - # Tri-state flags: neither --x nor --no-x given == None == interactive prompt. _PLUGIN_ACTIONS = { "install": lambda args: cmd_install( diff --git a/hermes_cli/plugins_cmd_capabilities.py b/hermes_cli/plugins_cmd_capabilities.py new file mode 100644 index 0000000000..49738d2469 --- /dev/null +++ b/hermes_cli/plugins_cmd_capabilities.py @@ -0,0 +1,167 @@ +"""Capability consent (#64228): declared-vs-granted reads, the consent screen, ``hermes plugins +capabilities`` and the legacy ``allow_tool_override`` grant. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is +imported late here, never at module level). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from hermes_cli.plugin_capabilities import _child_dict + + +def _pc(): + """The facade, read at call time: tests patch ``plugins_cmd.`` and sibling calls must see it.""" + from hermes_cli import plugins_cmd + return plugins_cmd + + +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 = _child_dict(_child_dict(_child_dict(config, "plugins"), "entries"), plugin_id) + entry[key] = bool(value) + save_config(config) + + +# ── 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 = _pc()._find_plugin_entry(key) + if entry is None: + return [] + if entry[3] == "entrypoint": + from hermes_cli.plugins import discover_entrypoint_manifests + for manifest in discover_entrypoint_manifests(): + if key in (manifest.key, manifest.name): + return list(manifest.capabilities) + return [] + if not entry[4]: + return [] + return _declared_capabilities_from_manifest(_pc()._read_manifest(Path(entry[4])), entry[0]) + + +def _run_capability_consent(console, plugin_id: str, declared: list, *, context: str = "install") -> bool: + """Show the capability consent screen and record the decision; True when granted. + + On consent the pending capabilities are granted under + ``plugins.entries..granted_capabilities`` with a hash of the declared set. On decline — + or in ANY non-interactive context — they stay ungranted (fail closed) and the plugin must + degrade via ``ctx.has_capability()``. Consent + audit, NOT a sandbox. + """ + from hermes_cli.plugin_capabilities import CAPABILITY_REGISTRY, pending_capabilities, record_consent + pending = pending_capabilities(plugin_id, declared) + if not pending: + # Refresh the consent hash so a later declaration change is detected. + if declared: + record_consent(plugin_id, [], declared) + return True + + verb = "requests" if context == "install" else "now requests" + console.print(f"\n [yellow]Plugin [bold]{plugin_id}[/bold] {verb} the following capabilities:[/yellow]") + for cap in pending: + spec = CAPABILITY_REGISTRY.get(cap) + console.print(f" [bold]{cap}[/bold] — {spec.description if spec else ''}") + console.print( + " [dim]Granting trusts the plugin author with these host surfaces. " + "This is consent, not a sandbox — plugins run as regular Python " + "in-process.[/dim]") + + if not _pc()._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 _pc()._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 = _pc()._console() + rows = [] + for entry in _pc()._discover_all_plugins(): + key = entry[5] or entry[0] + if name is not None and name not in (key, entry[0]): + continue + declared = _declared_capabilities_for_key(key) + granted = granted_capabilities(key) + # Effective state includes grants live via deprecated allow_* keys. + effective = {cap for cap in CAPABILITY_REGISTRY if plugin_capability_granted(key, cap)} + if not declared and not effective and name is None: + continue + rows.append((key, entry[3], declared, granted, effective)) + + if name is not None and not rows: + _pc()._fail(console, _pc()._unknown_plugin_message(name)) + if not rows: + console.print("[dim]No plugins declare or hold capabilities.[/dim]") + return + + for key, source, declared, granted, effective in sorted(rows): + console.print(f"[bold]{key}[/bold] [dim]({source})[/dim]") + if not declared: + console.print(" declared: [dim](none)[/dim]") + for cap in declared: + if cap not in effective: + mark = "[yellow]not granted[/yellow]" + elif cap in granted: + mark = "[green]granted[/green]" + else: + mark = "[green]granted[/green] [dim](via legacy allow_* key — deprecated)[/dim]" + console.print(f" {cap}: {mark}") + for cap in sorted(effective - set(declared)): + console.print(f" {cap}: [green]granted[/green] [dim](not declared in manifest)[/dim]") + + +def _resolve_tool_override_grant(console, key: str, allow_tool_override: Optional[bool]) -> None: + """Resolve and persist the ``allow_tool_override`` grant for a plugin.""" + if allow_tool_override is None: + # Default NO: a blind Enter or a non-interactive stdin denies safely. + allow_tool_override = _pc()._ask_yes( + "[yellow]Allow this plugin to replace built-in tools " + "(e.g. shell_exec, write_file)?[/yellow]\n" + " This is a privileged capability: an override can intercept " + "everything the agent routes through that tool.\n" + " Grant it? [y/N] ", + console.input, + ) + _set_plugin_entry_flag(key, "allow_tool_override", allow_tool_override) + if allow_tool_override: + console.print( + f"[green]✓[/green] Granted [bold]{key}[/bold] permission to " + "override built-in tools " + f"([dim]plugins.entries.{key}.allow_tool_override: true[/dim]).") + else: + console.print( + f"[dim]{key} may not override built-in tools. Re-run " + f"`hermes plugins enable {key} --allow-tool-override` to grant " + "this later.[/dim]") diff --git a/hermes_cli/plugins_cmd_git.py b/hermes_cli/plugins_cmd_git.py new file mode 100644 index 0000000000..75d0180d09 --- /dev/null +++ b/hermes_cli/plugins_cmd_git.py @@ -0,0 +1,368 @@ +"""Plugin install metadata (``.install-metadata.json`` source/revision/pin records) and the git plumbing +behind clone, exact-revision checkout, credential scrubbing and the autostashing ``git pull``. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is +imported late here, never at module level). +""" + +from __future__ import annotations + +import json +import re +import subprocess +import threading +import urllib.parse +from contextlib import contextmanager +from pathlib import Path +from typing import Callable, Optional + +from hermes_cli._subprocess_compat import noninteractive_git_env +from hermes_constants import get_hermes_home +from utils import atomic_write_text + + +def _pc(): + """The facade, read at call time: tests patch ``plugins_cmd.`` and sibling calls must see it.""" + from hermes_cli import plugins_cmd + return plugins_cmd + + +_EXACT_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + +def _install_metadata_path() -> Path: + return get_hermes_home() / "plugins" / ".install-metadata.json" + + +def _read_install_metadata() -> dict[str, dict[str, object]]: + """Read profile-local, non-secret plugin source metadata from disk.""" + path = _install_metadata_path() + if not path.exists(): + return {} + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as exc: + raise _pc().PluginOperationError(f"Could not read plugin install metadata: {exc}") from exc + if not isinstance(value, dict): + raise _pc().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-") + + +_INSTALL_METADATA_LOCK_HOLDER = threading.local() + + +@contextmanager +def _install_metadata_lock(): + """Serialize read-modify-write of the sidecar across threads and processes. Installs overlap (the + Desktop install card runs its rows a second apart); each held a snapshot read before its clone, so + the later write dropped the earlier plugin's record.""" + from hermes_cli.auth import _file_lock + + path = _install_metadata_path() + with _file_lock(path.with_name(f"{path.name}.lock"), _INSTALL_METADATA_LOCK_HOLDER, 10.0, + "Timed out waiting for the plugin install metadata lock"): + yield + + +def _update_install_record(name: str, update: Callable[[Optional[dict]], Optional[dict]]) -> None: + """Rewrite one plugin's record in the CURRENT sidecar, under the lock. *update* maps the current + record (None when absent) to the new one (None removes it); every other record is re-read here, + never carried over from a caller's earlier snapshot.""" + with _install_metadata_lock(): + metadata = _pc()._read_install_metadata() + record = update(metadata.get(name)) + if record is None: + if name not in metadata: + return + del metadata[name] + else: + metadata[name] = record + _pc()._write_install_metadata(metadata) + + +def pinned_revision(name: str, metadata: Optional[dict] = None) -> Optional[str]: + """Full SHA a ``--ref`` install of *name* is pinned to, else ``None``.""" + entry = (metadata if metadata is not None else _pc()._read_install_metadata()).get(name) + if isinstance(entry, dict) and entry.get("pinned") is True and isinstance(entry.get("revision"), str): + return entry["revision"] + return None + + +def _pin_annotation(name: str, metadata: dict) -> Optional[str]: + sha = pinned_revision(name, metadata) + return f"git pinned@{sha[:8]}" if sha else None + + +def _normalize_exact_revision(ref: str) -> str: + """Lowercase a full 40-hex commit SHA; anything else is a PluginOperationError.""" + if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref): + raise _pc().PluginOperationError("--ref must be a full 40-character commit SHA.") + return ref.lower() + + +def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -> str: + """Diagnosable Git output without echoing embedded credentials.""" + from agent.redact import redact_sensitive_text + error = (result.stderr or result.stdout or "").strip() + if source_url: + error = error.replace(source_url, _scrub_git_url(source_url)) + return redact_sensitive_text(error) + + +def _git_or_raise( + git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = "", + auth_url: str = "", +) -> subprocess.CompletedProcess: + """Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error).""" + result = _pc()._run_plugin_git(git_exe, repo, *args, timeout=timeout, auth_url=auth_url) + if result.returncode != 0: + raise _pc().PluginOperationError(failure_prefix + _safe_git_error(result, source_url)) + return result + + +def _git_head_revision(repo: Path, git_exe: str) -> str: + return _git_or_raise( + git_exe, repo, "rev-parse", "HEAD", timeout=15, + failure_prefix="Could not determine installed Git revision:\n", + ).stdout.strip().lower() + + +def _git_resolve_commit(repo: Path, git_exe: str, revision: str) -> str: + """The COMMIT a revision names, peeling annotated tags. + + A catalog pin is 40 hex, but that does not make it a commit: a tag object + has a sha of its own, and a pin recorded as `git rev-parse ` names the + tag object, not the commit it points at. Git detaches at the commit, so + comparing HEAD against the tag object's sha refuses a correct checkout + (and the catalog installer then cannot install that entry at all). Peeling + first keeps the guard — HEAD must still BE that commit — while admitting + the pins authors actually publish. Returns `revision` unchanged when it + resolves to nothing, so the mismatch guard below still fires. + """ + try: + result = _pc()._run_plugin_git( + git_exe, repo, "rev-parse", "--verify", "--quiet", f"{revision}^{{commit}}", timeout=15, + ) + except (OSError, subprocess.TimeoutExpired): + return revision + resolved = result.stdout.strip().lower() + return resolved if result.returncode == 0 and resolved else revision + + +def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None: + """Fetch and detach at one immutable commit, then verify the resulting HEAD. The checkout is + a network verb too: in a partial (subdirectory) clone it downloads the file contents.""" + timeout = _pc()._clone_timeout_seconds() + for verb, args, failure_prefix in ( + ("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"), + ("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"), + ): + try: + _git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url, + auth_url=source_url, timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise _pc().PluginOperationError( + f"Git {verb} of commit '{revision}' timed out after {timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from exc + actual = _pc()._git_head_revision(repo, git_exe) + if actual != _git_resolve_commit(repo, git_exe, revision): + raise _pc().PluginOperationError( + f"Checked-out revision '{actual}' does not match requested commit '{revision}'.") + + +def _scrub_git_url(git_url: str) -> str: + """Strip credentials and query/fragment data from an HTTP Git URL.""" + parsed = urllib.parse.urlsplit(git_url) + if parsed.scheme in {"http", "https"} and parsed.hostname: + host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + if parsed.port is not None: + host = f"{host}:{parsed.port}" + return urllib.parse.urlunsplit((parsed.scheme, host, parsed.path, "", "")) + return git_url + + +def _canonical_source(git_url: str, subdir: Optional[str]) -> str: + scrubbed = _scrub_git_url(git_url) + return f"{scrubbed}#{subdir}" if subdir else scrubbed + + +def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None: + """Ensure credentials used for cloning do not survive in ``.git/config``.""" + scrubbed = _scrub_git_url(git_url) + if scrubbed != git_url: + _git_or_raise( + git_exe, repo, "remote", "set-url", "origin", scrubbed, timeout=15, + failure_prefix="Could not sanitize installed Git remote:\n", source_url=git_url) + + +def _restrict_checkout_to_subdir(repo: Path, git_exe: str, subdir: str) -> None: + """Sparse-check-out only *subdir*. Written as the classic ``info/sparse-checkout`` file + rather than ``git sparse-checkout set`` so older Git clients work too.""" + _git_or_raise(git_exe, repo, "config", "core.sparseCheckout", "true", timeout=15, + failure_prefix="Could not enable sparse checkout:\n") + pattern_file = repo / ".git" / "info" / "sparse-checkout" + pattern_file.parent.mkdir(parents=True, exist_ok=True) + escaped = re.sub(r"([\\*?\[])", r"\\\1", subdir.strip("/")) + pattern_file.write_text(f"/{escaped}/\n", encoding="utf-8") + + +def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str], + subdir: Optional[str] = None) -> 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. + + A *subdir* install is a blobless clone with a sparse checkout of that subdirectory: a plugin + living in a monorepo (Hindsight: 170 MB at depth 1, 2 MB for its plugin folder) otherwise + downloads every file in the repository, which times out on slow connections.""" + git_exe = _pc()._resolve_git_executable() + if not git_exe: + raise _pc().PluginOperationError("git is not installed or not in PATH.") + clone_timeout = _pc()._clone_timeout_seconds() + partial = ["--filter=blob:none"] if subdir else [] + no_checkout = ["--no-checkout"] if revision or subdir else [] + clone_args = ["clone", "--depth", "1", *partial, *no_checkout, git_url, str(tmp_clone)] + try: + result = _pc()._run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url, + timeout=clone_timeout) + except FileNotFoundError as e: + raise _pc().PluginOperationError("git is not installed or not in PATH.") from e + except subprocess.TimeoutExpired as e: + raise _pc().PluginOperationError(f"Git clone timed out after {clone_timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from e + if result.returncode != 0: + raise _pc().PluginOperationError(_pc()._clone_failure_message(git_url, _safe_git_error(result, git_url))) + _scrub_cloned_origin(tmp_clone, git_exe, git_url) + if subdir: + _restrict_checkout_to_subdir(tmp_clone, git_exe, subdir) + if revision: + _checkout_exact_revision(tmp_clone, git_exe, revision, source_url=git_url) + elif subdir: + try: + _git_or_raise(git_exe, tmp_clone, "checkout", "HEAD", timeout=clone_timeout, source_url=git_url, + auth_url=git_url, failure_prefix="Git checkout of the plugin subdirectory failed:\n") + except subprocess.TimeoutExpired as e: + raise _pc().PluginOperationError( + f"Git checkout timed out after {clone_timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from e + return _pc()._git_head_revision(tmp_clone, git_exe) + + +def _run_plugin_git( + git_exe: str, target: Path, *args: str, timeout: int = 60, auth_url: str = "", +) -> subprocess.CompletedProcess: + """Run one git command inside a plugin checkout (non-interactive). *auth_url* names the remote + a network verb talks to; it runs anonymously first and a stored user credential for that host + is attached only when the remote refuses anonymous access (private repos).""" + from hermes_cli.git_credentials import run_git_with_credential_fallback + return run_git_with_credential_fallback( + [git_exe, *args], auth_url, env=noninteractive_git_env(), capture_output=True, text=True, + encoding='utf-8', errors='replace', timeout=timeout, cwd=str(target)) + + +def _stash_ref(git_exe: str, target: Path) -> str: + """Current ``refs/stash`` commit, or empty string when no stash exists.""" + probe = _pc()._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, stash_sha: str) -> bool: + """``stash apply`` the autostash commit *stash_sha*; drop it on a clean apply. False when it + applied with errors or left unmerged paths (the stash entry is kept in that case). + + Git is addressed by the stash's commit sha, never a ``stash@{N}`` selector: on native Windows + the MSYS runtime re-parses git.exe's argv and strips the braces, so ``stash@{0}`` reaches git + as ``stash@0`` and both the apply and the drop fail (#87542).""" + restore = _pc()._run_plugin_git(git_exe, target, "stash", "apply", stash_sha) + unmerged = _pc()._run_plugin_git(git_exe, target, "diff", "--name-only", "--diff-filter=U") + if restore.returncode != 0 or unmerged.stdout.strip(): + return False + # `stash drop` only takes a selector; a bare `drop` targets the newest entry, so drop + # positionally only while the newest entry is still our autostash. + if _stash_ref(git_exe, target) == stash_sha: + _pc()._run_plugin_git(git_exe, target, "stash", "drop") + return True + + +def _autostash_dirty_tree(git_exe: str, target: Path) -> tuple[str, str]: + """Stash local edits before a pull. Returns ``(stash_sha, error)``; *stash_sha* is empty when + the tree was clean, and a non-empty error means the tree is dirty but nothing was saved, so + the pull must not run.""" + status = _pc()._run_plugin_git(git_exe, target, "status", "--porcelain", "-z") + if status.returncode != 0 or not status.stdout.strip(): + return "", "" + # `git add -N` entries make `git stash push` fail outright (see update_cmd_stash), so promote them + # to real staged adds first; the checkout's own local edits are otherwise unstashable. + from hermes_cli.update_cmd_stash import _intent_to_add_paths + + intent_to_add = _intent_to_add_paths(status.stdout) + if intent_to_add: + _pc()._run_plugin_git(git_exe, target, "add", "--", *intent_to_add) + pre_stash = _stash_ref(git_exe, target) + push = _pc()._run_plugin_git( + git_exe, target, "stash", "push", "--include-untracked", "-m", "hermes-plugin-update-autostash") + post_stash = _stash_ref(git_exe, target) + if not post_stash or post_stash == pre_stash: + err = _safe_git_error(push) + return "", ( + "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. + _pc()._run_plugin_git(git_exe, target, "reset", "--hard", "HEAD") + return post_stash, "" + + +def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]: + """``git pull --ff-only`` a plugin checkout, autostashing local edits (users patch installed + plugins in place, and a plain ff-only pull would then refuse forever). + + Users tweak installed plugins in place (config constants, small patches), and a plain ``pull --ff-only`` + then aborts with "Your local changes ... would be overwritten by merge" — making the plugin permanently + un-updatable until they hand-run git. Same UX class Factory Droid fixed in v0.188 ("Updating a plugin + marketplace now succeeds when its checkout has local changes"), and the same autostash approach ``hermes + update`` already uses for the main checkout (PR #70161). + """ + git_exe = _pc()._resolve_git_executable() + if not git_exe: + return False, "git is not installed or not in PATH." + try: + stash_sha, err = _autostash_dirty_tree(git_exe, target) + if err: + return False, err + origin = _pc()._run_plugin_git(git_exe, target, "remote", "get-url", "origin", timeout=15) + result = _pc()._run_plugin_git(git_exe, target, "pull", "--ff-only", auth_url=origin.stdout.strip()) + if result.returncode != 0: + err = _safe_git_error(result) or "git pull failed." + if not stash_sha: + return False, err + # Put the user's edits back before reporting the failure. + if _reapply_stash(git_exe, target, stash_sha): + note = "Local changes were restored." + else: + note = "Local changes are preserved in git stash (restore with: git stash pop)." + return False, f"{err}\n{note}" + + pulled = result.stdout.strip() + if not stash_sha: + return True, pulled + if _reapply_stash(git_exe, target, stash_sha): + 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. + _pc()._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` 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." diff --git a/hermes_cli/plugins_cmd_install.py b/hermes_cli/plugins_cmd_install.py new file mode 100644 index 0000000000..8d69e1eb6d --- /dev/null +++ b/hermes_cli/plugins_cmd_install.py @@ -0,0 +1,596 @@ +"""``hermes plugins install``: dependency/env consent, the atomic clone-scan-publish installer core, and +the dashboard/TUI non-interactive install. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is +imported late here, never at module level). +""" + +from __future__ import annotations + +import logging +import os +import sys +import tempfile +from pathlib import Path +from typing import Any, Optional + +from hermes_cli.cli_output import line_input + +logger = logging.getLogger(__name__) + + +def _pc(): + """The facade, read at call time: tests patch ``plugins_cmd.`` and sibling calls must see it.""" + from hermes_cli import plugins_cmd + return plugins_cmd + + +def _install_plugin_python_deps( + manifest: dict, target: Path, console +) -> tuple[bool, Optional[str]]: + """Consent gate for plugin python deps (settled 2026-09-02; C13 rework). + + Node sidecar: y/n prompt → ``npm ci`` into the plugin's OWN + node_modules (separate question, failure never blocks enable). + Python deps: NO resolution here — the resolve runs inside the ONE + admission transaction when the enable commits + (:func:`_admit_and_save_plugin_sets`), so the environment and the + config always change together or not at all. Returns (consented, + reason): consented=True when the user accepted (or no prompt was + needed); a decline/skip returns False and NOTHING is installed. + Never raises — the caller keeps the plugin installed-but-disabled. + """ + from pm.plugin_declarations import read_python_declaration + + try: + declaration = read_python_declaration(target) + deps = declaration.install_requirements + except Exception as exc: + return False, f"invalid Python dependency declaration: {exc}" + has_python = declaration.is_member + has_package_json = (target / "package.json").is_file() + if not has_python and not has_package_json: + return True, None # no declared deps at all + + # Node sidecar (package.json): the npm ci executor — separate consent + # question, same try-then-enable posture. Failure never blocks the + # python path below. + node_reason = None + if has_package_json: + console.print(f"\n[bold]{manifest.get('name', 'this plugin')}[/bold] declares Node dependencies (package.json).") + if sys.stdin.isatty() and sys.stdout.isatty(): + try: + node_answer = input( + " Install them into the plugin's own node_modules now? [y/N]: " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + node_answer = "" + else: + node_answer = "" + if node_answer in {"y", "yes"}: + from pm.workspace import install_node_sidecar + + node_reason = install_node_sidecar(target, explicit=True) + if node_reason: + console.print(f"[yellow]⚠[/yellow] Node deps: {node_reason}") + else: + console.print("[dim]Skipped Node deps — run `hermes plugins install` again to retry.[/dim]\n") + + if not has_python: + return True, None + return _consent_python_deps(manifest.get("name", "this plugin"), deps, console) + + +def _consent_python_deps(plugin_name: str, deps: tuple[str, ...], console) -> tuple[bool, Optional[str]]: + """The y/N gate for Python deps entering the shared environment — install, + reinstall AND an update that declares new ones all pass through here. + Returns (consented, reason); never raises.""" + console.print( + f"\n[bold]{plugin_name}[/bold] declares Python dependencies:" + ) + if deps: + for dep in deps: + console.print(f" - {dep}") + else: + console.print(" - (declared in its pyproject.toml)") + + # A decline or non-interactive invocation leaves the new plugin disabled. + if not (sys.stdin.isatty() and sys.stdout.isatty()): + console.print( + "[dim]Non-interactive install — skipping dependency install. " + "Run `hermes plugins enable` when ready to prepare them.[/dim]\n" + ) + return False, "dependency install skipped (non-interactive)" + try: + answer = input( + " Prepare these with Hermes through PM now? [y/N]: " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "" + if answer not in {"y", "yes"}: + console.print( + "[dim]Skipped — run `hermes plugins enable` when ready " + "to prepare them.[/dim]\n" + ) + return False, "dependency install declined" + + # Consent only — the python-deps resolution itself runs inside the ONE + # admission transaction at enable-commit time (C13): env + config move + # together or not at all. + return True, None + + +def _python_dependency_summary(target: Path, warnings: list[str]) -> list[str]: + """Read dashboard dependency details; publication and admission own installation.""" + from pm.plugin_declarations import read_python_declaration + + try: + return list(read_python_declaration(target).install_requirements) + except Exception as exc: + warnings.append(f"Could not read Python dependencies: {exc}") + return [] + + +def _prompt_plugin_env_vars(manifest: dict, console) -> None: + """Prompt for unset ``requires_env`` variables and save the answers to the user's ``.env``.""" + missing = _pc()._missing_env_specs(manifest) + if not missing: + return + from hermes_cli.config import save_env_value + from hermes_constants import display_hermes_home + plugin_name = manifest.get("name", "this plugin") + console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n") + for spec in missing: + name = spec["name"] + desc = spec.get("description", "") + url = spec.get("url", "") + console.print(f" {name}" + (f" — {desc}" if desc else "")) + if url: + console.print(f" [dim]Get yours at: {url}[/dim]") + try: + value = (_pc().masked_secret_prompt if spec.get("secret", False) else line_input)(f" {name}: ").strip() + except (EOFError, KeyboardInterrupt): + console.print(f"\n[dim] Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]") + return + + if value: + save_env_value(name, value) + os.environ[name] = value + console.print(f" [green]✓[/green] Saved to {display_hermes_home()}/.env") + else: + console.print(f" [dim] Skipped (set {name} in {display_hermes_home()}/.env later)[/dim]") + + console.print() + + +def _display_after_install(plugin_dir: Path, identifier: str) -> None: + """Show after-install.md if it exists, otherwise a default message.""" + from rich.markdown import Markdown + from rich.panel import Panel + console = _pc()._console() + after_install = plugin_dir / "after-install.md" + if after_install.exists(): + body, title = Markdown(after_install.read_text(encoding="utf-8-sig")), 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 _check_manifest_version(manifest: dict, plugin_name: str) -> None: + """Reject manifests declaring a newer ``manifest_version`` than this installer supports.""" + from pm.plugin_declarations import manifest_version_error + + reason = manifest_version_error(manifest, plugin_name) + if reason: + from hermes_cli.config import recommended_update_command + raise _pc().PluginOperationError(f"{reason} Run {recommended_update_command()} to update Hermes.") + + +def _read_manifest_for_install(plugin_dir: Path) -> dict: + """A candidate's unreadable or malformed manifest must stop publication.""" + native = _pc()._native_manifest_file(plugin_dir) + if native is not None: + try: + manifest = _pc()._load_yaml_manifest(native) + except Exception as exc: + raise _pc().PluginOperationError(f"Could not read plugin manifest {native}: {exc}") from exc + if not isinstance(manifest, dict): + raise _pc().PluginOperationError(f"Plugin manifest must be a mapping: {native}") + return manifest + if not _pc()._has_portable_manifest(plugin_dir): + return {} + try: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + manifest, diagnostics = read_agent_plugin_manifest(plugin_dir) + except Exception as exc: + raise _pc().PluginOperationError(f"Portable plugin manifest validation failed: {exc}") from exc + for diagnostic in diagnostics: + logger.warning("Agent Plugin install: %s", diagnostic.message) + return manifest + + +def _probe_readable(path: Path) -> None: + """Raise ``OSError`` unless *path* can actually be listed (dir) or opened for reading (file).""" + if path.is_dir(): + os.listdir(path) + else: + with open(path, "rb"): + pass + + +def _ensure_tree_readable(root: Path, plugins_dir: Path) -> None: + """Refuse to ship a tree Hermes cannot read back. A clone can land unreadable (Windows ACL + inheritance -> WinError 5, a mode-000 file) and discovery would then skip the plugin forever + (#111804); repair ``u+rX`` where the OS supports it, otherwise fail before anything moves.""" + paths = [root] + for dirpath, dirnames, filenames in os.walk(root): + paths.extend(Path(dirpath) / name for name in (*dirnames, *filenames)) + for path in paths: + try: + _probe_readable(path) + continue + except OSError: + if os.name != "nt": # chmod only toggles the read-only bit on Windows; ACLs need icacls + try: + os.chmod(path, os.stat(path).st_mode | (0o500 if path.is_dir() else 0o400)) + except OSError: + pass + try: + _probe_readable(path) + except OSError as exc: + fix = (f'icacls "{plugins_dir}" /grant:r "%USERNAME%":(OI)(CI)F /T' if os.name == "nt" + else f"chmod -R u+rX {plugins_dir}") + raise _pc().PluginOperationError( + f"Installed file {path.relative_to(root)} is not readable ({exc.strerror or exc}); " + f"nothing was installed. Fix permissions on {plugins_dir} (e.g. `{fix}`) and retry." + ) from exc + + +def _refuse_unavailable_portable_plugin(plugin_name: str, tree: Path) -> None: + if not (tree / "plugin.json").is_file(): + return + from hermes_cli.agent_plugins import load_agent_plugin + from hermes_platform.resolver.availability import availability + + try: + package = load_agent_plugin(tree, tree.parent / ".hermes-install-data") + except ValueError as exc: + raise _pc().PluginOperationError(f"Plugin '{plugin_name}' is unavailable: {exc}.") from exc + for server_name, server_decl in package.server_declarations.items(): + result = availability(server_decl.declaration) + if result.offerable: + continue + found = f", found version {result.version}" if result.version else "" + raise _pc().PluginOperationError( + f"Plugin '{plugin_name}' server '{server_name}' is unavailable: {result.state}{found}." + ) + + +def _install_plugin_core( + identifier: str, + *, + force: bool, + ref: Optional[str] = None, + scan_decision_cb=None, + reviewed_pin: Optional[str] = None, + python_deps: bool = True, + catalog: Optional[dict] = None, + allow_removed: bool = False, + before_swap=None, +) -> tuple[Path, dict, str]: + """Clone a Git plugin and atomically record its source and exact revision. + + *reviewed_pin* is the curated-catalog sha for this install; the scan trusts the tree + only when the checked-out revision is exactly that sha (an annotated-tag pin is peeled to + its commit first — HEAD can only ever be the commit). *python_deps* False refuses active + replacements; it never bypasses PM dependency admission. *catalog* + (``{"name", "repo", "tier", "pin"}``) is recorded on the install-metadata record with the + checked-out sha — provenance lives OUTSIDE the plugin tree, so a repo cannot forge it; + its ``pin`` is kept only when the checkout satisfies it (a ``--ref`` install is off-pin). + *allow_removed* records that the user knowingly bypassed the kill list. + *before_swap(manifest, tree)* runs on the validated clone before anything moves into place + and may raise :class:`PluginOperationError` to abort (re-pin consent).""" + requested_revision = _pc()._normalize_exact_revision(ref) if ref is not None else None + try: + git_url, subdir = _pc()._resolve_git_url(identifier) + except ValueError as e: + raise _pc().PluginOperationError(str(e)) from e + + plugins_dir = _pc()._plugins_dir() + source = _pc()._canonical_source(git_url, subdir) + old_metadata = _pc()._read_install_metadata() + + # Reinstalling the same pinned source retains its pin, even if its plugin + # directory was manually removed. Moving a pin requires an explicit --ref. + if requested_revision is None: + pins = [e for e in old_metadata.values() if e.get("source") == source and e.get("pinned") is True] + if len(pins) == 1 and isinstance(pins[0].get("revision"), str): + requested_revision = _pc()._normalize_exact_revision(pins[0]["revision"]) + + with tempfile.TemporaryDirectory(prefix=".install-", dir=plugins_dir) as tmp: + tmp_clone = Path(tmp) / "plugin" + installed_revision = _pc()._clone_plugin_repo(tmp_clone, git_url, requested_revision, subdir) + git_exe = _pc()._resolve_git_executable() + at_reviewed_pin = bool(reviewed_pin) and installed_revision == ( + _pc()._git_resolve_commit(tmp_clone, git_exe, reviewed_pin) if git_exe and reviewed_pin else reviewed_pin) + tmp_target = _pc()._resolve_subdir_within(tmp_clone, subdir) if subdir else tmp_clone + _ensure_tree_readable(tmp_target, plugins_dir) + manifest = _read_manifest_for_install(tmp_target) + plugin_name = manifest.get("name") or ( + subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _pc()._repo_name_from_url(git_url)) + try: + target = _pc()._sanitize_plugin_name(plugin_name, plugins_dir) + except ValueError as e: + raise _pc().PluginOperationError(str(e)) from e + _check_manifest_version(manifest, plugin_name) + # Scan BEFORE anything is moved into place; raises PluginScanBlocked when blocked. + _pc()._scan_plugin_tree(tmp_target, identifier, force=force, scan_decision_cb=scan_decision_cb, + reviewed_pin=at_reviewed_pin) + if not python_deps: + from pm.workspace import enabled_plugin_dirs + + if target.resolve() in enabled_plugin_dirs(installing=target): + raise _pc().PluginOperationError( + "--no-deps cannot replace an active plugin. Retry without --no-deps; " + "PM must prepare its dependencies before publication.") + _refuse_unavailable_portable_plugin(plugin_name, tmp_target) + if before_swap is not None: + before_swap(manifest, tmp_target) + + if target.exists() and not force: + raise _pc().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 _pc().PluginOperationError( + f"Plugin '{plugin_name}' is pinned. Reinstall it with an explicit " + "--ref <40-character commit SHA> to change its source or revision.") + + record: dict[str, object] = { + "pinned": requested_revision is not None, + "revision": installed_revision, + "source": source, + } + # Saved update_url tag (settled: claims vs provenance): the + # manifest's update_url is COPIED into the row at install. Check + # time compares manifest vs tag; a mismatch is needs-fixing and + # only `hermes plugins trust-update-url` moves the tag. + if manifest.get("update_url"): + from hermes_cli.plugins_updates import https_update_url + try: + record["update_url"] = https_update_url(manifest["update_url"]) + except ValueError as exc: + raise _pc().PluginOperationError(f"Plugin '{plugin_name}' {exc}") from exc + if catalog: + # ``sha`` = the commit checked out; ``pin`` = the reviewed catalog sha it satisfies (the + # annotated-tag object for a tag pin), empty when installed off-pin via ``--ref``. + record["catalog"] = { + **catalog, + "sha": installed_revision, + "pin": reviewed_pin if at_reviewed_pin else "", + } + from hermes_cli.plugins_cmd_catalog import write_catalog_sidecar_record + write_catalog_sidecar_record(tmp_target, catalog, installed_revision) + if allow_removed: + record["allow_removed"] = True + new_metadata = {**old_metadata, plugin_name: record} + from hermes_cli.plugins_transaction import publish_plugin + + try: + publish_plugin(tmp_target, target, old_metadata, new_metadata, require_consent=True) + except Exception as exc: + raise _pc().PluginOperationError(f"Plugin '{plugin_name}' was not published: {exc}") from exc + + if not _pc()._looks_like_plugin_dir(target): + logger.warning("%s has no plugin.yaml / __init__.py; may not be a valid plugin", plugin_name) + _pc()._copy_example_files(target, _pc()._console()) + installed_manifest = _pc()._read_manifest(target) + return target, installed_manifest, installed_manifest.get("name") or target.name + + +def cmd_install( + identifier: str, + force: bool = False, + enable: Optional[bool] = None, + ref: Optional[str] = None, + allow_removed: bool = False, + no_deps: bool = False, +) -> None: + """Install a plugin from the curated catalog (bare name), a Git URL, or owner/repo shorthand. + + A catalog hit installs the reviewed pinned SHA and records catalog membership in the shared install + metadata. An explicit different ``--ref`` is a custom pin. URLs/shorthand are custom sources. Every + install is checked against the catalog kill list unless *allow_removed*. + *enable* None prompts "Enable now? [y/N]"; True/False skip the prompt. + """ + from hermes_cli import plugins_cmd_catalog as catalog + console = _pc()._console() + entry = None + if catalog.looks_like_catalog_name(identifier): + entry = catalog.resolve_catalog_name(identifier, console) + identifier = entry.install_identifier + console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan] [dim]pinned @ {entry.sha[:8]}[/dim]") + console.print(catalog.entry_capability_summary(entry)) + else: + console.print("[yellow]Warning:[/yellow] custom (unreviewed) source — not from the Hermes catalog.") + if allow_removed: + console.print( + "[bold red]WARNING:[/bold red] [red]--allow-removed set — skipping the catalog kill-list check. " + "This plugin may have been removed for security reasons.[/red]") + + try: + git_url, _subdir = _pc()._resolve_git_url(identifier) + if not allow_removed: + catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ())) + except (ValueError, _pc().PluginOperationError) as e: + _pc()._fail(console, f"[red]Error:[/red] {e}") + if git_url.startswith(("http://", "file://")): + console.print( + "[yellow]Warning:[/yellow] Using insecure/local URL scheme. " + "Consider using https:// or git@ for production installs.") + + console.print(f"[dim]Cloning {git_url}{f' (subdir: {_subdir})' if _subdir else ''}...[/dim]") + + def _interactive_scan_decision(scan_result) -> bool: + """Prompt the user to accept a caution-verdict plugin.""" + from tools.plugin_guard import format_scan_report + console.print() + console.print("[yellow]⚠ Security scan flagged this plugin:[/yellow]") + console.print(format_scan_report(scan_result)) + return _pc()._is_tty() and _pc()._ask_yes(" Install anyway? Only continue if you trust the source. [y/N]: ") + + try: + if entry is not None: + target, installed_manifest, installed_name = catalog.install_catalog_entry( + entry, force=force, ref=ref, allow_removed=allow_removed, scan_decision_cb=_interactive_scan_decision, + python_deps=not no_deps) + else: + target, installed_manifest, installed_name = _pc()._install_plugin_core( + identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision, + python_deps=not no_deps, allow_removed=allow_removed) + except _pc().PluginOperationError as e: + _pc()._fail(console, f"[red]{'Blocked' if isinstance(e, _pc().PluginScanBlocked) else 'Error'}:[/red] {e}") + if not _pc()._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) + + from pm.workspace import enabled_plugin_dirs + + # Active replacements settled consent against the staged tree before PM + # prepared or published it. Do not present a second, ineffective veto. + already_active = target.resolve() in enabled_plugin_dirs() + should_enable = False if no_deps else enable + if no_deps: + console.print("[dim]--no-deps: skipping dependency consent; the plugin stays disabled.[/dim]") + if should_enable is None and not already_active: + should_enable = _pc()._is_tty() and _pc()._ask_yes(f" Enable '{installed_name}' now? [y/N]: ") + deps_ok, deps_reason = (True, None) + if should_enable and not already_active: + deps_ok, deps_reason = _install_plugin_python_deps(installed_manifest, target, console) + + _pc()._display_after_install(target, identifier) + + # ONE admission transaction for the enable (C13): resolve the candidate + # union (enabled members + this target) and commit the config in the + # same step — env and config change together or not at all. No + # duplicate sync here: nothing was resolved before this point. + if should_enable and not deps_ok: + # Consent declined/skipped: nothing was installed or changed, so + # enabling is refused without touching config or environment. + console.print( + f"[red]✗[/red] Cannot enable [bold]{installed_name}[/bold]: " + f"{deps_reason}" + ) + console.print( + "[dim]The plugin stays installed but disabled; re-enable " + "after resolving the conflict.[/dim]" + ) + should_enable = False + + if already_active: + console.print("[dim]Replacement installed; plugin selection was not changed.[/dim]") + elif should_enable: + from hermes_cli.plugins_admission import AdmissionRefused + + try: + _pc()._set_plugin_enabled(installed_name, enable=True, console=console) + except AdmissionRefused: + console.print( + "[dim]The plugin stays installed but disabled; re-enable " + "after resolving the conflict.[/dim]" + ) + else: + console.print( + f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.", + ) + else: + console.print( + f"[dim]Plugin installed but not enabled. " + f"Run `hermes plugins enable {installed_name}` to activate.[/dim]") + + # Non-interactive installs and declines leave declared capabilities ungranted (fail closed). + declared_caps = _pc()._declared_capabilities_from_manifest(installed_manifest, installed_name) + if declared_caps: + _pc()._run_capability_consent(console, installed_name, declared_caps, context="install") + if enable: + # Loads it into the running gateway now (handlers live) or says what needs a restart (#87770). + from hermes_cli.plugins_activation import activate_plugin_now, activation_hint + console.print(f"[dim]{activation_hint(activate_plugin_now(installed_name, in_process=False))}[/dim]") + console.print() + + +def dashboard_install_plugin( + identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None, + ref: Optional[str] = None, +) -> dict[str, Any]: + """Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its + pinned SHA (identifier may be empty); *ref* pins a custom source to one full commit SHA (same + contract as ``--ref``); every path enforces the kill list (no GUI bypass).""" + from hermes_cli import plugins_cmd_catalog as catalog + warnings: list[str] = [] + entry = None + if catalog_name: + entry = catalog.get_live_catalog_entry(catalog_name) + if entry is None: + return {"ok": False, "error": f"'{catalog_name}' is not in the Hermes plugin catalog."} + identifier = entry.install_identifier + else: + warnings.append("Custom (unreviewed) source — not from the Hermes catalog.") + try: + git_url = _pc()._resolve_git_url(identifier)[0] + if git_url.startswith(("http://", "file://")): + warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.") + catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ())) + except ValueError: + pass + except _pc().PluginOperationError as exc: + return {"ok": False, "error": str(exc)} + try: + if entry is not None: + target, installed_manifest, installed_name = catalog.install_catalog_entry( + entry, force=force, allow_removed=False) + else: + target, installed_manifest, installed_name = _pc()._install_plugin_core( + identifier, force=force, ref=(ref or "").strip() or None) + except _pc().PluginScanBlocked as exc: + fields = ("pattern_id", "severity", "category", "file", "line", "description") + return { + "ok": False, "error": str(exc), "scan_blocked": True, + "scan_verdict": getattr(exc.scan_result, "verdict", "dangerous"), + "scan_findings": [ + {k: getattr(f, k) for k in fields} + for f in (exc.scan_result.findings if exc.scan_result is not None else ()) + ], + } + except _pc().PluginOperationError as exc: + return {"ok": False, "error": str(exc)} + + if enable: + from hermes_cli.plugins_admission import AdmissionRefused + + try: + _pc()._set_plugin_enabled(installed_name, enable=True) + except AdmissionRefused as exc: + return { + "ok": False, "error": f"enable refused: {exc}", + "plugin_name": installed_name, "enabled": False, + } + deps = _pc()._python_dependency_summary(target, warnings) + ap = target / "after-install.md" + # Deps first, then load: the plugin activates in this process (TUI/Desktop server subscribers see it) + # and in the running gateway; ``activation`` says what is live now vs next session (#87770). + from hermes_cli.plugins_activation import activate_plugin_now + activated = activate_plugin_now(installed_name) if enable else { + "gateway_reloaded": False, "activation": None, "restart_required": False} + return { + "ok": True, "plugin_name": installed_name, "warnings": warnings, + "python_dependencies": deps, + "missing_env": [s["name"] for s in _pc()._missing_env_specs(installed_manifest)], + "after_install_path": str(ap) if ap.exists() else None, "enabled": enable, **activated, + } diff --git a/hermes_cli/plugins_cmd_listing.py b/hermes_cli/plugins_cmd_listing.py new file mode 100644 index 0000000000..b1c29e10a7 --- /dev/null +++ b/hermes_cli/plugins_cmd_listing.py @@ -0,0 +1,173 @@ +"""Read-only presentation: ``hermes plugins list``, ``show`` and ``compat``. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is +imported late here, never at module level). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def _pc(): + """The facade, read at call time: tests patch ``plugins_cmd.`` and sibling calls must see it.""" + from hermes_cli import plugins_cmd + return plugins_cmd + + +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): + active = _pc()._category_active_names() + filtered = [ + entry for entry in filtered + if _pc()._plugin_status(entry[0], enabled, disabled, key=entry[5], source=entry[3], dir_path=entry[4], + active=active) == "enabled" + ] + return filtered + + +_STATUS_MARKUP = {"disabled": "[red]disabled[/red]", "enabled": "[green]enabled[/green]"} + + +def cmd_list(args: Any | None = None) -> None: + """List all plugins (bundled + user) with enabled/disabled state.""" + console = _pc()._console() + entries = _pc()._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 = _pc()._get_enabled_set() + disabled = _pc()._get_disabled_set() + entries = _filter_plugin_entries(entries, args, enabled, disabled) + from hermes_cli import plugins_cmd_catalog as catalog + # Source shows catalog provenance (``catalog:@``) or a ``--ref`` pin + # (``git pinned@``) so a team can eyeball that everyone runs the same commit. + pins = _pc()._read_install_metadata() + # One kill-list resolution for the whole listing: resolving per row costs a live-catalog + # fetch per installed plugin when the catalog host is slow or unreachable. + removed_entries = catalog.resolved_removed_entries() + active = _pc()._category_active_names() + rows = [ + (name, _pc()._plugin_status(name, enabled, disabled, key=key, source=source, dir_path=_dir, active=active), + str(version), description, + catalog.catalog_annotation(_dir) or _pc()._pin_annotation(name, pins) or source, + catalog.removed_annotation(name, _dir, removed_entries)) + for name, version, description, source, _dir, key in entries + ] + + if getattr(args, "json", False): + keys = ("name", "status", "version", "description", "source", "removed") + print(json.dumps([dict(zip(keys, row)) for row in rows], indent=2)) + return + + if getattr(args, "plain", False): + for name, status, version, _description, source, _removed in rows: + print(f"{status:12} {source:8} {version:8} {name}") + return + + if not entries: + console.print("[dim]No plugins matched the selected filters.[/dim]") + return + + table = _pc()._table( + (("Name", "bold"), ("Status", None), ("Version", "dim"), ("Description", None), ("Source", "dim")), + title="Plugins", show_lines=False) + # provenance class per user-installed dir (bundled entries show '-') + from hermes_cli.plugins_provenance import plugins_provenance + + prov_classes = { + p.name: p.klass.value for p in plugins_provenance(_pc()._plugins_dir()) + } + + removed_lines = [] + for name, status_name, version, description, source, removed in rows: + klass = prov_classes.get(name) + status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]") + if removed: + name = f"[red]{name} ✗[/red]" + removed_lines.append(f"[red]✗ {name}[/red] was removed from the plugin catalog: {removed}") + table.add_row(name, status, version, description, source) + # class line rides the Source column for user plugins + if source in {"user", "git"}: + if klass and klass != "git": + table.add_row( + "", "", "", f"[dim]provenance: {klass}[/dim]", "" + ) + console.print() + console.print(table) + for line in removed_lines: + console.print(line) + console.print() + console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled") + console.print("[dim]Interactive toggle:[/dim] hermes plugins") + console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable ") + console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]") + + +def cmd_show(name: str) -> None: + """Show details for a single plugin, including declared emits/listens.""" + console = _pc()._console() + match = _pc()._find_plugin_entry(name) + if match is None: + console.print(f"[red]Plugin '{name}' not found.[/red]") + _pc()._fail(console, "[dim]List installed plugins:[/dim] hermes plugins list") + + pname, version, description, source, dir_path, key = match + manifest = _pc()._read_manifest(Path(dir_path)) if dir_path else {} + emits = manifest.get("emits") or [] + listens = manifest.get("listens") or [] + status = _pc()._plugin_status(pname, _pc()._get_enabled_set(), _pc()._get_disabled_set(), key=key) + console.print() + console.print(f"[bold]{pname}[/bold]" + (f" [dim]v{version}[/dim]" if version else "")) + if description: + console.print(description) + console.print(f"[dim]Status:[/dim] {status}") + console.print(f"[dim]Source:[/dim] {source}") + console.print(f"[dim]Key:[/dim] {key}") + console.print("[dim]Emits:[/dim] " + (", ".join(emits) if emits else "[dim](none)[/dim]")) + console.print("[dim]Listens:[/dim] " + (", ".join(listens) if listens else "[dim](none)[/dim]")) + console.print() + + +def cmd_compat(args: Any | None = None) -> None: + """``hermes plugins compat`` — which installed plugins import paths scheduled for removal, and where.""" + import sys + from pathlib import Path + from hermes_cli.plugin_compat import ( + ALLOW_KEY, COMPAT_REMOVAL, compat_report, removal_in_effect, scan_plugin, summary_lines) + console = _pc()._console() + path = getattr(args, "path", None) + if path: + hits = scan_plugin(Path(path).expanduser().resolve()) + report = {Path(path).name: hits} if hits else {} + else: + report = compat_report(force=True) + if getattr(args, "json", False): + print(json.dumps({"removal_date": COMPAT_REMOVAL, "in_effect": removal_in_effect(), + "plugins": {k: [h.__dict__ for h in v] for k, v in report.items()}}, indent=2)) + sys.exit(1 if report else 0) + if not report: + console.print(f"[green]✓ No enabled plugin imports paths scheduled for removal on {COMPAT_REMOVAL}.[/green]") + return + head, tail = summary_lines(report) + console.print(f"[bold {'red' if removal_in_effect() else 'yellow'}]{head}[/]") + console.print(f"[dim]{tail}[/dim]") + for name, hits in sorted(report.items()): + table = _pc()._table(((f"{name} ({len(hits)} import{'s' if len(hits) != 1 else ''})", "bold"), ("old path", "yellow"), ("new path", "green")), + title=None, show_lines=False) + for h in hits: + table.add_row(f"{h.file}:{h.line}", h.old, h.new) + console.print() + console.print(table) + console.print() + console.print(f"[dim]After {COMPAT_REMOVAL} these plugins are not loaded. Update them, or force-load with " + f"plugins.{ALLOW_KEY}: true in config.yaml (the old paths still break once the compat layer is reverted).[/dim]") + sys.exit(1) diff --git a/hermes_cli/plugins_cmd_remove.py b/hermes_cli/plugins_cmd_remove.py new file mode 100644 index 0000000000..90ee86e833 --- /dev/null +++ b/hermes_cli/plugins_cmd_remove.py @@ -0,0 +1,94 @@ +"""``hermes plugins remove``: tree + install-metadata removal kept consistent, config bookkeeping, and the +dashboard/TUI remove path. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is +imported late here, never at module level). +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Any + + +def _pc(): + """The facade, read at call time: tests patch ``plugins_cmd.`` and sibling calls must see it.""" + from hermes_cli import plugins_cmd + return plugins_cmd + + +def _remove_plugin_core(target: Path) -> None: + """Remove one plugin and its metadata without splitting their state.""" + if target.name not in _pc()._read_install_metadata(): + _pc().rmtree_readonly(target) + return + staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.remove-", dir=target.parent)) + backup = staging / "plugin" + os.replace(target, backup) + try: + _pc()._update_install_record(target.name, lambda _current: None) + except Exception: + try: + os.replace(backup, target) + except OSError as restore_exc: + raise _pc().PluginOperationError( + f"Plugin metadata update failed and '{target.name}' could not be " + f"restored automatically; recovery copy remains at {backup}." + ) from restore_exc + _pc().rmtree_readonly(staging, ignore_errors=True) + raise + _pc().rmtree_readonly(staging) + + +def cmd_remove(name: str) -> None: + """Remove an installed plugin by name.""" + console = _pc()._console() + plugins_dir = _pc()._plugins_dir() + target = _pc()._require_installed_plugin(name, plugins_dir, console) + try: + result = _remove_user_plugin(plugins_dir, name, target) + except (OSError, _pc().PluginOperationError) as exc: + _pc()._fail(console, f"[red]Error:[/red] Could not remove plugin '{name}': {exc}") + console.print() + console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}") + if result.get("cleared_memory_provider"): + console.print("[yellow]memory.provider pointed at this plugin and was reset; " + "run `hermes memory setup` to pick another.[/yellow]") + console.print() + + +def _remove_user_plugin(plugins_dir: Path, name: str, target: Path) -> dict[str, Any]: + """Shared ``remove`` tail for the CLI, the dashboard and the ``plugins.manage`` RPC. + + *target* is the resolved directory; when ``plugins_dir/name`` itself is a symlink only the link + goes — the tree it points at may be another installed plugin (a dev alias to a sibling checkout), + and following it deleted that plugin plus its install metadata while the alias stayed dangling. + Config bookkeeping (aliases, toolset) is gathered before the tree disappears. + """ + link = plugins_dir / name.strip("/") + if link.is_symlink(): + link.unlink() + return {"ok": True, "name": name, **_pc()._forget_plugin_config({link.name})} + entry = next((e for e in _pc()._discover_all_plugins() if Path(str(e[4])) == target), None) + key = entry[5] if entry else target.name + aliases = _pc()._plugin_aliases(key) | {target.name} + if _pc()._read_manifest(target).get("provides_tools"): + _pc()._toggle_plugin_toolset(key, enable=False) + _remove_plugin_core(target) + return {"ok": True, "name": name, **_pc()._forget_plugin_config(aliases)} + + +def dashboard_remove_user_plugin(name: str) -> dict[str, Any]: + """Delete a plugin tree under ``~/.hermes/plugins/`` only.""" + plugins_dir = _pc()._plugins_dir() + if any(n == name and src == "bundled" for n, _ver, _d, src, _path, _key in _pc()._discover_all_plugins()): + return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."} + target = _pc()._user_installed_plugin_dir(name) + if target is None: + return {"ok": False, "error": f"Plugin '{name}' was not found under {plugins_dir}."} + try: + return _remove_user_plugin(plugins_dir, name, target) + except (OSError, _pc().PluginOperationError) as exc: + return {"ok": False, "error": f"Could not remove plugin '{name}': {exc}"} diff --git a/hermes_cli/plugins_cmd_toggle.py b/hermes_cli/plugins_cmd_toggle.py new file mode 100644 index 0000000000..20efb50674 --- /dev/null +++ b/hermes_cli/plugins_cmd_toggle.py @@ -0,0 +1,335 @@ +"""The interactive ``hermes plugins`` composite UI: general-plugin checkboxes (saved through the one +admission authority) and the memory-provider / context-engine category pickers. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is +imported late here, never at module level). +""" + +from __future__ import annotations + +import functools +import sys + + +def _pc(): + """The facade, read at call time: tests patch ``plugins_cmd.`` and sibling calls must see it.""" + from hermes_cli import plugins_cmd + return plugins_cmd + + +def _discover_memory_providers() -> list[tuple[str, str]]: + """``[(name, description), ...]`` for available memory providers.""" + try: + from plugins.memory import discover_memory_providers + return [(name, desc) for name, desc, _avail in discover_memory_providers()] + except Exception: + return [] + + +def _discover_context_engines() -> list[tuple[str, str]]: + """``[(name, description), ...]`` for repo-shipped context engines plus the plugin-registered + one (``ctx.register_context_engine``); repo-shipped descriptions win on a name collision.""" + engines: dict[str, str] = {} + try: + from plugins.context_engine import discover_context_engines + for name, desc, _avail in discover_context_engines(): + engines.setdefault(name, desc) + except Exception: + pass + try: + from hermes_cli.plugins import discover_plugins, get_plugin_context_engine + discover_plugins() + plugin_engine = get_plugin_context_engine() + if plugin_engine and getattr(plugin_engine, "name", None): + engines.setdefault(plugin_engine.name, "installed plugin") + except Exception: + pass + return list(engines.items()) + + +# (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: _pc()._get_current_memory_provider(), + lambda: _discover_memory_providers(), lambda v: _pc()._save_memory_provider(v)), + ("Context Engine", "compressor", "compressor", lambda: _pc()._get_current_context_engine(), + lambda: _pc()._discover_context_engines(), lambda v: _pc()._save_context_engine(v)), +) + + +def _configure_category_spec(spec) -> bool: + """Radio picker for one ``_PROVIDER_CATEGORY_SPECS`` row: the built-in default first, then the + discovered choices; a current value not among them is appended as ``(not found)``. Saves and + returns True when the choice changed.""" + from hermes_cli.curses_ui import curses_radiolist + title, default_label, default_name, current, discover, save = spec + current = current() + choices = discover() + names = [default_name] + [name for name, _desc in choices] + items = [f"{default_label} (default)"] + [f"{name} \u2014 {desc}" if desc else name for name, desc in choices] + if current not in names: + names.append(current) + items.append(f"{current} (not found)") + selected = max(i for i, name in enumerate(names) if name == current) + new_value = names[curses_radiolist(title=f"{title} (select one)", items=items, selected=selected)] + if new_value == current: + return False + save(new_value) + return True + + +def _provider_categories() -> list: + """``[(title, current_label, configure_fn), ...]`` rows for the composite UI.""" + return [(s[0], s[3]() or s[1], functools.partial(_configure_category_spec, s)) for s in _PROVIDER_CATEGORY_SPECS] + + +def cmd_toggle() -> None: + """Interactive composite UI — general plugins + provider plugin categories.""" + console = _pc()._console() + entries = _pc()._discover_all_plugins() + expected_config = _pc()._plugin_selection_version() + enabled_set = _pc()._get_enabled_set() + disabled_set = _pc()._get_disabled_set() + + # Track by CANONICAL KEY, not manifest name: the loader and enable/disable all gate on the + # key (``web/firecrawl``) while the name may differ (``web-firecrawl``); persisting the bare + # name let plugins.disabled drift so "explicit disable wins" kept a plugin off forever. + plugin_keys = [entry[5] for entry in entries] + # Keys keep every surface aligned. See #40190. + plugin_labels = [ + (f"{name} \u2014 {description}" if description else name) + (" [bundled]" if source == "bundled" else "") + for name, _version, description, source, _d, _key in entries + ] + # Selected when enabled AND not disabled; the legacy bare name counts on either side. + plugin_selected = { + i for i, (name, _v, _desc, _src, _d, key) in enumerate(entries) + if {key, name} & enabled_set and not ({key, name} & disabled_set) + } + categories = _pc()._provider_categories() + + if not sys.stdin.isatty(): + console.print("[dim]Interactive mode requires a terminal.[/dim]") + return + try: + import curses + _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config) + except ImportError: + _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console, expected_config=expected_config) + + +def _persist_plugin_selection(plugin_keys, chosen, disabled, *, expected_config=None) -> tuple[bool, set]: + """Save the composite UI's checkbox state; returns ``(changed, new_enabled)``. + + Unchecked plugins go to the disabled-list (so they stay off even if something auto-enables + them) under the canonical key ONLY, so the list can't drift from what ``cmd_enable`` clears. + Re-checking also drops any stale legacy bare-leaf disable. + """ + # See #40190. + # Persist by canonical key only — never the bare manifest name — so the disabled-list stays aligned with + # cmd_enable / PluginManager (#40190). + if expected_config is None: + expected_config = _pc()._plugin_selection_version() + 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) + _pc()._discard_key_and_leaf(new_disabled, key) + else: + new_disabled.add(key) + + changed = new_enabled != _pc()._get_enabled_set() or new_disabled != disabled + if changed: + # C13: the composite UI's candidate goes through the ONE admission + # authority — refusal raises AdmissionRefused BEFORE any config + # write; the caller surfaces it and the selection stays unsaved. + _pc()._admit_and_save_plugin_sets(new_enabled, new_disabled, action="Save plugin selection", expected_config=expected_config) + return changed, new_enabled + + +def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None): + """Custom curses screen with checkboxes + category action rows.""" + from hermes_cli.curses_ui import _addnstr, flush_stdin + chosen = set(plugin_selected) + n_plugins, n_categories = len(plugin_keys), len(categories) + total_items = n_plugins + n_categories # navigable rows (headers/separator are skipped) + providers_changed = False + nav = { # key -> new cursor, given (cursor, page_size) + key: move + for keys, move in ( + ((curses.KEY_UP, ord("k")), lambda c, p: (c - 1) % total_items), + ((curses.KEY_DOWN, ord("j")), lambda c, p: (c + 1) % total_items), + ((curses.KEY_NPAGE, ord("f")), lambda c, p: min(total_items - 1, c + p)), + ((curses.KEY_PPAGE, ord("b")), lambda c, p: max(0, c - p)), + ((curses.KEY_HOME,), lambda c, p: 0), + ((curses.KEY_END,), lambda c, p: total_items - 1), + ) + for key in keys + } + + def _init_colors(): + if curses.has_colors(): + curses.start_color() + curses.use_default_colors() + gray = 8 if curses.COLORS > 8 else curses.COLOR_WHITE + for pair, fg in ((1, curses.COLOR_GREEN), (2, curses.COLOR_YELLOW), (3, curses.COLOR_CYAN), (4, gray)): + curses.init_pair(pair, fg, -1) + + def _attr(base, pair): + return base | curses.color_pair(pair) if curses.has_colors() else base + + def _row(text, idx, cursor, pair): + """One navigable body row: arrow marker + bold color when *idx* is the cursor.""" + arrow = "\u2192" if idx == cursor else " " + return (f" {arrow} {text}", _attr(curses.A_BOLD, pair) if idx == cursor else curses.A_NORMAL) + + def _configure_category(ci): + """Leave curses, run the category's picker, refresh its row, re-enter curses.""" + nonlocal providers_changed + curses.endwin() + cat_name, _cat_cur, cat_fn = categories[ci] + if cat_fn(): + providers_changed = True + categories[ci] = (cat_name, _pc()._provider_categories()[ci][1], cat_fn) + stdscr = curses.initscr() + curses.noecho() + curses.cbreak() + stdscr.keypad(True) + _init_colors() + curses.curs_set(0) + return stdscr + + def _body_lines(cursor, scroll_offset, visible_rows): + """Body rows as (text, attr); "" is a blank separator.""" + lines = [] + if n_plugins > 0: + lines.append((" General Plugins", _attr(curses.A_BOLD, 2))) + for i in range(scroll_offset, min(n_plugins, scroll_offset + max(visible_rows, 0))): + check = "\u2713" if i in chosen else " " + lines.append(_row(f"[{check}] {plugin_labels[i]}", i, cursor, 1)) + lines.append(("", curses.A_NORMAL)) + if n_categories > 0: + lines.append((" Provider Plugins", _attr(curses.A_BOLD, 2))) + lines += [ + _row(f" {cat_name:<24} \u25b8 {cat_current}", n_plugins + ci, cursor, 3) + for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories) + ] + return lines + + def _draw(stdscr): + curses.curs_set(0) + _init_colors() + cursor = scroll_offset = 0 + while True: + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + _addnstr(stdscr, 0, 0, "Plugins", max_x - 1, _attr(curses.A_BOLD, 2)) + _addnstr( + stdscr, 1, 0, " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done", + max_x - 1, curses.A_DIM) + visible_rows = max_y - 4 + if cursor < scroll_offset: + scroll_offset = cursor + elif cursor >= scroll_offset + visible_rows: + scroll_offset = cursor - visible_rows + 1 + lines = _body_lines(cursor, scroll_offset, visible_rows) + for y, (text, attr) in enumerate(lines[: max(0, max_y - 4)], start=3): + if text: + _addnstr(stdscr, y, 0, text, max_x - 1, attr) + stdscr.refresh() + key = stdscr.getch() + + if key in nav: + if total_items > 0: # (with no rows, every motion leaves cursor at 0) + cursor = nav[key](cursor, max(1, max_y - 5)) + elif key == ord(" ") or key in {curses.KEY_ENTER, 10, 13}: + if cursor >= n_plugins: + # Provider category — launch sub-screen (SPACE and ENTER alike) + if cursor - n_plugins < n_categories: + stdscr = _configure_category(cursor - n_plugins) + elif key == ord(" "): + chosen.symmetric_difference_update({cursor}) + else: + return # ENTER on a plugin checkbox — confirm and exit + elif key in {27, ord("q")}: + return # plugin changes are saved on exit + + curses.wrapper(_draw) + flush_stdin() + + from hermes_cli.plugins_admission import AdmissionRefused + + try: + changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled, expected_config=expected_config) + except AdmissionRefused as exc: + console.print(f"[red]✗[/red] Plugin selection refused, not saved: {exc}") + console.print( + "[dim]config.yaml and the active environment are unchanged. " + "Run `hermes pm install` to resolve, then retry.[/dim]" + ) + return + if changed: + console.print( + f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, " + f"{len(plugin_keys) - len(new_enabled)} disabled.") + elif n_plugins > 0: + console.print("\n[dim]General plugins unchanged.[/dim]") + if providers_changed: + console.print( + f"[green]\u2713[/green] Memory provider: [bold]{_pc()._get_current_memory_provider() or 'built-in'}[/bold] " + f"Context engine: [bold]{_pc()._get_current_context_engine()}[/bold]") + if n_plugins > 0 or providers_changed: + console.print("[dim]Changes take effect on next session.[/dim]") + console.print() + + +def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled, categories, console, *, expected_config=None): + """Text-based fallback for the composite plugins UI.""" + from hermes_cli.colors import Colors, color + print(color("\n Plugins", Colors.YELLOW)) + if plugin_keys: + chosen = set(plugin_selected) + print(color("\n General Plugins", Colors.YELLOW)) + print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM)) + while True: + for i, label in enumerate(plugin_labels): + marker = color("[\u2713]", Colors.GREEN) if i in chosen else "[ ]" + print(f" {marker} {i + 1:>2}. {label}") + print() + try: + val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip() + if not val: + break + idx = int(val) - 1 + if 0 <= idx < len(plugin_keys): + chosen.symmetric_difference_update({idx}) + except (ValueError, KeyboardInterrupt, EOFError): + return + print() + _save_plugin_selection_fallback(plugin_keys, chosen, disabled, expected_config=expected_config) + + if categories: + print(color("\n Provider Plugins", Colors.YELLOW)) + for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories): + print(f" {ci + 1}. {cat_name} [{cat_current}]") + print() + try: + val = input(color(" Configure # (or Enter to skip): ", Colors.DIM)).strip() + if val: + ci = int(val) - 1 + if 0 <= ci < len(categories): + categories[ci][2]() + except (ValueError, KeyboardInterrupt, EOFError): + pass + print() + + +def _save_plugin_selection_fallback(plugin_keys, chosen, disabled, *, expected_config=None) -> None: + """The text fallback's save: same admission authority, refusal printed.""" + from hermes_cli.plugins_admission import AdmissionRefused + + try: + _persist_plugin_selection(plugin_keys, chosen, disabled, expected_config=expected_config) + except AdmissionRefused as exc: + print(f" Plugin selection refused, not saved: {exc}") + print(" config.yaml and the active environment are unchanged.") diff --git a/hermes_cli/plugins_cmd_update.py b/hermes_cli/plugins_cmd_update.py new file mode 100644 index 0000000000..b7ff1179f2 --- /dev/null +++ b/hermes_cli/plugins_cmd_update.py @@ -0,0 +1,319 @@ +"""``hermes plugins update`` plus the provenance verbs around it: ``adopt``, ``trust-update-url`` and the +read-only ``check-updates``; the dashboard update path shares the same pull/re-clone core. + +Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is +imported late here, never at module level). +""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from typing import Any + + +def _pc(): + """The facade, read at call time: tests patch ``plugins_cmd.`` and sibling calls must see it.""" + from hermes_cli import plugins_cmd + return plugins_cmd + + +def _pull_plugin_update(target: Path, pinned_msg, not_git_msg, before_pull=None, *, interactive: bool = False) -> str: + """Shared ``update`` core: refuse pinned checkouts, ``git pull`` (or re-install from the + recorded source when the tree carries no ``.git`` — subdirectory installs), 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 = _pc()._read_install_metadata() + install_record = metadata.get(target.name, {}) + if install_record.get("pinned") is True: + raise _pc().PluginOperationError(pinned_msg(install_record)) + # A URL install whose name/repo later landed on the kill list must not keep pulling or + # re-cloning new code, including subdirectory installs that carry no local .git directory. + from hermes_cli import plugins_cmd_catalog as catalog + catalog.refuse_if_installed_removed(target.name, target) + if not (target / ".git").exists(): + source = install_record.get("source") + if not isinstance(source, str) or not source: + raise _pc().PluginOperationError(not_git_msg()) + if before_pull is not None: + before_pull() + return _reclone_plugin_update(source, install_record.get("revision")) + if before_pull is not None: + before_pull() + from hermes_cli.plugins_transaction import update_plugin + + return update_plugin(target, interactive=interactive) + + +def _reclone_plugin_update(source: str, previous_revision: object) -> str: + """Update a plugin whose tree is not a git checkout: a subdirectory install ships only + ``/``, so the ``.git`` stays in the temp clone (#65314). Re-run the install + from the recorded source (same URL, same subdir) and swap the fresh tree in; the metadata + revision is rewritten by the installer. Returns pull-shaped output for the callers.""" + new_target, _manifest, _name = _pc()._install_plugin_core(source, force=True) + revision = str(_pc()._read_install_metadata().get(new_target.name, {}).get("revision") or "") + previous = previous_revision if isinstance(previous_revision, str) else "" + if revision and revision == previous: + return "Already up to date." + return f"Re-installed from {source}: {previous[:8]}..{revision[:8]}" + + +def cmd_update(name: str, *, interactive: bool = True) -> None: + """Update an installed plugin by pulling latest from its git remote.""" + from rich.markup import escape + from hermes_cli import plugins_cmd_catalog as catalog + console = _pc()._console() + target = _pc()._require_installed_plugin(name, _pc()._plugins_dir(), console) + sidecar = catalog.catalog_install_record(target) + if sidecar: # catalog installs re-pin to the reviewed SHA — never `git pull` + catalog.cmd_update_catalog(name, target, sidecar, console, interactive=interactive) + return + try: + output = _pull_plugin_update( + target, + lambda rec: ( + f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run " + f"`hermes plugins install {escape(str(rec.get('source', '')))} --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]"), + interactive=interactive) + except _pc().PluginOperationError as exc: + _pc()._fail(console, f"[red]Error:[/red] {exc}") + _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 = _pc()._read_manifest(target) + plugin_id = updated_manifest.get("name") or target.name + declared_caps = _pc()._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): + if interactive: + _pc()._run_capability_consent(console, plugin_id, declared_caps, context="update") + else: + console.print(f"[yellow]Plugin {plugin_id} has new capabilities; review them with `hermes plugins capabilities {plugin_id}`.[/yellow]") + + 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 publication: drop stale bytecode and copy any new example files.""" + # Same stale-bytecode class as the main checkout (#6207/#60242): the pull just changed .py files under + # this plugin dir, so drop any __pycache__ compiled from the previous revision. + _clear_plugin_bytecode(target) + _pc()._copy_example_files(target, console) + + +def cmd_adopt(name: str) -> None: + """Adopt a self-cloned plugin dir into the provenance sidecar. + + Reads the dir's git origin URL, validates it, writes the sidecar row + — from then on a normal git install (check-updates + update). The + ONLY mutation path for self-cloned dirs (settled: explicit verbs). + """ + from rich.console import Console + + console = Console() + plugins_dir = _pc()._plugins_dir() + target = _pc()._require_installed_plugin(name, plugins_dir, console) + + from hermes_cli.plugins_provenance import ProvenanceClass, plugins_provenance + + prov = next((p for p in plugins_provenance(plugins_dir) if p.name == target.name), None) + if prov is None: + console.print(f"[red]Error:[/red] Plugin '{name}' not classifiable.") + sys.exit(1) + if prov.klass is not ProvenanceClass.SELF_CLONED: + console.print( + f"[red]Error:[/red] Plugin '{name}' is {prov.klass.value}, not " + "self-cloned — there is nothing to adopt." + ) + sys.exit(1) + if not prov.origin_url: + console.print( + f"[red]Error:[/red] Plugin '{name}' has no readable git origin " + "remote. Add one (git remote add origin ) and retry." + ) + sys.exit(1) + + try: + _pc()._resolve_git_url(prov.origin_url) + except ValueError as e: + console.print(f"[red]Error:[/red] The dir's origin url is not installable: {e}") + sys.exit(1) + + metadata = _pc()._read_install_metadata() + if target.name in metadata: + console.print(f"[red]Error:[/red] Plugin '{name}' already has a provenance row.") + sys.exit(1) + + git_exe = _pc()._resolve_git_executable() + revision = _pc()._git_head_revision(target, git_exe) if git_exe else "" + metadata[target.name] = { + "pinned": False, + "revision": revision, + "source": _pc()._canonical_source(prov.origin_url, None), + } + _pc()._write_install_metadata(metadata) + console.print( + f"[green]✓[/green] Adopted [bold]{name}[/bold] " + f"(source: {prov.origin_url}, revision: {revision[:12] or 'unknown'}). " + "It is now a tracked git install." + ) + + +def cmd_trust_update_url(name: str) -> None: + """The ONLY path that moves a saved update_url tag. + + A needs-fixing mismatch (manifest update_url vs the saved tag) is + resolved here: confirms the manifest's url into the sidecar row, + prints old → new. Refuses when there is nothing to trust. + """ + from rich.console import Console + + console = Console() + plugins_dir = _pc()._plugins_dir() + target = _pc()._require_installed_plugin(name, plugins_dir, console) + + from hermes_cli.plugins_provenance import read_sidecar_rows + + rows = read_sidecar_rows(plugins_dir) + row = rows.get(target.name) + if not isinstance(row, dict): + console.print( + f"[red]Error:[/red] Plugin '{name}' has no provenance row — " + "nothing to trust. Reinstall it instead." + ) + sys.exit(1) + + saved = row.get("update_url") or None + manifest = _pc()._read_manifest(target) + claimed = (manifest or {}).get("update_url") or None + if claimed == saved: + console.print( + f"[yellow]Nothing to trust:[/yellow] '{name}' has no " + "update_url mismatch." + ) + return + if claimed is not None: + from hermes_cli.plugins_updates import https_update_url + try: + claimed = https_update_url(claimed) + except ValueError as exc: + console.print(f"[red]Error:[/red] Plugin '{name}' {exc}. Not trusted.") + sys.exit(1) + + row["update_url"] = claimed + rows[target.name] = row + _pc()._write_install_metadata(rows) + console.print( + f"[green]✓[/green] Trusted [bold]{name}[/bold] update_url:\n" + f" old: {saved or '(none)'}\n" + f" new: {claimed or '(none)'}" + ) + + +def cmd_check_updates(args: Any | None = None) -> None: + """Read-only: is any installed plugin outdated? NEVER mutates.""" + from rich.console import Console + from rich.table import Table + + console = Console() + plugins_dir = _pc()._plugins_dir() + + from hermes_cli.plugins_updates import run_checks + + results = run_checks(plugins_dir) + + if getattr(args, "json", False): + print(json.dumps([r.to_json() for r in results], indent=2)) + return + + table = Table(title="Plugin updates", show_lines=False) + table.add_column("Name", style="bold") + table.add_column("Class", style="dim") + table.add_column("Current") + table.add_column("Latest") + table.add_column("Status") + for r in results: + if r.needs_fixing: + status = f"[red]needs fixing[/red]\n[dim]{r.needs_fixing}[/dim]" + elif r.update_available is True: + status = "[green]update available[/green]" + elif r.update_available is False: + status = "[dim]up to date[/dim]" + else: + status = f"[yellow]unknown[/yellow]\n[dim]{r.reason}[/dim]" + table.add_row( + r.name, r.klass, (r.current or "-")[:12], r.latest or "-", status + ) + console.print() + console.print(table) + console.print() + console.print("[dim]Check-only. Apply with: hermes plugins update [/dim]") + + +def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False) -> dict[str, Any]: + """``git pull`` inside ``~/.hermes/plugins/``; catalog installs re-pin instead. A re-pin that + widens the plugin returns ``{"ok": False, "consent_required": True, "delta": {...}}`` with nothing + changed — the surface shows the delta and retries with *accept_capabilities*.""" + from hermes_cli import plugins_cmd_catalog as catalog + target = _pc()._user_installed_plugin_dir(name) + if target is None: + return {"ok": False, "error": f"Plugin '{name}' was not found under {_pc()._plugins_dir()}."} + sidecar = catalog.catalog_install_record(target) + try: + if sidecar: + result = catalog.repin_catalog_plugin( + target, sidecar, consent_cb=(lambda _delta: True) if accept_capabilities else None) + warnings = list(result.warnings) + new_target = target.parent / result.installed_name + deps = _pc()._python_dependency_summary(new_target, warnings) if result.changed else [] + from hermes_cli.plugins_activation import activate_plugin_now + activated = activate_plugin_now(result.installed_name) if result.changed else {} + return {"ok": True, "name": result.installed_name, "sha": result.sha, "unchanged": not result.changed, + "python_dependencies": deps, "warnings": warnings, **activated} + 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 catalog.RepinConsentRequired as exc: + return {"ok": False, "consent_required": True, "error": str(exc), "name": exc.name, "sha": exc.sha, + "delta": exc.delta, "delta_lines": catalog.surface_delta_lines(exc.delta)} + except _pc().PluginOperationError as exc: + return {"ok": False, "error": str(exc)} + _post_pull_housekeeping(target, _pc()._console()) + return {"ok": True, "name": name, "output": msg, "unchanged": "Already up to date" in msg} + + +def _clear_plugin_bytecode(target: Path) -> int: + """Remove ``__pycache__`` dirs under a just-updated plugin checkout. Plugin dirs sit outside + the repo, so the launch-time bytecode sweep never covers them and stale bytecode after a pull + can ImportError in the next process. Never raises. + + See #60242, #6207. + """ + removed = 0 + try: + for cache_dir in target.rglob("__pycache__"): + if cache_dir.is_dir(): + shutil.rmtree(cache_dir, ignore_errors=True) + removed += 0 if cache_dir.exists() else 1 + except OSError: + pass + return removed diff --git a/tests/hermes_cli/test_plugin_private_repo_auth.py b/tests/hermes_cli/test_plugin_private_repo_auth.py index 941831df35..c18696ee58 100644 --- a/tests/hermes_cli/test_plugin_private_repo_auth.py +++ b/tests/hermes_cli/test_plugin_private_repo_auth.py @@ -66,7 +66,7 @@ def test_private_clone_falls_back_to_auth_after_credential_required_error(tmp_pa argv = [a_ if a_ != target_url else str(tmp_path / "upstream.git") for a_ in argv] return real_run(argv, *a, **kw) - monkeypatch.setattr(plugins_cmd.subprocess, "run", spy_run) + monkeypatch.setattr(subprocess, "run", spy_run) monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", lambda url: ("alice", "s3cret")) dest = tmp_path / "clone" @@ -101,7 +101,7 @@ def test_public_clone_attempts_anonymously_when_credential_resolves(tmp_path, mo argv = [a_ if a_ != target_url else str(tmp_path / "upstream.git") for a_ in argv] return real_run(argv, *a, **kw) - monkeypatch.setattr(plugins_cmd.subprocess, "run", spy_run) + monkeypatch.setattr(subprocess, "run", spy_run) # Simulate exactly the failing user state from #114526: ``gh auth login`` has populated the # credential resolver, so for any https://github.com URL it returns a non-None basic auth pair. monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", @@ -151,7 +151,7 @@ def test_ref_fetch_and_update_pull_attach_credential_only_after_anonymous_refusa argv, 128, stdout="", stderr=f"fatal: repository '{public_url}/' not found\n") return subprocess.CompletedProcess(argv, 0, stdout="Already up to date.\n", stderr="") - monkeypatch.setattr(plugins_cmd.subprocess, "run", spy_run) + monkeypatch.setattr(subprocess, "run", spy_run) if outcome == "not_found": monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", lambda url: pytest.fail("credential must not be resolved for a non-credential failure")) diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index a4aaf14966..bcf0d66fe3 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -17,12 +17,12 @@ from hermes_cli.plugins_cmd import ( PluginOperationError, _copy_example_files, _read_manifest, - _refuse_unavailable_portable_plugin, _repo_name_from_url, _resolve_git_url, _resolve_subdir_within, _sanitize_plugin_name, ) +from hermes_cli.plugins_cmd_install import _refuse_unavailable_portable_plugin def _write_portable_app_plugin(root: Path, app: Path) -> None: @@ -358,7 +358,7 @@ class TestCmdInstall: @patch("hermes_cli.plugins_cmd.rmtree_readonly") @patch("hermes_cli.plugins_cmd._plugins_dir") @patch("hermes_cli.plugins_cmd._read_manifest") - @patch("hermes_cli.plugins_cmd.subprocess.run") + @patch("subprocess.run") def test_install_rejects_manifest_name_pointing_at_plugins_root( self, mock_run, @@ -711,6 +711,7 @@ class TestSubdirInstallE2E: import subprocess as sp from hermes_cli import plugins_cmd as pc + from hermes_cli.plugins_cmd_update import _pull_plugin_update repo_root = tmp_path / "monorepo" self._make_repo_with_subdir_plugin(repo_root) @@ -726,13 +727,13 @@ class TestSubdirInstallE2E: new_sha = sp.run(["git", "rev-parse", "HEAD"], cwd=repo_root, check=True, capture_output=True, text=True).stdout.strip() - output = pc._pull_plugin_update(target, lambda rec: "pinned", lambda: "not git") + output = _pull_plugin_update(target, lambda rec: "pinned", lambda: "not git") assert "VERSION = 2" in (target / "__init__.py").read_text(encoding="utf-8") assert pc._read_install_metadata()["my-plugin"]["revision"] == new_sha assert "Already up to date" not in output # A second update with nothing new upstream reports up to date, like `git pull`. - assert "Already up to date" in pc._pull_plugin_update(target, lambda rec: "pinned", lambda: "not git") + assert "Already up to date" in _pull_plugin_update(target, lambda rec: "pinned", lambda: "not git") def test_installs_portable_root_package_disabled(self, tmp_path, monkeypatch): if shutil.which("git") is None: @@ -904,7 +905,7 @@ def test_autostash_dirty_tree_promotes_intent_to_add_entries(tmp_path): """ import subprocess - from hermes_cli.plugins_cmd import _autostash_dirty_tree + from hermes_cli.plugins_cmd_git import _autostash_dirty_tree def git(*args, check=True): return subprocess.run(