Compare commits

...
Author SHA1 Message Date
Teknium fe9dc06071 feat(dashboard): plugin catalog surface — browse, capability-confirm install, removed banners
- GET /api/dashboard/plugins/catalog: catalog entries merged with
  installed-state (sidecar SHA, update_available, runtime_status) +
  removed blocklist + generated_at
- agent-plugins/install accepts catalog_name: resolves the catalog
  entry, refuses removed plugins (400, no dashboard bypass), installs
  at the pinned SHA and writes the .hermes-catalog.json sidecar
- plugins/hub rows annotated with removed_reason
- PluginsPage: Catalog section with search, tier badges, capability
  chips, sha/docs links, capability-summary confirm dialog install
  flow, and red removed banners on catalog + installed rows
- en i18n keys with en-only optional-key fallback convention
2026-07-22 07:57:39 -07:00
Teknium 6f5608bed3 test(plugins): cover plugin catalog, version gate, and installer
- catalog loader validation, search, removed matching, capability summary
- _version_satisfies operators and permissive fallbacks
- requires_hermes load gate (skip vs normal load)
- config spec parsing + ctx.plugin_config merge
- _install_plugin_core ref checkout and removed-block via local file:// repos
2026-07-22 07:30:55 -07:00
Teknium dc4d991373 feat(plugins): install-time ref checkout and removed-blocklist check
- _install_plugin_core accepts ref= (full-depth clone + git checkout,
  PluginOperationError on failure) and skip_removed_check=
- installs are refused when the identifier or resolved repo URL matches
  plugin-catalog/removed.yaml, with the recorded reason and date
2026-07-22 07:30:55 -07:00
Teknium dcdb9b25e4 feat(plugins): requires_hermes gate, config spec, ctx.plugin_config
- plugin.yaml gains requires_hermes (version spec) and config: (list of
  {key, prompt, type, default, secret}) parsed onto PluginManifest
- PluginManager skips loading (clean error, no traceback) when the
  running Hermes version does not satisfy requires_hermes; local
  _version_satisfies helper supports >=,>,<=,<,==,!= and comma specs
- PluginContext.plugin_config merges config-spec defaults under
  plugins.entries.<plugin_id> values from config.yaml
2026-07-22 07:30:55 -07:00
Teknium a22d2918d6 feat(plugins): add curated plugin catalog module and in-tree catalog dir
- hermes_cli/plugin_catalog.py: loader/search/removed-blocklist for the
  new plugin-catalog/ directory (exact 40-hex SHA pins, https-only repos,
  official/community tiers; invalid entries skipped with a warning)
- plugin-catalog/: admission-policy README, example-plugin seed entry,
  and removed.yaml blocklist
