Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c6282dae | ||
|
|
ab1d9f4c15 | ||
|
|
7880be4580 | ||
|
|
741d06445d | ||
|
|
1c3d6e59dd | ||
|
|
ae68edcfe3 | ||
|
|
355a8e4076 | ||
|
|
373ad70f4c | ||
|
|
f1b30414d5 | ||
|
|
44624631bf | ||
|
|
8b70bf4c40 | ||
|
|
fe9dc06071 | ||
|
|
6f5608bed3 | ||
|
|
dc4d991373 | ||
|
|
dcdb9b25e4 | ||
|
|
a22d2918d6 | ||
|
|
fb40a768fc | ||
|
|
8dd07bd517 |
@@ -0,0 +1,56 @@
|
||||
name: Hermes Plugin Validate
|
||||
description: >-
|
||||
Validate a Hermes Agent plugin (plugin.yaml manifest schema AND
|
||||
declared-vs-actually-registered capabilities) using
|
||||
`hermes plugins validate`. Drop this into your plugin repo's CI:
|
||||
|
||||
- uses: actions/checkout@<sha>
|
||||
- uses: NousResearch/hermes-agent/.github/actions/plugin-validate@main
|
||||
with:
|
||||
path: .
|
||||
|
||||
The caller's job owns checkout; this action installs Python + hermes-agent
|
||||
(git install — a supported CI-context install route) and runs the
|
||||
validator against your plugin directory.
|
||||
|
||||
inputs:
|
||||
path:
|
||||
description: Path to the plugin directory (containing plugin.yaml).
|
||||
default: "."
|
||||
hermes-ref:
|
||||
description: hermes-agent git ref (branch/tag/sha) to install and validate with.
|
||||
default: "main"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install hermes-agent
|
||||
shell: bash
|
||||
env:
|
||||
_HERMES_REF: ${{ inputs.hermes-ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# CI-context install from git; the ref lets plugin authors validate
|
||||
# against a pinned hermes release instead of main.
|
||||
pip install "git+https://github.com/NousResearch/hermes-agent@${_HERMES_REF}"
|
||||
|
||||
- name: Validate plugin
|
||||
shell: bash
|
||||
env:
|
||||
_PLUGIN_PATH: ${{ inputs.path }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# `hermes plugins validate` checks the plugin.yaml manifest schema
|
||||
# and loads the plugin in a scratch subprocess to verify that the
|
||||
# capabilities it DECLARES match what it actually registers.
|
||||
if hermes plugins validate "$_PLUGIN_PATH"; then
|
||||
echo "✅ PASS: plugin at '$_PLUGIN_PATH' validated cleanly"
|
||||
else
|
||||
echo "❌ FAIL: plugin at '$_PLUGIN_PATH' failed validation (see output above)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -157,6 +157,9 @@ jobs:
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
- name: Extract plugin catalog for the Plugins page
|
||||
run: python3 website/scripts/extract-plugins.py
|
||||
|
||||
- name: Regenerate per-skill docs pages + catalogs
|
||||
run: python3 website/scripts/generate-skill-docs.py
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
name: Plugin Catalog CI
|
||||
|
||||
# Admission gate for plugin-catalog entries. Fires ONLY on PRs touching
|
||||
# plugin-catalog/** so it can never go red on unrelated PRs.
|
||||
#
|
||||
# Two gates:
|
||||
# structural — cheap schema check, no hermes install needed
|
||||
# pinned-source-validate — supply-chain gate: the pinned sha MUST be
|
||||
# reachable in the entry's repo, and the plugin
|
||||
# at that exact commit must pass
|
||||
# `hermes plugins validate`.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "plugin-catalog/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
structural:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install PyYAML
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install pyyaml==6.0.2
|
||||
|
||||
- name: Validate catalog files (structural)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Validating the whole directory is simpler than diffing and keeps
|
||||
# the invariant that EVERYTHING in plugin-catalog/ stays valid.
|
||||
python3 scripts/validate_plugin_catalog.py plugin-catalog/
|
||||
|
||||
pinned-source-validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need the merge-base to diff changed catalog files
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Find changed catalog entries
|
||||
id: changed
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MERGE_BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
# Added + modified entry files only; deletions and removed.yaml
|
||||
# have nothing to clone.
|
||||
CHANGED=$(git diff --name-only --diff-filter=AM "$MERGE_BASE"...HEAD \
|
||||
-- 'plugin-catalog/*.yaml' 'plugin-catalog/*.yml' \
|
||||
| grep -v '/removed\.yaml$' || true)
|
||||
echo "Changed catalog entries:"
|
||||
echo "${CHANGED:-<none>}"
|
||||
{
|
||||
echo 'files<<__EOF__'
|
||||
echo "$CHANGED"
|
||||
echo '__EOF__'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install hermes-agent from the PR's own checkout
|
||||
if: steps.changed.outputs.files != ''
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install -e .
|
||||
|
||||
- name: Clone each entry at its pinned sha and validate
|
||||
if: steps.changed.outputs.files != ''
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changed.outputs.files }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FAILED=0
|
||||
while IFS= read -r entry; do
|
||||
[ -z "$entry" ] && continue
|
||||
echo "::group::validate $entry"
|
||||
|
||||
# Parse repo / sha / subdir from the entry yaml.
|
||||
eval "$(python3 - "$entry" <<'PYEOF'
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh) or {}
|
||||
print(f"REPO={shlex.quote(str(data.get('repo', '')))}")
|
||||
print(f"SHA={shlex.quote(str(data.get('sha', '')))}")
|
||||
print(f"SUBDIR={shlex.quote(str(data.get('subdir', '') or ''))}")
|
||||
PYEOF
|
||||
)"
|
||||
echo "repo=$REPO sha=$SHA subdir=$SUBDIR"
|
||||
|
||||
CLONE_DIR=$(mktemp -d)
|
||||
# Full clone (no --depth 1): the pinned sha may not be the branch tip.
|
||||
if ! git clone "$REPO" "$CLONE_DIR"; then
|
||||
echo "::error file=$entry::clone failed for $REPO"
|
||||
FAILED=1; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
# SUPPLY-CHAIN GATE: the pinned sha must be reachable in the repo.
|
||||
if ! git -C "$CLONE_DIR" checkout --detach "$SHA"; then
|
||||
echo "::error file=$entry::pinned sha $SHA is not reachable in $REPO"
|
||||
FAILED=1; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
PLUGIN_DIR="$CLONE_DIR${SUBDIR:+/$SUBDIR}"
|
||||
if [ ! -f "$PLUGIN_DIR/plugin.yaml" ]; then
|
||||
echo "::error file=$entry::no plugin.yaml at subdir '$SUBDIR' of $REPO@$SHA"
|
||||
FAILED=1; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
# Manifest schema + declared-vs-registered capability check.
|
||||
if hermes plugins validate "$PLUGIN_DIR"; then
|
||||
echo "✅ PASS: $entry"
|
||||
else
|
||||
echo "::error file=$entry::hermes plugins validate failed"
|
||||
FAILED=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "❌ FAIL: one or more catalog entries failed pinned-source validation"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ PASS: all changed catalog entries validated at their pinned shas"
|
||||
@@ -113,6 +113,10 @@ website/static/api/skills-index.json
|
||||
# every build).
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
# plugins.json + plugins-meta.json are build artifacts emitted by
|
||||
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
|
||||
website/static/api/plugins.json
|
||||
website/static/api/plugins-meta.json
|
||||
# automation-blueprints-index.json is a build artifact emitted by
|
||||
# website/scripts/extract-automation-blueprints.py during prebuild.
|
||||
website/static/api/automation-blueprints-index.json
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""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
|
||||
import time
|
||||
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.
|
||||
"""
|
||||
return _load_entries_from_dir(get_catalog_dir())
|
||||
|
||||
|
||||
def _load_entries_from_dir(root: Path) -> List[PluginCatalogEntry]:
|
||||
"""Parse all catalog entry files in *root* (skipping ``removed.yaml``)."""
|
||||
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."""
|
||||
return filter_entries(load_catalog(), query)
|
||||
|
||||
|
||||
def filter_entries(
|
||||
entries: List[PluginCatalogEntry], query: str
|
||||
) -> List[PluginCatalogEntry]:
|
||||
"""Filter *entries* with :func:`search_catalog` semantics.
|
||||
|
||||
Lets callers that already hold a (possibly live-fetched) entry list
|
||||
apply the same matching rules without re-loading the 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
|
||||
|
||||
|
||||
# ─── Live index ──────────────────────────────────────────────────────────────
|
||||
|
||||
# GitHub contents API for the in-repo catalog dir. Unauthenticated (60 req/hr
|
||||
# rate limit) — fine for interactive use, and any failure falls back to the
|
||||
# in-tree catalog silently.
|
||||
_LIVE_INDEX_URL = (
|
||||
"https://api.github.com/repos/NousResearch/hermes-agent/contents/"
|
||||
"plugin-catalog?ref=main"
|
||||
)
|
||||
_LIVE_TTL_SECONDS = 6 * 60 * 60 # 6h
|
||||
_REQUEST_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _live_cache_dir() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "cache" / "plugin-catalog"
|
||||
|
||||
|
||||
def fetch_live_catalog(*, force: bool = False) -> Optional[Path]:
|
||||
"""Refresh the catalog cache from the GitHub repo; return the cache dir.
|
||||
|
||||
Lists ``plugin-catalog/*.yaml`` via the GitHub contents API, raw-fetches
|
||||
each file, and stores them under ``<hermes_home>/cache/plugin-catalog/``
|
||||
with a 6-hour TTL (repeat searches don't re-hit the API). Returns the
|
||||
cache directory on success (or fresh cache), or ``None`` on ANY network
|
||||
or parse failure — callers then fall back to the in-tree catalog.
|
||||
"""
|
||||
cache = _live_cache_dir()
|
||||
marker = cache / ".fetched"
|
||||
if not force and marker.is_file():
|
||||
try:
|
||||
age = time.time() - marker.stat().st_mtime
|
||||
except OSError:
|
||||
age = _LIVE_TTL_SECONDS + 1
|
||||
if age < _LIVE_TTL_SECONDS:
|
||||
return cache
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
_LIVE_INDEX_URL,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
listing = resp.json()
|
||||
if not isinstance(listing, list):
|
||||
raise ValueError("unexpected contents-API payload")
|
||||
|
||||
fetched: dict[str, str] = {}
|
||||
for item in listing:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
fname = str(item.get("name") or "")
|
||||
url = str(item.get("download_url") or "")
|
||||
if not fname.endswith(".yaml") or not url:
|
||||
continue
|
||||
file_resp = httpx.get(
|
||||
url, timeout=_REQUEST_TIMEOUT, follow_redirects=True
|
||||
)
|
||||
file_resp.raise_for_status()
|
||||
fetched[fname] = file_resp.text
|
||||
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
# Replace stale cached entries wholesale so removed files disappear.
|
||||
for old in cache.glob("*.yaml"):
|
||||
if old.name not in fetched:
|
||||
old.unlink(missing_ok=True)
|
||||
for fname, text in fetched.items():
|
||||
(cache / fname).write_text(text, encoding="utf-8")
|
||||
marker.touch()
|
||||
return cache
|
||||
except Exception as exc:
|
||||
logger.debug("Plugin catalog: live index fetch failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def load_catalog_live() -> List[PluginCatalogEntry]:
|
||||
"""Return catalog entries, preferring a live-fetched (or cached) index.
|
||||
|
||||
Falls back silently to the in-tree catalog when the network is
|
||||
unavailable or the fetch fails.
|
||||
"""
|
||||
cache = fetch_live_catalog()
|
||||
if cache is not None and any(
|
||||
p.name != "removed.yaml" for p in cache.glob("*.yaml")
|
||||
):
|
||||
return _load_entries_from_dir(cache)
|
||||
return load_catalog()
|
||||
|
||||
|
||||
# ─── 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)
|
||||
@@ -0,0 +1,434 @@
|
||||
"""``hermes plugins validate`` — admission checks for a plugin directory.
|
||||
|
||||
This is the command the plugin-catalog admission CI (and the
|
||||
``.github/actions/plugin-validate`` composite action) runs against a
|
||||
candidate plugin. It performs static manifest checks plus a
|
||||
subprocess-isolated capability probe: the plugin is imported and its
|
||||
``register(ctx)`` called against a minimal recording stub context in a
|
||||
scratch child process (with a throwaway ``HERMES_HOME``), so a crashing or
|
||||
malicious plugin cannot take down the CLI, and the *actually registered*
|
||||
tools/hooks/middleware are compared against the manifest's declared
|
||||
``provides_*`` lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
_UPPER_SNAKE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
||||
_CONFIG_TYPES = {"str", "bool", "int"}
|
||||
_PROBE_TIMEOUT = 30
|
||||
_PROBE_SENTINEL = "HERMES_VALIDATE_JSON:"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
"""Result of validating one plugin directory."""
|
||||
|
||||
checks: List[Tuple[str, bool, str]] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def failures(self) -> List[str]:
|
||||
return [detail or name for name, ok, detail in self.checks if not ok]
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return all(ok for _name, ok, _detail in self.checks)
|
||||
|
||||
@property
|
||||
def exit_code(self) -> int:
|
||||
return 0 if self.ok else 1
|
||||
|
||||
def add(self, name: str, ok: bool, detail: str = "") -> None:
|
||||
self.checks.append((name, ok, detail))
|
||||
|
||||
def warn(self, message: str) -> None:
|
||||
self.warnings.append(message)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"checks": [
|
||||
{"name": name, "ok": ok, "detail": detail}
|
||||
for name, ok, detail in self.checks
|
||||
],
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
# ─── Static checks ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _requires_hermes_spec_valid(spec: str) -> bool:
|
||||
"""Strictly validate a ``requires_hermes`` spec.
|
||||
|
||||
Unlike :func:`hermes_cli.plugins._version_satisfies` (permissive at load
|
||||
time), validation REJECTS clauses whose version segment doesn't parse —
|
||||
a typo'd spec should fail admission, not silently gate nothing.
|
||||
"""
|
||||
from hermes_cli.plugins import _VERSION_COMPARATOR_RE, _version_tuple
|
||||
|
||||
for clause in spec.split(","):
|
||||
clause = clause.strip()
|
||||
if not clause:
|
||||
continue
|
||||
m = _VERSION_COMPARATOR_RE.match(clause)
|
||||
target = m.group(2) if m else clause
|
||||
if _version_tuple(target) is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _check_manifest_fields(report: ValidationReport, manifest: dict) -> None:
|
||||
missing = [
|
||||
f for f in ("name", "version", "description") if not manifest.get(f)
|
||||
]
|
||||
if missing:
|
||||
report.add(
|
||||
"manifest fields",
|
||||
False,
|
||||
f"plugin.yaml missing required field(s): {', '.join(missing)}",
|
||||
)
|
||||
else:
|
||||
report.add("manifest fields", True, "name, version, description present")
|
||||
|
||||
|
||||
def _check_requires_hermes(report: ValidationReport, manifest: dict) -> None:
|
||||
spec = str(manifest.get("requires_hermes") or "").strip()
|
||||
if not spec:
|
||||
report.add("requires_hermes", True, "not declared")
|
||||
return
|
||||
if _requires_hermes_spec_valid(spec):
|
||||
report.add("requires_hermes", True, f"spec {spec!r} parses")
|
||||
else:
|
||||
report.add(
|
||||
"requires_hermes",
|
||||
False,
|
||||
f"requires_hermes spec {spec!r} does not parse "
|
||||
"(expected e.g. \">=0.19\" or \">=0.19, <1.0\")",
|
||||
)
|
||||
|
||||
|
||||
def _check_config_spec(report: ValidationReport, manifest: dict) -> None:
|
||||
raw = manifest.get("config")
|
||||
if raw in (None, [], {}):
|
||||
report.add("config spec", True, "not declared")
|
||||
return
|
||||
problems: List[str] = []
|
||||
if not isinstance(raw, list):
|
||||
problems.append("config: must be a list of mappings")
|
||||
else:
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict) or not item.get("key"):
|
||||
problems.append(f"config[{i}]: must be a mapping with a 'key'")
|
||||
continue
|
||||
typ = item.get("type")
|
||||
if typ is not None and str(typ) not in _CONFIG_TYPES:
|
||||
problems.append(
|
||||
f"config[{i}] ({item['key']}): type must be one of "
|
||||
f"{'/'.join(sorted(_CONFIG_TYPES))}"
|
||||
)
|
||||
secret = item.get("secret")
|
||||
if secret is not None and not isinstance(secret, bool):
|
||||
problems.append(
|
||||
f"config[{i}] ({item['key']}): secret must be a boolean"
|
||||
)
|
||||
if problems:
|
||||
report.add("config spec", False, "; ".join(problems))
|
||||
else:
|
||||
report.add("config spec", True, "shape valid")
|
||||
|
||||
|
||||
def _check_requires_env(report: ValidationReport, manifest: dict) -> None:
|
||||
raw = manifest.get("requires_env") or []
|
||||
problems: List[str] = []
|
||||
if not isinstance(raw, list):
|
||||
problems.append("requires_env: must be a list")
|
||||
raw = []
|
||||
for i, entry in enumerate(raw):
|
||||
if isinstance(entry, str):
|
||||
name = entry
|
||||
elif isinstance(entry, dict):
|
||||
name = str(entry.get("name") or "")
|
||||
else:
|
||||
problems.append(f"requires_env[{i}]: must be a string or mapping")
|
||||
continue
|
||||
if not _UPPER_SNAKE_RE.match(name):
|
||||
problems.append(
|
||||
f"requires_env[{i}]: {name!r} is not UPPER_SNAKE_CASE"
|
||||
)
|
||||
if problems:
|
||||
report.add("requires_env", False, "; ".join(problems))
|
||||
else:
|
||||
report.add("requires_env", True, "all entries UPPER_SNAKE")
|
||||
|
||||
|
||||
# ─── Capability probe (subprocess-isolated) ──────────────────────────────────
|
||||
|
||||
# Self-contained harness run in a scratch child process. Imports the plugin
|
||||
# module using the same file-location mechanics PluginManager uses, calls
|
||||
# register() against a recording stub ctx, and prints a sentinel-prefixed
|
||||
# JSON line of what was actually registered. Deliberately imports NOTHING
|
||||
# from hermes so a hostile plugin only sees a bare interpreter.
|
||||
_PROBE_SCRIPT = r"""
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
|
||||
plugin_dir = sys.argv[1]
|
||||
sentinel = sys.argv[2]
|
||||
|
||||
recorded = {"tools": [], "hooks": [], "middleware": [], "commands": []}
|
||||
|
||||
|
||||
class RecordingContext:
|
||||
plugin_config = {}
|
||||
profile_name = "default"
|
||||
|
||||
def register_tool(self, name, *args, **kwargs):
|
||||
recorded["tools"].append(str(name))
|
||||
|
||||
def register_hook(self, hook_name, callback):
|
||||
recorded["hooks"].append(str(hook_name))
|
||||
|
||||
def register_middleware(self, kind, callback):
|
||||
recorded["middleware"].append(str(kind))
|
||||
|
||||
def register_command(self, name, *args, **kwargs):
|
||||
recorded["commands"].append(str(name))
|
||||
|
||||
def register_cli_command(self, name, *args, **kwargs):
|
||||
recorded["commands"].append(str(name))
|
||||
|
||||
def __getattr__(self, _name):
|
||||
# Any other registration surface (platforms, providers, skills,
|
||||
# context engines, ...) is accepted as a no-op — the probe only
|
||||
# audits the declared-capability categories.
|
||||
def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
return _noop
|
||||
|
||||
|
||||
def emit(payload):
|
||||
print(sentinel + json.dumps(payload))
|
||||
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_validate_probe_plugin",
|
||||
plugin_dir + "/__init__.py",
|
||||
submodule_search_locations=[plugin_dir],
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__path__ = [plugin_dir]
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as exc:
|
||||
emit({"error": "import failed: %s" % exc})
|
||||
sys.exit(0)
|
||||
|
||||
register = getattr(module, "register", None)
|
||||
if register is None:
|
||||
emit({"error": "no register() function"})
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
register(RecordingContext())
|
||||
except Exception as exc:
|
||||
emit({"error": "register() raised: %s" % exc})
|
||||
sys.exit(0)
|
||||
|
||||
emit(recorded)
|
||||
"""
|
||||
|
||||
|
||||
def _run_capability_probe(plugin_dir: Path) -> Tuple[Optional[dict], str]:
|
||||
"""Run the recording probe in a scratch subprocess.
|
||||
|
||||
Returns ``(recorded, error)`` — exactly one is meaningful: *recorded*
|
||||
is the ``{tools, hooks, middleware, commands}`` dict on success, and
|
||||
*error* is a human-readable failure description otherwise.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-validate-") as scratch:
|
||||
env = dict(os.environ)
|
||||
env["HERMES_HOME"] = scratch
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
_PROBE_SCRIPT,
|
||||
str(plugin_dir),
|
||||
_PROBE_SENTINEL,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_PROBE_TIMEOUT,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, f"capability probe timed out after {_PROBE_TIMEOUT}s"
|
||||
|
||||
payload: Optional[dict] = None
|
||||
for line in (result.stdout or "").splitlines():
|
||||
if line.startswith(_PROBE_SENTINEL):
|
||||
try:
|
||||
payload = json.loads(line[len(_PROBE_SENTINEL):])
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
|
||||
if payload is None:
|
||||
err = (result.stderr or "").strip()
|
||||
return None, (
|
||||
"capability probe produced no result "
|
||||
f"(exit {result.returncode})" + (f": {err}" if err else "")
|
||||
)
|
||||
if "error" in payload:
|
||||
return None, str(payload["error"])
|
||||
return payload, ""
|
||||
|
||||
|
||||
def _declared_list(manifest: dict, key: str) -> List[str]:
|
||||
raw = manifest.get(key) or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [str(item) for item in raw if isinstance(item, str)]
|
||||
|
||||
|
||||
def _check_capabilities(
|
||||
report: ValidationReport, manifest: dict, plugin_dir: Path
|
||||
) -> Optional[dict]:
|
||||
"""Probe actual registrations and diff against declared capabilities.
|
||||
|
||||
Returns the recorded dict (for the built-in collision check) or None
|
||||
when the probe failed / was skipped.
|
||||
"""
|
||||
if not (plugin_dir / "__init__.py").is_file():
|
||||
report.warn(
|
||||
"no __init__.py — capability probe skipped (manifest-only plugin)"
|
||||
)
|
||||
report.add("capability probe", True, "skipped (no __init__.py)")
|
||||
return None
|
||||
|
||||
recorded, error = _run_capability_probe(plugin_dir)
|
||||
if recorded is None:
|
||||
report.add("capability probe", False, error)
|
||||
return None
|
||||
report.add("capability probe", True, "register() ran in isolation")
|
||||
|
||||
for kind, manifest_key in (
|
||||
("tools", "provides_tools"),
|
||||
("hooks", "provides_hooks"),
|
||||
("middleware", "provides_middleware"),
|
||||
):
|
||||
declared = set(_declared_list(manifest, manifest_key))
|
||||
actual = set(recorded.get(kind) or [])
|
||||
undeclared = sorted(actual - declared)
|
||||
unregistered = sorted(declared - actual)
|
||||
if undeclared:
|
||||
report.add(
|
||||
f"declared {kind}",
|
||||
False,
|
||||
f"undeclared {kind} registered (not in {manifest_key}): "
|
||||
f"{', '.join(undeclared)}",
|
||||
)
|
||||
else:
|
||||
report.add(f"declared {kind}", True, "matches registrations")
|
||||
if unregistered:
|
||||
report.warn(
|
||||
f"{manifest_key} declares {', '.join(unregistered)} "
|
||||
f"but register() did not register them"
|
||||
)
|
||||
return recorded
|
||||
|
||||
|
||||
def _builtin_tool_names() -> List[str]:
|
||||
"""Return the built-in tool registry names (discovery-timing safe).
|
||||
|
||||
``tools.registry`` starts empty — built-in tool modules self-register on
|
||||
import, so we must run ``discover_builtin_tools()`` first (idempotent;
|
||||
see the AGENTS.md discover_plugins timing pitfall).
|
||||
"""
|
||||
try:
|
||||
from tools.registry import discover_builtin_tools, registry
|
||||
|
||||
discover_builtin_tools()
|
||||
return list(registry.get_all_tool_names())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _check_builtin_collisions(
|
||||
report: ValidationReport, manifest: dict, recorded: Optional[dict]
|
||||
) -> None:
|
||||
candidate_tools = set(_declared_list(manifest, "provides_tools"))
|
||||
if recorded:
|
||||
candidate_tools.update(recorded.get("tools") or [])
|
||||
if not candidate_tools:
|
||||
report.add("built-in tool collisions", True, "no tools to check")
|
||||
return
|
||||
builtin = set(_builtin_tool_names())
|
||||
collisions = sorted(candidate_tools & builtin)
|
||||
if collisions:
|
||||
report.add(
|
||||
"built-in tool collisions",
|
||||
False,
|
||||
"tool name(s) collide with built-in tools: "
|
||||
f"{', '.join(collisions)}",
|
||||
)
|
||||
else:
|
||||
report.add("built-in tool collisions", True, "no collisions")
|
||||
|
||||
|
||||
# ─── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_plugin_dir(plugin_dir: Path) -> ValidationReport:
|
||||
"""Run every admission check against *plugin_dir* and return the report."""
|
||||
report = ValidationReport()
|
||||
plugin_dir = Path(plugin_dir)
|
||||
|
||||
if not plugin_dir.is_dir():
|
||||
report.add(
|
||||
"plugin directory", False, f"{plugin_dir} is not a directory"
|
||||
)
|
||||
return report
|
||||
|
||||
manifest_file = plugin_dir / "plugin.yaml"
|
||||
if not manifest_file.is_file():
|
||||
manifest_file = plugin_dir / "plugin.yml"
|
||||
if not manifest_file.is_file():
|
||||
report.add("manifest", False, "no plugin.yaml in the plugin directory")
|
||||
return report
|
||||
|
||||
import yaml
|
||||
|
||||
try:
|
||||
manifest = yaml.safe_load(
|
||||
manifest_file.read_text(encoding="utf-8")
|
||||
)
|
||||
except Exception as exc:
|
||||
report.add("manifest", False, f"plugin.yaml failed to parse: {exc}")
|
||||
return report
|
||||
if not isinstance(manifest, dict):
|
||||
report.add("manifest", False, "plugin.yaml must be a mapping")
|
||||
return report
|
||||
report.add("manifest", True, "plugin.yaml parses")
|
||||
|
||||
_check_manifest_fields(report, manifest)
|
||||
_check_requires_hermes(report, manifest)
|
||||
_check_config_spec(report, manifest)
|
||||
_check_requires_env(report, manifest)
|
||||
recorded = _check_capabilities(report, manifest, plugin_dir)
|
||||
_check_builtin_collisions(report, manifest, recorded)
|
||||
return report
|
||||
@@ -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,
|
||||
|
||||
+724
-18
@@ -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)
|
||||
@@ -547,12 +613,98 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
|
||||
return target, installed_manifest, installed_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog integration helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CATALOG_SIDECAR = ".hermes-catalog.json"
|
||||
|
||||
|
||||
def _looks_like_catalog_name(identifier: str) -> bool:
|
||||
"""True when *identifier* could be a catalog entry name (not a URL/shorthand)."""
|
||||
if not identifier or "/" in identifier or "\\" in identifier:
|
||||
return False
|
||||
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
|
||||
return False
|
||||
from hermes_cli.plugin_catalog import _NAME_RE
|
||||
|
||||
return bool(_NAME_RE.match(identifier))
|
||||
|
||||
|
||||
def _get_live_catalog_entry(name: str):
|
||||
"""Look up *name* in the live-refreshed catalog (falls back in-tree)."""
|
||||
from hermes_cli.plugin_catalog import load_catalog_live
|
||||
|
||||
for entry in load_catalog_live():
|
||||
if entry.name == name:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _catalog_install_identifier(entry) -> str:
|
||||
"""Build the ``_install_plugin_core`` identifier for a catalog entry.
|
||||
|
||||
Uses the explicit ``#subdir`` fragment form understood by
|
||||
:func:`_resolve_git_url` when the entry lives in a repo subdirectory.
|
||||
"""
|
||||
if entry.subdir:
|
||||
return f"{entry.repo}#{entry.subdir}"
|
||||
return entry.repo
|
||||
|
||||
|
||||
def _write_catalog_sidecar(target: Path, entry) -> None:
|
||||
"""Record catalog provenance in ``.hermes-catalog.json`` inside *target*.
|
||||
|
||||
The sidecar is how ``update``/``list``/``doctor`` know the plugin came
|
||||
from the catalog (and at which pin).
|
||||
"""
|
||||
import datetime
|
||||
|
||||
sidecar = {
|
||||
"catalog_name": entry.name,
|
||||
"repo": entry.repo,
|
||||
"sha": entry.sha,
|
||||
"installed_at": datetime.datetime.now(datetime.timezone.utc)
|
||||
.isoformat(timespec="seconds")
|
||||
.replace("+00:00", "Z"),
|
||||
"tier": entry.tier,
|
||||
}
|
||||
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 _read_catalog_sidecar(plugin_dir: Path) -> Optional[dict]:
|
||||
"""Return the parsed catalog provenance sidecar, or None."""
|
||||
path = plugin_dir / _CATALOG_SIDECAR
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
# Unreadable / corrupt sidecar — treat as a non-catalog install.
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def cmd_install(
|
||||
identifier: str,
|
||||
force: bool = False,
|
||||
enable: Optional[bool] = None,
|
||||
allow_removed: bool = False,
|
||||
) -> None:
|
||||
"""Install a plugin from a Git URL or owner/repo shorthand.
|
||||
"""Install a plugin from the catalog, a Git URL, or owner/repo shorthand.
|
||||
|
||||
When *identifier* matches a catalog entry name (and is not a URL or
|
||||
``owner/repo`` shorthand), the install resolves to the entry's pinned
|
||||
commit SHA and records catalog provenance in a ``.hermes-catalog.json``
|
||||
sidecar. Raw git URLs keep the direct flow but are flagged as custom
|
||||
(unreviewed) sources.
|
||||
|
||||
``allow_removed=True`` bypasses the removed-blocklist check (loudly).
|
||||
|
||||
After install, prompt "Enable now? [y/N]" unless *enable* is provided
|
||||
(True = auto-enable without prompting, False = install disabled).
|
||||
@@ -561,6 +713,51 @@ def cmd_install(
|
||||
|
||||
console = Console()
|
||||
|
||||
entry = None
|
||||
if _looks_like_catalog_name(identifier):
|
||||
from hermes_cli.plugin_catalog import (
|
||||
entry_capability_summary,
|
||||
find_removed,
|
||||
)
|
||||
|
||||
entry = _get_live_catalog_entry(identifier)
|
||||
if entry is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{identifier}' is not in the Hermes "
|
||||
"plugin catalog and is not a Git URL or owner/repo "
|
||||
"shorthand.\n"
|
||||
"Browse available entries with `hermes plugins search`."
|
||||
)
|
||||
sys.exit(1)
|
||||
if not allow_removed:
|
||||
removed = find_removed(entry.name) or find_removed(entry.repo)
|
||||
if removed is not None:
|
||||
try:
|
||||
_raise_removed(removed)
|
||||
except PluginOperationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
console.print(
|
||||
f"[bold]{entry.name}[/bold] "
|
||||
f"[cyan]\\[{entry.tier}][/cyan] "
|
||||
f"[dim]pinned @ {entry.sha[:8]}[/dim]"
|
||||
)
|
||||
console.print(entry_capability_summary(entry))
|
||||
identifier = _catalog_install_identifier(entry)
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] custom (unreviewed) source — "
|
||||
"not from the Hermes catalog."
|
||||
)
|
||||
|
||||
if allow_removed:
|
||||
console.print(
|
||||
"[bold red]WARNING:[/bold red] [red]--allow-removed set — "
|
||||
"skipping the removed-plugin blocklist check. This plugin may "
|
||||
"have been removed from the catalog for security reasons. "
|
||||
"Proceed at your own risk.[/red]"
|
||||
)
|
||||
|
||||
try:
|
||||
git_url, _subdir = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
@@ -582,11 +779,16 @@ def cmd_install(
|
||||
target, installed_manifest, installed_name = _install_plugin_core(
|
||||
identifier,
|
||||
force=force,
|
||||
ref=entry.sha if entry is not None else None,
|
||||
skip_removed_check=allow_removed,
|
||||
)
|
||||
except PluginOperationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if entry is not None:
|
||||
_write_catalog_sidecar(target, entry)
|
||||
|
||||
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
|
||||
target / "__init__.py"
|
||||
).exists():
|
||||
@@ -634,7 +836,13 @@ def cmd_install(
|
||||
|
||||
|
||||
def cmd_update(name: str) -> None:
|
||||
"""Update an installed plugin by pulling latest from its git remote."""
|
||||
"""Update an installed plugin.
|
||||
|
||||
Catalog installs (``.hermes-catalog.json`` sidecar present) are compared
|
||||
against the current catalog pin: if the pinned SHA changed, the plugin is
|
||||
force-reinstalled at the new pin (enabled state preserved). Plain git
|
||||
installs keep the existing ``git pull`` behavior.
|
||||
"""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
@@ -646,6 +854,11 @@ def cmd_update(name: str) -> None:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
sidecar = _read_catalog_sidecar(target)
|
||||
if sidecar is not None and sidecar.get("catalog_name"):
|
||||
_update_catalog_plugin(name, target, sidecar, console)
|
||||
return
|
||||
|
||||
if not (target / ".git").exists():
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{name}' was not installed from git "
|
||||
@@ -673,6 +886,55 @@ def cmd_update(name: str) -> None:
|
||||
console.print(f"[dim]{out}[/dim]")
|
||||
|
||||
|
||||
def _update_catalog_plugin(name: str, target: Path, sidecar: dict, console) -> None:
|
||||
"""Re-pin a catalog-installed plugin to the current catalog SHA."""
|
||||
catalog_name = str(sidecar.get("catalog_name") or name)
|
||||
entry = _get_live_catalog_entry(catalog_name)
|
||||
if entry is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{catalog_name}' is no longer in the "
|
||||
"catalog — it may have been removed. Check "
|
||||
"`hermes plugins doctor` and the removed blocklist."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
installed_sha = str(sidecar.get("sha") or "").strip().lower()
|
||||
if installed_sha == entry.sha:
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{catalog_name}[/bold] is "
|
||||
f"already at catalog pin ({entry.sha[:8]})."
|
||||
)
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[dim]Updating {catalog_name} to catalog pin:[/dim] "
|
||||
f"{installed_sha[:8] or '(unknown)'} → {entry.sha[:8]}"
|
||||
)
|
||||
|
||||
# Preserve enabled state across the force reinstall.
|
||||
was_enabled = _get_enabled_set()
|
||||
|
||||
try:
|
||||
new_target, _manifest, _installed_name = _install_plugin_core(
|
||||
_catalog_install_identifier(entry),
|
||||
force=True,
|
||||
ref=entry.sha,
|
||||
)
|
||||
except PluginOperationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
sys.exit(1)
|
||||
|
||||
_write_catalog_sidecar(new_target, entry)
|
||||
# Restore the pre-update enabled/disabled state verbatim (the reinstall
|
||||
# itself never touches it, but be explicit in case core ever does).
|
||||
_save_enabled_set(was_enabled)
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{catalog_name}[/bold] updated to "
|
||||
f"{entry.sha[:8]}."
|
||||
)
|
||||
|
||||
|
||||
def cmd_remove(name: str) -> None:
|
||||
"""Remove an installed plugin by name."""
|
||||
from rich.console import Console
|
||||
@@ -1122,6 +1384,44 @@ def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set
|
||||
return filtered
|
||||
|
||||
|
||||
def _catalog_annotation(dir_path) -> Optional[str]:
|
||||
"""Return ``catalog:<tier>@<shaShort>`` for a catalog install, else None."""
|
||||
if not dir_path:
|
||||
return None
|
||||
try:
|
||||
sidecar = _read_catalog_sidecar(Path(dir_path))
|
||||
except Exception:
|
||||
return None
|
||||
if not sidecar or not sidecar.get("catalog_name"):
|
||||
return None
|
||||
tier = str(sidecar.get("tier") or "community")
|
||||
sha = str(sidecar.get("sha") or "")
|
||||
return f"catalog:{tier}@{sha[:8]}"
|
||||
|
||||
|
||||
def _removed_annotation(name: str, dir_path) -> Optional[str]:
|
||||
"""Return the removed-blocklist reason when *name* matches, else None."""
|
||||
try:
|
||||
from hermes_cli.plugin_catalog import find_removed
|
||||
except Exception:
|
||||
return None
|
||||
candidates = [name]
|
||||
if dir_path:
|
||||
try:
|
||||
sidecar = _read_catalog_sidecar(Path(dir_path))
|
||||
except Exception:
|
||||
sidecar = None
|
||||
if sidecar:
|
||||
candidates.extend(
|
||||
str(v) for v in (sidecar.get("catalog_name"), sidecar.get("repo")) if v
|
||||
)
|
||||
for candidate in candidates:
|
||||
removed = find_removed(candidate)
|
||||
if removed is not None:
|
||||
return removed.reason or "no reason recorded"
|
||||
return None
|
||||
|
||||
|
||||
def cmd_list(args: Any | None = None) -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
from rich.console import Console
|
||||
@@ -1139,16 +1439,22 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
entries = _filter_plugin_entries(entries, args, enabled, disabled)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
payload = [
|
||||
{
|
||||
payload = []
|
||||
for name, version, description, source, _dir, key in entries:
|
||||
row = {
|
||||
"name": name,
|
||||
"status": _plugin_status(name, enabled, disabled, key=key),
|
||||
"version": str(version),
|
||||
"description": description,
|
||||
"source": source,
|
||||
}
|
||||
for name, version, description, source, _dir, key in entries
|
||||
]
|
||||
catalog = _catalog_annotation(_dir)
|
||||
if catalog:
|
||||
row["catalog"] = catalog
|
||||
removed_reason = _removed_annotation(name, _dir)
|
||||
if removed_reason is not None:
|
||||
row["removed"] = removed_reason
|
||||
payload.append(row)
|
||||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
@@ -1169,6 +1475,7 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
|
||||
removed_lines: list[str] = []
|
||||
for name, version, description, source, _dir, key in entries:
|
||||
status_name = _plugin_status(name, enabled, disabled, key=key)
|
||||
if status_name == "disabled":
|
||||
@@ -1177,10 +1484,20 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
status = "[green]enabled[/green]"
|
||||
else:
|
||||
status = "[yellow]not enabled[/yellow]"
|
||||
table.add_row(name, status, str(version), description, source)
|
||||
catalog = _catalog_annotation(_dir)
|
||||
source_label = f"{source} [cyan]{catalog}[/cyan]" if catalog else source
|
||||
table.add_row(name, status, str(version), description, source_label)
|
||||
removed_reason = _removed_annotation(name, _dir)
|
||||
if removed_reason is not None:
|
||||
removed_lines.append(
|
||||
f"[red bold]✗ {name} — REMOVED from catalog: "
|
||||
f"{removed_reason}[/red bold]"
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
for line in removed_lines:
|
||||
console.print(line)
|
||||
console.print()
|
||||
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
|
||||
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
|
||||
@@ -1188,6 +1505,337 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog commands — search / browse / info / validate / doctor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entry_capability_counts(entry) -> str:
|
||||
"""Compact capability summary like ``2 tools, 1 hook`` for table rows."""
|
||||
caps = entry.capabilities
|
||||
parts: list[str] = []
|
||||
for count, singular in (
|
||||
(len(caps.provides_tools), "tool"),
|
||||
(len(caps.provides_hooks), "hook"),
|
||||
(len(caps.provides_middleware), "middleware"),
|
||||
):
|
||||
if count:
|
||||
plural = "" if count == 1 or singular == "middleware" else "s"
|
||||
parts.append(f"{count} {singular}{plural}")
|
||||
if caps.requires_env:
|
||||
parts.append(f"{len(caps.requires_env)} env")
|
||||
return ", ".join(parts) or "—"
|
||||
|
||||
|
||||
def _render_catalog_entries(entries, console) -> None:
|
||||
"""Render catalog entries as the shared search/browse Rich table."""
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(title="Hermes Plugin Catalog", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Tier")
|
||||
table.add_column("Description")
|
||||
table.add_column("Pinned", style="dim")
|
||||
table.add_column("Capabilities", style="dim")
|
||||
|
||||
for entry in entries:
|
||||
tier = (
|
||||
"[cyan]official[/cyan]"
|
||||
if entry.tier == "official"
|
||||
else "[magenta]community[/magenta]"
|
||||
)
|
||||
description = entry.description
|
||||
if len(description) > 60:
|
||||
description = description[:57] + "..."
|
||||
table.add_row(
|
||||
entry.name,
|
||||
tier,
|
||||
description,
|
||||
entry.sha[:8],
|
||||
_entry_capability_counts(entry),
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[dim]Details:[/dim] hermes plugins info <name>")
|
||||
console.print("[dim]Install:[/dim] hermes plugins install <name>")
|
||||
|
||||
|
||||
def cmd_search(query: str = "") -> None:
|
||||
"""Search the plugin catalog (live index when reachable)."""
|
||||
from rich.console import Console
|
||||
|
||||
from hermes_cli.plugin_catalog import filter_entries, load_catalog_live
|
||||
|
||||
console = Console()
|
||||
entries = filter_entries(load_catalog_live(), query)
|
||||
if not entries:
|
||||
if query:
|
||||
console.print(
|
||||
f"[dim]No catalog entries match '{query}'. "
|
||||
"Browse everything with `hermes plugins browse`.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print("[dim]No catalog entries available.[/dim]")
|
||||
return
|
||||
_render_catalog_entries(entries, console)
|
||||
|
||||
|
||||
def cmd_browse() -> None:
|
||||
"""List every plugin catalog entry."""
|
||||
cmd_search("")
|
||||
|
||||
|
||||
def cmd_info(name: str) -> None:
|
||||
"""Show the full catalog entry for *name*."""
|
||||
from rich.console import Console
|
||||
|
||||
from hermes_cli.plugin_catalog import find_removed
|
||||
|
||||
console = Console()
|
||||
entry = _get_live_catalog_entry(name)
|
||||
if entry is None:
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{name}' is not in the plugin catalog. "
|
||||
"Browse entries with `hermes plugins search`."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
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()
|
||||
console.print(f"[dim]Repo:[/dim] {entry.repo}")
|
||||
if entry.subdir:
|
||||
console.print(f"[dim]Subdir:[/dim] {entry.subdir}")
|
||||
console.print(f"[dim]Pinned SHA:[/dim] {entry.sha}")
|
||||
console.print(f"[dim]Maintainer:[/dim] {entry.maintainer}")
|
||||
if entry.requires_hermes:
|
||||
console.print(f"[dim]Requires:[/dim] hermes {entry.requires_hermes}")
|
||||
if entry.platforms:
|
||||
console.print(f"[dim]Platforms:[/dim] {', '.join(entry.platforms)}")
|
||||
if entry.docs_url:
|
||||
console.print(f"[dim]Docs:[/dim] {entry.docs_url}")
|
||||
console.print()
|
||||
console.print(f"[dim]Tools:[/dim] {', '.join(caps.provides_tools) or '(none)'}")
|
||||
console.print(f"[dim]Hooks:[/dim] {', '.join(caps.provides_hooks) or '(none)'}")
|
||||
console.print(f"[dim]Middleware:[/dim] {', '.join(caps.provides_middleware) or '(none)'}")
|
||||
console.print(f"[dim]Env vars:[/dim] {', '.join(caps.requires_env) or '(none)'}")
|
||||
console.print()
|
||||
|
||||
removed = find_removed(entry.name) or find_removed(entry.repo)
|
||||
if removed is not None:
|
||||
detail = removed.reason or "no reason recorded"
|
||||
if removed.date:
|
||||
detail += f" (removed {removed.date})"
|
||||
console.print(
|
||||
f"[red bold]✗ REMOVED from catalog: {detail}[/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) -> None:
|
||||
"""Validate a plugin directory for catalog admission. Exits 0/1."""
|
||||
from rich.console import Console
|
||||
|
||||
from hermes_cli.plugin_validate import validate_plugin_dir
|
||||
|
||||
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 name, ok, detail in report.checks:
|
||||
mark = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
||||
line = f"{mark} {name}"
|
||||
if detail:
|
||||
line += f" [dim]— {detail}[/dim]"
|
||||
console.print(line)
|
||||
for warning in report.warnings:
|
||||
console.print(f"[yellow]⚠ {warning}[/yellow]")
|
||||
console.print()
|
||||
if report.ok:
|
||||
console.print("[green bold]Validation passed.[/green bold]")
|
||||
else:
|
||||
console.print("[red bold]Validation failed.[/red bold]")
|
||||
sys.exit(report.exit_code)
|
||||
|
||||
|
||||
def _runtime_load_errors() -> dict[str, str]:
|
||||
"""Return ``{plugin_key: error}`` from a fresh PluginManager scan.
|
||||
|
||||
Uses the idempotent ``discover_plugins()`` path so we don't need a full
|
||||
agent boot; failures are non-fatal (doctor still reports what it can).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
|
||||
discover_plugins()
|
||||
manager = get_plugin_manager()
|
||||
return {
|
||||
key: loaded.error
|
||||
for key, loaded in manager._plugins.items()
|
||||
if loaded.error
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("doctor: runtime plugin scan failed: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _doctor_plugin_report(name: str, dir_path: Path, key: str,
|
||||
enabled: set, disabled: set,
|
||||
load_errors: dict[str, str]) -> dict:
|
||||
"""Collect doctor facts for one installed plugin directory."""
|
||||
from hermes_cli.plugins import _running_hermes_version, _version_satisfies
|
||||
|
||||
manifest = _read_manifest(dir_path)
|
||||
facts: dict[str, Any] = {
|
||||
"name": name,
|
||||
"manifest_ok": bool(manifest.get("name")),
|
||||
"status": _plugin_status(name, enabled, disabled, key=key),
|
||||
"load_error": load_errors.get(key) or load_errors.get(name) or "",
|
||||
"missing_env": _missing_requires_env_names(manifest),
|
||||
"requires_hermes": "",
|
||||
"catalog": "",
|
||||
"pin": "",
|
||||
"removed": "",
|
||||
}
|
||||
|
||||
spec = str(manifest.get("requires_hermes") or "").strip()
|
||||
if spec:
|
||||
current = _running_hermes_version()
|
||||
if _version_satisfies(spec, current):
|
||||
facts["requires_hermes"] = f"{spec} ✓"
|
||||
else:
|
||||
facts["requires_hermes"] = f"{spec} ✗ (running {current})"
|
||||
|
||||
sidecar = _read_catalog_sidecar(dir_path)
|
||||
if sidecar and sidecar.get("catalog_name"):
|
||||
tier = str(sidecar.get("tier") or "community")
|
||||
sha = str(sidecar.get("sha") or "")
|
||||
facts["catalog"] = f"catalog:{tier}@{sha[:8]}"
|
||||
entry = _get_live_catalog_entry(str(sidecar["catalog_name"]))
|
||||
if entry is None:
|
||||
facts["pin"] = "entry gone from catalog"
|
||||
elif entry.sha != sha:
|
||||
facts["pin"] = (
|
||||
f"behind catalog pin ({sha[:8]} → {entry.sha[:8]}) — "
|
||||
"run `hermes plugins update`"
|
||||
)
|
||||
else:
|
||||
facts["pin"] = "at catalog pin"
|
||||
|
||||
removed_reason = _removed_annotation(name, dir_path)
|
||||
if removed_reason is not None:
|
||||
facts["removed"] = removed_reason
|
||||
return facts
|
||||
|
||||
|
||||
def cmd_doctor(name: Optional[str] = None) -> None:
|
||||
"""Diagnose installed plugins (or a single one when *name* given)."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
installed = [
|
||||
(d.name, d) for d in sorted(plugins_dir.iterdir()) if d.is_dir()
|
||||
]
|
||||
if name is not None:
|
||||
installed = [(n, d) for n, d in installed if n == name]
|
||||
if not installed:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{name}' not found in {plugins_dir}."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if not installed:
|
||||
console.print("[dim]No plugins installed under[/dim] "
|
||||
f"{plugins_dir}")
|
||||
return
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
load_errors = _runtime_load_errors()
|
||||
|
||||
reports = [
|
||||
_doctor_plugin_report(n, d, n, enabled, disabled, load_errors)
|
||||
for n, d in installed
|
||||
]
|
||||
|
||||
if name is not None:
|
||||
facts = reports[0]
|
||||
console.print()
|
||||
console.print(f"[bold]{facts['name']}[/bold]")
|
||||
console.print(
|
||||
f"[dim]Manifest:[/dim] "
|
||||
+ ("[green]ok[/green]" if facts["manifest_ok"]
|
||||
else "[red]missing/invalid plugin.yaml[/red]")
|
||||
)
|
||||
console.print(f"[dim]Status:[/dim] {facts['status']}")
|
||||
if facts["load_error"]:
|
||||
console.print(f"[dim]Load error:[/dim] [red]{facts['load_error']}[/red]")
|
||||
if facts["missing_env"]:
|
||||
console.print(
|
||||
f"[dim]Missing env:[/dim] [yellow]{', '.join(facts['missing_env'])}[/yellow]"
|
||||
)
|
||||
if facts["requires_hermes"]:
|
||||
console.print(f"[dim]Requires:[/dim] hermes {facts['requires_hermes']}")
|
||||
if facts["catalog"]:
|
||||
console.print(f"[dim]Provenance:[/dim] {facts['catalog']}")
|
||||
console.print(f"[dim]Pin:[/dim] {facts['pin']}")
|
||||
if facts["removed"]:
|
||||
console.print(
|
||||
f"[red bold]✗ REMOVED from catalog: {facts['removed']}[/red bold]"
|
||||
)
|
||||
console.print()
|
||||
return
|
||||
|
||||
table = Table(title="Plugin Doctor", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Manifest")
|
||||
table.add_column("Status")
|
||||
table.add_column("Issues")
|
||||
table.add_column("Catalog", style="dim")
|
||||
|
||||
for facts in reports:
|
||||
issues: list[str] = []
|
||||
if facts["load_error"]:
|
||||
issues.append(f"[red]{facts['load_error']}[/red]")
|
||||
if facts["missing_env"]:
|
||||
issues.append(
|
||||
f"[yellow]missing env: {', '.join(facts['missing_env'])}[/yellow]"
|
||||
)
|
||||
if facts["requires_hermes"] and "✗" in facts["requires_hermes"]:
|
||||
issues.append(f"[red]requires hermes {facts['requires_hermes']}[/red]")
|
||||
if facts["removed"]:
|
||||
issues.append(
|
||||
f"[red bold]REMOVED from catalog: {facts['removed']}[/red bold]"
|
||||
)
|
||||
if facts["pin"] and "behind" in facts["pin"]:
|
||||
issues.append(f"[yellow]{facts['pin']}[/yellow]")
|
||||
table.add_row(
|
||||
facts["name"],
|
||||
"[green]ok[/green]" if facts["manifest_ok"] else "[red]bad[/red]",
|
||||
facts["status"],
|
||||
"\n".join(issues) or "[dim]—[/dim]",
|
||||
facts["catalog"] or "[dim]—[/dim]",
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider plugin discovery helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1759,31 +2407,78 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected,
|
||||
print()
|
||||
|
||||
|
||||
# Public aliases for the sidecar helpers defined above — the dashboard
|
||||
# (web_server.py) imports these names; the CLI paths use the private ones.
|
||||
write_catalog_sidecar = _write_catalog_sidecar
|
||||
read_catalog_sidecar = _read_catalog_sidecar
|
||||
|
||||
|
||||
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()
|
||||
@@ -2017,7 +2712,18 @@ def plugins_command(args) -> None:
|
||||
args.identifier,
|
||||
force=getattr(args, "force", False),
|
||||
enable=enable_arg,
|
||||
allow_removed=getattr(args, "allow_removed", False),
|
||||
)
|
||||
elif action == "search":
|
||||
cmd_search(getattr(args, "query", "") or "")
|
||||
elif action == "browse":
|
||||
cmd_browse()
|
||||
elif action == "info":
|
||||
cmd_info(args.name)
|
||||
elif action == "validate":
|
||||
cmd_validate(args.path, as_json=getattr(args, "json", False))
|
||||
elif action == "doctor":
|
||||
cmd_doctor(getattr(args, "name", None))
|
||||
elif action == "update":
|
||||
cmd_update(args.name)
|
||||
elif action in {"remove", "rm", "uninstall"}:
|
||||
|
||||
@@ -42,6 +42,51 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
||||
action="store_true",
|
||||
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
|
||||
)
|
||||
plugins_install.add_argument(
|
||||
"--allow-removed",
|
||||
action="store_true",
|
||||
help="DANGEROUS: bypass the catalog removed-plugin blocklist check",
|
||||
)
|
||||
|
||||
plugins_search = plugins_subparsers.add_parser(
|
||||
"search", help="Search the Hermes plugin catalog"
|
||||
)
|
||||
plugins_search.add_argument(
|
||||
"query",
|
||||
nargs="?",
|
||||
default="",
|
||||
help="Substring to match against entry names, descriptions, and tools",
|
||||
)
|
||||
|
||||
plugins_subparsers.add_parser(
|
||||
"browse", help="Browse every plugin catalog entry"
|
||||
)
|
||||
|
||||
plugins_info = plugins_subparsers.add_parser(
|
||||
"info", help="Show full catalog details for an entry"
|
||||
)
|
||||
plugins_info.add_argument("name", help="Catalog entry name")
|
||||
|
||||
plugins_validate = plugins_subparsers.add_parser(
|
||||
"validate",
|
||||
help="Validate a plugin directory for catalog admission (CI gate)",
|
||||
)
|
||||
plugins_validate.add_argument("path", help="Path to the plugin directory")
|
||||
plugins_validate.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print machine-readable JSON (for CI)",
|
||||
)
|
||||
|
||||
plugins_doctor = plugins_subparsers.add_parser(
|
||||
"doctor", help="Diagnose installed plugins"
|
||||
)
|
||||
plugins_doctor.add_argument(
|
||||
"name",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Plugin name to inspect in detail (default: all installed)",
|
||||
)
|
||||
|
||||
plugins_update = plugins_subparsers.add_parser(
|
||||
"update", help="Pull latest changes for an installed plugin"
|
||||
|
||||
+135
-10
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,14 @@
|
||||
name: plugin-llm-example
|
||||
repo: https://github.com/NousResearch/hermes-example-plugins
|
||||
sha: 38fe0fb53eff98d477f807432e965429e665ca33
|
||||
subdir: "plugin-llm-example"
|
||||
description: Reference plugin showing host-owned structured LLM access via ctx.llm.
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
docs_url: ""
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -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: []
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone structural validator for plugin-catalog entry files.
|
||||
|
||||
Validates ``plugin-catalog/*.yaml`` catalog entries and
|
||||
``plugin-catalog/removed.yaml`` against the catalog contract schema, using
|
||||
only stdlib + PyYAML so the admission CI (and third-party repos) can run it
|
||||
WITHOUT installing hermes-agent.
|
||||
|
||||
NOTE: this script intentionally duplicates the schema rules instead of
|
||||
importing ``hermes_cli`` — the whole point is the no-install requirement for
|
||||
cheap cross-repo CI use. The runtime twin of this schema lives in
|
||||
``hermes_cli/plugin_catalog.py``; if the contract changes there, update the
|
||||
rules here in lockstep.
|
||||
|
||||
Usage:
|
||||
python3 scripts/validate_plugin_catalog.py plugin-catalog/
|
||||
python3 scripts/validate_plugin_catalog.py entry.yaml removed.yaml
|
||||
python3 scripts/validate_plugin_catalog.py --json plugin-catalog/
|
||||
|
||||
Exit codes: 0 = all files valid (warnings allowed), 1 = at least one error.
|
||||
Human output is one ``<file>: ERROR: ...`` / ``<file>: warning: ...`` line
|
||||
per finding; ``--json`` emits a machine-readable report on stdout instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover - dependency guidance only
|
||||
print(
|
||||
"ERROR: PyYAML is required (pip install pyyaml)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TIERS = ("official", "community")
|
||||
PLATFORMS = ("linux", "macos", "windows")
|
||||
CAPABILITY_KEYS = (
|
||||
"provides_tools",
|
||||
"provides_hooks",
|
||||
"provides_middleware",
|
||||
"requires_env",
|
||||
)
|
||||
# Top-level keys the contract knows about. Unknown keys WARN (forward
|
||||
# compatibility: newer catalogs must stay valid under older validators).
|
||||
KNOWN_KEYS = {
|
||||
"name",
|
||||
"repo",
|
||||
"sha",
|
||||
"subdir",
|
||||
"description",
|
||||
"maintainer",
|
||||
"tier",
|
||||
"requires_hermes",
|
||||
"docs_url",
|
||||
"platforms",
|
||||
"capabilities",
|
||||
}
|
||||
REQUIRED_KEYS = ("name", "repo", "sha", "description", "maintainer")
|
||||
|
||||
# One comparator clause of a requires_hermes spec, e.g. ">=0.19" or "!=1.2.3".
|
||||
_COMPARATOR_RE = re.compile(r"^(>=|<=|==|!=|>|<)\s*\d+(\.\d+)*$")
|
||||
|
||||
|
||||
def _is_nonempty_str(value: object) -> bool:
|
||||
return isinstance(value, str) and value.strip() != ""
|
||||
|
||||
|
||||
def _check_requires_hermes(spec: object, errors: list[str]) -> None:
|
||||
if not isinstance(spec, str):
|
||||
errors.append(f"requires_hermes must be a string, got {type(spec).__name__}")
|
||||
return
|
||||
if spec.strip() == "":
|
||||
return # empty = no constraint
|
||||
for clause in spec.split(","):
|
||||
if not _COMPARATOR_RE.match(clause.strip()):
|
||||
errors.append(
|
||||
f"requires_hermes clause {clause.strip()!r} is not a valid "
|
||||
"comparator spec (expected e.g. '>=0.19')"
|
||||
)
|
||||
|
||||
|
||||
def validate_entry(data: object) -> tuple[list[str], list[str]]:
|
||||
"""Validate one catalog entry document. Returns (errors, warnings)."""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return ["top-level document must be a YAML mapping"], warnings
|
||||
|
||||
for key in REQUIRED_KEYS:
|
||||
if key not in data:
|
||||
errors.append(f"missing required key: {key}")
|
||||
|
||||
for key in sorted(set(data) - KNOWN_KEYS):
|
||||
warnings.append(f"unknown top-level key {key!r} (ignored by this validator)")
|
||||
|
||||
name = data.get("name")
|
||||
if "name" in data and (not isinstance(name, str) or not NAME_RE.match(name)):
|
||||
errors.append(f"name {name!r} must match [a-z0-9_-]{{1,64}}")
|
||||
|
||||
repo = data.get("repo")
|
||||
if "repo" in data and (
|
||||
not isinstance(repo, str) or not repo.startswith("https://")
|
||||
):
|
||||
errors.append(f"repo {repo!r} must be an https:// URL")
|
||||
|
||||
sha = data.get("sha")
|
||||
if "sha" in data and (not isinstance(sha, str) or not SHA_RE.match(sha)):
|
||||
errors.append(f"sha {sha!r} must be exactly 40 lowercase hex characters")
|
||||
|
||||
for key in ("description", "maintainer"):
|
||||
if key in data and not _is_nonempty_str(data[key]):
|
||||
errors.append(f"{key} must be a non-empty string")
|
||||
|
||||
tier = data.get("tier", "community")
|
||||
if tier not in TIERS:
|
||||
errors.append(f"tier {tier!r} must be one of {list(TIERS)}")
|
||||
|
||||
if "requires_hermes" in data:
|
||||
_check_requires_hermes(data["requires_hermes"], errors)
|
||||
|
||||
platforms = data.get("platforms", [])
|
||||
if platforms is None:
|
||||
platforms = []
|
||||
if not isinstance(platforms, list):
|
||||
errors.append("platforms must be a list")
|
||||
else:
|
||||
bad = [p for p in platforms if p not in PLATFORMS]
|
||||
if bad:
|
||||
errors.append(f"platforms {bad!r} not in allowed set {list(PLATFORMS)}")
|
||||
|
||||
caps = data.get("capabilities", {})
|
||||
if caps is None:
|
||||
caps = {}
|
||||
if not isinstance(caps, dict):
|
||||
errors.append("capabilities must be a mapping")
|
||||
else:
|
||||
for key in sorted(set(caps) - set(CAPABILITY_KEYS)):
|
||||
warnings.append(f"unknown capabilities key {key!r}")
|
||||
for key in CAPABILITY_KEYS:
|
||||
if key not in caps:
|
||||
continue
|
||||
value = caps[key]
|
||||
if not isinstance(value, list) or not all(
|
||||
isinstance(item, str) for item in value
|
||||
):
|
||||
errors.append(f"capabilities.{key} must be a list of strings")
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def validate_removed(data: object) -> tuple[list[str], list[str]]:
|
||||
"""Validate the removed.yaml document. Returns (errors, warnings)."""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return ["top-level document must be a YAML mapping"], warnings
|
||||
|
||||
removed = data.get("removed")
|
||||
if removed is None:
|
||||
errors.append("missing required key: removed")
|
||||
return errors, warnings
|
||||
if not isinstance(removed, list):
|
||||
errors.append("removed must be a list")
|
||||
return errors, warnings
|
||||
|
||||
for i, item in enumerate(removed):
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"removed[{i}] must be a mapping")
|
||||
continue
|
||||
if not _is_nonempty_str(item.get("name")):
|
||||
errors.append(f"removed[{i}] missing non-empty 'name'")
|
||||
for key in ("repo", "reason", "date"):
|
||||
if key in item and not isinstance(item[key], str):
|
||||
errors.append(f"removed[{i}].{key} must be a string")
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def validate_file(path: Path) -> tuple[list[str], list[str]]:
|
||||
"""Validate one YAML file (dispatching on filename). Returns (errors, warnings)."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except OSError as exc:
|
||||
return [f"cannot read file: {exc}"], []
|
||||
except yaml.YAMLError as exc:
|
||||
return [f"invalid YAML: {exc}"], []
|
||||
|
||||
if path.name == "removed.yaml":
|
||||
return validate_removed(data)
|
||||
return validate_entry(data)
|
||||
|
||||
|
||||
def collect_paths(args: list[str]) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
for arg in args:
|
||||
p = Path(arg)
|
||||
if p.is_dir():
|
||||
paths.extend(sorted(p.glob("*.yaml")))
|
||||
paths.extend(sorted(p.glob("*.yml")))
|
||||
else:
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Standalone structural validator for plugin-catalog entry files."
|
||||
)
|
||||
parser.add_argument(
|
||||
"paths",
|
||||
nargs="+",
|
||||
help="catalog entry files, removed.yaml, or a directory of them",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="emit a machine-readable JSON report on stdout",
|
||||
)
|
||||
opts = parser.parse_args(argv)
|
||||
|
||||
files = collect_paths(opts.paths)
|
||||
if not files:
|
||||
print("ERROR: no YAML files found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
report = []
|
||||
any_errors = False
|
||||
for path in files:
|
||||
errors, warnings = validate_file(path)
|
||||
any_errors = any_errors or bool(errors)
|
||||
report.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"ok": not errors,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
if opts.json:
|
||||
print(json.dumps({"ok": not any_errors, "files": report}, indent=2))
|
||||
else:
|
||||
for entry in report:
|
||||
for err in entry["errors"]:
|
||||
print(f"{entry['path']}: ERROR: {err}")
|
||||
for warn in entry["warnings"]:
|
||||
print(f"{entry['path']}: warning: {warn}")
|
||||
checked = len(report)
|
||||
bad = sum(1 for e in report if not e["ok"])
|
||||
if any_errors:
|
||||
print(f"FAIL: {bad}/{checked} file(s) invalid")
|
||||
else:
|
||||
print(f"OK: {checked} file(s) valid")
|
||||
|
||||
return 1 if any_errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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,214 @@
|
||||
"""Tests for ``hermes plugins validate`` (hermes_cli/plugin_validate.py).
|
||||
|
||||
Static manifest checks + subprocess-isolated capability probing against a
|
||||
recording stub context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import hermes_cli.plugins_cmd as plugins_cmd
|
||||
from hermes_cli.plugin_validate import validate_plugin_dir
|
||||
|
||||
|
||||
def _make_plugin(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
manifest: dict,
|
||||
init_py: str = "def register(ctx):\n pass\n",
|
||||
) -> Path:
|
||||
d = tmp_path / manifest.get("name", "fixture-plugin")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8")
|
||||
(d / "__init__.py").write_text(init_py, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
BASE_MANIFEST = {
|
||||
"name": "fixture-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "A fixture plugin.",
|
||||
}
|
||||
|
||||
|
||||
class TestStaticChecks:
|
||||
def test_valid_plugin_passes(self, tmp_path):
|
||||
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST))
|
||||
report = validate_plugin_dir(d)
|
||||
assert report.ok
|
||||
assert report.exit_code == 0
|
||||
|
||||
def test_missing_manifest_fails(self, tmp_path):
|
||||
d = tmp_path / "empty-plugin"
|
||||
d.mkdir()
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
assert report.exit_code == 1
|
||||
assert any("plugin.yaml" in f for f in report.failures)
|
||||
|
||||
def test_missing_required_fields_fail(self, tmp_path):
|
||||
d = _make_plugin(tmp_path, manifest={"name": "fixture-plugin"})
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
joined = " ".join(report.failures)
|
||||
assert "version" in joined
|
||||
assert "description" in joined
|
||||
|
||||
def test_bad_requires_hermes_spec_fails(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, requires_hermes=">=not.a.version")
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
assert any("requires_hermes" in f for f in report.failures)
|
||||
|
||||
def test_good_requires_hermes_spec_passes(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, requires_hermes=">=0.1, <99")
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert report.ok
|
||||
|
||||
def test_invalid_config_section_fails(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, config=[{"prompt": "no key here"}])
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
assert any("config" in f for f in report.failures)
|
||||
|
||||
def test_valid_config_section_passes(self, tmp_path):
|
||||
manifest = dict(
|
||||
BASE_MANIFEST,
|
||||
config=[
|
||||
{"key": "endpoint", "prompt": "Endpoint?", "type": "str"},
|
||||
{"key": "token", "secret": True, "type": "str"},
|
||||
],
|
||||
)
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert report.ok
|
||||
|
||||
def test_lower_snake_requires_env_fails(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, requires_env=["lower_case_bad"])
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
assert any("requires_env" in f for f in report.failures)
|
||||
|
||||
def test_upper_snake_requires_env_passes(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, requires_env=["MY_API_KEY_2"])
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert report.ok
|
||||
|
||||
def test_rich_requires_env_dict_entries_accepted(self, tmp_path):
|
||||
manifest = dict(
|
||||
BASE_MANIFEST,
|
||||
requires_env=[{"name": "MY_KEY", "description": "key"}],
|
||||
)
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert report.ok
|
||||
|
||||
|
||||
class TestCapabilityProbe:
|
||||
def test_undeclared_tool_registration_fails_with_diff(self, tmp_path):
|
||||
init = (
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_tool('sneaky_tool', 'sneaky', {}, lambda a: '')\n"
|
||||
)
|
||||
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
joined = " ".join(report.failures)
|
||||
assert "sneaky_tool" in joined
|
||||
assert "undeclared" in joined.lower()
|
||||
|
||||
def test_declared_and_registered_passes(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, provides_tools=["good_tool"])
|
||||
init = (
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_tool('good_tool', 'good', {}, lambda a: '')\n"
|
||||
)
|
||||
d = _make_plugin(tmp_path, manifest=manifest, init_py=init)
|
||||
report = validate_plugin_dir(d)
|
||||
assert report.ok
|
||||
|
||||
def test_declared_but_not_registered_warns(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, provides_tools=["phantom_tool"])
|
||||
d = _make_plugin(tmp_path, manifest=manifest)
|
||||
report = validate_plugin_dir(d)
|
||||
assert report.ok # warn, not fail
|
||||
assert any("phantom_tool" in w for w in report.warnings)
|
||||
|
||||
def test_undeclared_hook_registration_fails(self, tmp_path):
|
||||
init = (
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_hook('pre_tool_call', lambda **kw: None)\n"
|
||||
)
|
||||
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
assert any("pre_tool_call" in f for f in report.failures)
|
||||
|
||||
def test_crashing_register_is_contained(self, tmp_path):
|
||||
init = "def register(ctx):\n raise RuntimeError('boom')\n"
|
||||
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
|
||||
report = validate_plugin_dir(d) # must not raise / kill the CLI
|
||||
assert not report.ok
|
||||
assert any("boom" in f or "register()" in f for f in report.failures)
|
||||
|
||||
def test_import_time_os_exit_is_contained(self, tmp_path):
|
||||
init = "import os\nos._exit(7)\n"
|
||||
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST), init_py=init)
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
|
||||
def test_builtin_tool_collision_fails(self, tmp_path):
|
||||
manifest = dict(BASE_MANIFEST, provides_tools=["terminal"])
|
||||
init = (
|
||||
"def register(ctx):\n"
|
||||
" ctx.register_tool('terminal', 'shadow', {}, lambda a: '')\n"
|
||||
)
|
||||
d = _make_plugin(tmp_path, manifest=manifest, init_py=init)
|
||||
report = validate_plugin_dir(d)
|
||||
assert not report.ok
|
||||
joined = " ".join(report.failures)
|
||||
assert "terminal" in joined
|
||||
assert "built-in" in joined
|
||||
|
||||
|
||||
class TestCmdValidate:
|
||||
def test_cmd_validate_exit_zero_on_pass(self, tmp_path, capsys):
|
||||
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST))
|
||||
with pytest.raises(SystemExit) as e:
|
||||
plugins_cmd.cmd_validate(str(d))
|
||||
assert e.value.code == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "✓" in out
|
||||
|
||||
def test_cmd_validate_exit_one_on_fail(self, tmp_path, capsys):
|
||||
d = tmp_path / "not-a-plugin"
|
||||
d.mkdir()
|
||||
with pytest.raises(SystemExit) as e:
|
||||
plugins_cmd.cmd_validate(str(d))
|
||||
assert e.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "✗" in out
|
||||
|
||||
def test_cmd_validate_json_output(self, tmp_path, capsys):
|
||||
d = _make_plugin(tmp_path, manifest=dict(BASE_MANIFEST))
|
||||
with pytest.raises(SystemExit) as e:
|
||||
plugins_cmd.cmd_validate(str(d), as_json=True)
|
||||
assert e.value.code == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["ok"] is True
|
||||
assert "checks" in payload
|
||||
|
||||
def test_cmd_validate_missing_dir_fails(self, tmp_path, capsys):
|
||||
with pytest.raises(SystemExit) as e:
|
||||
plugins_cmd.cmd_validate(str(tmp_path / "ghost"))
|
||||
assert e.value.code == 1
|
||||
@@ -0,0 +1,626 @@
|
||||
"""Tests for the catalog-driven ``hermes plugins`` CLI surface.
|
||||
|
||||
Covers: catalog-name install resolution (pinned ref + provenance sidecar),
|
||||
custom-URL banner, --allow-removed wiring, catalog-pin updates, list
|
||||
annotations, live-index fetch/fallback/TTL, search/browse/info rendering,
|
||||
doctor, and argparse dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import hermes_cli.plugin_catalog as plugin_catalog
|
||||
import hermes_cli.plugins_cmd as plugins_cmd
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
SHA_A = "a" * 40
|
||||
SHA_B = "b" * 40
|
||||
|
||||
|
||||
# ── Helpers / fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path:
|
||||
data = {
|
||||
"name": name,
|
||||
"repo": f"https://github.com/example/{name}",
|
||||
"sha": SHA_A,
|
||||
"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
|
||||
|
||||
|
||||
def _install_user_plugin(name: str, *, sidecar: dict | None = None) -> Path:
|
||||
"""Create a fake installed plugin under the per-test HERMES_HOME."""
|
||||
d = get_hermes_home() / "plugins" / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "plugin.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"name": name, "version": "1.0.0", "description": f"{name} plugin"}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if sidecar is not None:
|
||||
(d / ".hermes-catalog.json").write_text(
|
||||
json.dumps(sidecar), encoding="utf-8"
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def catalog_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "catalog"
|
||||
d.mkdir()
|
||||
monkeypatch.setenv("HERMES_PLUGIN_CATALOG_DIR", str(d))
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def offline(monkeypatch):
|
||||
"""Force the live-index path to fall back to the in-tree catalog."""
|
||||
monkeypatch.setattr(plugin_catalog, "fetch_live_catalog", lambda **kw: None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_core(monkeypatch, tmp_path):
|
||||
"""Replace _install_plugin_core with a recording fake."""
|
||||
calls: list[dict] = []
|
||||
target = tmp_path / "fake-installed"
|
||||
|
||||
def fake(identifier, *, force, ref=None, skip_removed_check=False):
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
calls.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"force": force,
|
||||
"ref": ref,
|
||||
"skip_removed_check": skip_removed_check,
|
||||
}
|
||||
)
|
||||
return target, {"name": "my-entry"}, "my-entry"
|
||||
|
||||
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake)
|
||||
return types.SimpleNamespace(calls=calls, target=target)
|
||||
|
||||
|
||||
# ── Catalog-name install ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCatalogInstall:
|
||||
def test_catalog_name_resolves_to_pinned_repo(
|
||||
self, catalog_dir, offline, fake_core
|
||||
):
|
||||
_write_entry(catalog_dir, "my-entry", sha=SHA_A)
|
||||
plugins_cmd.cmd_install("my-entry", enable=False)
|
||||
assert len(fake_core.calls) == 1
|
||||
call = fake_core.calls[0]
|
||||
assert call["identifier"] == "https://github.com/example/my-entry"
|
||||
assert call["ref"] == SHA_A
|
||||
assert call["skip_removed_check"] is False
|
||||
|
||||
def test_subdir_entry_uses_fragment_identifier(
|
||||
self, catalog_dir, offline, fake_core
|
||||
):
|
||||
_write_entry(catalog_dir, "my-entry", subdir="plugins/inner")
|
||||
plugins_cmd.cmd_install("my-entry", enable=False)
|
||||
assert fake_core.calls[0]["identifier"] == (
|
||||
"https://github.com/example/my-entry#plugins/inner"
|
||||
)
|
||||
|
||||
def test_sidecar_written_with_provenance(
|
||||
self, catalog_dir, offline, fake_core
|
||||
):
|
||||
_write_entry(catalog_dir, "my-entry", tier="official")
|
||||
plugins_cmd.cmd_install("my-entry", enable=False)
|
||||
sidecar_path = fake_core.target / ".hermes-catalog.json"
|
||||
assert sidecar_path.is_file()
|
||||
sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
|
||||
assert sidecar["catalog_name"] == "my-entry"
|
||||
assert sidecar["repo"] == "https://github.com/example/my-entry"
|
||||
assert sidecar["sha"] == SHA_A
|
||||
assert sidecar["tier"] == "official"
|
||||
assert sidecar["installed_at"]
|
||||
|
||||
def test_capability_summary_and_tier_shown(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
_write_entry(
|
||||
catalog_dir,
|
||||
"my-entry",
|
||||
tier="official",
|
||||
capabilities={"provides_tools": ["cool_tool"]},
|
||||
)
|
||||
plugins_cmd.cmd_install("my-entry", enable=False)
|
||||
out = capsys.readouterr().out
|
||||
assert "official" in out
|
||||
assert "cool_tool" in out
|
||||
|
||||
def test_unknown_catalog_like_name_errors(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_install("nonexistent-entry", enable=False)
|
||||
out = capsys.readouterr().out
|
||||
assert "search" in out
|
||||
assert not fake_core.calls
|
||||
|
||||
def test_custom_url_gets_unreviewed_banner(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
plugins_cmd.cmd_install(
|
||||
"https://github.com/foo/bar.git", enable=False
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "custom (unreviewed) source" in out
|
||||
# Custom installs never get a ref pin.
|
||||
assert fake_core.calls[0]["ref"] is None
|
||||
|
||||
def test_allow_removed_passes_skip_flag_and_warns(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
plugins_cmd.cmd_install(
|
||||
"https://github.com/foo/bar.git",
|
||||
enable=False,
|
||||
allow_removed=True,
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert fake_core.calls[0]["skip_removed_check"] is True
|
||||
assert "removed" in out.lower()
|
||||
|
||||
|
||||
# ── Catalog update ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCatalogUpdate:
|
||||
def test_update_reinstalls_at_new_pin(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
_write_entry(catalog_dir, "my-entry", sha=SHA_B)
|
||||
target = _install_user_plugin(
|
||||
"my-entry",
|
||||
sidecar={
|
||||
"catalog_name": "my-entry",
|
||||
"repo": "https://github.com/example/my-entry",
|
||||
"sha": SHA_A,
|
||||
"tier": "community",
|
||||
"installed_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
plugins_cmd.cmd_update("my-entry")
|
||||
assert len(fake_core.calls) == 1
|
||||
call = fake_core.calls[0]
|
||||
assert call["ref"] == SHA_B
|
||||
assert call["force"] is True
|
||||
out = capsys.readouterr().out
|
||||
assert SHA_A[:8] in out
|
||||
assert SHA_B[:8] in out
|
||||
# Sidecar refreshed to the new pin (written into the reinstall target).
|
||||
sidecar = json.loads(
|
||||
(fake_core.target / ".hermes-catalog.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert sidecar["sha"] == SHA_B
|
||||
assert target.exists() or True # target replaced by reinstall
|
||||
|
||||
def test_update_already_at_pin_is_noop(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
_write_entry(catalog_dir, "my-entry", sha=SHA_A)
|
||||
_install_user_plugin(
|
||||
"my-entry",
|
||||
sidecar={
|
||||
"catalog_name": "my-entry",
|
||||
"repo": "https://github.com/example/my-entry",
|
||||
"sha": SHA_A,
|
||||
"tier": "community",
|
||||
"installed_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
plugins_cmd.cmd_update("my-entry")
|
||||
out = capsys.readouterr().out
|
||||
assert "already at catalog pin" in out
|
||||
assert not fake_core.calls
|
||||
|
||||
def test_update_preserves_enabled_state(
|
||||
self, catalog_dir, offline, fake_core
|
||||
):
|
||||
_write_entry(catalog_dir, "my-entry", sha=SHA_B)
|
||||
_install_user_plugin(
|
||||
"my-entry",
|
||||
sidecar={
|
||||
"catalog_name": "my-entry",
|
||||
"repo": "https://github.com/example/my-entry",
|
||||
"sha": SHA_A,
|
||||
"tier": "community",
|
||||
"installed_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
plugins_cmd._save_enabled_set({"my-entry"})
|
||||
plugins_cmd.cmd_update("my-entry")
|
||||
assert "my-entry" in plugins_cmd._get_enabled_set()
|
||||
|
||||
def test_update_without_sidecar_keeps_git_flow(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
_install_user_plugin("plain-git-plugin") # no sidecar, no .git
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_update("plain-git-plugin")
|
||||
out = capsys.readouterr().out
|
||||
assert "not installed from git" in out
|
||||
assert not fake_core.calls
|
||||
|
||||
def test_update_entry_gone_from_catalog_errors(
|
||||
self, catalog_dir, offline, fake_core, capsys
|
||||
):
|
||||
_install_user_plugin(
|
||||
"my-entry",
|
||||
sidecar={
|
||||
"catalog_name": "my-entry",
|
||||
"repo": "https://github.com/example/my-entry",
|
||||
"sha": SHA_A,
|
||||
"tier": "community",
|
||||
"installed_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_update("my-entry")
|
||||
out = capsys.readouterr().out
|
||||
assert "no longer in the catalog" in out
|
||||
|
||||
|
||||
# ── List annotations ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListAnnotations:
|
||||
def test_json_includes_catalog_annotation(
|
||||
self, catalog_dir, offline, capsys
|
||||
):
|
||||
_install_user_plugin(
|
||||
"cat-plugin",
|
||||
sidecar={
|
||||
"catalog_name": "cat-plugin",
|
||||
"repo": "https://github.com/example/cat-plugin",
|
||||
"sha": SHA_A,
|
||||
"tier": "official",
|
||||
"installed_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
args = argparse.Namespace(json=True)
|
||||
plugins_cmd.cmd_list(args)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
row = next(p for p in payload if p["name"] == "cat-plugin")
|
||||
assert row["catalog"] == f"catalog:official@{SHA_A[:8]}"
|
||||
|
||||
def test_removed_plugin_flagged(self, catalog_dir, offline, capsys):
|
||||
_install_user_plugin("evil-plugin")
|
||||
_write_removed(
|
||||
catalog_dir,
|
||||
[{"name": "evil-plugin", "reason": "exfiltrated env vars"}],
|
||||
)
|
||||
plugins_cmd.cmd_list(argparse.Namespace())
|
||||
out = capsys.readouterr().out
|
||||
assert "REMOVED from catalog" in out
|
||||
assert "exfiltrated env vars" in out
|
||||
|
||||
|
||||
# ── Live index fetch ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, *, json_data=None, text=""):
|
||||
self._json = json_data
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
def _fake_httpx_get(listing, files, counter):
|
||||
def fake_get(url, **kwargs):
|
||||
counter.append(url)
|
||||
if "api.github.com" in url:
|
||||
return _FakeResp(json_data=listing)
|
||||
fname = url.rsplit("/", 1)[-1]
|
||||
return _FakeResp(text=files[fname])
|
||||
|
||||
return fake_get
|
||||
|
||||
|
||||
class TestLiveIndex:
|
||||
def _remote_entry_yaml(self, name, sha=SHA_B):
|
||||
return yaml.safe_dump(
|
||||
{
|
||||
"name": name,
|
||||
"repo": f"https://github.com/example/{name}",
|
||||
"sha": sha,
|
||||
"description": f"Remote entry {name}.",
|
||||
"maintainer": "Example",
|
||||
}
|
||||
)
|
||||
|
||||
def test_live_fetch_populates_cache_and_entries(
|
||||
self, catalog_dir, monkeypatch
|
||||
):
|
||||
_write_entry(catalog_dir, "local-entry")
|
||||
listing = [
|
||||
{
|
||||
"name": "remote-entry.yaml",
|
||||
"download_url": "https://raw.example/remote-entry.yaml",
|
||||
},
|
||||
]
|
||||
files = {"remote-entry.yaml": self._remote_entry_yaml("remote-entry")}
|
||||
counter: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"httpx.get", _fake_httpx_get(listing, files, counter)
|
||||
)
|
||||
entries = plugin_catalog.load_catalog_live()
|
||||
names = [e.name for e in entries]
|
||||
assert names == ["remote-entry"]
|
||||
cache = get_hermes_home() / "cache" / "plugin-catalog"
|
||||
assert (cache / "remote-entry.yaml").is_file()
|
||||
|
||||
def test_network_failure_falls_back_to_in_tree(
|
||||
self, catalog_dir, monkeypatch
|
||||
):
|
||||
_write_entry(catalog_dir, "local-entry")
|
||||
|
||||
def boom(url, **kwargs):
|
||||
raise OSError("no network")
|
||||
|
||||
monkeypatch.setattr("httpx.get", boom)
|
||||
entries = plugin_catalog.load_catalog_live()
|
||||
assert [e.name for e in entries] == ["local-entry"]
|
||||
|
||||
def test_ttl_cache_skips_refetch(self, catalog_dir, monkeypatch):
|
||||
listing = [
|
||||
{
|
||||
"name": "remote-entry.yaml",
|
||||
"download_url": "https://raw.example/remote-entry.yaml",
|
||||
},
|
||||
]
|
||||
files = {"remote-entry.yaml": self._remote_entry_yaml("remote-entry")}
|
||||
counter: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"httpx.get", _fake_httpx_get(listing, files, counter)
|
||||
)
|
||||
plugin_catalog.load_catalog_live()
|
||||
first_count = len(counter)
|
||||
assert first_count >= 2 # listing + file
|
||||
|
||||
# Second call within TTL must not hit the network at all — even if
|
||||
# the network is now broken.
|
||||
def boom(url, **kwargs):
|
||||
raise AssertionError("network hit despite fresh cache")
|
||||
|
||||
monkeypatch.setattr("httpx.get", boom)
|
||||
entries = plugin_catalog.load_catalog_live()
|
||||
assert [e.name for e in entries] == ["remote-entry"]
|
||||
|
||||
|
||||
# ── search / browse / info ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearchBrowseInfo:
|
||||
def test_search_filters_entries(self, catalog_dir, offline, capsys):
|
||||
_write_entry(catalog_dir, "alpha-entry")
|
||||
_write_entry(catalog_dir, "beta-entry")
|
||||
plugins_cmd.cmd_search("alpha")
|
||||
out = capsys.readouterr().out
|
||||
assert "alpha-entry" in out
|
||||
assert "beta-entry" not in out
|
||||
|
||||
def test_browse_lists_all(self, catalog_dir, offline, capsys):
|
||||
_write_entry(catalog_dir, "alpha-entry")
|
||||
_write_entry(catalog_dir, "beta-entry")
|
||||
plugins_cmd.cmd_browse()
|
||||
out = capsys.readouterr().out
|
||||
assert "alpha-entry" in out
|
||||
assert "beta-entry" in out
|
||||
|
||||
def test_search_no_results_message(self, catalog_dir, offline, capsys):
|
||||
plugins_cmd.cmd_search("zzz-nothing")
|
||||
out = capsys.readouterr().out
|
||||
assert "No catalog entries" in out
|
||||
|
||||
def test_info_shows_full_detail(self, catalog_dir, offline, capsys):
|
||||
_write_entry(
|
||||
catalog_dir,
|
||||
"alpha-entry",
|
||||
tier="official",
|
||||
requires_hermes=">=0.19",
|
||||
docs_url="https://example.com/docs",
|
||||
platforms=["linux"],
|
||||
capabilities={
|
||||
"provides_tools": ["cool_tool"],
|
||||
"requires_env": ["ALPHA_KEY"],
|
||||
},
|
||||
)
|
||||
plugins_cmd.cmd_info("alpha-entry")
|
||||
out = capsys.readouterr().out
|
||||
assert SHA_A in out
|
||||
assert "official" in out
|
||||
assert "cool_tool" in out
|
||||
assert "ALPHA_KEY" in out
|
||||
assert ">=0.19" in out
|
||||
assert "hermes plugins install alpha-entry" in out
|
||||
|
||||
def test_info_unknown_entry_exits(self, catalog_dir, offline, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_info("ghost-entry")
|
||||
|
||||
def test_info_warns_when_removed(self, catalog_dir, offline, capsys):
|
||||
_write_entry(catalog_dir, "alpha-entry")
|
||||
_write_removed(
|
||||
catalog_dir,
|
||||
[{"name": "alpha-entry", "reason": "bad actor"}],
|
||||
)
|
||||
plugins_cmd.cmd_info("alpha-entry")
|
||||
out = capsys.readouterr().out
|
||||
assert "REMOVED" in out
|
||||
assert "bad actor" in out
|
||||
|
||||
|
||||
# ── doctor ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDoctor:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_runtime_scan(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
plugins_cmd, "_runtime_load_errors", lambda: {}
|
||||
)
|
||||
|
||||
def test_doctor_table_lists_installed_plugin(
|
||||
self, catalog_dir, offline, capsys
|
||||
):
|
||||
_install_user_plugin(
|
||||
"doc-plugin",
|
||||
sidecar={
|
||||
"catalog_name": "doc-plugin",
|
||||
"repo": "https://github.com/example/doc-plugin",
|
||||
"sha": SHA_A,
|
||||
"tier": "official",
|
||||
"installed_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
_write_entry(catalog_dir, "doc-plugin", sha=SHA_A, tier="official")
|
||||
plugins_cmd.cmd_doctor()
|
||||
out = capsys.readouterr().out
|
||||
assert "doc-plugin" in out
|
||||
assert "official" in out
|
||||
|
||||
def test_doctor_detail_flags_pin_mismatch(
|
||||
self, catalog_dir, offline, capsys
|
||||
):
|
||||
_install_user_plugin(
|
||||
"doc-plugin",
|
||||
sidecar={
|
||||
"catalog_name": "doc-plugin",
|
||||
"repo": "https://github.com/example/doc-plugin",
|
||||
"sha": SHA_A,
|
||||
"tier": "official",
|
||||
"installed_at": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
)
|
||||
_write_entry(catalog_dir, "doc-plugin", sha=SHA_B)
|
||||
plugins_cmd.cmd_doctor("doc-plugin")
|
||||
out = capsys.readouterr().out
|
||||
assert "doc-plugin" in out
|
||||
assert "behind catalog pin" in out or "pin mismatch" in out
|
||||
|
||||
def test_doctor_flags_removed_plugin(self, catalog_dir, offline, capsys):
|
||||
_install_user_plugin("evil-plugin")
|
||||
_write_removed(
|
||||
catalog_dir,
|
||||
[{"name": "evil-plugin", "reason": "exfiltrated env vars"}],
|
||||
)
|
||||
plugins_cmd.cmd_doctor("evil-plugin")
|
||||
out = capsys.readouterr().out
|
||||
assert "REMOVED" in out
|
||||
|
||||
def test_doctor_unknown_plugin_exits(self, catalog_dir, offline, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_doctor("no-such-plugin")
|
||||
|
||||
|
||||
# ── argparse dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
def _dispatch(self, monkeypatch, action, **attrs):
|
||||
recorded = {}
|
||||
|
||||
def record(fn_name):
|
||||
def _rec(*args, **kwargs):
|
||||
recorded["fn"] = fn_name
|
||||
recorded["args"] = args
|
||||
recorded["kwargs"] = kwargs
|
||||
|
||||
return _rec
|
||||
|
||||
for fn in (
|
||||
"cmd_search",
|
||||
"cmd_browse",
|
||||
"cmd_info",
|
||||
"cmd_validate",
|
||||
"cmd_doctor",
|
||||
"cmd_install",
|
||||
):
|
||||
monkeypatch.setattr(plugins_cmd, fn, record(fn))
|
||||
ns = argparse.Namespace(plugins_action=action, **attrs)
|
||||
plugins_cmd.plugins_command(ns)
|
||||
return recorded
|
||||
|
||||
def test_search_dispatch(self, monkeypatch):
|
||||
rec = self._dispatch(monkeypatch, "search", query="foo")
|
||||
assert rec["fn"] == "cmd_search"
|
||||
assert "foo" in rec["args"] or rec["kwargs"].get("query") == "foo"
|
||||
|
||||
def test_browse_dispatch(self, monkeypatch):
|
||||
rec = self._dispatch(monkeypatch, "browse")
|
||||
assert rec["fn"] == "cmd_browse"
|
||||
|
||||
def test_info_dispatch(self, monkeypatch):
|
||||
rec = self._dispatch(monkeypatch, "info", name="foo")
|
||||
assert rec["fn"] == "cmd_info"
|
||||
|
||||
def test_validate_dispatch(self, monkeypatch):
|
||||
rec = self._dispatch(monkeypatch, "validate", path="/tmp/x", json=True)
|
||||
assert rec["fn"] == "cmd_validate"
|
||||
|
||||
def test_doctor_dispatch(self, monkeypatch):
|
||||
rec = self._dispatch(monkeypatch, "doctor", name=None)
|
||||
assert rec["fn"] == "cmd_doctor"
|
||||
|
||||
def test_install_allow_removed_dispatch(self, monkeypatch):
|
||||
rec = self._dispatch(
|
||||
monkeypatch,
|
||||
"install",
|
||||
identifier="x",
|
||||
force=False,
|
||||
enable=False,
|
||||
no_enable=True,
|
||||
allow_removed=True,
|
||||
)
|
||||
assert rec["fn"] == "cmd_install"
|
||||
assert rec["kwargs"].get("allow_removed") is True
|
||||
|
||||
def test_parser_wires_new_subcommands(self):
|
||||
from hermes_cli.subcommands.plugins import build_plugins_parser
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
build_plugins_parser(sub, cmd_plugins=lambda args: None)
|
||||
for argv in (
|
||||
["plugins", "search", "foo"],
|
||||
["plugins", "browse"],
|
||||
["plugins", "info", "foo"],
|
||||
["plugins", "validate", "/tmp/x", "--json"],
|
||||
["plugins", "doctor"],
|
||||
["plugins", "install", "foo", "--allow-removed"],
|
||||
):
|
||||
args = parser.parse_args(argv)
|
||||
assert args.plugins_action == argv[1]
|
||||
@@ -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
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Behavior tests for scripts/validate_plugin_catalog.py.
|
||||
|
||||
The script is the no-install structural validator used by the plugin-catalog
|
||||
admission CI: it must run with only stdlib + pyyaml, take file paths or a
|
||||
directory, exit 0/1, and support --json machine output. These tests exercise
|
||||
the CLI contract via subprocess (the same way CI invokes it).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = REPO_ROOT / "scripts" / "validate_plugin_catalog.py"
|
||||
|
||||
VALID_ENTRY = {
|
||||
"name": "example-plugin",
|
||||
"repo": "https://github.com/NousResearch/hermes-example-plugins",
|
||||
"sha": "38fe0fb53eff98d477f807432e965429e665ca33",
|
||||
"subdir": "",
|
||||
"description": "One-line description.",
|
||||
"maintainer": "NousResearch",
|
||||
"tier": "official",
|
||||
"requires_hermes": ">=0.19",
|
||||
"docs_url": "",
|
||||
"platforms": [],
|
||||
"capabilities": {
|
||||
"provides_tools": ["example_tool"],
|
||||
"provides_hooks": [],
|
||||
"provides_middleware": [],
|
||||
"requires_env": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_entry(tmp_path: Path, data: dict, filename: str | None = None) -> Path:
|
||||
name = filename or f"{data.get('name', 'entry')}.yaml"
|
||||
path = tmp_path / name
|
||||
path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def run_validator(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
# ── valid input ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_valid_entry_passes(tmp_path):
|
||||
path = write_entry(tmp_path, VALID_ENTRY)
|
||||
result = run_validator(str(path))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_valid_entry_without_optional_fields_passes(tmp_path):
|
||||
entry = {
|
||||
"name": "minimal-plugin",
|
||||
"repo": "https://github.com/example/minimal",
|
||||
"sha": "a" * 40,
|
||||
"description": "Minimal.",
|
||||
"maintainer": "someone",
|
||||
}
|
||||
path = write_entry(tmp_path, entry)
|
||||
result = run_validator(str(path))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
# ── each malformed field fails with a pointed error ────────────────────
|
||||
|
||||
|
||||
def _expect_error(tmp_path, mutation: dict, expected_substring: str, drop: str = ""):
|
||||
entry = {**VALID_ENTRY, **mutation}
|
||||
if drop:
|
||||
entry.pop(drop, None)
|
||||
path = write_entry(tmp_path, entry, filename="entry.yaml")
|
||||
result = run_validator(str(path))
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 1, combined
|
||||
assert expected_substring in combined, combined
|
||||
assert "entry.yaml" in combined, combined
|
||||
|
||||
|
||||
def test_bad_name_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"name": "Bad Name!"}, "name")
|
||||
|
||||
|
||||
def test_name_too_long_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"name": "x" * 65}, "name")
|
||||
|
||||
|
||||
def test_non_https_repo_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"repo": "git@github.com:evil/x.git"}, "repo")
|
||||
|
||||
|
||||
def test_short_sha_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"sha": "abc123"}, "sha")
|
||||
|
||||
|
||||
def test_non_hex_sha_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"sha": "z" * 40}, "sha")
|
||||
|
||||
|
||||
def test_bad_tier_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"tier": "platinum"}, "tier")
|
||||
|
||||
|
||||
def test_empty_description_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"description": ""}, "description")
|
||||
|
||||
|
||||
def test_empty_maintainer_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"maintainer": ""}, "maintainer")
|
||||
|
||||
|
||||
def test_missing_required_field_fails(tmp_path):
|
||||
_expect_error(tmp_path, {}, "sha", drop="sha")
|
||||
|
||||
|
||||
def test_capabilities_value_not_a_list_fails(tmp_path):
|
||||
_expect_error(
|
||||
tmp_path,
|
||||
{"capabilities": {"provides_tools": "not-a-list"}},
|
||||
"provides_tools",
|
||||
)
|
||||
|
||||
|
||||
def test_capabilities_list_of_non_strings_fails(tmp_path):
|
||||
_expect_error(
|
||||
tmp_path,
|
||||
{"capabilities": {"requires_env": [1, 2]}},
|
||||
"requires_env",
|
||||
)
|
||||
|
||||
|
||||
def test_bad_requires_hermes_spec_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"requires_hermes": "banana"}, "requires_hermes")
|
||||
|
||||
|
||||
def test_comma_separated_requires_hermes_passes(tmp_path):
|
||||
entry = {**VALID_ENTRY, "requires_hermes": ">=0.19, <2.0"}
|
||||
path = write_entry(tmp_path, entry)
|
||||
result = run_validator(str(path))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_unknown_platform_fails(tmp_path):
|
||||
_expect_error(tmp_path, {"platforms": ["linux", "amiga"]}, "platforms")
|
||||
|
||||
|
||||
def test_entry_not_a_mapping_fails(tmp_path):
|
||||
path = tmp_path / "entry.yaml"
|
||||
path.write_text("- just\n- a\n- list\n", encoding="utf-8")
|
||||
result = run_validator(str(path))
|
||||
assert result.returncode == 1
|
||||
assert "mapping" in (result.stdout + result.stderr)
|
||||
|
||||
|
||||
# ── unknown top-level keys warn but do not fail ────────────────────────
|
||||
|
||||
|
||||
def test_unknown_key_warns_but_passes(tmp_path):
|
||||
entry = {**VALID_ENTRY, "future_field": "hello"}
|
||||
path = write_entry(tmp_path, entry)
|
||||
result = run_validator(str(path))
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 0, combined
|
||||
assert "future_field" in combined
|
||||
assert "warning" in combined.lower()
|
||||
|
||||
|
||||
# ── removed.yaml shape ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_valid_removed_yaml_passes(tmp_path):
|
||||
path = tmp_path / "removed.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"removed": [
|
||||
{
|
||||
"name": "some-plugin",
|
||||
"repo": "https://github.com/evil/some-plugin",
|
||||
"reason": "Exfiltrated env vars",
|
||||
"date": "2026-07-02",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = run_validator(str(path))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_removed_yaml_not_a_list_fails(tmp_path):
|
||||
path = tmp_path / "removed.yaml"
|
||||
path.write_text(yaml.safe_dump({"removed": "nope"}), encoding="utf-8")
|
||||
result = run_validator(str(path))
|
||||
assert result.returncode == 1
|
||||
assert "removed" in (result.stdout + result.stderr)
|
||||
|
||||
|
||||
def test_removed_item_missing_name_fails(tmp_path):
|
||||
path = tmp_path / "removed.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump({"removed": [{"reason": "bad", "date": "2026-01-01"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = run_validator(str(path))
|
||||
assert result.returncode == 1
|
||||
assert "name" in (result.stdout + result.stderr)
|
||||
|
||||
|
||||
# ── --json machine output ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_json_output_shape_on_failure(tmp_path):
|
||||
bad = write_entry(tmp_path, {**VALID_ENTRY, "sha": "short"}, filename="bad.yaml")
|
||||
result = run_validator("--json", str(bad))
|
||||
assert result.returncode == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is False
|
||||
assert isinstance(payload["files"], list)
|
||||
entry = next(f for f in payload["files"] if f["path"].endswith("bad.yaml"))
|
||||
assert entry["ok"] is False
|
||||
assert any("sha" in e for e in entry["errors"])
|
||||
|
||||
|
||||
def test_json_output_shape_on_success_with_warning(tmp_path):
|
||||
good = write_entry(tmp_path, {**VALID_ENTRY, "future_field": 1})
|
||||
result = run_validator("--json", str(good))
|
||||
assert result.returncode == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
(entry,) = payload["files"]
|
||||
assert entry["ok"] is True
|
||||
assert entry["errors"] == []
|
||||
assert any("future_field" in w for w in entry["warnings"])
|
||||
|
||||
|
||||
# ── directory mode ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_directory_mode_validates_all_entries_and_removed(tmp_path):
|
||||
write_entry(tmp_path, VALID_ENTRY)
|
||||
write_entry(tmp_path, {**VALID_ENTRY, "name": "bad-one", "sha": "nope"})
|
||||
(tmp_path / "removed.yaml").write_text(
|
||||
yaml.safe_dump({"removed": [{"name": "gone", "reason": "test"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = run_validator(str(tmp_path))
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode == 1
|
||||
assert "bad-one.yaml" in combined
|
||||
# the valid entry and removed.yaml must not produce errors
|
||||
assert combined.count("ERROR") == combined.count("bad-one.yaml: ERROR")
|
||||
|
||||
|
||||
def test_directory_mode_all_valid_exits_zero(tmp_path):
|
||||
write_entry(tmp_path, VALID_ENTRY)
|
||||
(tmp_path / "removed.yaml").write_text(
|
||||
yaml.safe_dump({"removed": []}), encoding="utf-8"
|
||||
)
|
||||
result = run_validator(str(tmp_path))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for website/scripts/extract-plugins.py.
|
||||
|
||||
Behavioral contracts for the /docs/plugins catalog extractor:
|
||||
|
||||
1. Reads ``plugin-catalog/*.yaml`` entries (skipping ``removed.yaml``) and
|
||||
emits ``plugins.json`` rows carrying name/repo/sha/tier/capabilities plus
|
||||
a synthesized ``hermes plugins install <name>`` command.
|
||||
2. Entries missing any of name/repo/sha are skipped (logged, not fatal).
|
||||
3. A missing ``plugin-catalog/`` directory degrades gracefully: empty
|
||||
catalog list, zero counts in the meta sidecar, exit 0 — the docs build
|
||||
must stay green before the catalog directory lands on main.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
EXTRACT = REPO_ROOT / "website" / "scripts" / "extract-plugins.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mod():
|
||||
spec = importlib.util.spec_from_file_location("extract_plugins", EXTRACT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path:
|
||||
import yaml
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"repo": f"https://github.com/example/{name}",
|
||||
"sha": "38fe0fb53eff98d477f807432e965429e665ca33",
|
||||
"description": f"{name} does things.",
|
||||
"maintainer": "Example",
|
||||
"tier": "community",
|
||||
}
|
||||
entry.update(overrides)
|
||||
# Drop keys explicitly set to None so tests can simulate missing fields.
|
||||
entry = {k: v for k, v in entry.items() if v is not None}
|
||||
path = catalog_dir / f"{name}.yaml"
|
||||
path.write_text(yaml.safe_dump(entry), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Entry loading + validation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_valid_entry_is_extracted_with_install_command(mod, tmp_path):
|
||||
catalog = tmp_path / "plugin-catalog"
|
||||
catalog.mkdir()
|
||||
_write_entry(
|
||||
catalog,
|
||||
"example-plugin",
|
||||
tier="official",
|
||||
docs_url="https://example.com/docs",
|
||||
requires_hermes=">=0.19",
|
||||
platforms=["linux"],
|
||||
capabilities={
|
||||
"provides_tools": ["do_thing"],
|
||||
"provides_hooks": ["on_start"],
|
||||
"provides_middleware": [],
|
||||
"requires_env": ["EXAMPLE_TOKEN"],
|
||||
},
|
||||
)
|
||||
|
||||
entries = mod.load_catalog_entries(catalog)
|
||||
|
||||
assert len(entries) == 1
|
||||
e = entries[0]
|
||||
assert e["name"] == "example-plugin"
|
||||
assert e["repo"] == "https://github.com/example/example-plugin"
|
||||
assert e["sha"] == "38fe0fb53eff98d477f807432e965429e665ca33"
|
||||
assert e["shaShort"] == "38fe0fb"
|
||||
assert e["tier"] == "official"
|
||||
assert e["maintainer"] == "Example"
|
||||
assert e["requiresHermes"] == ">=0.19"
|
||||
assert e["platforms"] == ["linux"]
|
||||
assert e["docsUrl"] == "https://example.com/docs"
|
||||
assert e["capabilities"]["providesTools"] == ["do_thing"]
|
||||
assert e["capabilities"]["providesHooks"] == ["on_start"]
|
||||
assert e["capabilities"]["requiresEnv"] == ["EXAMPLE_TOKEN"]
|
||||
assert e["installCommand"] == "hermes plugins install example-plugin"
|
||||
|
||||
|
||||
def test_entries_missing_required_fields_are_skipped(mod, tmp_path, capsys):
|
||||
catalog = tmp_path / "plugin-catalog"
|
||||
catalog.mkdir()
|
||||
_write_entry(catalog, "good-plugin")
|
||||
_write_entry(catalog, "no-sha", sha=None)
|
||||
_write_entry(catalog, "no-repo", repo=None)
|
||||
|
||||
entries = mod.load_catalog_entries(catalog)
|
||||
|
||||
assert [e["name"] for e in entries] == ["good-plugin"]
|
||||
err = capsys.readouterr().err
|
||||
assert "no-sha" in err
|
||||
assert "no-repo" in err
|
||||
|
||||
|
||||
def test_removed_yaml_is_not_treated_as_an_entry(mod, tmp_path):
|
||||
catalog = tmp_path / "plugin-catalog"
|
||||
catalog.mkdir()
|
||||
_write_entry(catalog, "kept-plugin")
|
||||
(catalog / "removed.yaml").write_text(
|
||||
"removed:\n - name: evil-plugin\n repo: https://github.com/evil/x\n"
|
||||
' reason: "bad"\n date: "2026-07-02"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
entries = mod.load_catalog_entries(catalog)
|
||||
assert [e["name"] for e in entries] == ["kept-plugin"]
|
||||
assert mod.count_removed(catalog) == 1
|
||||
|
||||
|
||||
def test_unknown_tier_normalizes_to_community(mod, tmp_path):
|
||||
catalog = tmp_path / "plugin-catalog"
|
||||
catalog.mkdir()
|
||||
_write_entry(catalog, "weird-tier", tier="platinum")
|
||||
|
||||
entries = mod.load_catalog_entries(catalog)
|
||||
assert entries[0]["tier"] == "community"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Full run: outputs + graceful degradation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_main_writes_catalog_and_meta(mod, tmp_path):
|
||||
catalog = tmp_path / "plugin-catalog"
|
||||
catalog.mkdir()
|
||||
_write_entry(catalog, "alpha", tier="official")
|
||||
_write_entry(catalog, "beta")
|
||||
(catalog / "removed.yaml").write_text(
|
||||
"removed:\n - name: gone\n", encoding="utf-8"
|
||||
)
|
||||
out_dir = tmp_path / "api"
|
||||
|
||||
rc = mod.main(catalog_dir=catalog, output_dir=out_dir)
|
||||
|
||||
assert rc == 0
|
||||
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
|
||||
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
|
||||
assert [p["name"] for p in plugins] == ["alpha", "beta"]
|
||||
assert meta["total"] == 2
|
||||
assert meta["byTier"] == {"official": 1, "community": 1}
|
||||
assert meta["removedCount"] == 1
|
||||
assert meta["generatedAt"]
|
||||
|
||||
|
||||
def test_missing_catalog_dir_degrades_to_empty_outputs_exit_zero(mod, tmp_path):
|
||||
out_dir = tmp_path / "api"
|
||||
|
||||
rc = mod.main(catalog_dir=tmp_path / "does-not-exist", output_dir=out_dir)
|
||||
|
||||
assert rc == 0
|
||||
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
|
||||
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
|
||||
assert plugins == []
|
||||
assert meta["total"] == 0
|
||||
assert meta["byTier"] == {"official": 0, "community": 0}
|
||||
assert meta["removedCount"] == 0
|
||||
|
||||
|
||||
def test_script_exits_zero_as_subprocess_when_catalog_missing(tmp_path):
|
||||
"""CLI contract: the deploy step runs the script hard (no `|| true`);
|
||||
it must exit 0 even when plugin-catalog/ hasn't landed yet."""
|
||||
out_dir = tmp_path / "api"
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(EXTRACT),
|
||||
"--catalog-dir",
|
||||
str(tmp_path / "missing"),
|
||||
"--output-dir",
|
||||
str(out_dir),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (out_dir / "plugins.json").exists()
|
||||
assert (out_dir / "plugins-meta.json").exists()
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
sidebar_position: 13
|
||||
sidebar_label: "Plugin Catalog"
|
||||
title: "Plugin Catalog"
|
||||
description: "Browse and install reviewed, SHA-pinned Hermes plugins from the curated catalog"
|
||||
---
|
||||
|
||||
# Plugin Catalog
|
||||
|
||||
The plugin catalog is a curated, human-reviewed directory of Hermes plugins you
|
||||
can install by name with a single command:
|
||||
|
||||
```bash
|
||||
hermes plugins install <name>
|
||||
```
|
||||
|
||||
Browse it visually at **[/docs/plugins](/plugins)** — search, tier filters
|
||||
(Official / Community), capability chips, and copyable install commands for
|
||||
every entry.
|
||||
|
||||
The catalog complements — it does not replace — the existing
|
||||
[plugin system](plugins.md). Anything you can install from the catalog is a
|
||||
normal plugin under the hood; the catalog just adds discovery and a review
|
||||
layer on top.
|
||||
|
||||
## What's in an entry
|
||||
|
||||
Each catalog entry is a small YAML file in the
|
||||
[`plugin-catalog/`](https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog)
|
||||
directory of the hermes-agent repository, declaring:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `name` | The catalog key you pass to `hermes plugins install` |
|
||||
| `repo` | The plugin's public git repository |
|
||||
| `sha` | The **exact 40-hex commit** that was reviewed — installs check out this pin, not a branch tip |
|
||||
| `tier` | `official` (maintained by NousResearch) or `community` |
|
||||
| `maintainer` | Who owns the plugin |
|
||||
| `capabilities` | Declared tools, hooks, middleware, and required env vars |
|
||||
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) |
|
||||
| `platforms` | OS restrictions, empty = all (optional) |
|
||||
| `docs_url` | External documentation link (optional) |
|
||||
|
||||
## Trust model
|
||||
|
||||
The catalog is designed so you know exactly what you're installing:
|
||||
|
||||
- **Human-merged admission.** Every entry (and every pin update) lands via a
|
||||
pull request reviewed by a maintainer. Nothing enters the catalog
|
||||
automatically.
|
||||
- **Exact SHA pins.** Entries pin a specific commit, not a branch. A plugin
|
||||
author pushing new code to their repo does **not** change what the catalog
|
||||
installs — updating the pin requires another reviewed PR.
|
||||
- **Capability declarations.** Entries state up front which tools, hooks, and
|
||||
middleware the plugin provides and which environment variables (API keys
|
||||
etc.) it needs, so you can judge its blast radius before installing.
|
||||
- **Removed list.** Plugins pulled from the catalog (for example after a
|
||||
security incident) go on `plugin-catalog/removed.yaml` with a reason and
|
||||
date. The installer refuses to install anything on the removed list.
|
||||
- **Installed ≠ enabled.** Installing a catalog plugin puts it on disk; like
|
||||
any plugin it must still be enabled before it loads. See
|
||||
[Plugins → Enabling and disabling](plugins.md).
|
||||
|
||||
:::warning Catalog review is a point-in-time review
|
||||
A catalog entry means the pinned commit was looked at by a human, capability
|
||||
declarations were checked, and the repo met the submission bar. It is not a
|
||||
security audit, and it says nothing about other commits in the same
|
||||
repository. Review the code of anything you give credentials to.
|
||||
:::
|
||||
|
||||
## Installing from the catalog
|
||||
|
||||
```bash
|
||||
# Install a reviewed catalog entry by name (checks out the pinned SHA)
|
||||
hermes plugins install <name>
|
||||
|
||||
# Then enable it, as with any plugin
|
||||
hermes plugins enable <name>
|
||||
```
|
||||
|
||||
The install prompt shows the entry's capability summary — declared tools,
|
||||
hooks, and required env vars — before anything is cloned.
|
||||
|
||||
### Custom git URLs are different
|
||||
|
||||
`hermes plugins install <git-url>` still works for any repository, but it
|
||||
bypasses the catalog entirely:
|
||||
|
||||
- **No review** — you get whatever is at the branch tip, not a reviewed pin.
|
||||
- **A warning banner** is shown to make clear the code is unvetted.
|
||||
- The removed list is still consulted (a known-bad repo is refused by URL).
|
||||
|
||||
Use the git-URL path for your own plugins and repos you already trust; use the
|
||||
catalog for discovery.
|
||||
|
||||
## Submitting a plugin to the catalog
|
||||
|
||||
Submissions are pull requests that add one `plugin-catalog/<name>.yaml` file.
|
||||
The full checklist lives in the
|
||||
[plugin-catalog README](https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog);
|
||||
in short, an entry must be:
|
||||
|
||||
1. **Owner-submitted** — the PR author owns or maintains the plugin repo.
|
||||
2. **A public repository** — the `repo` URL is publicly cloneable.
|
||||
3. **Released** — the repo has real releases/tags, not just a default branch.
|
||||
4. **Passing validation** — the catalog validation GitHub Action is green on
|
||||
the PR (schema, SHA format, reachability).
|
||||
5. **Pinned to settled code** — the pinned SHA is at least **2 weeks old**, so
|
||||
the catalog never points at code pushed moments before review.
|
||||
|
||||
Pin updates (bumping `sha` to a newer commit) follow the same PR + review
|
||||
process.
|
||||
|
||||
## See also
|
||||
|
||||
- [Plugins](plugins.md) — the plugin system itself: manifest format, enabling,
|
||||
configuration
|
||||
- [Built-in Plugins](built-in-plugins.md) — plugins that ship with Hermes
|
||||
- [Build a Hermes Plugin](/developer-guide/plugins) — write your own
|
||||
- [Plugin Catalog page](/plugins) — the browsable catalog
|
||||
@@ -144,6 +144,11 @@ const config: Config = {
|
||||
label: 'Skills',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: '/plugins',
|
||||
label: 'Plugins',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
href: 'https://hermes-agent.nousresearch.com/',
|
||||
label: 'Download',
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract plugin-catalog entries into website/static/api/plugins.json.
|
||||
|
||||
Feeds the Plugin Catalog page at /docs/plugins (website/src/pages/plugins/).
|
||||
|
||||
Data source: ``plugin-catalog/*.yaml`` at the repo root — one YAML file per
|
||||
catalog entry (see plugin-catalog/README.md for the entry schema), plus
|
||||
``plugin-catalog/removed.yaml`` listing plugins pulled from the catalog.
|
||||
No network, no crawling: the catalog is human-merged data in the checkout.
|
||||
|
||||
Graceful degradation: when ``plugin-catalog/`` does not exist yet (the
|
||||
catalog PR may not have merged), we emit an EMPTY catalog list and zeroed
|
||||
meta counts and exit 0 so the docs build stays green. The page renders a
|
||||
"catalog is just getting started" state.
|
||||
|
||||
Outputs (both under website/static/api/, CDN-served at /docs/api/):
|
||||
|
||||
- ``plugins.json`` — list of catalog entries for the page
|
||||
- ``plugins-meta.json`` — counts by tier + generatedAt + removedCount
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog"
|
||||
DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api"
|
||||
|
||||
CATALOG_TIERS = ("official", "community")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[extract-plugins] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def _str_list(value) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
if isinstance(value, list):
|
||||
return [str(x) for x in value if x]
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_capabilities(raw) -> dict:
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
return {
|
||||
"providesTools": _str_list(raw.get("provides_tools")),
|
||||
"providesHooks": _str_list(raw.get("provides_hooks")),
|
||||
"providesMiddleware": _str_list(raw.get("provides_middleware")),
|
||||
"requiresEnv": _str_list(raw.get("requires_env")),
|
||||
}
|
||||
|
||||
|
||||
def load_catalog_entries(catalog_dir: Path) -> list[dict]:
|
||||
"""Parse all ``*.yaml`` files (except removed.yaml) into page entries.
|
||||
|
||||
Entries missing any of name/repo/sha are skipped with a stderr log —
|
||||
a malformed community entry must never break the docs deploy.
|
||||
"""
|
||||
entries: list[dict] = []
|
||||
if not catalog_dir.is_dir():
|
||||
return entries
|
||||
|
||||
for path in sorted(catalog_dir.glob("*.yaml")):
|
||||
if path.name == "removed.yaml":
|
||||
continue
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError) as e:
|
||||
_log(f"skipping {path.name}: unreadable YAML ({e})")
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
_log(f"skipping {path.name}: not a mapping")
|
||||
continue
|
||||
|
||||
name = str(raw.get("name") or "").strip()
|
||||
repo = str(raw.get("repo") or "").strip()
|
||||
sha = str(raw.get("sha") or "").strip().lower()
|
||||
missing = [
|
||||
field
|
||||
for field, value in (("name", name), ("repo", repo), ("sha", sha))
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
_log(f"skipping {path.name}: missing required field(s) {', '.join(missing)}")
|
||||
continue
|
||||
if not SHA_RE.match(sha):
|
||||
_log(f"skipping {path.name} ({name}): sha is not a 40-hex commit pin")
|
||||
continue
|
||||
|
||||
tier = str(raw.get("tier") or "community").strip().lower()
|
||||
if tier not in CATALOG_TIERS:
|
||||
_log(f"{path.name} ({name}): unknown tier {tier!r}, treating as community")
|
||||
tier = "community"
|
||||
|
||||
entries.append({
|
||||
"name": name,
|
||||
"description": str(raw.get("description") or "").strip(),
|
||||
"repo": repo,
|
||||
"sha": sha,
|
||||
"shaShort": sha[:7],
|
||||
"tier": tier,
|
||||
"maintainer": str(raw.get("maintainer") or "").strip(),
|
||||
"requiresHermes": str(raw.get("requires_hermes") or "").strip(),
|
||||
"platforms": _str_list(raw.get("platforms")),
|
||||
"capabilities": _normalize_capabilities(raw.get("capabilities")),
|
||||
"docsUrl": str(raw.get("docs_url") or "").strip(),
|
||||
"installCommand": f"hermes plugins install {name}",
|
||||
})
|
||||
|
||||
entries.sort(key=lambda e: (0 if e["tier"] == "official" else 1, e["name"]))
|
||||
return entries
|
||||
|
||||
|
||||
def count_removed(catalog_dir: Path) -> int:
|
||||
"""Number of entries in plugin-catalog/removed.yaml (``removed:`` list)."""
|
||||
removed_path = catalog_dir / "removed.yaml"
|
||||
if not removed_path.is_file():
|
||||
return 0
|
||||
try:
|
||||
raw = yaml.safe_load(removed_path.read_text(encoding="utf-8"))
|
||||
except (yaml.YAMLError, OSError) as e:
|
||||
_log(f"could not read removed.yaml: {e}")
|
||||
return 0
|
||||
if not isinstance(raw, dict):
|
||||
return 0
|
||||
removed = raw.get("removed")
|
||||
return len(removed) if isinstance(removed, list) else 0
|
||||
|
||||
|
||||
def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUTPUT_DIR) -> int:
|
||||
if not catalog_dir.is_dir():
|
||||
_log(
|
||||
f"plugin-catalog directory not found at {catalog_dir}; "
|
||||
"emitting empty catalog (this is expected until the catalog lands)"
|
||||
)
|
||||
|
||||
entries = load_catalog_entries(catalog_dir)
|
||||
removed_count = count_removed(catalog_dir)
|
||||
|
||||
by_tier = Counter(e["tier"] for e in entries)
|
||||
meta = {
|
||||
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"total": len(entries),
|
||||
"byTier": {tier: by_tier.get(tier, 0) for tier in CATALOG_TIERS},
|
||||
"removedCount": removed_count,
|
||||
}
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_dir / "plugins.json", "w", encoding="utf-8") as f:
|
||||
json.dump(entries, f, separators=(",", ":"), ensure_ascii=False)
|
||||
with open(output_dir / "plugins-meta.json", "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
print(
|
||||
f"Extracted {len(entries)} plugin catalog entries "
|
||||
f"({meta['byTier']['official']} official, {meta['byTier']['community']} community, "
|
||||
f"{removed_count} removed) to {output_dir / 'plugins.json'}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--catalog-dir", type=Path, default=DEFAULT_CATALOG_DIR)
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||
args = parser.parse_args()
|
||||
sys.exit(main(catalog_dir=args.catalog_dir, output_dir=args.output_dir))
|
||||
@@ -32,7 +32,10 @@ const websiteDir = resolve(scriptDir, "..");
|
||||
const extractScript = join(scriptDir, "extract-skills.py");
|
||||
const llmsScript = join(scriptDir, "generate-llms-txt.py");
|
||||
const cronBlueprintsScript = join(scriptDir, "extract-automation-blueprints.py");
|
||||
const pluginsScript = join(scriptDir, "extract-plugins.py");
|
||||
const outputFile = join(websiteDir, "static", "api", "skills.json");
|
||||
const pluginsOutputFile = join(websiteDir, "static", "api", "plugins.json");
|
||||
const pluginsMetaOutputFile = join(websiteDir, "static", "api", "plugins-meta.json");
|
||||
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
|
||||
const UNIFIED_INDEX_URL =
|
||||
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json";
|
||||
@@ -143,3 +146,22 @@ runPython(llmsScript, "generate-llms-txt.py");
|
||||
// 3) automation-blueprints-index.json — Automation Blueprints catalog page. Non-fatal; the page
|
||||
// renders an empty state if the generator can't run.
|
||||
runPython(cronBlueprintsScript, "extract-automation-blueprints.py");
|
||||
|
||||
// 4) plugins.json + plugins-meta.json — Plugin Catalog page. The script itself
|
||||
// degrades gracefully (empty catalog, exit 0) when plugin-catalog/ is absent;
|
||||
// if python3 is missing entirely, write the same empty fallback so the page
|
||||
// renders its "just getting started" state instead of a fetch error.
|
||||
if (!runPython(pluginsScript, "extract-plugins.py")) {
|
||||
mkdirSync(dirname(pluginsOutputFile), { recursive: true });
|
||||
writeFileSync(pluginsOutputFile, "[]\n");
|
||||
writeFileSync(
|
||||
pluginsMetaOutputFile,
|
||||
JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
total: 0,
|
||||
byTier: { official: 0, community: 0 },
|
||||
removedCount: 0,
|
||||
}) + "\n",
|
||||
);
|
||||
console.warn("[prebuild] wrote empty plugins.json fallback");
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/features/skins',
|
||||
'user-guide/features/plugins',
|
||||
'user-guide/features/built-in-plugins',
|
||||
'user-guide/features/plugin-catalog',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
import Link from "@docusaurus/Link";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface PluginCapabilities {
|
||||
providesTools?: string[];
|
||||
providesHooks?: string[];
|
||||
providesMiddleware?: string[];
|
||||
requiresEnv?: string[];
|
||||
}
|
||||
|
||||
interface CatalogPlugin {
|
||||
name: string;
|
||||
description: string;
|
||||
repo: string;
|
||||
sha: string;
|
||||
shaShort: string;
|
||||
tier: string;
|
||||
maintainer: string;
|
||||
requiresHermes?: string;
|
||||
platforms?: string[];
|
||||
capabilities?: PluginCapabilities;
|
||||
docsUrl?: string;
|
||||
installCommand: string;
|
||||
/** Lowercase pre-joined haystack for the search filter (built at load). */
|
||||
_search?: string;
|
||||
}
|
||||
|
||||
interface CatalogMeta {
|
||||
generatedAt?: string;
|
||||
total?: number;
|
||||
byTier?: Record<string, number>;
|
||||
removedCount?: number;
|
||||
}
|
||||
|
||||
// Routes Docusaurus serves the static API JSON from. `baseUrl` is `/docs/`,
|
||||
// `static/api/` ends up at `/docs/api/` — same pattern as the Skills Hub.
|
||||
const PLUGINS_URL = "/docs/api/plugins.json";
|
||||
const META_URL = "/docs/api/plugins-meta.json";
|
||||
|
||||
const CATALOG_README_URL =
|
||||
"https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog";
|
||||
|
||||
const TIER_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; color: string; bg: string; border: string; icon: string }
|
||||
> = {
|
||||
official: {
|
||||
label: "Official",
|
||||
color: "#ffd700",
|
||||
bg: "rgba(255, 215, 0, 0.08)",
|
||||
border: "rgba(255, 215, 0, 0.25)",
|
||||
icon: "\u{2713}",
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
color: "#94a3b8",
|
||||
bg: "rgba(148, 163, 184, 0.08)",
|
||||
border: "rgba(148, 163, 184, 0.2)",
|
||||
icon: "\u{2756}",
|
||||
},
|
||||
};
|
||||
|
||||
const TIER_ORDER = ["all", "official", "community"];
|
||||
|
||||
function formatRelativeTime(iso?: string): string | null {
|
||||
if (!iso) return null;
|
||||
const then = new Date(iso).getTime();
|
||||
if (!Number.isFinite(then)) return null;
|
||||
const diffMs = Date.now() - then;
|
||||
if (diffMs < 0) return "just now";
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months} month${months === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query || !text) return text;
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className={styles.highlight}>{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
},
|
||||
[text],
|
||||
);
|
||||
return (
|
||||
<button
|
||||
className={styles.copyBtn}
|
||||
onClick={onCopy}
|
||||
title="Copy install command"
|
||||
aria-label="Copy install command"
|
||||
>
|
||||
{copied ? (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
|
||||
<path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.12A1.5 1.5 0 0117 6.622V12.5a1.5 1.5 0 01-1.5 1.5h-1v-3.379a3 3 0 00-.879-2.121L10.5 5.379A3 3 0 008.379 4.5H7v-1z" />
|
||||
<path d="M4.5 6A1.5 1.5 0 003 7.5v9A1.5 1.5 0 004.5 18h7a1.5 1.5 0 001.5-1.5v-5.879a1.5 1.5 0 00-.44-1.06L9.44 6.439A1.5 1.5 0 008.378 6H4.5z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={styles.copyBtnLabel}>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginCard({
|
||||
plugin,
|
||||
query,
|
||||
expanded,
|
||||
onToggle,
|
||||
style,
|
||||
}: {
|
||||
plugin: CatalogPlugin;
|
||||
query: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const tier = TIER_CONFIG[plugin.tier] || TIER_CONFIG.community;
|
||||
const caps = plugin.capabilities || {};
|
||||
const toolCount = caps.providesTools?.length || 0;
|
||||
const hookCount = caps.providesHooks?.length || 0;
|
||||
const middlewareCount = caps.providesMiddleware?.length || 0;
|
||||
const pinUrl = `${plugin.repo.replace(/\.git$/, "").replace(/\/$/, "")}/tree/${plugin.sha}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.card} ${expanded ? styles.cardExpanded : ""}`}
|
||||
onClick={onToggle}
|
||||
style={style}
|
||||
>
|
||||
<div className={styles.cardAccent} style={{ background: tier.color }} />
|
||||
|
||||
<div className={styles.cardInner}>
|
||||
<div className={styles.cardTop}>
|
||||
<span className={styles.cardIcon}>{"\u{1F50C}"}</span>
|
||||
<div className={styles.cardTitleGroup}>
|
||||
<h3 className={styles.cardTitle}>{highlightMatch(plugin.name, query)}</h3>
|
||||
<span
|
||||
className={styles.tierPill}
|
||||
style={{
|
||||
color: tier.color,
|
||||
background: tier.bg,
|
||||
borderColor: tier.border,
|
||||
}}
|
||||
>
|
||||
{tier.icon} {tier.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={`${styles.cardDesc} ${expanded ? styles.cardDescFull : ""}`}>
|
||||
{highlightMatch(plugin.description || "No description available.", query)}
|
||||
</p>
|
||||
|
||||
<div className={styles.cardMeta}>
|
||||
{toolCount > 0 && (
|
||||
<span className={styles.capChip}>
|
||||
{toolCount} tool{toolCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{hookCount > 0 && (
|
||||
<span className={styles.capChip}>
|
||||
{hookCount} hook{hookCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{middlewareCount > 0 && (
|
||||
<span className={styles.capChip}>
|
||||
{middlewareCount} middleware
|
||||
</span>
|
||||
)}
|
||||
{caps.requiresEnv?.map((v) => (
|
||||
<code key={v} className={styles.envChip}>
|
||||
{v}
|
||||
</code>
|
||||
))}
|
||||
{plugin.platforms?.map((p) => (
|
||||
<span key={p} className={styles.platformPill}>
|
||||
{p === "macos" ? "\u{F8FF} macOS" : p === "linux" ? "\u{1F427} Linux" : p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.cardDetail}>
|
||||
{plugin.maintainer && (
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaLabel}>Maintainer</span>
|
||||
<span className={styles.metaValue}>{plugin.maintainer}</span>
|
||||
</div>
|
||||
)}
|
||||
{plugin.requiresHermes && (
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaLabel}>Requires</span>
|
||||
<span className={styles.metaValue}>
|
||||
<code>hermes {plugin.requiresHermes}</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaLabel}>Pinned</span>
|
||||
<span className={styles.metaValue}>
|
||||
<a
|
||||
href={pinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={styles.shaLink}
|
||||
title={plugin.sha}
|
||||
>
|
||||
<code>{plugin.shaShort}</code> ↗
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{caps.providesTools?.length ? (
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaLabel}>Tools</span>
|
||||
<span className={styles.chipList}>
|
||||
{caps.providesTools.map((t) => (
|
||||
<code key={t} className={styles.envChip}>
|
||||
{t}
|
||||
</code>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.installHint}>
|
||||
<code>{plugin.installCommand}</code>
|
||||
<CopyButton text={plugin.installCommand} />
|
||||
</div>
|
||||
<div className={styles.cardLinks}>
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={plugin.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Repository ↗
|
||||
</a>
|
||||
{plugin.docsUrl ? (
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={plugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Documentation ↗
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue} style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
<span className={styles.statLabel}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSearchHaystack(p: CatalogPlugin): string {
|
||||
return [
|
||||
p.name,
|
||||
p.description,
|
||||
p.maintainer,
|
||||
p.tier,
|
||||
...(p.capabilities?.providesTools || []),
|
||||
...(p.capabilities?.providesHooks || []),
|
||||
...(p.capabilities?.requiresEnv || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export default function PluginCatalogPage() {
|
||||
const [data, setData] = useState<{ plugins: CatalogPlugin[]; meta: CatalogMeta } | null>(
|
||||
null,
|
||||
);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [tierFilter, setTierFilter] = useState("all");
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [pl, mt] = await Promise.all([
|
||||
fetch(PLUGINS_URL).then((r) => {
|
||||
if (!r.ok) throw new Error(`plugins.json HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
fetch(META_URL).then((r) => (r.ok ? r.json() : {})).catch(() => ({})),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const arr = Array.isArray(pl) ? (pl as CatalogPlugin[]) : [];
|
||||
for (const p of arr) p._search = buildSearchHaystack(p);
|
||||
setData({ plugins: arr, meta: mt || {} });
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setLoadError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
searchRef.current?.blur();
|
||||
setExpandedCard(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const allPlugins: CatalogPlugin[] = data?.plugins ?? [];
|
||||
const meta: CatalogMeta = data?.meta ?? {};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return allPlugins.filter((p) => {
|
||||
if (tierFilter !== "all" && p.tier !== tierFilter) return false;
|
||||
if (q) return (p._search || "").includes(q);
|
||||
return true;
|
||||
});
|
||||
}, [search, tierFilter, allPlugins]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedCard(null);
|
||||
}, [search, tierFilter]);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setSearch("");
|
||||
setTierFilter("all");
|
||||
}, []);
|
||||
|
||||
const catalogEmpty = data !== null && allPlugins.length === 0;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Plugin Catalog"
|
||||
description="Browse reviewed, SHA-pinned plugins for Hermes Agent"
|
||||
>
|
||||
<div className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div className={styles.heroGlow} />
|
||||
<div className={styles.heroContent}>
|
||||
<p className={styles.heroEyebrow}>Hermes Agent</p>
|
||||
<h1 className={styles.heroTitle}>Plugin Catalog</h1>
|
||||
<nav className={styles.crossNav} aria-label="Catalog pages">
|
||||
<Link className={styles.crossNavLink} to="/skills">
|
||||
Skills
|
||||
</Link>
|
||||
<span className={`${styles.crossNavLink} ${styles.crossNavActive}`}>
|
||||
Plugins
|
||||
</span>
|
||||
</nav>
|
||||
<p className={styles.heroSub}>
|
||||
Reviewed, SHA-pinned plugins you can install with one command.
|
||||
{loadError && (
|
||||
<span style={{ color: "#f87171", marginLeft: 8 }}>
|
||||
· failed to load catalog ({loadError})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{meta.generatedAt && !catalogEmpty && (
|
||||
<p className={styles.heroSub} style={{ fontSize: "0.85rem", opacity: 0.75 }}>
|
||||
Catalog refreshed{" "}
|
||||
<span title={meta.generatedAt}>
|
||||
{formatRelativeTime(meta.generatedAt) || "recently"}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!catalogEmpty && (
|
||||
<div className={styles.statsRow}>
|
||||
<StatCard
|
||||
value={allPlugins.filter((p) => p.tier === "official").length}
|
||||
label="Official"
|
||||
color="#ffd700"
|
||||
/>
|
||||
<StatCard
|
||||
value={allPlugins.filter((p) => p.tier === "community").length}
|
||||
label="Community"
|
||||
color="#94a3b8"
|
||||
/>
|
||||
<StatCard value={meta.removedCount ?? 0} label="Removed" color="#f87171" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!catalogEmpty && (
|
||||
<div className={styles.controlsBar}>
|
||||
<div className={styles.searchWrap}>
|
||||
<svg
|
||||
className={styles.searchIcon}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder='Search plugins... (press "/" to focus)'
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{search && (
|
||||
<button className={styles.clearBtn} onClick={() => setSearch("")}>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.tierPills}>
|
||||
{TIER_ORDER.map((tier) => {
|
||||
const active = tierFilter === tier;
|
||||
const conf = TIER_CONFIG[tier];
|
||||
const count =
|
||||
tier === "all"
|
||||
? allPlugins.length
|
||||
: allPlugins.filter((p) => p.tier === tier).length;
|
||||
return (
|
||||
<button
|
||||
key={tier}
|
||||
className={`${styles.tierBtn} ${active ? styles.tierBtnActive : ""}`}
|
||||
onClick={() => setTierFilter(tier)}
|
||||
style={
|
||||
active && conf
|
||||
? ({
|
||||
"--pill-color": conf.color,
|
||||
"--pill-bg": conf.bg,
|
||||
"--pill-border": conf.border,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tier === "all" ? "All" : conf?.label || tier}
|
||||
<span className={styles.tierCount}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className={styles.main}>
|
||||
{!data && !loadError ? (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.loadingSpinner} />
|
||||
<h3 className={styles.emptyTitle}>Loading the catalog…</h3>
|
||||
</div>
|
||||
) : catalogEmpty ? (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>{"\u{1F331}"}</div>
|
||||
<h3 className={styles.emptyTitle}>The catalog is just getting started</h3>
|
||||
<p className={styles.emptyDesc}>
|
||||
The plugin catalog is a curated, human-reviewed list of Hermes
|
||||
plugins — each entry pinned to an exact commit. Want yours listed?
|
||||
Submissions are open.
|
||||
</p>
|
||||
<div className={styles.emptyActions}>
|
||||
<a
|
||||
className={styles.emptyCta}
|
||||
href={CATALOG_README_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
How to submit a plugin ↗
|
||||
</a>
|
||||
<Link className={styles.emptyCtaSecondary} to="/user-guide/features/plugin-catalog">
|
||||
Read the catalog docs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : filtered.length > 0 ? (
|
||||
<div className={styles.grid}>
|
||||
{filtered.map((plugin, i) => {
|
||||
const key = `${plugin.tier}-${plugin.name}`;
|
||||
return (
|
||||
<PluginCard
|
||||
key={key}
|
||||
plugin={plugin}
|
||||
query={search}
|
||||
expanded={expandedCard === key}
|
||||
onToggle={() => setExpandedCard(expandedCard === key ? null : key)}
|
||||
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>{"\u{1F50D}"}</div>
|
||||
<h3 className={styles.emptyTitle}>No plugins found</h3>
|
||||
<p className={styles.emptyDesc}>
|
||||
Try a different search term or clear your filters.
|
||||
</p>
|
||||
<button className={styles.emptyReset} onClick={clearAll}>
|
||||
Reset all filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
|
||||
.page {
|
||||
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 4rem 2rem 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroGlow {
|
||||
position: absolute;
|
||||
top: -120px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(255, 215, 0, 0.07) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.heroEyebrow {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 215, 0, 0.5);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .heroTitle {
|
||||
color: #fafaf6;
|
||||
}
|
||||
|
||||
.heroSub {
|
||||
font-size: 1.05rem;
|
||||
color: var(--ifm-font-color-secondary, #9a968e);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Cross-nav between the Skills Hub and Plugin Catalog pages. */
|
||||
.crossNav {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid rgba(255, 215, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.crossNavLink {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 7px;
|
||||
color: var(--ifm-font-color-secondary, #9a968e);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.crossNavLink:hover {
|
||||
color: #ffd700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.crossNavActive {
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ifm-font-color-secondary, #9a968e);
|
||||
}
|
||||
|
||||
.controlsBar {
|
||||
position: sticky;
|
||||
top: 60px; /* below Docusaurus navbar */
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
backdrop-filter: blur(16px) saturate(1.4);
|
||||
border-bottom: 1px solid rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .controlsBar {
|
||||
background: rgba(7, 7, 13, 0.85);
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(255, 215, 0, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 0.7rem 2.5rem 0.7rem 2.6rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
border: 1px solid rgba(255, 215, 0, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 15, 24, 0.6);
|
||||
color: var(--ifm-font-color-base, #e8e4dc);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: rgba(255, 215, 0, 0.4);
|
||||
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--ifm-font-color-secondary, #9a968e);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
display: flex;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
opacity: 1;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
/* Tier tabs: All / Official / Community (skills page's source-pill pattern). */
|
||||
.tierPills {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tierBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: var(--ifm-font-color-secondary, #9a968e);
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tierBtn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
|
||||
.tierBtnActive {
|
||||
border-color: var(--pill-border, rgba(255, 215, 0, 0.3));
|
||||
background: var(--pill-bg, rgba(255, 215, 0, 0.06));
|
||||
color: var(--pill-color, #ffd700);
|
||||
}
|
||||
|
||||
.tierCount {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.68rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tierBtnActive .tierCount {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 2rem 3rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||
animation: cardIn 0.35s ease both;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .card {
|
||||
background: #0c0c16;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(255, 215, 0, 0.15);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 215, 0, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cardExpanded {
|
||||
border-color: rgba(255, 215, 0, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
.cardAccent {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.card:hover .cardAccent {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cardInner {
|
||||
padding: 1rem 1rem 0.85rem 1.15rem;
|
||||
}
|
||||
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cardTitleGroup {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
|
||||
.tierPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 500;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ifm-font-color-secondary, #9a968e);
|
||||
margin: 0 0 0.6rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardDescFull {
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Capability chips: tools/hooks/middleware counts. */
|
||||
.capChip {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.66rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid rgba(255, 215, 0, 0.12);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 215, 0, 0.04);
|
||||
color: rgba(255, 215, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Required env var chips. */
|
||||
.envChip {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.66rem;
|
||||
padding: 0.12rem 0.4rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: rgba(255, 215, 0, 0.6);
|
||||
}
|
||||
|
||||
.platformPill {
|
||||
font-size: 0.66rem;
|
||||
padding: 0.12rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(96, 165, 250, 0.06);
|
||||
color: rgba(96, 165, 250, 0.8);
|
||||
border: 1px solid rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.cardDetail {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
animation: cardIn 0.2s ease both;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
opacity: 0.5;
|
||||
min-width: 4.5rem;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
|
||||
.metaValue code {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chipList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.shaLink {
|
||||
color: rgba(96, 165, 250, 0.9);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.shaLink:hover {
|
||||
color: rgba(96, 165, 250, 1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.installHint {
|
||||
margin-top: 0.65rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 215, 0, 0.06);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.installHint code {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(255, 215, 0, 0.7);
|
||||
background: none;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.installHint code::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.copyBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid rgba(255, 215, 0, 0.18);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 215, 0, 0.06);
|
||||
color: rgba(255, 215, 0, 0.85);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.copyBtn:hover {
|
||||
background: rgba(255, 215, 0, 0.14);
|
||||
color: rgba(255, 215, 0, 1);
|
||||
}
|
||||
|
||||
.copyBtnLabel {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cardLinks {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cardLinks .docsLink {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
display: block;
|
||||
margin-top: 0.65rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid rgba(96, 165, 250, 0.2);
|
||||
border-radius: 5px;
|
||||
background: rgba(96, 165, 250, 0.06);
|
||||
color: rgba(96, 165, 250, 0.9);
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.docsLink:hover {
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
color: rgba(96, 165, 250, 1);
|
||||
border-color: rgba(96, 165, 250, 0.35);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: rgba(255, 215, 0, 0.2);
|
||||
color: #ffd700;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
margin: 0 auto 1rem;
|
||||
border: 3px solid rgba(255, 215, 0, 0.15);
|
||||
border-top-color: rgba(255, 215, 0, 0.7);
|
||||
border-radius: 50%;
|
||||
animation: pluginsSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pluginsSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
|
||||
.emptyDesc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
margin: 0 0 1.25rem;
|
||||
max-width: 480px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.emptyActions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.emptyCta {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.55rem 1.25rem;
|
||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 215, 0, 0.06);
|
||||
color: #ffd700;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.emptyCta:hover {
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
color: #ffd700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.emptyCtaSecondary {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 1.25rem;
|
||||
border: 1px solid rgba(96, 165, 250, 0.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(96, 165, 250, 0.05);
|
||||
color: rgba(96, 165, 250, 0.9);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.emptyCtaSecondary:hover {
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: rgba(96, 165, 250, 1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.emptyReset {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid rgba(255, 215, 0, 0.25);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #ffd700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.emptyReset:hover {
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
padding: 2.5rem 1.25rem 1.75rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.controlsBar {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 0.75rem 1rem 2rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import Layout from "@theme/Layout";
|
||||
import Link from "@docusaurus/Link";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Skill {
|
||||
@@ -650,6 +651,14 @@ export default function SkillsDashboard() {
|
||||
<div className={styles.heroContent}>
|
||||
<p className={styles.heroEyebrow}>Hermes Agent</p>
|
||||
<h1 className={styles.heroTitle}>Skills Hub</h1>
|
||||
<nav className={styles.crossNav} aria-label="Catalog pages">
|
||||
<span className={`${styles.crossNavLink} ${styles.crossNavActive}`}>
|
||||
Skills
|
||||
</span>
|
||||
<Link className={styles.crossNavLink} to="/plugins">
|
||||
Plugins
|
||||
</Link>
|
||||
</nav>
|
||||
<p className={styles.heroSub}>
|
||||
Discover, search, and install from{" "}
|
||||
<strong className={styles.heroAccent}>
|
||||
|
||||
@@ -69,6 +69,38 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Cross-nav between the Skills Hub and Plugin Catalog pages. */
|
||||
.crossNav {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid rgba(255, 215, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.crossNavLink {
|
||||
font-family: "DM Sans", sans-serif;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border-radius: 7px;
|
||||
color: var(--ifm-font-color-secondary, #9a968e);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.crossNavLink:hover {
|
||||
color: #ffd700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.crossNavActive {
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user