refactor(cli): split plugins_cmd.py into topical siblings

plugins_cmd.py had grown to 2,858 lines, past the ~2,000-line gate. Move
each verb family into a plugins_cmd_<topic>.py sibling: git (install
metadata + git plumbing), install, update (plus adopt / trust-update-url /
check-updates), remove, capabilities, toggle (composite UI) and listing.
The facade keeps the shared primitives, enable/disable selection,
discovery and the dispatch table, and re-exports the names other modules,
tests and the old-updater surface import (978 lines now).

Siblings never import the facade at module level; they read facade names
through _pc() at call time, so monkeypatching plugins_cmd.<name> still
intercepts calls made from a sibling. No behaviour change. Tests that
imported three sibling-only helpers now import them from the defining
module, and two subprocess.run patches target subprocess directly.
This commit is contained in:
ethernet
2026-09-24 11:45:32 -04:00
parent 4aadff7bd6
commit 1e76db800c
10 changed files with 2103 additions and 1939 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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.<name>`` 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.<plugin_id>.<key> = value`` into config.yaml."""
from hermes_cli.config import load_config, save_config
config = load_config()
entry = _child_dict(_child_dict(_child_dict(config, "plugins"), "entries"), plugin_id)
entry[key] = bool(value)
save_config(config)
# ── 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.<id>.granted_capabilities`` with a hash of the declared set. On decline —
or in ANY non-interactive context — they stay ungranted (fail closed) and the plugin must
degrade via ``ctx.has_capability()``. Consent + audit, NOT a sandbox.
"""
from hermes_cli.plugin_capabilities import CAPABILITY_REGISTRY, pending_capabilities, record_consent
pending = pending_capabilities(plugin_id, declared)
if not pending:
# Refresh the consent hash so a later declaration change is detected.
if declared:
record_consent(plugin_id, [], declared)
return True
verb = "requests" if context == "install" else "now requests"
console.print(f"\n [yellow]Plugin [bold]{plugin_id}[/bold] {verb} the following capabilities:[/yellow]")
for cap in pending:
spec = CAPABILITY_REGISTRY.get(cap)
console.print(f" [bold]{cap}[/bold] — {spec.description if spec else ''}")
console.print(
" [dim]Granting trusts the plugin author with these host surfaces. "
"This is consent, not a sandbox — plugins run as regular Python "
"in-process.[/dim]")
if not _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 [<id>]`` — 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]")

View File

@@ -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.<name>`` 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 <tag>` 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."

View File

@@ -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.<name>`` 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,
}

View File

@@ -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.<name>`` 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:<tier>@<sha8>``) or a ``--ref`` pin
# (``git pinned@<sha8>``) 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 <name>")
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)

View File

@@ -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.<name>`` 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}"}

View File

@@ -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.<name>`` 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.")

View File

@@ -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.<name>`` 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
``<clone>/<subdir>``, 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', '<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 <url>) 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 <name>[/dim]")
def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False) -> dict[str, Any]:
"""``git pull`` inside ``~/.hermes/plugins/<name>``; 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', '<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

View File

@@ -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] argv = [a_ if a_ != target_url else str(tmp_path / "upstream.git") for a_ in argv]
return real_run(argv, *a, **kw) 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")) monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", lambda url: ("alice", "s3cret"))
dest = tmp_path / "clone" 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] argv = [a_ if a_ != target_url else str(tmp_path / "upstream.git") for a_ in argv]
return real_run(argv, *a, **kw) 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 # 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. # 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", 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") argv, 128, stdout="", stderr=f"fatal: repository '{public_url}/' not found\n")
return subprocess.CompletedProcess(argv, 0, stdout="Already up to date.\n", stderr="") 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": if outcome == "not_found":
monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", monkeypatch.setattr(git_credentials, "resolve_git_basic_auth",
lambda url: pytest.fail("credential must not be resolved for a non-credential failure")) lambda url: pytest.fail("credential must not be resolved for a non-credential failure"))

View File

@@ -17,12 +17,12 @@ from hermes_cli.plugins_cmd import (
PluginOperationError, PluginOperationError,
_copy_example_files, _copy_example_files,
_read_manifest, _read_manifest,
_refuse_unavailable_portable_plugin,
_repo_name_from_url, _repo_name_from_url,
_resolve_git_url, _resolve_git_url,
_resolve_subdir_within, _resolve_subdir_within,
_sanitize_plugin_name, _sanitize_plugin_name,
) )
from hermes_cli.plugins_cmd_install import _refuse_unavailable_portable_plugin
def _write_portable_app_plugin(root: Path, app: Path) -> None: 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.rmtree_readonly")
@patch("hermes_cli.plugins_cmd._plugins_dir") @patch("hermes_cli.plugins_cmd._plugins_dir")
@patch("hermes_cli.plugins_cmd._read_manifest") @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( def test_install_rejects_manifest_name_pointing_at_plugins_root(
self, self,
mock_run, mock_run,
@@ -711,6 +711,7 @@ class TestSubdirInstallE2E:
import subprocess as sp import subprocess as sp
from hermes_cli import plugins_cmd as pc from hermes_cli import plugins_cmd as pc
from hermes_cli.plugins_cmd_update import _pull_plugin_update
repo_root = tmp_path / "monorepo" repo_root = tmp_path / "monorepo"
self._make_repo_with_subdir_plugin(repo_root) 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, new_sha = sp.run(["git", "rev-parse", "HEAD"], cwd=repo_root, check=True,
capture_output=True, text=True).stdout.strip() 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 "VERSION = 2" in (target / "__init__.py").read_text(encoding="utf-8")
assert pc._read_install_metadata()["my-plugin"]["revision"] == new_sha assert pc._read_install_metadata()["my-plugin"]["revision"] == new_sha
assert "Already up to date" not in output assert "Already up to date" not in output
# A second update with nothing new upstream reports up to date, like `git pull`. # 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): def test_installs_portable_root_package_disabled(self, tmp_path, monkeypatch):
if shutil.which("git") is None: if shutil.which("git") is None:
@@ -904,7 +905,7 @@ def test_autostash_dirty_tree_promotes_intent_to_add_entries(tmp_path):
""" """
import subprocess 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): def git(*args, check=True):
return subprocess.run( return subprocess.run(