2026-07-22 07:30:55 -07:00
14 changed files with 2150 additions and 23 deletions
+307
View File
@@ -0,0 +1,307 @@
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
Mirrors the ``optional-mcps/`` MCP-catalog pattern (see
:mod:`hermes_cli.mcp_catalog`): each catalog entry is a single YAML file under
the in-tree ``plugin-catalog/`` directory, pinned to an exact 40-character
commit SHA. Users discover entries via ``hermes plugins catalog`` /
``hermes plugins search`` and install them with
``hermes plugins install <name>``, which clones the pinned commit.
Catalog policy (see plugin-catalog/README.md for the full admission policy):
- Entries are added only by merging a PR into hermes-agent — presence in the
``plugin-catalog/`` directory is the human-merged approval gate.
- Every entry pins an exact 40-hex commit SHA. SHA bumps are new PRs,
re-reviewed as diffs. The pinned release should be at least 2 weeks old at
pin time, mirroring the optional-mcps supply-chain rules.
- ``plugin-catalog/removed.yaml`` is the blocklist: entries pulled from the
catalog for security or policy reasons are recorded there so installs of
the same name/repo are refused with the recorded reason.
"""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, List, Optional
import yaml
logger = logging.getLogger(__name__)
CATALOG_TIERS = ("official", "community")
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
# ─── Data classes ────────────────────────────────────────────────────────────
@dataclass
class RemovedEntry:
name: str
repo: str = ""
reason: str = ""
date: str = "" # ISO date string
@dataclass
class CatalogCapabilities:
provides_tools: List[str] = field(default_factory=list)
provides_hooks: List[str] = field(default_factory=list)
provides_middleware: List[str] = field(default_factory=list)
requires_env: List[str] = field(default_factory=list)
@dataclass
class PluginCatalogEntry:
name: str # catalog key, [a-z0-9_-]{1,64}
repo: str # https:// git URL
sha: str # 40-hex pinned commit — MANDATORY, validated
description: str
maintainer: str
tier: str = "community" # one of CATALOG_TIERS
requires_hermes: str = "" # e.g. ">=0.19" (optional)
subdir: str = "" # optional path within the repo
docs_url: str = ""
platforms: List[str] = field(default_factory=list) # empty = all OSes
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
# ─── Directory resolution ────────────────────────────────────────────────────
def get_catalog_dir() -> Path:
"""Return the ``plugin-catalog/`` directory shipped with this checkout.
``HERMES_PLUGIN_CATALOG_DIR`` overrides the location for tests only —
read via ``os.getenv`` at call time so monkeypatched values take effect.
"""
override = os.getenv("HERMES_PLUGIN_CATALOG_DIR", "").strip()
if override:
return Path(override)
return Path(__file__).resolve().parent.parent / "plugin-catalog"
# ─── Loading / validation ────────────────────────────────────────────────────
def _str_list(raw: Any) -> List[str]:
"""Coerce a YAML value into a list of strings (drop non-strings)."""
if not isinstance(raw, list):
return []
return [str(item) for item in raw if isinstance(item, (str, int, float))]
def _parse_entry(path: Path) -> Optional[PluginCatalogEntry]:
"""Parse and validate one catalog YAML file.
Returns ``None`` (after logging a warning) on any validation failure —
the loader never raises for a bad entry.
"""
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as exc:
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning("Plugin catalog: %s: entry must be a mapping", path)
return None
name = str(data.get("name") or "")
if not _NAME_RE.match(name):
logger.warning(
"Plugin catalog: %s: invalid name %r (must match [a-z0-9_-]{1,64})",
path, name,
)
return None
repo = str(data.get("repo") or "")
if not repo.startswith("https://"):
logger.warning(
"Plugin catalog: %s: repo must be an https:// URL (got %r)",
path, repo,
)
return None
sha = str(data.get("sha") or "").strip().lower()
if not _SHA_RE.match(sha):
logger.warning(
"Plugin catalog: %s: sha must be a full 40-character hex commit "
"SHA (got %r)", path, data.get("sha"),
)
return None
tier = str(data.get("tier") or "community")
if tier not in CATALOG_TIERS:
logger.warning(
"Plugin catalog: %s: tier must be one of %s (got %r)",
path, "/".join(CATALOG_TIERS), tier,
)
return None
caps_raw = data.get("capabilities") or {}
if not isinstance(caps_raw, dict):
caps_raw = {}
capabilities = CatalogCapabilities(
provides_tools=_str_list(caps_raw.get("provides_tools")),
provides_hooks=_str_list(caps_raw.get("provides_hooks")),
provides_middleware=_str_list(caps_raw.get("provides_middleware")),
requires_env=_str_list(caps_raw.get("requires_env")),
)
return PluginCatalogEntry(
name=name,
repo=repo,
sha=sha,
description=str(data.get("description") or "").strip(),
maintainer=str(data.get("maintainer") or "").strip(),
tier=tier,
requires_hermes=str(data.get("requires_hermes") or "").strip(),
subdir=str(data.get("subdir") or "").strip(),
docs_url=str(data.get("docs_url") or "").strip(),
platforms=_str_list(data.get("platforms")),
capabilities=capabilities,
)
def load_catalog() -> List[PluginCatalogEntry]:
"""Return all valid catalog entries, sorted by name.
Parses every ``*.yaml`` in the catalog dir except ``removed.yaml``.
Invalid entries are skipped with a logged warning; this function never
raises for a malformed entry.
"""
root = get_catalog_dir()
if not root.is_dir():
return []
entries: List[PluginCatalogEntry] = []
for path in sorted(root.glob("*.yaml")):
if path.name == "removed.yaml":
continue
entry = _parse_entry(path)
if entry is not None:
entries.append(entry)
return entries
def get_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
"""Look up a single catalog entry by name."""
for entry in load_catalog():
if entry.name == name:
return entry
return None
def search_catalog(query: str) -> List[PluginCatalogEntry]:
"""Case-insensitive substring search over name, description, and
declared tools. An empty query returns the whole catalog."""
entries = load_catalog()
q = (query or "").strip().lower()
if not q:
return entries
results: List[PluginCatalogEntry] = []
for entry in entries:
haystacks = [entry.name, entry.description]
haystacks.extend(entry.capabilities.provides_tools)
if any(q in h.lower() for h in haystacks):
results.append(entry)
return results
# ─── Removed / blocklist ─────────────────────────────────────────────────────
def _normalize_repo(url: str) -> str:
"""Normalize a repo URL for comparison (.git suffix and trailing slash
stripped, lowercased)."""
return url.strip().rstrip("/").removesuffix(".git").lower()
def load_removed_list() -> List[RemovedEntry]:
"""Load ``plugin-catalog/removed.yaml`` (the ``removed:`` list).
Missing or malformed files yield an empty list — never raises.
"""
path = get_catalog_dir() / "removed.yaml"
if not path.is_file():
return []
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as exc:
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
return []
raw_list = data.get("removed") if isinstance(data, dict) else None
if not isinstance(raw_list, list):
return []
removed: List[RemovedEntry] = []
for raw in raw_list:
if not isinstance(raw, dict):
continue
name = str(raw.get("name") or "")
if not name:
continue
removed.append(
RemovedEntry(
name=name,
repo=str(raw.get("repo") or ""),
reason=str(raw.get("reason") or ""),
date=str(raw.get("date") or ""),
)
)
return removed
def find_removed(name_or_repo: str) -> Optional[RemovedEntry]:
"""Match *name_or_repo* against the removed blocklist.
Matches by exact catalog name OR by repo URL (normalized — ``.git``
suffix and trailing slashes are ignored).
"""
if not name_or_repo:
return None
candidate = name_or_repo.strip()
candidate_repo = _normalize_repo(candidate)
for entry in load_removed_list():
if candidate == entry.name:
return entry
if entry.repo and candidate_repo == _normalize_repo(entry.repo):
return entry
return None
# ─── Human summaries ─────────────────────────────────────────────────────────
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
"""One-paragraph human summary of what an entry declares, shown at
install prompts so the user knows what they're granting."""
caps = entry.capabilities
parts: List[str] = []
if caps.provides_tools:
parts.append(f"registers tool(s): {', '.join(caps.provides_tools)}")
if caps.provides_hooks:
parts.append(f"hook(s): {', '.join(caps.provides_hooks)}")
if caps.provides_middleware:
parts.append(f"middleware: {', '.join(caps.provides_middleware)}")
if caps.requires_env:
parts.append(f"requires env var(s): {', '.join(caps.requires_env)}")
if not parts:
capability_text = "declares no tools, hooks, middleware, or env vars"
else:
capability_text = "; ".join(parts)
bits = [
f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})",
]
if entry.description:
bits.append(entry.description)
bits.append(f"This plugin {capability_text}.")
if entry.platforms:
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
if entry.requires_hermes:
bits.append(f"Requires Hermes {entry.requires_hermes}.")
return " ".join(bits)
+169
View File
@@ -39,6 +39,7 @@ import importlib.util
import inspect
import logging
import os
import re
import sys
import threading
import types
@@ -79,6 +80,93 @@ class PluginToolOverrideError(PermissionError):
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Hermes version gate (manifest ``requires_hermes``)
# ---------------------------------------------------------------------------
_VERSION_COMPARATOR_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$")
def _running_hermes_version() -> str:
"""Return the running Hermes version string.
Prefers installed package metadata (matches ``hermes_cli/main.py``'s
version reporting), falling back to ``hermes_cli.__version__`` for
source checkouts, then ``"0.0.0"`` as a last resort.
"""
try:
return importlib.metadata.version("hermes-agent")
except Exception:
pass
try:
from hermes_cli import __version__
return __version__
except Exception:
return "0.0.0"
def _version_tuple(v: str) -> Optional[tuple]:
"""Parse ``major.minor.patch`` into a comparable tuple.
Leading ``v`` and pre-release/build metadata (``-rc1``, ``+abc``) are
stripped; missing segments default to 0. Returns ``None`` when any
segment is non-numeric.
"""
s = str(v).strip().lstrip("v")
s = re.split(r"[-+]", s, 1)[0]
parts = s.split(".")
while len(parts) < 3:
parts.append("0")
try:
return tuple(int(p) for p in parts[:3])
except ValueError:
return None
def _version_satisfies(spec: str, current: str) -> bool:
"""Return True when *current* satisfies *spec*.
*spec* supports ``>=``, ``>``, ``<=``, ``<``, ``==``, ``!=`` and
comma-separated combinations (all must hold). A bare version is treated
as ``>=``. Non-numeric version segments fall back to permissive True
(with a debug log) — no new dependency, so no full PEP 440 handling.
"""
if not spec or not spec.strip():
return True
cur = _version_tuple(current)
if cur is None:
logger.debug(
"requires_hermes: unparseable running version %r — allowing", current,
)
return True
for clause in spec.split(","):
clause = clause.strip()
if not clause:
continue
m = _VERSION_COMPARATOR_RE.match(clause)
if m:
op, target = m.group(1), m.group(2)
else:
op, target = ">=", clause
tgt = _version_tuple(target)
if tgt is None:
logger.debug(
"requires_hermes: unparseable version spec %r — allowing", clause,
)
continue
ok = {
">=": cur >= tgt,
"<=": cur <= tgt,
"==": cur == tgt,
"!=": cur != tgt,
">": cur > tgt,
"<": cur < tgt,
}[op]
if not ok:
return False
return True
# ---------------------------------------------------------------------------
# Plugin developer debug logging
# ---------------------------------------------------------------------------
@@ -312,6 +400,16 @@ class PluginManifest:
# category plugin at ``plugins/image_gen/openai/`` the key is
# ``image_gen/openai``. When empty, falls back to ``name``.
key: str = ""
# Minimum/exact Hermes version requirement, e.g. ``">=0.19"``. Empty =
# no requirement. Checked at load time; unsatisfied plugins are recorded
# with an error and skipped (no register() call, no traceback).
requires_hermes: str = ""
# Declared config keys from the manifest's ``config:`` section — a list
# of ``{key, prompt, type (str|bool|int), default, secret (bool)}``
# dicts. secret=true values are prompted into ~/.hermes/.env; secret
# =false values live under ``plugins.entries.<plugin_id>.<key>`` in
# config.yaml. Exposed to plugins via ``ctx.plugin_config``.
config_spec: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
@@ -386,6 +484,40 @@ class PluginContext:
except Exception:
return "default"
# -- declared plugin config ---------------------------------------------
@property
def plugin_config(self) -> Dict[str, Any]:
"""Return this plugin's effective config values.
Built from the manifest's ``config:`` spec defaults, overlaid with
whatever the operator set under ``plugins.entries.<plugin_id>`` in
config.yaml (config.yaml wins on key collision). Secret keys
(``secret: true``) are stored in ``~/.hermes/.env`` instead and are
NOT surfaced here — read them via ``os.environ``.
"""
merged: Dict[str, Any] = {}
for spec in self.manifest.config_spec or []:
key = spec.get("key")
if not key:
continue
if spec.get("secret"):
continue # secrets live in .env, never in config.yaml
if "default" in spec:
merged[key] = spec.get("default")
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
except Exception:
cfg = {}
plugin_id = self.manifest.key or self.manifest.name
entries = (cfg.get("plugins") or {}).get("entries") or {}
entry = entries.get(plugin_id) or {}
if isinstance(entry, dict):
for key, value in entry.items():
merged[key] = value
return merged
# -- tool registration --------------------------------------------------
def register_tool(
@@ -1632,6 +1764,23 @@ class PluginManager:
"Parsed manifest: key=%s name=%s kind=%s source=%s path=%s",
key, name, kind, source, plugin_dir,
)
raw_config = data.get("config", [])
config_spec: List[Dict[str, Any]] = []
if isinstance(raw_config, list):
for item in raw_config:
if isinstance(item, dict) and item.get("key"):
config_spec.append(dict(item))
else:
logger.warning(
"Plugin %s: ignoring invalid config entry %r "
"(must be a mapping with a 'key')", key, item,
)
elif raw_config:
logger.warning(
"Plugin %s: 'config' must be a list of mappings; ignoring",
key,
)
return PluginManifest(
name=name,
version=str(data.get("version", "")),
@@ -1644,6 +1793,8 @@ class PluginManager:
path=str(plugin_dir),
kind=kind,
key=key,
requires_hermes=str(data.get("requires_hermes") or "").strip(),
config_spec=config_spec,
)
except Exception as exc:
logger.warning(
@@ -1748,6 +1899,24 @@ class PluginManager:
def _load_plugin(self, manifest: PluginManifest) -> None:
"""Import a plugin module and call its ``register(ctx)`` function."""
loaded = LoadedPlugin(manifest=manifest)
# requires_hermes gate — skip cleanly (no import, no traceback) when
# the running Hermes version doesn't satisfy the manifest spec.
if manifest.requires_hermes:
current = _running_hermes_version()
if not _version_satisfies(manifest.requires_hermes, current):
loaded.enabled = False
loaded.error = (
f"requires hermes {manifest.requires_hermes}, "
f"running {current}"
)
self._plugins[manifest.key or manifest.name] = loaded
logger.warning(
"Plugin '%s' skipped: %s",
manifest.key or manifest.name, loaded.error,
)
return
logger.debug(
"Loading plugin '%s' (source=%s, kind=%s, path=%s)",
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
+160 -11
View File
@@ -446,19 +446,61 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
# ---------------------------------------------------------------------------
def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, str]:
def _raise_removed(removed) -> None:
"""Raise PluginOperationError describing a blocklisted plugin."""
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 "
f"catalog and is blocked from installation: {detail}"
)
def _install_plugin_core(
identifier: str,
*,
force: bool,
ref: Optional[str] = None,
skip_removed_check: bool = False,
) -> tuple[Path, dict, str]:
"""Clone Git plugin into ``~/.hermes/plugins``.
``ref`` — optional git commit SHA (or tag) checked out after clone.
When given, the clone is full-depth (no ``--depth 1``) so any commit is
reachable.
Unless ``skip_removed_check`` is set, the identifier and the resolved
repo URL are checked against the plugin catalog's removed blocklist
(``plugin-catalog/removed.yaml``); a hit raises ``PluginOperationError``
with the recorded reason and date.
Returns ``(target_dir, installed_manifest, canonical_name)``.
Raises ``PluginOperationError`` on failure.
"""
import tempfile
if not skip_removed_check:
from hermes_cli.plugin_catalog import find_removed
# Check the raw identifier first (catches catalog names before URL
# resolution), then the resolved repo URL below.
removed = find_removed(identifier)
if removed is not None:
_raise_removed(removed)
try:
git_url, subdir = _resolve_git_url(identifier)
except ValueError as e:
raise PluginOperationError(str(e)) from e
if not skip_removed_check:
from hermes_cli.plugin_catalog import find_removed
removed = find_removed(git_url)
if removed is not None:
_raise_removed(removed)
plugins_dir = _plugins_dir()
with tempfile.TemporaryDirectory() as tmp:
@@ -468,9 +510,15 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
if not git_exe:
raise PluginOperationError("git is not installed or not in PATH.")
clone_cmd = [git_exe, "clone"]
if ref is None:
# Fast path — only the tip is needed.
clone_cmd += ["--depth", "1"]
clone_cmd += [git_url, str(tmp_clone)]
try:
result = subprocess.run(
[git_exe, "clone", "--depth", "1", git_url, str(tmp_clone)],
clone_cmd,
capture_output=True,
text=True,
timeout=60,
@@ -488,6 +536,24 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
err = (result.stderr or result.stdout or "").strip()
raise PluginOperationError(f"Git clone failed:\n{err}")
if ref is not None:
try:
checkout = subprocess.run(
[git_exe, "-C", str(tmp_clone), "checkout", ref],
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired as e:
raise PluginOperationError(
f"Git checkout of ref '{ref}' timed out after 60 seconds.",
) from e
if checkout.returncode != 0:
err = (checkout.stderr or checkout.stdout or "").strip()
raise PluginOperationError(
f"Git checkout of ref '{ref}' failed:\n{err}"
)
# Resolve the directory within the clone that holds the plugin.
if subdir:
tmp_target = _resolve_subdir_within(tmp_clone, subdir)
@@ -1759,31 +1825,114 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected,
print()
_CATALOG_SIDECAR_FILENAME = ".hermes-catalog.json"
def write_catalog_sidecar(target: Path, entry: Any) -> None:
"""Record catalog provenance next to an installed plugin.
The ``.hermes-catalog.json`` sidecar lets ``hermes plugins list`` and the
dashboard tell a catalog-pinned install apart from a raw git install and
detect when the catalog has moved to a newer pinned SHA.
"""
from datetime import datetime, timezone
payload = {
"catalog_name": entry.name,
"repo": entry.repo,
"sha": entry.sha,
"installed_at": datetime.now(timezone.utc)
.isoformat()
.replace("+00:00", "Z"),
"tier": entry.tier,
}
(target / _CATALOG_SIDECAR_FILENAME).write_text(
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
)
def read_catalog_sidecar(plugin_dir: Path) -> Optional[dict]:
"""Read a plugin dir's ``.hermes-catalog.json`` sidecar, or ``None``.
Returns ``None`` for missing, unreadable, or non-mapping sidecars —
callers degrade to "installed, provenance unknown".
"""
path = plugin_dir / _CATALOG_SIDECAR_FILENAME
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return data if isinstance(data, dict) else None
def dashboard_install_plugin(
identifier: str,
*,
force: bool,
enable: bool,
catalog_name: Optional[str] = None,
) -> dict[str, Any]:
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict.
When *catalog_name* is given the identifier is resolved from the plugin
catalog and the pinned commit SHA is checked out (``ref=``). Removed
(blocklisted) plugins are refused with the recorded reason — the
dashboard deliberately has no bypass flag (CLI-only decision).
"""
warnings: list[str] = []
try:
git_url, _subdir = _resolve_git_url(identifier)
if git_url.startswith(("http://", "file://")):
warnings.append(
"Insecure URL scheme; prefer https:// or git@ for production installs.",
)
except ValueError:
pass
entry = None
ref: Optional[str] = None
if catalog_name:
from hermes_cli.plugin_catalog import find_removed, get_catalog_entry
removed = find_removed(catalog_name)
if removed is not None:
detail = removed.reason or "no reason recorded"
if removed.date:
detail += f" (removed {removed.date})"
return {
"ok": False,
"error": (
f"Plugin '{removed.name}' was removed from the Hermes "
f"plugin catalog and is blocked from installation: {detail}"
),
}
entry = get_catalog_entry(catalog_name)
if entry is None:
return {
"ok": False,
"error": f"'{catalog_name}' is not in the Hermes plugin catalog.",
}
identifier = f"{entry.repo}#{entry.subdir}" if entry.subdir else entry.repo
ref = entry.sha
else:
try:
git_url, _subdir = _resolve_git_url(identifier)
if git_url.startswith(("http://", "file://")):
warnings.append(
"Insecure URL scheme; prefer https:// or git@ for production installs.",
)
except ValueError:
pass
try:
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
ref=ref,
)
except PluginOperationError as exc:
return {"ok": False, "error": str(exc)}
if entry is not None:
try:
write_catalog_sidecar(target, entry)
except OSError as exc:
warnings.append(f"Could not record catalog provenance: {exc}")
missing_env = _missing_requires_env_names(installed_manifest)
if enable:
en = _get_enabled_set()
+135 -10
View File
@@ -18849,12 +18849,27 @@ class _AgentPluginInstallBody(BaseModel):
identifier: str
force: bool = False
enable: bool = True
catalog_name: Optional[str] = None
def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in p.items() if not k.startswith("_")}
def _plugin_runtime_status(aliases: set, enabled_set: set, disabled_set: set) -> str:
"""Map a plugin's name aliases onto enabled/disabled/inactive.
Both the path-derived key (nested category plugins) and the bare
manifest name count for enabled/disabled state, matching the runtime
loader's back-compat lookup.
"""
if aliases & disabled_set:
return "disabled"
if aliases & enabled_set:
return "enabled"
return "inactive"
def _merged_plugins_hub() -> Dict[str, Any]:
"""Agent discovery + dashboard manifests + optional provider picker metadata."""
from hermes_cli.plugins_cmd import (
@@ -18866,6 +18881,7 @@ def _merged_plugins_hub() -> Dict[str, Any]:
_get_enabled_set,
_read_manifest as _read_plugin_manifest_at,
)
from hermes_cli.plugin_catalog import find_removed
dashboard_list = _get_dashboard_plugins()
dash_by_name = {str(p["name"]): p for p in dashboard_list}
@@ -18881,18 +18897,10 @@ def _merged_plugins_hub() -> Dict[str, Any]:
rows: List[Dict[str, Any]] = []
for name, version, description, source, dir_str, key in _discover_all_plugins():
# Both the path-derived key (nested category plugins) and the bare
# manifest name count for enabled/disabled state, matching the runtime
# loader's back-compat lookup.
aliases = {name}
if key:
aliases.add(key)
if aliases & disabled_set:
runtime_status = "disabled"
elif aliases & enabled_set:
runtime_status = "enabled"
else:
runtime_status = "inactive"
runtime_status = _plugin_runtime_status(aliases, enabled_set, disabled_set)
dir_path = Path(dir_str)
dm = dash_by_name.get(name)
@@ -18926,6 +18934,14 @@ def _merged_plugins_hub() -> Dict[str, Any]:
except Exception:
pass
removed_reason = None
try:
removed = find_removed(name)
if removed is not None:
removed_reason = removed.reason or "removed from the plugin catalog"
except Exception:
removed_reason = None
rows.append({
"name": name,
"version": version or "",
@@ -18940,6 +18956,7 @@ def _merged_plugins_hub() -> Dict[str, Any]:
"auth_required": auth_required,
"auth_command": auth_command,
"user_hidden": name in hidden_plugins,
"removed_reason": removed_reason,
})
agent_names = {r["name"] for r in rows}
@@ -18981,15 +18998,123 @@ async def get_plugins_hub(request: Request):
raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc
def _plugins_catalog_payload() -> Dict[str, Any]:
"""Catalog entries merged with installed-state for the dashboard.
Each entry carries the static catalog metadata plus:
* ``installed`` — a plugin with the same name is discoverable locally.
* ``installed_sha`` — pinned SHA recorded in the plugin's
``.hermes-catalog.json`` sidecar at install time (``None`` when the
sidecar is absent, e.g. a pre-catalog raw-git install).
* ``update_available`` — sidecar SHA differs from the catalog pin.
* ``runtime_status`` — enabled/disabled/inactive for installed entries,
``None`` otherwise.
"""
from hermes_cli.plugin_catalog import (
entry_capability_summary,
load_catalog,
load_removed_list,
)
from hermes_cli.plugins_cmd import (
_discover_all_plugins,
_get_disabled_set,
_get_enabled_set,
read_catalog_sidecar,
)
disabled_set = _get_disabled_set()
enabled_set = _get_enabled_set()
installed: Dict[str, Dict[str, Any]] = {}
for name, _version, _description, _source, dir_str, key in _discover_all_plugins():
aliases = {name}
if key:
aliases.add(key)
info = {
"dir": dir_str,
"runtime_status": _plugin_runtime_status(aliases, enabled_set, disabled_set),
}
for alias in aliases:
installed[alias] = info
entries: List[Dict[str, Any]] = []
for entry in load_catalog():
caps = entry.capabilities
local = installed.get(entry.name)
installed_sha = None
if local is not None:
sidecar = read_catalog_sidecar(Path(local["dir"]))
if sidecar:
raw_sha = sidecar.get("sha")
installed_sha = str(raw_sha) if raw_sha else None
entries.append({
"name": entry.name,
"description": entry.description,
"repo": entry.repo,
"sha": entry.sha,
"sha_short": entry.sha[:7],
"tier": entry.tier,
"maintainer": entry.maintainer,
"requires_hermes": entry.requires_hermes,
"platforms": entry.platforms,
"capabilities": {
"provides_tools": caps.provides_tools,
"provides_hooks": caps.provides_hooks,
"provides_middleware": caps.provides_middleware,
"requires_env": caps.requires_env,
},
"docs_url": entry.docs_url,
"capability_summary": entry_capability_summary(entry),
"installed": local is not None,
"installed_sha": installed_sha,
"update_available": bool(installed_sha) and installed_sha != entry.sha,
"runtime_status": local["runtime_status"] if local is not None else None,
})
removed = [
{"name": r.name, "repo": r.repo, "reason": r.reason, "date": r.date}
for r in load_removed_list()
]
return {
"entries": entries,
"removed": removed,
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
@app.get("/api/dashboard/plugins/catalog")
async def get_plugins_catalog(request: Request):
"""Curated plugin catalog merged with installed-state (session protected)."""
_require_token(request)
try:
return _plugins_catalog_payload()
except Exception as exc:
_log.warning("plugins/catalog failed: %s", exc)
raise HTTPException(
status_code=500, detail="Failed to build plugins catalog."
) from exc
@app.post("/api/dashboard/agent-plugins/install")
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
_require_token(request)
from hermes_cli.plugins_cmd import dashboard_install_plugin
catalog_name = (body.catalog_name or "").strip()
identifier = body.identifier.strip()
if not identifier and not catalog_name:
raise HTTPException(
status_code=400,
detail="Provide an identifier or a catalog_name.",
)
result = dashboard_install_plugin(
body.identifier.strip(),
identifier,
force=body.force,
enable=body.enable,
catalog_name=catalog_name or None,
)
if not result.get("ok"):
raise HTTPException(
+60
View File
@@ -0,0 +1,60 @@
# Hermes Plugin Catalog
Curated, Nous-approved Hermes plugins. Each YAML file in this directory
(except `removed.yaml`) is one catalog entry, discoverable via
`hermes plugins catalog` / `hermes plugins search` and installable with
`hermes plugins install <name>`.
## Admission policy
Presence in this directory **is** the trust signal. The rules that keep it
meaningful:
1. **Human-merged gate.** Entries are added *only* via a PR to the
`hermes-agent` repository, reviewed and merged by a maintainer. There is
no self-serve registry, no automated ingestion.
2. **Exact SHA pins are mandatory.** Every entry pins a full 40-character
commit SHA. Branches, tags, and short SHAs are rejected by the loader.
Installs clone the repository and check out exactly that commit.
3. **Pin maturity.** The pinned release should be **at least 2 weeks old**
at pin time, mirroring the supply-chain policy used for `optional-mcps/`
and pyproject dependencies. This gives the community time to notice a
compromised release before Hermes ships a pointer to it.
4. **SHA bumps are new PRs.** Updating an entry's pin is a new PR whose diff
(old SHA → new SHA) is re-reviewed like any other change — reviewers are
expected to look at the upstream commit range being adopted.
5. **Owner-or-major-contributor submissions only.** An entry may only be
submitted by the plugin repository's owner or a major contributor to it.
Drive-by submissions of third-party repos are declined.
6. **Declared capabilities must match reality.** The `capabilities:` block
(tools, hooks, middleware, env vars) must match what the plugin actually
registers at the pinned commit. Validation fails the entry otherwise —
undeclared capability creep is treated as a security issue.
## Entry schema
```yaml
name: example-plugin # [a-z0-9_-]{1,64}, the catalog key
repo: https://github.com/owner/repo # https:// only
sha: <40-hex commit sha> # mandatory exact pin
subdir: "" # optional path within the repo
description: One-line description.
maintainer: OwnerName
tier: official # official | community (default community)
requires_hermes: ">=0.19" # optional
docs_url: "" # optional
platforms: [] # optional, e.g. [linux, macos]; empty = all
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
```
## removed.yaml — the blocklist
When an entry is pulled from the catalog for security or policy reasons, it
is recorded in `removed.yaml` with a reason and date. The installer refuses
to install anything matching a removed entry's name or repo URL, so a
malicious plugin cannot be re-installed from a stale identifier after
removal. Removals, like additions, land via reviewed PRs.
+14
View File
@@ -0,0 +1,14 @@
name: example-plugin
repo: https://github.com/NousResearch/hermes-example-plugins
sha: 38fe0fb53eff98d477f807432e965429e665ca33
subdir: ""
description: Reference example plugins for the Hermes plugin system.
maintainer: NousResearch
tier: official
docs_url: ""
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+6
View File
@@ -0,0 +1,6 @@
# Blocklist for plugins pulled from the catalog for security or policy
# reasons. The installer refuses to install anything whose name or repo URL
# matches an entry here (unless the caller explicitly bypasses the check).
# Each entry: {name, repo, reason, date}. Removals land via reviewed PRs,
# same as additions.
removed: []
+594
View File
@@ -0,0 +1,594 @@
"""Tests for the Hermes plugin catalog (hermes_cli.plugin_catalog) and the
catalog-driven install/manifest extensions in plugins_cmd.py / plugins.py."""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
import yaml
from hermes_cli.plugin_catalog import (
CATALOG_TIERS,
PluginCatalogEntry,
RemovedEntry,
entry_capability_summary,
find_removed,
get_catalog_dir,
get_catalog_entry,
load_catalog,
load_removed_list,
search_catalog,
)
VALID_SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
# ── Helpers ────────────────────────────────────────────────────────────────
def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path:
"""Write a minimal valid catalog entry yaml, applying overrides."""
data = {
"name": name,
"repo": f"https://github.com/example/{name}",
"sha": VALID_SHA,
"description": f"Test entry {name}.",
"maintainer": "Example",
}
data.update(overrides)
catalog_dir.mkdir(parents=True, exist_ok=True)
path = catalog_dir / f"{name}.yaml"
path.write_text(yaml.safe_dump(data), encoding="utf-8")
return path
def _write_removed(catalog_dir: Path, removed: list) -> Path:
catalog_dir.mkdir(parents=True, exist_ok=True)
path = catalog_dir / "removed.yaml"
path.write_text(yaml.safe_dump({"removed": removed}), encoding="utf-8")
return path
@pytest.fixture()
def catalog_dir(tmp_path, monkeypatch):
d = tmp_path / "catalog"
d.mkdir()
monkeypatch.setenv("HERMES_PLUGIN_CATALOG_DIR", str(d))
return d
# ── get_catalog_dir ────────────────────────────────────────────────────────
class TestGetCatalogDir:
def test_env_override_wins(self, catalog_dir):
assert get_catalog_dir() == catalog_dir
def test_default_is_repo_plugin_catalog(self, monkeypatch):
monkeypatch.delenv("HERMES_PLUGIN_CATALOG_DIR", raising=False)
d = get_catalog_dir()
assert d.name == "plugin-catalog"
# ── load_catalog ───────────────────────────────────────────────────────────
class TestLoadCatalog:
def test_valid_entry_parses(self, catalog_dir):
_write_entry(
catalog_dir,
"my-plugin",
tier="official",
requires_hermes=">=0.19",
subdir="plugins/my-plugin",
docs_url="https://example.com/docs",
platforms=["linux"],
capabilities={
"provides_tools": ["my_tool"],
"provides_hooks": ["on_start"],
"provides_middleware": ["llm_request"],
"requires_env": ["MY_API_KEY"],
},
)
entries = load_catalog()
assert len(entries) == 1
e = entries[0]
assert isinstance(e, PluginCatalogEntry)
assert e.name == "my-plugin"
assert e.repo == "https://github.com/example/my-plugin"
assert e.sha == VALID_SHA
assert e.tier == "official"
assert e.requires_hermes == ">=0.19"
assert e.subdir == "plugins/my-plugin"
assert e.docs_url == "https://example.com/docs"
assert e.platforms == ["linux"]
assert e.capabilities.provides_tools == ["my_tool"]
assert e.capabilities.provides_hooks == ["on_start"]
assert e.capabilities.provides_middleware == ["llm_request"]
assert e.capabilities.requires_env == ["MY_API_KEY"]
def test_tier_defaults_to_community(self, catalog_dir):
_write_entry(catalog_dir, "no-tier")
(entry,) = load_catalog()
assert entry.tier == "community"
assert entry.tier in CATALOG_TIERS
def test_bad_sha_rejected(self, catalog_dir, caplog):
_write_entry(catalog_dir, "bad-sha", sha="main")
_write_entry(catalog_dir, "short-sha", sha="38fe0fb")
_write_entry(catalog_dir, "good", sha=VALID_SHA)
with caplog.at_level("WARNING"):
entries = load_catalog()
assert [e.name for e in entries] == ["good"]
def test_bad_name_rejected(self, catalog_dir, caplog):
_write_entry(catalog_dir, "BadName")
_write_entry(catalog_dir, "has spaces")
with caplog.at_level("WARNING"):
entries = load_catalog()
assert entries == []
def test_non_https_repo_rejected(self, catalog_dir, caplog):
_write_entry(catalog_dir, "sshrepo", repo="git@github.com:x/y.git")
with caplog.at_level("WARNING"):
entries = load_catalog()
assert entries == []
def test_invalid_tier_rejected(self, catalog_dir, caplog):
_write_entry(catalog_dir, "weird-tier", tier="platinum")
with caplog.at_level("WARNING"):
entries = load_catalog()
assert entries == []
def test_removed_yaml_is_not_an_entry(self, catalog_dir):
_write_entry(catalog_dir, "real-entry")
_write_removed(catalog_dir, [])
entries = load_catalog()
assert [e.name for e in entries] == ["real-entry"]
def test_unparseable_yaml_skipped_without_raising(self, catalog_dir, caplog):
(catalog_dir / "broken.yaml").write_text(
"name: [unclosed", encoding="utf-8"
)
_write_entry(catalog_dir, "ok-entry")
with caplog.at_level("WARNING"):
entries = load_catalog()
assert [e.name for e in entries] == ["ok-entry"]
def test_missing_dir_returns_empty(self, tmp_path, monkeypatch):
monkeypatch.setenv(
"HERMES_PLUGIN_CATALOG_DIR", str(tmp_path / "does-not-exist")
)
assert load_catalog() == []
# ── get_catalog_entry / search_catalog ─────────────────────────────────────
class TestLookupAndSearch:
def test_get_catalog_entry_by_name(self, catalog_dir):
_write_entry(catalog_dir, "alpha")
_write_entry(catalog_dir, "beta")
entry = get_catalog_entry("beta")
assert entry is not None and entry.name == "beta"
assert get_catalog_entry("nope") is None
def test_search_matches_name_case_insensitive(self, catalog_dir):
_write_entry(catalog_dir, "weather-tools")
_write_entry(catalog_dir, "other")
results = search_catalog("WEATHER")
assert [e.name for e in results] == ["weather-tools"]
def test_search_matches_description(self, catalog_dir):
_write_entry(catalog_dir, "abc", description="Fetches Stock Quotes.")
results = search_catalog("stock")
assert [e.name for e in results] == ["abc"]
def test_search_matches_declared_tools(self, catalog_dir):
_write_entry(
catalog_dir,
"toolful",
capabilities={"provides_tools": ["get_forecast"]},
)
_write_entry(catalog_dir, "toolless")
results = search_catalog("Forecast")
assert [e.name for e in results] == ["toolful"]
def test_empty_query_returns_all(self, catalog_dir):
_write_entry(catalog_dir, "one")
_write_entry(catalog_dir, "two")
assert len(search_catalog("")) == 2
# ── removed list ───────────────────────────────────────────────────────────
class TestRemovedList:
def test_load_removed_list(self, catalog_dir):
_write_removed(
catalog_dir,
[
{
"name": "evil-plugin",
"repo": "https://github.com/evil/evil-plugin",
"reason": "Exfiltrated env vars",
"date": "2026-07-02",
}
],
)
removed = load_removed_list()
assert len(removed) == 1
r = removed[0]
assert isinstance(r, RemovedEntry)
assert r.name == "evil-plugin"
assert r.reason == "Exfiltrated env vars"
assert r.date == "2026-07-02"
def test_missing_removed_yaml_returns_empty(self, catalog_dir):
assert load_removed_list() == []
assert find_removed("anything") is None
def test_find_removed_by_name(self, catalog_dir):
_write_removed(catalog_dir, [{"name": "evil-plugin", "reason": "bad"}])
hit = find_removed("evil-plugin")
assert hit is not None and hit.reason == "bad"
def test_find_removed_by_repo_url_with_and_without_git_suffix(
self, catalog_dir
):
_write_removed(
catalog_dir,
[
{
"name": "evil-plugin",
"repo": "https://github.com/evil/evil-plugin",
"reason": "bad",
}
],
)
assert find_removed("https://github.com/evil/evil-plugin") is not None
assert find_removed("https://github.com/evil/evil-plugin.git") is not None
assert find_removed("https://github.com/good/fine.git") is None
# ── entry_capability_summary ───────────────────────────────────────────────
class TestCapabilitySummary:
def test_summary_contains_declared_capabilities(self):
entry = PluginCatalogEntry(
name="cap-plugin",
repo="https://github.com/example/cap-plugin",
sha=VALID_SHA,
description="Does capable things.",
maintainer="Example",
)
entry.capabilities.provides_tools = ["tool_a", "tool_b"]
entry.capabilities.provides_hooks = ["session_start"]
entry.capabilities.requires_env = ["CAP_API_KEY"]
summary = entry_capability_summary(entry)
assert "tool_a" in summary
assert "tool_b" in summary
assert "session_start" in summary
assert "CAP_API_KEY" in summary
def test_summary_for_empty_capabilities_mentions_none(self):
entry = PluginCatalogEntry(
name="plain",
repo="https://github.com/example/plain",
sha=VALID_SHA,
description="Plain.",
maintainer="Example",
)
summary = entry_capability_summary(entry)
assert summary # non-empty human text
# ── shipped catalog seed ───────────────────────────────────────────────────
class TestShippedCatalog:
def test_shipped_catalog_entries_are_valid(self, monkeypatch):
"""Every yaml shipped in <repo>/plugin-catalog must load cleanly."""
monkeypatch.delenv("HERMES_PLUGIN_CATALOG_DIR", raising=False)
shipped = get_catalog_dir()
yaml_files = [
p for p in shipped.glob("*.yaml") if p.name != "removed.yaml"
]
entries = load_catalog()
assert len(entries) == len(yaml_files)
# removed.yaml must exist and parse
assert (shipped / "removed.yaml").exists()
load_removed_list()
# ── _version_satisfies ─────────────────────────────────────────────────────
class TestVersionSatisfies:
@pytest.fixture(autouse=True)
def _import(self):
from hermes_cli.plugins import _version_satisfies
self.satisfies = _version_satisfies
def test_ge(self):
assert self.satisfies(">=0.19", "0.19.0") is True
assert self.satisfies(">=0.19", "0.20.1") is True
assert self.satisfies(">=0.19", "0.18.2") is False
def test_gt_lt_le(self):
assert self.satisfies(">0.19", "0.19.1") is True
assert self.satisfies(">0.19", "0.19.0") is False
assert self.satisfies("<1.0", "0.19.0") is True
assert self.satisfies("<=0.19.0", "0.19.0") is True
def test_eq_ne(self):
assert self.satisfies("==0.19.0", "0.19.0") is True
assert self.satisfies("==0.19.0", "0.19.1") is False
assert self.satisfies("!=0.19.0", "0.19.1") is True
assert self.satisfies("!=0.19.0", "0.19.0") is False
def test_comma_separated_all_must_hold(self):
assert self.satisfies(">=0.10, <1.0", "0.19.0") is True
assert self.satisfies(">=0.10, <0.15", "0.19.0") is False
def test_bare_version_treated_as_ge(self):
assert self.satisfies("0.10", "0.19.0") is True
assert self.satisfies("999", "0.19.0") is False
def test_empty_spec_is_satisfied(self):
assert self.satisfies("", "0.19.0") is True
def test_non_numeric_segments_fall_back_permissive(self):
assert self.satisfies(">=abc.def", "0.19.0") is True
assert self.satisfies(">=0.19", "unknown") is True
# ── requires_hermes manifest gate ──────────────────────────────────────────
def _make_plugin(base: Path, name: str, *, manifest_extra: dict | None = None,
register_body: str = "pass", enable: bool = True) -> Path:
"""Create a plugin dir under <HERMES_HOME>/plugins and opt it in."""
plugin_dir = base / name
plugin_dir.mkdir(parents=True, exist_ok=True)
manifest = {"name": name, "version": "0.1.0", "description": name}
if manifest_extra:
manifest.update(manifest_extra)
(plugin_dir / "plugin.yaml").write_text(yaml.safe_dump(manifest))
(plugin_dir / "__init__.py").write_text(
f"def register(ctx):\n {register_body}\n"
)
if enable:
hermes_home = Path(os.environ["HERMES_HOME"])
cfg_path = hermes_home / "config.yaml"
cfg: dict = {}
if cfg_path.exists():
cfg = yaml.safe_load(cfg_path.read_text()) or {}
cfg.setdefault("plugins", {}).setdefault("enabled", []).append(name)
cfg_path.write_text(yaml.safe_dump(cfg))
return plugin_dir
class TestRequiresHermesGate:
def test_unsatisfied_requires_hermes_skips_load(self, monkeypatch):
from hermes_cli.plugins import PluginManager
hermes_home = Path(os.environ["HERMES_HOME"])
plugins_dir = hermes_home / "plugins"
_make_plugin(
plugins_dir, "future_plugin",
manifest_extra={"requires_hermes": ">=999.0"},
)
mgr = PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["future_plugin"]
assert loaded.enabled is False
assert loaded.error is not None
assert "requires hermes" in loaded.error
assert ">=999.0" in loaded.error
assert loaded.module is None # register() never ran
def test_satisfied_requires_hermes_loads_normally(self, monkeypatch):
from hermes_cli.plugins import PluginManager
hermes_home = Path(os.environ["HERMES_HOME"])
plugins_dir = hermes_home / "plugins"
_make_plugin(
plugins_dir, "old_ok_plugin",
manifest_extra={"requires_hermes": ">=0.1"},
)
mgr = PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["old_ok_plugin"]
assert loaded.enabled is True
assert loaded.error is None
def test_requires_hermes_parsed_onto_manifest(self):
from hermes_cli.plugins import PluginManager
hermes_home = Path(os.environ["HERMES_HOME"])
plugins_dir = hermes_home / "plugins"
_make_plugin(
plugins_dir, "spec_plugin",
manifest_extra={"requires_hermes": ">=0.19"},
enable=False,
)
mgr = PluginManager()
mgr.discover_and_load()
assert mgr._plugins["spec_plugin"].manifest.requires_hermes == ">=0.19"
# ── config: spec parsing + ctx.plugin_config ───────────────────────────────
class TestPluginConfig:
def test_config_spec_parsed_onto_manifest(self):
from hermes_cli.plugins import PluginManager
hermes_home = Path(os.environ["HERMES_HOME"])
plugins_dir = hermes_home / "plugins"
spec = [
{"key": "api_url", "prompt": "API URL", "type": "str",
"default": "https://api.example.com", "secret": False},
{"key": "token", "prompt": "Token", "type": "str", "secret": True},
]
_make_plugin(
plugins_dir, "cfg_plugin",
manifest_extra={"config": spec},
enable=False,
)
mgr = PluginManager()
mgr.discover_and_load()
manifest = mgr._plugins["cfg_plugin"].manifest
assert isinstance(manifest.config_spec, list)
assert manifest.config_spec[0]["key"] == "api_url"
assert manifest.config_spec[1]["secret"] is True
def test_plugin_config_merges_defaults_under_config_entries(self):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
hermes_home = Path(os.environ["HERMES_HOME"])
cfg_path = hermes_home / "config.yaml"
cfg_path.write_text(yaml.safe_dump({
"plugins": {"entries": {"merge_plugin": {"api_url": "https://override"}}}
}))
manifest = PluginManifest(
name="merge_plugin",
key="merge_plugin",
config_spec=[
{"key": "api_url", "default": "https://default"},
{"key": "retries", "type": "int", "default": 3},
],
)
ctx = PluginContext(manifest, PluginManager())
cfg = ctx.plugin_config
assert cfg["api_url"] == "https://override" # config.yaml wins
assert cfg["retries"] == 3 # default fills the gap
def test_plugin_config_empty_without_spec_or_entries(self):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
manifest = PluginManifest(name="bare_plugin", key="bare_plugin")
ctx = PluginContext(manifest, PluginManager())
assert ctx.plugin_config == {}
# ── _install_plugin_core: ref checkout + removed blocklist ────────────────
def _make_git_repo(tmp_path: Path) -> tuple[Path, str, str]:
"""Create a local git repo with two commits; return (path, sha1, sha2)."""
repo = tmp_path / "src-repo"
repo.mkdir()
def git(*args):
subprocess.run(
["git", *args], cwd=repo, check=True, capture_output=True, text=True,
env={**os.environ,
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"},
)
git("init", "-b", "main")
(repo / "plugin.yaml").write_text(
yaml.safe_dump({"name": "refplugin", "version": "1"})
)
(repo / "__init__.py").write_text("def register(ctx):\n pass\n")
(repo / "marker.txt").write_text("first\n")
git("add", "-A")
git("commit", "-m", "first")
sha1 = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True,
capture_output=True, text=True,
).stdout.strip()
(repo / "marker.txt").write_text("second\n")
git("add", "-A")
git("commit", "-m", "second")
sha2 = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo, check=True,
capture_output=True, text=True,
).stdout.strip()
return repo, sha1, sha2
class TestInstallPluginCore:
def test_ref_checkout_installs_pinned_commit(self, tmp_path, catalog_dir):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, sha1, _sha2 = _make_git_repo(tmp_path)
target, manifest, name = _install_plugin_core(
f"file://{repo}", force=False, ref=sha1
)
assert name == "refplugin"
assert (target / "marker.txt").read_text() == "first\n"
def test_default_install_gets_head(self, tmp_path, catalog_dir):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, _sha1, _sha2 = _make_git_repo(tmp_path)
target, _manifest, _name = _install_plugin_core(
f"file://{repo}", force=False
)
assert (target / "marker.txt").read_text() == "second\n"
def test_bad_ref_raises(self, tmp_path, catalog_dir):
from hermes_cli.plugins_cmd import PluginOperationError, _install_plugin_core
repo, _sha1, _sha2 = _make_git_repo(tmp_path)
with pytest.raises(PluginOperationError):
_install_plugin_core(
f"file://{repo}", force=False,
ref="0000000000000000000000000000000000000000",
)
def test_removed_repo_blocked(self, tmp_path, catalog_dir):
from hermes_cli.plugins_cmd import PluginOperationError, _install_plugin_core
repo, _sha1, _sha2 = _make_git_repo(tmp_path)
_write_removed(
catalog_dir,
[{
"name": "refplugin",
"repo": f"file://{repo}",
"reason": "exfiltrated env vars",
"date": "2026-07-02",
}],
)
with pytest.raises(PluginOperationError, match="exfiltrated env vars"):
_install_plugin_core(f"file://{repo}", force=False)
def test_removed_identifier_blocked_by_name(self, tmp_path, catalog_dir):
from hermes_cli.plugins_cmd import PluginOperationError, _install_plugin_core
_write_removed(
catalog_dir,
[{"name": "evil-plugin", "reason": "malware", "date": "2026-01-01"}],
)
with pytest.raises(PluginOperationError, match="malware"):
_install_plugin_core("evil-plugin", force=False)
def test_skip_removed_check_bypasses_block(self, tmp_path, catalog_dir):
from hermes_cli.plugins_cmd import _install_plugin_core
repo, _sha1, _sha2 = _make_git_repo(tmp_path)
_write_removed(
catalog_dir,
[{
"name": "refplugin",
"repo": f"file://{repo}",
"reason": "bad",
"date": "2026-07-02",
}],
)
target, _manifest, name = _install_plugin_core(
f"file://{repo}", force=False, skip_removed_check=True
)
assert name == "refplugin"
assert target.exists()
@@ -0,0 +1,296 @@
"""Tests for the dashboard plugin-catalog surface in hermes_cli.web_server.
Covers:
- GET /api/dashboard/plugins/catalog — entry serialization, installed-state
merge (via the ``.hermes-catalog.json`` sidecar), removed list exposure.
- POST /api/dashboard/agent-plugins/install — removed-blocklist refusal for
raw identifiers AND catalog names, catalog_name resolution to a pinned-ref
install, sidecar write.
- /api/dashboard/plugins/hub — ``removed_reason`` annotation on rows.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import yaml
VALID_SHA = "38fe0fb53eff98d477f807432e965429e665ca33"
OTHER_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
def _write_entry(catalog_dir: Path, name: str, **overrides) -> dict:
data = {
"name": name,
"repo": f"https://github.com/example/{name}",
"sha": VALID_SHA,
"description": f"Test entry {name}.",
"maintainer": "Example",
"tier": "official",
"docs_url": f"https://example.com/docs/{name}",
"capabilities": {
"provides_tools": ["tool_a"],
"provides_hooks": ["hook_b"],
"provides_middleware": [],
"requires_env": ["EXAMPLE_API_KEY"],
},
}
data.update(overrides)
catalog_dir.mkdir(parents=True, exist_ok=True)
(catalog_dir / f"{name}.yaml").write_text(
yaml.safe_dump(data), encoding="utf-8"
)
return data
def _write_removed(catalog_dir: Path, removed: list) -> None:
catalog_dir.mkdir(parents=True, exist_ok=True)
(catalog_dir / "removed.yaml").write_text(
yaml.safe_dump({"removed": removed}), encoding="utf-8"
)
def _make_installed_plugin(name: str, sidecar: dict | None = None) -> Path:
"""Drop a minimal plugin dir under the isolated HERMES_HOME."""
from hermes_constants import get_hermes_home
plugin_dir = get_hermes_home() / "plugins" / name
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / "plugin.yaml").write_text(
yaml.safe_dump({"name": name, "version": "1.0", "description": "x"}),
encoding="utf-8",
)
if sidecar is not None:
(plugin_dir / ".hermes-catalog.json").write_text(
json.dumps(sidecar), encoding="utf-8"
)
return plugin_dir
class TestDashboardPluginCatalog:
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch, tmp_path, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.catalog_dir = tmp_path / "catalog"
self.catalog_dir.mkdir()
monkeypatch.setenv("HERMES_PLUGIN_CATALOG_DIR", str(self.catalog_dir))
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# ── GET /api/dashboard/plugins/catalog ──────────────────────────────
def test_catalog_endpoint_requires_token(self):
from starlette.testclient import TestClient
from hermes_cli.web_server import app
unauth = TestClient(app)
resp = unauth.get("/api/dashboard/plugins/catalog")
assert resp.status_code == 401
def test_catalog_endpoint_shape(self):
_write_entry(self.catalog_dir, "alpha-plugin")
_write_removed(
self.catalog_dir,
[{"name": "bad-plugin", "repo": "https://github.com/evil/bad-plugin",
"reason": "exfiltrated env vars", "date": "2026-07-02"}],
)
resp = self.client.get("/api/dashboard/plugins/catalog")
assert resp.status_code == 200
data = resp.json()
assert "generated_at" in data
assert isinstance(data["entries"], list) and len(data["entries"]) == 1
entry = data["entries"][0]
assert entry["name"] == "alpha-plugin"
assert entry["repo"] == "https://github.com/example/alpha-plugin"
assert entry["sha"] == VALID_SHA
assert entry["sha_short"] == VALID_SHA[:7]
assert entry["tier"] == "official"
assert entry["maintainer"] == "Example"
assert entry["docs_url"] == "https://example.com/docs/alpha-plugin"
assert entry["capabilities"]["provides_tools"] == ["tool_a"]
assert entry["capabilities"]["requires_env"] == ["EXAMPLE_API_KEY"]
assert "tool_a" in entry["capability_summary"]
# Not installed → degraded install state.
assert entry["installed"] is False
assert entry["installed_sha"] is None
assert entry["update_available"] is False
assert entry["runtime_status"] is None
assert len(data["removed"]) == 1
removed = data["removed"][0]
assert removed["name"] == "bad-plugin"
assert removed["reason"] == "exfiltrated env vars"
def test_catalog_installed_state_merge_with_sidecar(self):
_write_entry(self.catalog_dir, "alpha-plugin")
_make_installed_plugin(
"alpha-plugin",
sidecar={
"catalog_name": "alpha-plugin",
"repo": "https://github.com/example/alpha-plugin",
"sha": OTHER_SHA,
"installed_at": "2026-07-01T00:00:00Z",
"tier": "official",
},
)
resp = self.client.get("/api/dashboard/plugins/catalog")
assert resp.status_code == 200
entry = resp.json()["entries"][0]
assert entry["installed"] is True
assert entry["installed_sha"] == OTHER_SHA
assert entry["update_available"] is True
assert entry["runtime_status"] == "inactive"
def test_catalog_installed_no_sidecar_degrades_to_null_sha(self):
_write_entry(self.catalog_dir, "alpha-plugin")
_make_installed_plugin("alpha-plugin", sidecar=None)
resp = self.client.get("/api/dashboard/plugins/catalog")
entry = resp.json()["entries"][0]
assert entry["installed"] is True
assert entry["installed_sha"] is None
assert entry["update_available"] is False
def test_catalog_installed_same_sha_no_update(self):
_write_entry(self.catalog_dir, "alpha-plugin")
_make_installed_plugin(
"alpha-plugin",
sidecar={
"catalog_name": "alpha-plugin",
"repo": "https://github.com/example/alpha-plugin",
"sha": VALID_SHA,
"installed_at": "2026-07-01T00:00:00Z",
"tier": "official",
},
)
entry = self.client.get("/api/dashboard/plugins/catalog").json()["entries"][0]
assert entry["installed"] is True
assert entry["installed_sha"] == VALID_SHA
assert entry["update_available"] is False
# ── POST /api/dashboard/agent-plugins/install ───────────────────────
def test_install_refuses_removed_raw_identifier(self):
_write_removed(
self.catalog_dir,
[{"name": "bad-plugin", "repo": "https://github.com/evil/bad-plugin",
"reason": "exfiltrated env vars", "date": "2026-07-02"}],
)
resp = self.client.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "https://github.com/evil/bad-plugin"},
)
assert resp.status_code == 400
assert "exfiltrated env vars" in resp.json()["detail"]
def test_install_refuses_removed_catalog_name(self):
_write_removed(
self.catalog_dir,
[{"name": "bad-plugin", "reason": "policy violation",
"date": "2026-07-02"}],
)
resp = self.client.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "", "catalog_name": "bad-plugin"},
)
assert resp.status_code == 400
assert "policy violation" in resp.json()["detail"]
def test_install_unknown_catalog_name_is_400(self):
resp = self.client.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "", "catalog_name": "does-not-exist"},
)
assert resp.status_code == 400
assert "does-not-exist" in resp.json()["detail"]
def test_install_missing_identifier_and_catalog_name_is_400(self):
resp = self.client.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": ""},
)
assert resp.status_code == 400
def test_catalog_name_install_resolves_pinned_ref_and_writes_sidecar(
self, monkeypatch, tmp_path
):
from hermes_constants import get_hermes_home
import hermes_cli.plugins_cmd as plugins_cmd
_write_entry(self.catalog_dir, "alpha-plugin")
captured = {}
def fake_core(identifier, *, force, ref=None, skip_removed_check=False):
captured["identifier"] = identifier
captured["ref"] = ref
target = get_hermes_home() / "plugins" / "alpha-plugin"
target.mkdir(parents=True, exist_ok=True)
(target / "plugin.yaml").write_text(
yaml.safe_dump({"name": "alpha-plugin"}), encoding="utf-8"
)
return target, {"name": "alpha-plugin"}, "alpha-plugin"
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
resp = self.client.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "", "catalog_name": "alpha-plugin",
"enable": False},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["plugin_name"] == "alpha-plugin"
assert captured["ref"] == VALID_SHA
assert captured["identifier"].startswith(
"https://github.com/example/alpha-plugin"
)
sidecar_path = (
get_hermes_home() / "plugins" / "alpha-plugin" / ".hermes-catalog.json"
)
assert sidecar_path.is_file()
sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
assert sidecar["catalog_name"] == "alpha-plugin"
assert sidecar["repo"] == "https://github.com/example/alpha-plugin"
assert sidecar["sha"] == VALID_SHA
assert sidecar["tier"] == "official"
assert sidecar["installed_at"]
# ── /api/dashboard/plugins/hub removed_reason annotation ─────────────
def test_hub_rows_annotated_with_removed_reason(self):
_write_removed(
self.catalog_dir,
[{"name": "bad-plugin", "reason": "supply chain incident",
"date": "2026-07-02"}],
)
_make_installed_plugin("bad-plugin")
_make_installed_plugin("good-plugin")
resp = self.client.get("/api/dashboard/plugins/hub")
assert resp.status_code == 200
rows = {r["name"]: r for r in resp.json()["plugins"]}
assert rows["bad-plugin"]["removed_reason"] == "supply chain incident"
assert rows["good-plugin"]["removed_reason"] is None
+15
View File
@@ -402,6 +402,21 @@ export const en: Translations = {
versionBadge: "Version",
showInSidebar: "Show in sidebar",
hideFromSidebar: "Hide from sidebar",
catalogHeading: "Plugin catalog",
catalogHint:
"Curated, Nous-reviewed plugins pinned to exact commits. Install from here for supply-chain-safe versions.",
catalogSearchPlaceholder: "Search catalog...",
catalogEmpty: "No catalog entries match.",
catalogEmptyDocsLink: "Learn about Hermes plugins",
catalogInstallBtn: "Install",
catalogInstalledBadge: "Installed ✓",
catalogUpdateBtn: "Update available",
catalogRemovedBadge: "Removed",
catalogConfirmTitle: "Install this plugin?",
catalogConfirmInstallNote:
"Plugins install disabled; enable it after install to activate.",
catalogRequiresEnv: "Requires env",
removedFromCatalog: "Removed from catalog",
},
skills: {
+14
View File
@@ -351,6 +351,20 @@ export interface Translations {
versionBadge: string;
showInSidebar: string;
hideFromSidebar: string;
// Catalog section (en-only fallback convention — optional keys).
catalogHeading?: string;
catalogHint?: string;
catalogSearchPlaceholder?: string;
catalogEmpty?: string;
catalogEmptyDocsLink?: string;
catalogInstallBtn?: string;
catalogInstalledBadge?: string;
catalogUpdateBtn?: string;
catalogRemovedBadge?: string;
catalogConfirmTitle?: string;
catalogConfirmInstallNote?: string;
catalogRequiresEnv?: string;
removedFromCatalog?: string;
};
// ── Profiles page ──
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api } from "./api";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function jsonFetchMock(body: unknown = { ok: true }) {
return vi.fn<typeof fetch>(
async () =>
new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json" },
status: 200,
}),
);
}
describe("api.getPluginsCatalog", () => {
it("fetches the dashboard plugins catalog endpoint", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ entries: [], removed: [], generated_at: "" });
vi.stubGlobal("fetch", fetchMock);
const result = await api.getPluginsCatalog();
expect(fetchMock).toHaveBeenCalledWith(
"/api/dashboard/plugins/catalog",
expect.objectContaining({ credentials: "include" }),
);
expect(result.entries).toEqual([]);
expect(result.removed).toEqual([]);
});
});
describe("api.installAgentPlugin with catalog_name", () => {
it("posts catalog_name through to the install endpoint", async () => {
vi.stubGlobal("window", {});
const fetchMock = jsonFetchMock({ ok: true, plugin_name: "alpha-plugin" });
vi.stubGlobal("fetch", fetchMock);
await api.installAgentPlugin({
identifier: "",
catalog_name: "alpha-plugin",
enable: false,
});
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("/api/dashboard/agent-plugins/install");
const body = JSON.parse(String((init as RequestInit).body));
expect(body.catalog_name).toBe("alpha-plugin");
expect(body.identifier).toBe("");
expect(body.enable).toBe(false);
});
});
+49
View File
@@ -921,6 +921,9 @@ export const api = {
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
getPluginsCatalog: () =>
fetchJSON<CatalogResponse>("/api/dashboard/plugins/catalog"),
installAgentPlugin: (body: AgentPluginInstallRequest) =>
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
method: "POST",
@@ -2506,6 +2509,8 @@ export interface HubAgentPluginRow {
auth_required: boolean;
auth_command: string;
user_hidden: boolean;
/** Reason string when this plugin is on the catalog removed blocklist. */
removed_reason?: string | null;
}
export interface PluginsHubProviders {
@@ -2525,6 +2530,8 @@ export interface AgentPluginInstallRequest {
identifier: string;
force?: boolean;
enable?: boolean;
/** Install by curated-catalog name (resolves repo + pinned SHA server-side). */
catalog_name?: string;
}
export interface AgentPluginInstallResponse {
@@ -2537,6 +2544,48 @@ export interface AgentPluginInstallResponse {
error?: string;
}
// ── Plugin catalog types ───────────────────────────────────────────────
export interface CatalogCapabilities {
provides_tools: string[];
provides_hooks: string[];
provides_middleware: string[];
requires_env: string[];
}
export interface CatalogEntry {
name: string;
description: string;
repo: string;
sha: string;
sha_short: string;
tier: "official" | "community";
maintainer: string;
requires_hermes: string;
platforms: string[];
capabilities: CatalogCapabilities;
docs_url: string;
capability_summary: string;
/** Installed-state merge (computed server-side). */
installed: boolean;
installed_sha: string | null;
update_available: boolean;
runtime_status: "disabled" | "enabled" | "inactive" | null;
}
export interface CatalogRemovedEntry {
name: string;
repo: string;
reason: string;
date: string;
}
export interface CatalogResponse {
entries: CatalogEntry[];
removed: CatalogRemovedEntry[];
generated_at: string;
}
export interface AgentPluginUpdateResponse {
ok: boolean;
name?: string;
+273 -2
View File
@@ -1,9 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
import type { Translations } from "@/i18n/types";
import { Link } from "react-router-dom";
import { api } from "@/lib/api";
import type {
CatalogEntry,
CatalogRemovedEntry,
CatalogResponse,
HubAgentPluginRow,
MemoryProviderConfig,
MemoryProviderField,
@@ -281,6 +284,11 @@ function MemoryProviderSetupHint({
export default function PluginsPage() {
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
const [loading, setLoading] = useState(true);
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
const [catalogLoading, setCatalogLoading] = useState(true);
const [catalogSearch, setCatalogSearch] = useState("");
const [catalogConfirm, setCatalogConfirm] = useState<CatalogEntry | null>(null);
const [catalogBusy, setCatalogBusy] = useState<string | null>(null);
const [installId, setInstallId] = useState("");
const [installForce, setInstallForce] = useState(false);
const [installEnable, setInstallEnable] = useState(true);
@@ -316,9 +324,17 @@ export default function PluginsPage() {
.catch(() => showToast(t.common.loading, "error"));
}, [showToast, t.common.loading]);
const loadCatalog = useCallback(() => {
return api
.getPluginsCatalog()
.then(setCatalog)
.catch(() => setCatalog(null));
}, []);
useEffect(() => {
void loadHub().finally(() => setLoading(false));
}, [loadHub]);
void loadCatalog().finally(() => setCatalogLoading(false));
}, [loadHub, loadCatalog]);
useEffect(() => {
const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel;
@@ -391,6 +407,27 @@ export default function PluginsPage() {
}
};
const onCatalogInstall = async (entry: CatalogEntry) => {
setCatalogConfirm(null);
setCatalogBusy(entry.name);
try {
const r = await api.installAgentPlugin({
identifier: "",
catalog_name: entry.name,
force: entry.installed,
enable: false,
});
showToast(`${r.plugin_name ?? entry.name} installed`, "success");
if ((r.missing_env?.length ?? 0) > 0)
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
await Promise.all([loadHub(), loadCatalog()]);
} catch (e) {
showToast(e instanceof Error ? e.message : "Install failed", "error");
} finally {
setCatalogBusy(null);
}
};
const onRescan = useCallback(async () => {
setRescanBusy(true);
try {
@@ -506,6 +543,27 @@ export default function PluginsPage() {
const rows = hub?.plugins ?? [];
const providers = hub?.providers;
const catalogEntries = useMemo(() => {
const entries = catalog?.entries ?? [];
const q = catalogSearch.trim().toLowerCase();
if (!q) return entries;
return entries.filter((entry) =>
[
entry.name,
entry.description,
entry.maintainer,
...entry.capabilities.provides_tools,
].some((haystack) => haystack.toLowerCase().includes(q)),
);
}, [catalog, catalogSearch]);
const removedByName = useMemo(() => {
const map = new Map<string, CatalogRemovedEntry>();
for (const r of catalog?.removed ?? []) map.set(r.name, r);
return map;
}, [catalog]);
const selectedMemoryName = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel;
const selectedMemoryInfo = selectedMemoryName
? providers?.memory_options.find((provider) => provider.name === selectedMemoryName)
@@ -822,6 +880,59 @@ export default function PluginsPage() {
</CardContent>
</Card>
<div className="flex flex-col gap-3" data-testid="plugin-catalog-section">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
{t.pluginsPage.catalogHeading ?? "Plugin catalog"}
</h3>
<p className="text-xs tracking-[0.06em] text-text-tertiary">
{t.pluginsPage.catalogHint ??
"Curated, Nous-reviewed plugins pinned to exact commits."}
</p>
<Input
className="max-w-md"
placeholder={t.pluginsPage.catalogSearchPlaceholder ?? "Search catalog..."}
value={catalogSearch}
onChange={(e) => setCatalogSearch(e.target.value)}
aria-label={t.pluginsPage.catalogSearchPlaceholder ?? "Search catalog..."}
/>
{catalogLoading ? (
<div className="flex items-center gap-2 py-4 text-xs text-text-tertiary">
<Spinner />
<span>{t.common.loading}</span>
</div>
) : catalogEntries.length === 0 ? (
<p className="text-xs text-text-tertiary">
{t.pluginsPage.catalogEmpty ?? "No catalog entries match."}{" "}
<a
className="underline"
href="https://hermes-agent.nousresearch.com/docs/plugins"
target="_blank"
rel="noreferrer"
>
{t.pluginsPage.catalogEmptyDocsLink ?? "Learn about Hermes plugins"}
</a>
</p>
) : (
<ul className="flex flex-col gap-3">
{catalogEntries.map((entry) => (
<li key={entry.name}>
<CatalogEntryCard
busy={catalogBusy === entry.name}
entry={entry}
onInstall={() => setCatalogConfirm(entry)}
removed={removedByName.get(entry.name) ?? null}
t={t}
/>
</li>
))}
</ul>
)}
</div>
<div className="flex flex-col gap-3">
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
@@ -896,6 +1007,30 @@ export default function PluginsPage() {
<Toast toast={toast} />
<PluginSlot name="plugins:bottom" />
<ConfirmDialog
open={catalogConfirm !== null}
onCancel={() => setCatalogConfirm(null)}
onConfirm={() => {
if (catalogConfirm) void onCatalogInstall(catalogConfirm);
}}
title={t.pluginsPage.catalogConfirmTitle ?? "Install this plugin?"}
description={
catalogConfirm
? [
catalogConfirm.capability_summary,
catalogConfirm.capabilities.requires_env.length
? `${t.pluginsPage.catalogRequiresEnv ?? "Requires env"}: ${catalogConfirm.capabilities.requires_env.join(", ")}`
: "",
t.pluginsPage.catalogConfirmInstallNote ??
"Plugins install disabled; enable it after install to activate.",
]
.filter(Boolean)
.join("\n\n")
: ""
}
confirmLabel={t.pluginsPage.catalogInstallBtn ?? "Install"}
/>
</div>
);
}
@@ -961,6 +1096,12 @@ function PluginRowCard(props: PluginRowCardProps) {
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
{row.removed_reason ? (
<Badge tone="destructive">
{t.pluginsPage.catalogRemovedBadge ?? "Removed"}
</Badge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
@@ -1070,6 +1211,12 @@ function PluginRowCard(props: PluginRowCardProps) {
</p>
) : null}
{row.removed_reason ? (
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
{t.pluginsPage.removedFromCatalog ?? "Removed from catalog"}: {row.removed_reason}
</p>
) : null}
{dm?.slots?.length ? (
<p className="text-xs tracking-[0.05em] text-text-tertiary">
@@ -1111,3 +1258,127 @@ function PluginRowCard(props: PluginRowCardProps) {
</Card>
);
}
interface CatalogEntryCardProps {
busy: boolean;
entry: CatalogEntry;
onInstall: () => void;
removed: CatalogRemovedEntry | null;
t: Translations;
}
function CatalogEntryCard(props: CatalogEntryCardProps) {
const { busy, entry, onInstall, removed, t } = props;
const caps = entry.capabilities;
const chips: string[] = [];
if (caps.provides_tools.length) chips.push(`${caps.provides_tools.length} tools`);
if (caps.provides_hooks.length) chips.push(`${caps.provides_hooks.length} hooks`);
if (caps.provides_middleware.length)
chips.push(`${caps.provides_middleware.length} middleware`);
if (caps.requires_env.length) chips.push(`env: ${caps.requires_env.join(", ")}`);
const isRemoved = removed !== null;
return (
<Card className={cn(busy ? "opacity-70" : undefined)}>
<CardContent className="flex flex-col gap-3 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<span className="truncate font-semibold">{entry.name}</span>
<Badge tone={entry.tier === "official" ? "success" : "secondary"}>
{entry.tier}
</Badge>
{entry.installed && entry.runtime_status ? (
<Badge tone="outline">{entry.runtime_status}</Badge>
) : null}
{isRemoved ? (
<Badge tone="destructive">
{t.pluginsPage.catalogRemovedBadge ?? "Removed"}
</Badge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
{isRemoved ? null : entry.installed && !entry.update_available ? (
<Badge tone="success">
{t.pluginsPage.catalogInstalledBadge ?? "Installed ✓"}
</Badge>
) : (
<Button disabled={busy} ghost size="sm" onClick={onInstall}>
{busy ? <Spinner /> : null}
{entry.update_available
? t.pluginsPage.catalogUpdateBtn ?? "Update available"
: t.pluginsPage.catalogInstallBtn ?? "Install"}
</Button>
)}
</div>
</div>
{isRemoved ? (
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
{t.pluginsPage.removedFromCatalog ?? "Removed from catalog"}
{removed.reason ? `: ${removed.reason}` : ""}
{removed.date ? ` (${removed.date})` : ""}
</p>
) : null}
{entry.description ? (
<p className="min-w-0 w-full text-xs tracking-[0.06em] text-text-secondary break-words">
{entry.description}
</p>
) : null}
{chips.length ? (
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<code
key={chip}
className="border border-border bg-background/40 px-2 py-1 font-mono text-[0.6875rem]"
>
{chip}
</code>
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-text-tertiary">
<span>{entry.maintainer}</span>
<a
className="inline-flex items-center gap-1 font-mono underline"
href={`${entry.repo.replace(/\.git$/, "")}/tree/${entry.sha}`}
target="_blank"
rel="noreferrer"
>
{entry.sha_short}
<ExternalLink className="h-3 w-3 opacity-65" />
</a>
{entry.docs_url ? (
<a
className="inline-flex items-center gap-1 underline"
href={entry.docs_url}
target="_blank"
rel="noreferrer"
>
docs
<ExternalLink className="h-3 w-3 opacity-65" />
</a>
) : null}
{entry.requires_hermes ? (
<span>hermes {entry.requires_hermes}</span>
) : null}
{entry.platforms.length ? (
<span>{entry.platforms.join(", ")}</span>
) : null}
</div>
</CardContent>
</Card>
);
}