Files
hermes-agent/hermes_cli/plugins_cmd_catalog.py
Brooklyn Nicholson 31a68e9235 fix(tools): resolve the live plugin catalog once per plugins.list
The #119975 display-name lookup called get_live_catalog_entry() inside
the per-plugin loop of _plugin_rows, paying a full catalog resolution
(load_catalog_live: fetch or cache read + parse of every in-tree catalog
yaml, no memoization) once per installed plugin. plugins.manage list went
from O(1) to O(installed plugins) catalog resolutions, and a dead
catalog host cost one request timeout per plugin — the exact per-candidate
cost resolved_removed_entries() exists to eliminate.

Hoist the resolution next to pins/versions: catalog_titles() builds
{catalog_name: title} in one resolution and _plugin_server_rows reads
from the pre-resolved map, mirroring catalog_pins/catalog_versions.
Regression test counts load_catalog_live calls across a 3-plugin
listing: 3 before, 1 after.
2026-09-27 06:26:52 -05:00

870 lines
42 KiB
Python

"""``hermes plugins`` catalog surface: resolution, provenance sidecar, search/browse/info/validate,
catalog-aware update, plus the dashboard/TUI-facing catalog payload helpers.
Sibling of :mod:`hermes_cli.plugins_cmd` (the installer core, enable/disable state and console helpers
live there and are imported late — this module is imported BY ``plugins_cmd``).
"""
from __future__ import annotations
import datetime
import json
import logging
import os
import shutil
import stat
import sys
import tempfile
from pathlib import Path
from typing import Any, Dict, List, NamedTuple, Optional
from hermes_cli.plugin_catalog import (
PluginCatalogEntry, RemovedEntry, cached_removed_entries, entry_capability_summary, filter_entries,
find_removed, get_live_catalog_entry, load_catalog_live, match_removed, resolved_removed_entries,
_NAME_RE, _normalize_repo,
)
from pm.filesystem import is_junction
logger = logging.getLogger(__name__)
CATALOG_SIDECAR = ".hermes-catalog.json"
# ── Resolution / provenance ──────────────────────────────────────────────────
def looks_like_catalog_name(identifier: str) -> bool:
"""Bare ``[a-z0-9_-]`` token — not a URL, ``owner/repo`` or path."""
from hermes_cli.plugins_cmd import _URL_SCHEMES
return bool(identifier) and "/" not in identifier and "\\" not in identifier \
and not identifier.startswith(_URL_SCHEMES) and bool(_NAME_RE.match(identifier))
def raise_if_removed(*candidates: str) -> None:
"""``PluginOperationError`` when any candidate (name or repo URL) is on the kill list."""
from hermes_cli.plugins_cmd import PluginOperationError
for candidate in candidates:
removed = find_removed(candidate)
if removed is not None:
detail = removed.reason or "no reason recorded"
if removed.date:
detail += f" (removed {removed.date})"
raise PluginOperationError(
f"Plugin '{removed.name}' was removed from the Hermes plugin catalog and is blocked from "
f"installation: {detail}")
def resolve_catalog_name(identifier: str, console) -> PluginCatalogEntry:
"""Bare name → live catalog entry, or exit 1 with a pointer to ``search``."""
from hermes_cli.plugins_cmd import _fail
entry = get_live_catalog_entry(identifier)
if entry is None:
_fail(console, (
f"[red]Error:[/red] '{identifier}' is not in the Hermes plugin catalog and is not a Git URL or "
"owner/repo shorthand. Browse entries with `hermes plugins search`."))
raise SystemExit(1) # _fail exits; keeps type-checkers honest
return entry
def write_catalog_sidecar_record(target: Path, catalog: dict, sha: str) -> None:
"""Human/Desktop-readable ``.hermes-catalog.json`` inside the install dir. It is a CONVENIENCE COPY:
the authoritative provenance is the ``catalog`` block on the ``.install-metadata.json`` record (see
:func:`read_catalog_sidecar`), because anything inside the tree is under the repo's control."""
sidecar = {
"catalog_name": catalog["name"], "repo": catalog["repo"], "sha": sha,
"tier": catalog.get("tier") or "community",
"installed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
.replace("+00:00", "Z"),
}
try:
(target / CATALOG_SIDECAR).write_text(json.dumps(sidecar, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
logger.warning("Failed to write catalog sidecar in %s: %s", target, exc)
def write_catalog_sidecar(target: Path, entry: PluginCatalogEntry, sha: Optional[str] = None) -> None:
write_catalog_sidecar_record(
target,
{"name": entry.name, "repo": entry.repo, "tier": entry.tier},
sha or entry.sha,
)
def _install_record(plugin_dir: Path) -> Optional[dict]:
"""The installer-owned ``.install-metadata.json`` record for a dir under the plugins dir, else ``None``."""
from hermes_cli.plugins_cmd import PluginOperationError, _plugins_dir, _read_install_metadata
if plugin_dir.parent != _plugins_dir():
return None
try:
record = _read_install_metadata().get(plugin_dir.name)
except PluginOperationError:
return None
return record if isinstance(record, dict) else None
def _write_catalog_block(plugin_dir: Path, record: dict, block: dict) -> dict:
"""Migrate one trusted installer record to the nested catalog contract."""
from hermes_cli.plugins_cmd import _update_install_record
def migrate(current: Optional[dict]) -> Optional[dict]:
if current is None:
return None
migrated = dict(current)
migrated["catalog"] = block
migrated.pop("catalog_name", None)
migrated.pop("catalog_tier", None)
return migrated
_update_install_record(plugin_dir.name, migrate)
return block
def _adopt_legacy_sidecar(plugin_dir: Path, record: dict) -> Optional[dict]:
"""Installs made before provenance moved out of the tree carry only the in-tree file. Trust it once —
only when the installer record agrees (pinned at that sha, cloned from that catalog entry's repo) —
and copy it onto the record so later reads never consult the tree again."""
path = plugin_dir / CATALOG_SIDECAR
try:
data = json.loads(path.read_text(encoding="utf-8-sig")) if path.is_file() else None
except Exception:
return None
if not isinstance(data, dict) or not data.get("catalog_name"):
return None
sha = str(data.get("sha") or "").lower()
entry = get_live_catalog_entry(str(data["catalog_name"]))
if entry is None or record.get("pinned") is not True or record.get("revision") != sha:
return None
source = str(record.get("source") or "").split("#", 1)[0]
if _normalize_repo(source) != _normalize_repo(entry.repo):
return None
block = {"name": entry.name, "repo": entry.repo, "tier": str(data.get("tier") or entry.tier), "pin": sha, "sha": sha}
return _write_catalog_block(plugin_dir, record, block)
def read_catalog_sidecar(plugin_dir) -> Optional[dict]:
"""Catalog provenance of an installed plugin (``catalog_name``/``repo``/``sha``/``tier``/``pin``), or
``None`` for a non-catalog install. Read from the installer-owned metadata record, never from the
tree: a URL-installed repo that ships its own ``.hermes-catalog.json`` must not render as a reviewed
catalog install nor mark the real entry installed."""
if not plugin_dir:
return None
plugin_dir = Path(plugin_dir)
record = _install_record(plugin_dir)
if record is None:
return None
block = record.get("catalog")
if not isinstance(block, dict):
# PM-era installs already kept catalog identity in this installer-owned
# record, but used top-level fields. Migrate those without consulting
# the plugin tree, then retain the older sidecar migration for releases
# that predate the shared record.
legacy_name = record.get("catalog_name")
if legacy_name:
sha = str(record.get("revision") or "").lower()
block = {
"name": str(legacy_name),
"repo": str(record.get("source") or "").split("#", 1)[0],
"tier": str(record.get("catalog_tier") or "community"),
"pin": sha,
"sha": sha,
}
block = _write_catalog_block(plugin_dir, record, block)
else:
block = _adopt_legacy_sidecar(plugin_dir, record)
if not block or not block.get("name"):
return None
return {"catalog_name": block["name"], "repo": block.get("repo", ""), "sha": block.get("sha", ""),
"tier": block.get("tier") or "community", "pin": block.get("pin", "")}
def at_catalog_pin(sidecar: dict, entry_sha: str) -> bool:
"""The install satisfies the catalog pin *entry_sha*: HEAD is that commit, or the pin is an
annotated tag whose commit was checked out (``sha`` records the peeled commit, ``pin`` the tag
object the installer verified). Neither matches after a re-pin or for an off-pin ``--ref`` install."""
return bool(entry_sha) and entry_sha in (
str(sidecar.get("sha") or "").lower(), str(sidecar.get("pin") or "").lower())
def catalog_install_record(plugin_dir) -> Optional[dict]:
"""Catalog fields from the authoritative installer-owned record."""
return read_catalog_sidecar(plugin_dir)
def catalog_annotation(dir_path) -> Optional[str]:
"""``catalog:<tier>@<sha8>`` for a catalog install (``list`` Source column), else ``None``."""
sidecar = catalog_install_record(dir_path)
if not sidecar:
return None
return f"catalog:{sidecar.get('tier') or 'community'}@{str(sidecar.get('sha') or '')[:8]}"
def removed_annotation(name: str, dir_path, removed_entries: List[RemovedEntry]) -> Optional[str]:
"""Kill-list reason when an INSTALLED plugin matches by name, catalog name or repo, else ``None``.
``removed_entries`` is required: callers annotating many rows (``plugins list``, the dashboard hub)
resolve the kill list once with :func:`plugin_catalog.resolved_removed_entries` and pass it in.
Resolving per row cost one live-catalog fetch — one network timeout, offline — per plugin.
"""
sidecar = catalog_install_record(dir_path) or {}
for candidate in (name, sidecar.get("catalog_name"), sidecar.get("repo")):
removed = match_removed(str(candidate), removed_entries) if candidate else None
if removed is not None:
return removed.reason or "no reason recorded"
return None
# ── Catalog-aware install / update ───────────────────────────────────────────
_PLATFORM_ALIASES = {"windows": "win32", "macos": "darwin"}
def normalized_platforms(platforms: List[str]) -> set[str]:
"""Return catalog platform names in host OS-family vocabulary."""
return {_PLATFORM_ALIASES.get(value.lower(), value.lower()) for value in platforms}
def _refuse_unsupported_catalog_platform(entry: PluginCatalogEntry) -> None:
if not entry.platforms:
return
from hermes_cli.plugins_cmd import PluginOperationError
from hermes_platform.host.facts import os_family
current = os_family()
if current not in normalized_platforms(entry.platforms):
raise PluginOperationError(
f"Plugin '{entry.name}' is unavailable on {current}; supported platforms: "
f"{', '.join(entry.platforms)}."
)
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,
allow_removed: bool = False, scan_decision_cb=None, python_deps: bool = True,
before_swap=None) -> tuple:
"""``_install_plugin_core`` at the catalog pin (an explicit *ref* wins) + provenance recorded on the
install-metadata record at the sha ACTUALLY checked out (a ``--ref`` install is not at the reviewed
pin, so ``update_available`` must say so). Returns the core's ``(target, manifest, installed_name)``."""
from hermes_cli.plugins_cmd import _install_plugin_core
if not allow_removed:
raise_if_removed(entry.name, entry.repo)
_refuse_unsupported_catalog_platform(entry)
target, manifest, installed_name = _install_plugin_core(
entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb,
reviewed_pin=entry.sha, python_deps=python_deps, allow_removed=allow_removed, before_swap=before_swap,
catalog={"name": entry.name, "repo": entry.repo, "tier": entry.tier, "pin": entry.sha})
return target, manifest, installed_name
def installed_plugin_removal(name: str, plugin_dir) -> Optional[RemovedEntry]:
"""Kill-list verdict for an INSTALLED plugin (manifest name, dir name, catalog name or recorded
source), or ``None``. A record carrying ``allow_removed`` (the user bypassed the list at install) is
honoured; the check is offline (in-tree list + cached live copy) so load time never blocks on the
catalog host."""
plugin_dir = Path(plugin_dir) if plugin_dir else None
record = (_install_record(plugin_dir) if plugin_dir else None) or {}
if record.get("allow_removed") is True:
return None
block = record.get("catalog") if isinstance(record.get("catalog"), dict) else {}
source = str(record.get("source") or "").split("#", 1)[0]
candidates = [name, source, block.get("name"), block.get("repo"), plugin_dir.name if plugin_dir else None]
entries = cached_removed_entries()
for candidate in candidates:
removed = match_removed(str(candidate), entries) if candidate else None
if removed is not None:
return removed
return None
def refuse_if_installed_removed(name: str, plugin_dir) -> None:
"""``PluginOperationError`` form of :func:`installed_plugin_removal` for ``update``/``enable``, which
otherwise keep pulling and activating code the catalog recalled."""
from hermes_cli.plugins_cmd import PluginOperationError
removed = installed_plugin_removal(name, plugin_dir)
if removed is not None:
raise PluginOperationError(
f"Plugin '{name}' was removed from the Hermes plugin catalog: "
f"{removed.reason or 'no reason recorded'}. Remove it with `hermes plugins remove {name}`, "
"or reinstall with `hermes plugins install <source> --force --allow-removed` if you trust it.")
_PRESERVE_SKIP = ("__pycache__", ".git", CATALOG_SIDECAR)
_NO_GIT_REVISION_FILES = frozenset({
"plugin.yaml", "plugin.yml", "plugin.json", "mcp.json",
"pyproject.toml", "package.json", "package-lock.json", "uv.lock",
})
_NO_GIT_REVISION_DIRS = frozenset({"dashboard", "desktop", "skills", "sidecar", "node_modules"})
# JS module/JSX variants the guard does not classify as code. Kept local: adding them to
# tools.plugin_guard.CODE_FILE_EXTENSIONS would exempt them from env-secret scan patterns.
_NO_GIT_REVISION_EXTENSIONS = frozenset({".mjs", ".cjs", ".jsx", ".tsx"})
def _skip_preserve(name: str) -> bool:
"""Installer/cache names that are never user state (checked per path component)."""
return name in _PRESERVE_SKIP or name.endswith(".pyc")
def _revision_owned_without_git(rel: Path) -> bool:
"""True for plugin code/control surfaces an update must never resurrect from the old tree."""
from tools.plugin_guard import CODE_FILE_EXTENSIONS
suffix = rel.suffix.lower()
return (
suffix in CODE_FILE_EXTENSIONS
or suffix in _NO_GIT_REVISION_EXTENSIONS
or rel.as_posix() in _NO_GIT_REVISION_FILES
or bool(rel.parts and rel.parts[0] in _NO_GIT_REVISION_DIRS)
)
def _local_changes(target: Path) -> tuple[Optional[list[str]], list[str]]:
"""``(untracked_or_ignored, modified_tracked)`` in a git checkout. The first item is ``None``
when git cannot classify the installed tree (notably subdirectory installs, which carry no ``.git``)."""
from hermes_cli.plugins_cmd import PluginOperationError, _resolve_git_executable, _run_plugin_git
git_exe = _resolve_git_executable()
if not (target / ".git").exists():
return None, []
if not git_exe:
raise PluginOperationError(
f"Could not inspect local changes for '{target.name}': git executable is unavailable."
)
status = _run_plugin_git(git_exe, target, "status", "--porcelain", "--ignored", "-z", "--untracked-files=all",
"--ignored=matching", timeout=30)
if status.returncode != 0:
detail = (status.stderr or status.stdout or "git status failed").strip()
raise PluginOperationError(
f"Could not inspect local changes for '{target.name}': {detail}"
)
local, modified = [], []
for item in status.stdout.split("\0"):
if len(item) < 4:
continue
code, rel = item[:2], item[3:]
if any(_skip_preserve(part) for part in Path(rel).parts):
continue
(local if code in ("??", "!!") else modified).append(rel)
return local, modified
def _stash_local_files(target: Path, rels: list[str], stash: Path) -> None:
for rel in rels:
src = target / rel
if src.is_file():
dst = stash / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def _carry_user_files(old: Path, new: Path, local: Optional[list[str]]) -> list[str]:
"""Carry user-owned files into a staged replacement without reviving old plugin code.
For a git checkout, *local* is the ``??``/``!!`` set and may contain a directory entry
such as ``data/``; descendants of those entries are copied and win over same-path files in
the new tree. ``None`` means there is no git checkout, so user-state files absent from the new
tree are carried while executable/declarative plugin surfaces remain revision-owned. If a
user-owned path cannot be represented safely in the new tree (a layout clash, or a symlink in a
git checkout's untracked/ignored set), fail before publication rather than silently dropping it.
Dirs in ``tools.plugin_guard.EXCLUDED_DIRS`` (``.venv/``, ``node_modules/``, tool caches) are
install artefacts: they are neither carried nor inspected, so links inside them never stop an update.
Returns the carried paths (POSIX, relative to the tree) so a later scan block can name them.
"""
from hermes_cli.plugins_cmd import PluginOperationError
from tools.plugin_guard import EXCLUDED_DIRS
keep = {Path(rel) for rel in local or ()}
linked: list[str] = []
carried: list[str] = []
def _user_link(rel: Path) -> None:
# Git-owned user state that is a symlink is refused, never followed: a link injected after the
# installer's scan could point outside the plugin root past the guard (which skips links).
if local is not None and not keep.isdisjoint((rel, *rel.parents)):
linked.append(rel.as_posix())
def _walk_error(exc: OSError) -> None:
raise PluginOperationError(f"Could not preserve user files from '{old}': {exc}") from exc
def _conflict(rel: Path, reason: str = "its destination conflicts with the updated plugin") -> PluginOperationError:
return PluginOperationError(
f"Cannot preserve user file '{rel}': {reason}. The installed plugin was left unchanged."
)
for dirpath, dirnames, filenames in os.walk(old, onerror=_walk_error):
here = Path(dirpath)
walk = []
for name in dirnames:
# The guard's excluded dirs (.venv, node_modules, tool caches) are reproducible install
# artefacts, not user state: never walk or carry them, so the fresh tree rebuilds them whole
# (a partial copy has no bin/python or .bin shims and suppresses `npm ci`). Without git,
# top-level revision-owned dirs are never carried either.
if (_skip_preserve(name) or name in EXCLUDED_DIRS
or (local is None and here == old and name in _NO_GIT_REVISION_DIRS)):
continue
if (here / name).is_symlink() or is_junction(here / name):
_user_link((here / name).relative_to(old))
continue
walk.append(name)
dirnames[:] = walk
for name in filenames:
# Skipped directories are pruned above, so only the file name itself needs checking.
if _skip_preserve(name):
continue
src = here / name
rel = src.relative_to(old)
if local is None:
# A no-git subdir install cannot distinguish removed upstream code from user files.
# Never resurrect known executable/control surfaces.
if _revision_owned_without_git(rel):
continue
elif keep.isdisjoint((rel, *rel.parents)):
continue
try:
src_mode = src.lstat().st_mode
except OSError as exc:
raise PluginOperationError(f"Could not preserve user file '{rel}': {exc}") from exc
# Only regular files are durable state. FIFOs, sockets and devices are runtime objects.
# Symlinks are never carried; in a git checkout a user-owned one fails the update below.
if stat.S_ISLNK(src_mode):
_user_link(rel)
continue
if not stat.S_ISREG(src_mode):
continue
dst = new / rel
if os.path.lexists(dst):
if is_junction(dst):
raise _conflict(rel)
# A file -> directory clash cannot be skipped: that would delete a user-state file,
# so keep the live install intact and make the user resolve it.
if dst.is_dir() and not dst.is_symlink():
raise _conflict(rel, "the updated plugin now has a directory at that path")
if local is None:
# A same-shape path belongs to the new revision when git cannot prove otherwise.
continue
parent = new
source_parent = old
for part in rel.parent.parts:
parent /= part
source_parent /= part
if os.path.lexists(parent):
if is_junction(parent) or parent.is_symlink() or not parent.is_dir():
raise _conflict(rel)
continue
try:
source_info = source_parent.lstat()
if is_junction(source_parent) or not stat.S_ISDIR(source_info.st_mode):
raise _conflict(rel, "its source path changed during the update")
mode = stat.S_IMODE(source_info.st_mode)
parent.mkdir(mode=mode)
parent.chmod(mode)
except PluginOperationError:
raise
except OSError as exc:
raise _conflict(rel, "its destination could not be prepared") from exc
if dst.is_symlink() or dst.is_file():
dst.unlink()
shutil.copy2(src, dst, follow_symlinks=False)
carried.append(rel.as_posix())
if linked:
raise PluginOperationError(
f"Cannot preserve symlinked user file(s) {', '.join(sorted(linked))}: links are not followed "
"into an update. Replace each with a regular file (or remove it) and retry. "
"The installed plugin was left unchanged."
)
return carried
class RepinResult(NamedTuple):
sha: str
changed: bool
installed_name: str
warnings: list[str]
# Surfaces a re-pin can widen without the user seeing a diff: each is a list of identifiers the
# new manifest adds (``desktop`` = a Desktop half appeared). Compared as sets — removals are not consent events.
_SURFACE_LABELS = {"capabilities": "host capabilities", "tools": "tools", "hooks": "hooks",
"python_dependencies": "Python dependencies", "desktop": "Desktop UI half"}
def plugin_surface(manifest: dict, tree: Path) -> Dict[str, set]:
"""What an installed tree exposes: declared host capabilities, tools, hooks, Python deps, Desktop half."""
from hermes_cli.plugins_cmd import _declared_capabilities_from_manifest
manifest = manifest or {}
def _list(key: str, *alts: str) -> set:
for k in (key, *alts):
raw = manifest.get(k)
if isinstance(raw, list):
return {str(x) for x in raw if isinstance(x, (str, int, float))}
return set()
return {
"capabilities": set(_declared_capabilities_from_manifest(manifest, str(manifest.get("name") or "?"))),
"tools": _list("provides_tools"), "hooks": _list("provides_hooks", "hooks"),
"python_dependencies": _list("python_dependencies"),
"desktop": {"desktop/plugin.js"} if (tree / "desktop" / "plugin.js").is_file() else set(),
}
def surface_delta(old: Dict[str, set], new: Dict[str, set]) -> Dict[str, List[str]]:
"""``{surface: [added...]}`` for every surface the new tree widens; empty when nothing widened."""
return {k: sorted(new.get(k, set()) - old.get(k, set())) for k in _SURFACE_LABELS
if new.get(k, set()) - old.get(k, set())}
def surface_delta_lines(delta: Dict[str, List[str]]) -> List[str]:
return [f"{_SURFACE_LABELS[k]}: {', '.join(v)}" for k, v in delta.items()]
class RepinConsentRequired(Exception):
"""The new pin widens the plugin's surface and no consent was given; nothing was changed on disk.
``delta`` is :func:`surface_delta`'s mapping — surfaces hand it to the user and retry with consent."""
def __init__(self, name: str, sha: str, delta: Dict[str, List[str]]):
self.name, self.sha, self.delta = name, sha, delta
super().__init__(
f"Updating '{name}' to {sha[:8]} adds {'; '.join(surface_delta_lines(delta))}. Confirm to continue.")
def repin_catalog_plugin(
target: Path,
sidecar: dict,
*,
interactive: bool = False,
consent_cb=None,
) -> RepinResult:
"""Re-pin a catalog install to the current catalog SHA (never ``git pull``).
Publication stays PM-owned and recoverable. Untracked/ignored user files are copied into the
staged replacement before publication; tracked edits are backed up under
``<HERMES_HOME>/plugins-backup/<name>-<sha8>/``. A manifest rename moves the selection and removes
the stale directory.
A pin that widens the plugin (new tools, hooks, Python deps, host capabilities or a Desktop half)
is a new grant. ``consent_cb(delta) -> bool`` decides before publication; absent or declined raises
:class:`RepinConsentRequired` with the installed tree untouched. The immutable catalog pin is
previewed separately because the PM update transaction owns and publishes its own staged clone.
"""
from hermes_cli.plugins_cmd import (
PluginOperationError,
_clone_plugin_repo,
_plugins_dir,
_read_install_metadata,
_read_manifest,
_read_manifest_for_install,
_resolve_git_url,
_resolve_subdir_within,
)
catalog_name = str(sidecar["catalog_name"])
entry = get_live_catalog_entry(catalog_name)
if entry is None:
raise PluginOperationError(
f"Plugin '{catalog_name}' is no longer in the catalog — it may have been removed. "
"See `hermes plugins info` and the removed blocklist.")
refuse_if_installed_removed(catalog_name, target)
if at_catalog_pin(sidecar, entry.sha):
return RepinResult(entry.sha, False, target.name, [])
local, modified = _local_changes(target)
old_sha8 = str(sidecar.get("sha") or "old")[:8]
installed_surface = plugin_surface(_read_manifest(target), target)
def _consent_gate(manifest: dict, tree: Path) -> None:
delta = surface_delta(installed_surface, plugin_surface(manifest, tree))
if delta and not (consent_cb is not None and consent_cb(delta)):
raise RepinConsentRequired(catalog_name, entry.sha, delta)
# The catalog pin is immutable. Preview it before PM begins publication so a widened surface can
# be declined without touching the live tree or writing a backup; update_plugin clones the same pin
# again and owns validation, dependency preparation, metadata and code publication as one handoff.
with tempfile.TemporaryDirectory(prefix=".repin-preview-", dir=_plugins_dir()) as preview_tmp:
preview_root = Path(preview_tmp) / "plugin"
git_url, subdir = _resolve_git_url(entry.install_identifier)
_clone_plugin_repo(preview_root, git_url, entry.sha)
preview_target = _resolve_subdir_within(preview_root, subdir) if subdir else preview_root
_consent_gate(_read_manifest_for_install(preview_target), preview_target)
# Outside the plugins dir: the discovery scanners recurse into every subdirectory there.
backup = _plugins_dir().parent / "plugins-backup" / f"{target.name}-{old_sha8}"
_stash_local_files(target, modified, backup)
from hermes_cli.plugins_transaction import update_plugin
update_plugin(
target,
catalog_entry=entry,
interactive=interactive,
carry_user_files=lambda staged: _carry_user_files(target, staged, local),
)
matches = []
for installed_name, row in _read_install_metadata().items():
if not isinstance(row, dict):
continue
block = row.get("catalog")
if (
isinstance(block, dict)
and block.get("name") == entry.name
and at_catalog_pin(block, entry.sha)
):
matches.append(installed_name)
if len(matches) != 1:
raise PluginOperationError(
f"Catalog update published but its install record is ambiguous: {matches or 'missing'}."
)
installed_name = matches[0]
new_target = target.parent / installed_name
warnings: list[str] = []
if modified:
warnings.append(f"Local edits to {len(modified)} tracked file(s) were not carried over; copies are under "
f"{backup} (the previous version's files, re-apply by hand).")
if new_target != target and target.exists():
from hermes_cli.plugins_cmd import (
_admit_and_save_plugin_sets, _get_disabled_set, _get_enabled_set, _remove_plugin_core)
enabled, disabled = _get_enabled_set(), _get_disabled_set()
selection_changed = False
for selected in (enabled, disabled):
if target.name in selected:
selected.remove(target.name)
selected.add(installed_name)
selection_changed = True
if selection_changed:
_admit_and_save_plugin_sets(
enabled, disabled, action=f"Rename plugin '{target.name}' to '{installed_name}'",
plugin=installed_name)
_remove_plugin_core(target)
warnings.append(f"Plugin renamed itself from '{target.name}' to '{installed_name}'; the old directory was removed.")
return RepinResult(entry.sha, True, installed_name, warnings)
def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, interactive: bool = True) -> None:
from hermes_cli.plugins_cmd import (
PluginOperationError, _ask_yes, _declared_capabilities_from_manifest, _fail, _is_tty, _read_manifest,
_run_capability_consent)
console.print(f"[dim]Checking catalog pin for {name}...[/dim]")
def _confirm_widening(delta: Dict[str, List[str]]) -> bool:
console.print(f"\n [yellow]The new pin of [bold]{name}[/bold] adds:[/yellow]")
for line in surface_delta_lines(delta):
console.print(f" {line}")
if not interactive or not _is_tty():
console.print(" [yellow]Non-interactive session: update NOT applied (fail closed). "
"Re-run `hermes plugins update` in a terminal to review and confirm.[/yellow]")
return False
return _ask_yes(" Apply this update? [y/N]: ")
try:
result = repin_catalog_plugin(
target,
sidecar,
interactive=interactive,
consent_cb=_confirm_widening,
)
except RepinConsentRequired as exc:
_fail(console, f"[yellow]Update of {name} not applied:[/yellow] {exc}")
raise SystemExit(1)
except PluginOperationError as exc:
_fail(console, f"[red]Error:[/red] {exc}")
raise SystemExit(1)
verb = "updated to" if result.changed else "is already at catalog pin"
console.print(f"[green]✓[/green] Plugin [bold]{result.installed_name}[/bold] {verb} {result.sha[:8]}.")
for warning in result.warnings:
console.print(f"[yellow]⚠ {warning}[/yellow]")
if result.changed:
# PM admitted Python dependencies before publishing the replacement. Host capabilities use
# their separate grant store, so additions stay ungranted until the user consents here.
new_target = target.parent / result.installed_name
declared = _declared_capabilities_from_manifest(_read_manifest(new_target), result.installed_name)
if declared:
from hermes_cli.plugin_capabilities import declared_set_changed, pending_capabilities
if pending_capabilities(result.installed_name, declared) or declared_set_changed(result.installed_name, declared):
if interactive:
_run_capability_consent(console, result.installed_name, declared, context="update")
else:
console.print(
f"[yellow]Plugin {result.installed_name} has new capabilities; review them with "
f"`hermes plugins capabilities {result.installed_name}`.[/yellow]")
# ── search / browse / info / validate ────────────────────────────────────────
def _capability_counts(entry: PluginCatalogEntry) -> str:
caps = entry.capabilities
parts = [f"{len(items)} {label}{'s' if len(items) != 1 and label != 'middleware' else ''}"
for items, label in ((caps.provides_tools, "tool"), (caps.provides_hooks, "hook"),
(caps.provides_middleware, "middleware")) if items]
if caps.requires_env:
parts.append(f"{len(caps.requires_env)} env")
return ", ".join(parts) or "—"
def pin_label(entry: PluginCatalogEntry) -> str:
"""``1.4.0 @ abcd1234`` when the entry carries a version label, else the short sha."""
return f"{entry.version} @ {entry.sha[:8]}" if entry.version else entry.sha[:8]
def _render_entries(entries: List[PluginCatalogEntry], console) -> None:
from hermes_cli.plugins_cmd import _table
table = _table(((("Name", "bold")), ("Category", None), ("Tier", None), ("Description", None),
("Pinned", "dim"), ("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)")
for e in sorted(entries, key=lambda e: (e.category, e.tier != "official", e.name)):
tier = "[cyan]official[/cyan]" if e.tier == "official" else "[magenta]community[/magenta]"
desc = e.description if len(e.description) <= 60 else e.description[:57] + "..."
table.add_row(e.name, e.category, tier, desc, pin_label(e), _capability_counts(e))
console.print()
console.print(table)
console.print()
console.print("[dim]Details:[/dim] hermes plugins info <name> [dim]Install:[/dim] hermes plugins install <name>")
def cmd_search(term: str = "", *, json_output: bool = False) -> None:
"""Search the curated catalog (name/description/declared tools); empty term = browse everything."""
from hermes_cli.plugins_cmd import _console
matches = filter_entries(load_catalog_live(), term)
if json_output:
print(json.dumps({"query": term, "results": [e.to_dict() for e in matches]}, indent=2))
return
console = _console()
if not matches:
console.print(f"[yellow]No catalog entries matched '{term}'[/yellow]" if term
else "[dim]No catalog entries available.[/dim]")
return
_render_entries(matches, console)
def cmd_info(name: str) -> None:
"""Full catalog entry for *name*; falls back to installed-plugin details for non-catalog names."""
from hermes_cli.plugins_cmd import _console, cmd_show
entry = get_live_catalog_entry(name)
if entry is None:
cmd_show(name)
return
console = _console()
caps = entry.capabilities
console.print()
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan]")
if entry.description:
console.print(entry.description)
console.print()
rows = [("Repo", entry.repo), ("Subdir", entry.subdir), ("Version", entry.version), ("Pinned SHA", entry.sha),
("Image", entry.image),
("Maintainer", entry.maintainer), ("Requires", f"hermes {entry.requires_hermes}" if entry.requires_hermes else ""),
("Platforms", ", ".join(entry.platforms)), ("Docs", entry.docs_url)]
for label, value in rows:
if value:
console.print(f"[dim]{label + ':':<12}[/dim] {value}")
console.print()
for label, items in (("Tools", caps.provides_tools), ("Hooks", caps.provides_hooks),
("Middleware", caps.provides_middleware), ("Env vars", caps.requires_env)):
console.print(f"[dim]{label + ':':<12}[/dim] {', '.join(items) or '(none)'}")
console.print()
removed = find_removed(entry.name) or find_removed(entry.repo)
if removed is not None:
console.print(f"[red bold]✗ REMOVED from catalog: {removed.reason or 'no reason recorded'}"
f"{f' ({removed.date})' if removed.date else ''}[/red bold]")
console.print()
console.print(f"[dim]Install:[/dim] hermes plugins install {entry.name}")
console.print()
def cmd_validate(path: str, as_json: bool = False, install_deps: bool = False) -> None:
"""Catalog-admission validation of a plugin directory (the CI gate); exits 0/1. *install_deps*
installs the declared Python deps first so the capability probe imports what an install would."""
from hermes_cli.plugin_validate import validate_plugin_dir
from hermes_cli.plugins_cmd import _console
if install_deps:
import pm
from pm.plugin_inputs import Candidates
try:
pm.sync_venv(plugins=Candidates([Path(path)]))
except Exception as exc: # validation still runs; the probe reports what is missing
print(f"dependency preparation failed: {exc}", file=sys.stderr)
report = validate_plugin_dir(Path(path))
if as_json:
print(json.dumps(report.to_dict(), indent=2))
sys.exit(report.exit_code)
console = _console()
console.print()
for check_name, ok, detail in report.checks:
console.print(f"{'[green]✓[/green]' if ok else '[red]✗[/red]'} {check_name}"
+ (f" [dim]— {detail}[/dim]" if detail else ""))
for warning in report.warnings:
console.print(f"[yellow]⚠ {warning}[/yellow]")
console.print()
console.print("[green bold]Validation passed.[/green bold]" if report.ok else "[red bold]Validation failed.[/red bold]")
sys.exit(report.exit_code)
# ── Dashboard / TUI payloads ─────────────────────────────────────────────────
def installed_catalog_state(installed: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
"""Catalog entries merged with local state for the dashboard. *installed* maps every alias (name
and registry key) of a discovered plugin to ``{"dir", "runtime_status"}``. A catalog name rarely
equals the manifest name (``hermes-plugin-x`` vs ``x``), so installs are matched through the
sidecar's ``catalog_name`` first and by name only as a fallback."""
by_catalog_name: Dict[str, Dict[str, Any]] = {}
for local in installed.values():
sidecar = catalog_install_record(local["dir"])
if sidecar:
by_catalog_name[str(sidecar["catalog_name"])] = {**local, "sidecar": sidecar}
entries = []
for entry in load_catalog_live():
local = by_catalog_name.get(entry.name) or installed.get(entry.name)
sidecar = local.get("sidecar") if local else None
installed_sha = str(sidecar["sha"]) if sidecar and sidecar.get("sha") else None
entries.append({
**entry.to_dict(), "sha_short": entry.sha[:7],
"capability_summary": entry_capability_summary(entry),
"installed": local is not None, "installed_sha": installed_sha,
"update_available": bool(installed_sha) and not at_catalog_pin(sidecar or {}, entry.sha),
"runtime_status": local["runtime_status"] if local else None,
})
return {
"entries": entries,
"removed": [{"name": r.name, "repo": r.repo, "reason": r.reason, "date": r.date} for r in resolved_removed_entries()],
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
}
def catalog_row_fields(dir_path, pins: Dict[str, str], versions: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Provenance fields for one installed-plugin row (TUI/desktop ``plugins.manage list``): catalog
name/tier/installed SHA and, when *pins* has the entry, the current pin (+ its version label from
*versions*) and ``update_available``."""
versions = versions or {}
sidecar = catalog_install_record(dir_path)
if not sidecar:
return {}
installed_sha = str(sidecar.get("sha") or "").lower()
row: Dict[str, Any] = {
"catalog_name": sidecar["catalog_name"], "catalog_tier": str(sidecar.get("tier") or "community"),
"installed_sha": installed_sha}
pin = pins.get(str(sidecar["catalog_name"]))
if pin:
row["catalog_sha"] = pin
row["catalog_version"] = versions.get(str(sidecar["catalog_name"])) or None
row["update_available"] = bool(installed_sha) and not at_catalog_pin(sidecar, pin)
return row
def catalog_pins() -> Dict[str, str]:
"""``{catalog_name: pinned_sha}`` from the live catalog; empty on failure (best effort)."""
try:
return {e.name: e.sha for e in load_catalog_live()}
except Exception:
return {}
def catalog_titles() -> Dict[str, str]:
"""``{catalog_name: title}`` for entries that carry one — the Plugins hub server-sentence display
name. One resolution for a whole listing: callers that annotate every installed plugin must not
pay a live-catalog fetch per candidate (see ``resolved_removed_entries``); empty on failure."""
try:
return {e.name: e.title for e in load_catalog_live() if e.title}
except Exception:
return {}
def catalog_versions() -> Dict[str, str]:
"""``{catalog_name: version_label}`` for entries that carry one; empty on failure (best effort)."""
try:
return {e.name: e.version for e in load_catalog_live() if e.version}
except Exception:
return {}