From 8dd07bd51797db3294fef816cd5cf766370d45f0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:22:43 -0700 Subject: [PATCH 01/25] feat(ci): plugin-validate reusable action + plugin-catalog admission gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/validate_plugin_catalog.py: standalone stdlib+pyyaml structural validator for plugin-catalog entries and removed.yaml (no hermes install needed; runtime twin of hermes_cli/plugin_catalog.py). --json support, unknown top-level keys warn instead of failing for forward compat. - .github/actions/plugin-validate: composite action plugin authors drop into their own repo's CI — installs hermes-agent from a chosen ref and runs 'hermes plugins validate '. - .github/workflows/plugin-catalog-ci.yml: admission gate on PRs touching plugin-catalog/** — structural job plus pinned-source job that clones each changed entry's repo, hard-fails on unreachable pinned sha (supply-chain gate), and validates the plugin at that exact commit. --- .github/actions/plugin-validate/action.yml | 56 ++++ .github/workflows/plugin-catalog-ci.yml | 140 +++++++++ scripts/validate_plugin_catalog.py | 271 +++++++++++++++++ tests/scripts/test_validate_plugin_catalog.py | 273 ++++++++++++++++++ 4 files changed, 740 insertions(+) create mode 100644 .github/actions/plugin-validate/action.yml create mode 100644 .github/workflows/plugin-catalog-ci.yml create mode 100644 scripts/validate_plugin_catalog.py create mode 100644 tests/scripts/test_validate_plugin_catalog.py diff --git a/.github/actions/plugin-validate/action.yml b/.github/actions/plugin-validate/action.yml new file mode 100644 index 0000000000..5484aaf56c --- /dev/null +++ b/.github/actions/plugin-validate/action.yml @@ -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@ + - 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 diff --git a/.github/workflows/plugin-catalog-ci.yml b/.github/workflows/plugin-catalog-ci.yml new file mode 100644 index 0000000000..32fb597c16 --- /dev/null +++ b/.github/workflows/plugin-catalog-ci.yml @@ -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:-}" + { + 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" diff --git a/scripts/validate_plugin_catalog.py b/scripts/validate_plugin_catalog.py new file mode 100644 index 0000000000..eb0078c7c0 --- /dev/null +++ b/scripts/validate_plugin_catalog.py @@ -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 ``: ERROR: ...`` / ``: 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()) diff --git a/tests/scripts/test_validate_plugin_catalog.py b/tests/scripts/test_validate_plugin_catalog.py new file mode 100644 index 0000000000..7b369c96ec --- /dev/null +++ b/tests/scripts/test_validate_plugin_catalog.py @@ -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 From fb40a768fcfba5206cfb5181feaf545d92e5cead Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:28:53 -0700 Subject: [PATCH 02/25] feat(docs): add /docs/plugins catalog page fed by plugin-catalog/ extractor - website/scripts/extract-plugins.py: reads plugin-catalog/*.yaml (+removed.yaml), emits static/api/plugins.json + plugins-meta.json; degrades to an empty catalog with exit 0 when plugin-catalog/ does not exist yet - website/src/pages/plugins/: catalog page with search, tier tabs (All/Official/Community), capability chips, pinned-SHA repo links, copyable install commands, and an empty-state submission CTA - cross-nav between Skills Hub and Plugin Catalog pages + navbar item - user docs: user-guide/features/plugin-catalog.md (trust model, install, submission checklist, custom git-URL contrast), registered in sidebars.ts - wired into deploy-site.yml and prebuild.mjs; artifacts gitignored - tests: tests/website/test_extract_plugins.py --- .github/workflows/deploy-site.yml | 3 + .gitignore | 4 + tests/website/test_extract_plugins.py | 195 +++++ .../user-guide/features/plugin-catalog.md | 120 +++ website/docusaurus.config.ts | 5 + website/scripts/extract-plugins.py | 178 +++++ website/scripts/prebuild.mjs | 22 + website/sidebars.ts | 1 + website/src/pages/plugins/index.tsx | 576 +++++++++++++++ website/src/pages/plugins/styles.module.css | 691 ++++++++++++++++++ website/src/pages/skills/index.tsx | 9 + website/src/pages/skills/styles.module.css | 32 + 12 files changed, 1836 insertions(+) create mode 100644 tests/website/test_extract_plugins.py create mode 100644 website/docs/user-guide/features/plugin-catalog.md create mode 100644 website/scripts/extract-plugins.py create mode 100644 website/src/pages/plugins/index.tsx create mode 100644 website/src/pages/plugins/styles.module.css diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index fd09205a05..1fc7a39b27 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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 diff --git a/.gitignore b/.gitignore index b5d594e194..42ce66680b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/tests/website/test_extract_plugins.py b/tests/website/test_extract_plugins.py new file mode 100644 index 0000000000..6edb3c1242 --- /dev/null +++ b/tests/website/test_extract_plugins.py @@ -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 `` 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() diff --git a/website/docs/user-guide/features/plugin-catalog.md b/website/docs/user-guide/features/plugin-catalog.md new file mode 100644 index 0000000000..d443a014d0 --- /dev/null +++ b/website/docs/user-guide/features/plugin-catalog.md @@ -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 +``` + +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 + +# Then enable it, as with any plugin +hermes plugins enable +``` + +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 ` 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/.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 diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 1c85d7693a..4630d0afcd 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -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', diff --git a/website/scripts/extract-plugins.py b/website/scripts/extract-plugins.py new file mode 100644 index 0000000000..72848de765 --- /dev/null +++ b/website/scripts/extract-plugins.py @@ -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)) diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index b873a9b208..44ab207958 100644 --- a/website/scripts/prebuild.mjs +++ b/website/scripts/prebuild.mjs @@ -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"); +} diff --git a/website/sidebars.ts b/website/sidebars.ts index 76b948aa72..4a1bb3ca09 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -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', ], }, { diff --git a/website/src/pages/plugins/index.tsx b/website/src/pages/plugins/index.tsx new file mode 100644 index 0000000000..f1bfbc80f5 --- /dev/null +++ b/website/src/pages/plugins/index.tsx @@ -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; + 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)} + {text.slice(idx, idx + query.length)} + {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 ( + + ); +} + +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 ( +
+
+ +
+
+ {"\u{1F50C}"} +
+

{highlightMatch(plugin.name, query)}

+ + {tier.icon} {tier.label} + +
+
+ +

+ {highlightMatch(plugin.description || "No description available.", query)} +

+ +
+ {toolCount > 0 && ( + + {toolCount} tool{toolCount === 1 ? "" : "s"} + + )} + {hookCount > 0 && ( + + {hookCount} hook{hookCount === 1 ? "" : "s"} + + )} + {middlewareCount > 0 && ( + + {middlewareCount} middleware + + )} + {caps.requiresEnv?.map((v) => ( + + {v} + + ))} + {plugin.platforms?.map((p) => ( + + {p === "macos" ? "\u{F8FF} macOS" : p === "linux" ? "\u{1F427} Linux" : p} + + ))} +
+ + {expanded && ( +
+ {plugin.maintainer && ( +
+ Maintainer + {plugin.maintainer} +
+ )} + {plugin.requiresHermes && ( +
+ Requires + + hermes {plugin.requiresHermes} + +
+ )} + + {caps.providesTools?.length ? ( +
+ Tools + + {caps.providesTools.map((t) => ( + + {t} + + ))} + +
+ ) : null} +
+ {plugin.installCommand} + +
+ +
+ )} +
+
+ ); +} + +function StatCard({ value, label, color }: { value: number; label: string; color: string }) { + return ( +
+ + {value} + + {label} +
+ ); +} + +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(null); + + const [search, setSearch] = useState(""); + const [tierFilter, setTierFilter] = useState("all"); + const [expandedCard, setExpandedCard] = useState(null); + const searchRef = useRef(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 ( + +
+
+
+
+

Hermes Agent

+

Plugin Catalog

+ +

+ Reviewed, SHA-pinned plugins you can install with one command. + {loadError && ( + + · failed to load catalog ({loadError}) + + )} +

+ {meta.generatedAt && !catalogEmpty && ( +

+ Catalog refreshed{" "} + + {formatRelativeTime(meta.generatedAt) || "recently"} + +

+ )} + + {!catalogEmpty && ( +
+ p.tier === "official").length} + label="Official" + color="#ffd700" + /> + p.tier === "community").length} + label="Community" + color="#94a3b8" + /> + +
+ )} +
+
+ + {!catalogEmpty && ( +
+
+ + + + setSearch(e.target.value)} + className={styles.searchInput} + /> + {search && ( + + )} +
+ +
+ {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 ( + + ); + })} +
+
+ )} + +
+ {!data && !loadError ? ( +
+
+

Loading the catalog…

+
+ ) : catalogEmpty ? ( +
+
{"\u{1F331}"}
+

The catalog is just getting started

+

+ 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. +

+
+ + How to submit a plugin ↗ + + + Read the catalog docs + +
+
+ ) : filtered.length > 0 ? ( +
+ {filtered.map((plugin, i) => { + const key = `${plugin.tier}-${plugin.name}`; + return ( + setExpandedCard(expandedCard === key ? null : key)} + style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }} + /> + ); + })} +
+ ) : ( +
+
{"\u{1F50D}"}
+

No plugins found

+

+ Try a different search term or clear your filters. +

+ +
+ )} +
+
+
+ ); +} diff --git a/website/src/pages/plugins/styles.module.css b/website/src/pages/plugins/styles.module.css new file mode 100644 index 0000000000..c7a80fbc6d --- /dev/null +++ b/website/src/pages/plugins/styles.module.css @@ -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; + } +} diff --git a/website/src/pages/skills/index.tsx b/website/src/pages/skills/index.tsx index 735ccafb54..484e371494 100644 --- a/website/src/pages/skills/index.tsx +++ b/website/src/pages/skills/index.tsx @@ -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() {

Hermes Agent

Skills Hub

+

Discover, search, and install from{" "} diff --git a/website/src/pages/skills/styles.module.css b/website/src/pages/skills/styles.module.css index 018703c676..d7b78e35f9 100644 --- a/website/src/pages/skills/styles.module.css +++ b/website/src/pages/skills/styles.module.css @@ -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; From a22d2918d6c19ea0d10c7a31f0843f88ea5a072c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:30:55 -0700 Subject: [PATCH 03/25] feat(plugins): add curated plugin catalog module and in-tree catalog dir - hermes_cli/plugin_catalog.py: loader/search/removed-blocklist for the new plugin-catalog/ directory (exact 40-hex SHA pins, https-only repos, official/community tiers; invalid entries skipped with a warning) - plugin-catalog/: admission-policy README, example-plugin seed entry, and removed.yaml blocklist --- hermes_cli/plugin_catalog.py | 307 +++++++++++++++++++++++++++++ plugin-catalog/README.md | 60 ++++++ plugin-catalog/example-plugin.yaml | 14 ++ plugin-catalog/removed.yaml | 6 + 4 files changed, 387 insertions(+) create mode 100644 hermes_cli/plugin_catalog.py create mode 100644 plugin-catalog/README.md create mode 100644 plugin-catalog/example-plugin.yaml create mode 100644 plugin-catalog/removed.yaml diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py new file mode 100644 index 0000000000..3de7c1ca9e --- /dev/null +++ b/hermes_cli/plugin_catalog.py @@ -0,0 +1,307 @@ +"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo. + +Mirrors the ``optional-mcps/`` MCP-catalog pattern (see +:mod:`hermes_cli.mcp_catalog`): each catalog entry is a single YAML file under +the in-tree ``plugin-catalog/`` directory, pinned to an exact 40-character +commit SHA. Users discover entries via ``hermes plugins catalog`` / +``hermes plugins search`` and install them with +``hermes plugins install ``, which clones the pinned commit. + +Catalog policy (see plugin-catalog/README.md for the full admission policy): +- Entries are added only by merging a PR into hermes-agent — presence in the + ``plugin-catalog/`` directory is the human-merged approval gate. +- Every entry pins an exact 40-hex commit SHA. SHA bumps are new PRs, + re-reviewed as diffs. The pinned release should be at least 2 weeks old at + pin time, mirroring the optional-mcps supply-chain rules. +- ``plugin-catalog/removed.yaml`` is the blocklist: entries pulled from the + catalog for security or policy reasons are recorded there so installs of + the same name/repo are refused with the recorded reason. +""" + +from __future__ import annotations + +import logging +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, List, Optional + +import yaml + +logger = logging.getLogger(__name__) + +CATALOG_TIERS = ("official", "community") + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$") + + +# ─── Data classes ──────────────────────────────────────────────────────────── + + +@dataclass +class RemovedEntry: + name: str + repo: str = "" + reason: str = "" + date: str = "" # ISO date string + + +@dataclass +class CatalogCapabilities: + provides_tools: List[str] = field(default_factory=list) + provides_hooks: List[str] = field(default_factory=list) + provides_middleware: List[str] = field(default_factory=list) + requires_env: List[str] = field(default_factory=list) + + +@dataclass +class PluginCatalogEntry: + name: str # catalog key, [a-z0-9_-]{1,64} + repo: str # https:// git URL + sha: str # 40-hex pinned commit — MANDATORY, validated + description: str + maintainer: str + tier: str = "community" # one of CATALOG_TIERS + requires_hermes: str = "" # e.g. ">=0.19" (optional) + subdir: str = "" # optional path within the repo + docs_url: str = "" + platforms: List[str] = field(default_factory=list) # empty = all OSes + capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities) + + +# ─── Directory resolution ──────────────────────────────────────────────────── + + +def get_catalog_dir() -> Path: + """Return the ``plugin-catalog/`` directory shipped with this checkout. + + ``HERMES_PLUGIN_CATALOG_DIR`` overrides the location for tests only — + read via ``os.getenv`` at call time so monkeypatched values take effect. + """ + override = os.getenv("HERMES_PLUGIN_CATALOG_DIR", "").strip() + if override: + return Path(override) + return Path(__file__).resolve().parent.parent / "plugin-catalog" + + +# ─── Loading / validation ──────────────────────────────────────────────────── + + +def _str_list(raw: Any) -> List[str]: + """Coerce a YAML value into a list of strings (drop non-strings).""" + if not isinstance(raw, list): + return [] + return [str(item) for item in raw if isinstance(item, (str, int, float))] + + +def _parse_entry(path: Path) -> Optional[PluginCatalogEntry]: + """Parse and validate one catalog YAML file. + + Returns ``None`` (after logging a warning) on any validation failure — + the loader never raises for a bad entry. + """ + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception as exc: + logger.warning("Plugin catalog: failed to read %s: %s", path, exc) + return None + + if not isinstance(data, dict): + logger.warning("Plugin catalog: %s: entry must be a mapping", path) + return None + + name = str(data.get("name") or "") + if not _NAME_RE.match(name): + logger.warning( + "Plugin catalog: %s: invalid name %r (must match [a-z0-9_-]{1,64})", + path, name, + ) + return None + + repo = str(data.get("repo") or "") + if not repo.startswith("https://"): + logger.warning( + "Plugin catalog: %s: repo must be an https:// URL (got %r)", + path, repo, + ) + return None + + sha = str(data.get("sha") or "").strip().lower() + if not _SHA_RE.match(sha): + logger.warning( + "Plugin catalog: %s: sha must be a full 40-character hex commit " + "SHA (got %r)", path, data.get("sha"), + ) + return None + + tier = str(data.get("tier") or "community") + if tier not in CATALOG_TIERS: + logger.warning( + "Plugin catalog: %s: tier must be one of %s (got %r)", + path, "/".join(CATALOG_TIERS), tier, + ) + return None + + caps_raw = data.get("capabilities") or {} + if not isinstance(caps_raw, dict): + caps_raw = {} + capabilities = CatalogCapabilities( + provides_tools=_str_list(caps_raw.get("provides_tools")), + provides_hooks=_str_list(caps_raw.get("provides_hooks")), + provides_middleware=_str_list(caps_raw.get("provides_middleware")), + requires_env=_str_list(caps_raw.get("requires_env")), + ) + + return PluginCatalogEntry( + name=name, + repo=repo, + sha=sha, + description=str(data.get("description") or "").strip(), + maintainer=str(data.get("maintainer") or "").strip(), + tier=tier, + requires_hermes=str(data.get("requires_hermes") or "").strip(), + subdir=str(data.get("subdir") or "").strip(), + docs_url=str(data.get("docs_url") or "").strip(), + platforms=_str_list(data.get("platforms")), + capabilities=capabilities, + ) + + +def load_catalog() -> List[PluginCatalogEntry]: + """Return all valid catalog entries, sorted by name. + + Parses every ``*.yaml`` in the catalog dir except ``removed.yaml``. + Invalid entries are skipped with a logged warning; this function never + raises for a malformed entry. + """ + root = get_catalog_dir() + if not root.is_dir(): + return [] + entries: List[PluginCatalogEntry] = [] + for path in sorted(root.glob("*.yaml")): + if path.name == "removed.yaml": + continue + entry = _parse_entry(path) + if entry is not None: + entries.append(entry) + return entries + + +def get_catalog_entry(name: str) -> Optional[PluginCatalogEntry]: + """Look up a single catalog entry by name.""" + for entry in load_catalog(): + if entry.name == name: + return entry + return None + + +def search_catalog(query: str) -> List[PluginCatalogEntry]: + """Case-insensitive substring search over name, description, and + declared tools. An empty query returns the whole catalog.""" + entries = load_catalog() + q = (query or "").strip().lower() + if not q: + return entries + results: List[PluginCatalogEntry] = [] + for entry in entries: + haystacks = [entry.name, entry.description] + haystacks.extend(entry.capabilities.provides_tools) + if any(q in h.lower() for h in haystacks): + results.append(entry) + return results + + +# ─── Removed / blocklist ───────────────────────────────────────────────────── + + +def _normalize_repo(url: str) -> str: + """Normalize a repo URL for comparison (.git suffix and trailing slash + stripped, lowercased).""" + return url.strip().rstrip("/").removesuffix(".git").lower() + + +def load_removed_list() -> List[RemovedEntry]: + """Load ``plugin-catalog/removed.yaml`` (the ``removed:`` list). + + Missing or malformed files yield an empty list — never raises. + """ + path = get_catalog_dir() / "removed.yaml" + if not path.is_file(): + return [] + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception as exc: + logger.warning("Plugin catalog: failed to read %s: %s", path, exc) + return [] + raw_list = data.get("removed") if isinstance(data, dict) else None + if not isinstance(raw_list, list): + return [] + removed: List[RemovedEntry] = [] + for raw in raw_list: + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or "") + if not name: + continue + removed.append( + RemovedEntry( + name=name, + repo=str(raw.get("repo") or ""), + reason=str(raw.get("reason") or ""), + date=str(raw.get("date") or ""), + ) + ) + return removed + + +def find_removed(name_or_repo: str) -> Optional[RemovedEntry]: + """Match *name_or_repo* against the removed blocklist. + + Matches by exact catalog name OR by repo URL (normalized — ``.git`` + suffix and trailing slashes are ignored). + """ + if not name_or_repo: + return None + candidate = name_or_repo.strip() + candidate_repo = _normalize_repo(candidate) + for entry in load_removed_list(): + if candidate == entry.name: + return entry + if entry.repo and candidate_repo == _normalize_repo(entry.repo): + return entry + return None + + +# ─── Human summaries ───────────────────────────────────────────────────────── + + +def entry_capability_summary(entry: PluginCatalogEntry) -> str: + """One-paragraph human summary of what an entry declares, shown at + install prompts so the user knows what they're granting.""" + caps = entry.capabilities + parts: List[str] = [] + if caps.provides_tools: + parts.append(f"registers tool(s): {', '.join(caps.provides_tools)}") + if caps.provides_hooks: + parts.append(f"hook(s): {', '.join(caps.provides_hooks)}") + if caps.provides_middleware: + parts.append(f"middleware: {', '.join(caps.provides_middleware)}") + if caps.requires_env: + parts.append(f"requires env var(s): {', '.join(caps.requires_env)}") + if not parts: + capability_text = "declares no tools, hooks, middleware, or env vars" + else: + capability_text = "; ".join(parts) + bits = [ + f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})", + ] + if entry.description: + bits.append(entry.description) + bits.append(f"This plugin {capability_text}.") + if entry.platforms: + bits.append(f"Platforms: {', '.join(entry.platforms)}.") + if entry.requires_hermes: + bits.append(f"Requires Hermes {entry.requires_hermes}.") + return " ".join(bits) diff --git a/plugin-catalog/README.md b/plugin-catalog/README.md new file mode 100644 index 0000000000..6a44c3a6e6 --- /dev/null +++ b/plugin-catalog/README.md @@ -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 `. + +## 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. diff --git a/plugin-catalog/example-plugin.yaml b/plugin-catalog/example-plugin.yaml new file mode 100644 index 0000000000..236b6fa9a7 --- /dev/null +++ b/plugin-catalog/example-plugin.yaml @@ -0,0 +1,14 @@ +name: example-plugin +repo: https://github.com/NousResearch/hermes-example-plugins +sha: 38fe0fb53eff98d477f807432e965429e665ca33 +subdir: "" +description: Reference example plugins for the Hermes plugin system. +maintainer: NousResearch +tier: official +docs_url: "" +platforms: [] +capabilities: + provides_tools: [] + provides_hooks: [] + provides_middleware: [] + requires_env: [] diff --git a/plugin-catalog/removed.yaml b/plugin-catalog/removed.yaml new file mode 100644 index 0000000000..75c6ab8c6c --- /dev/null +++ b/plugin-catalog/removed.yaml @@ -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: [] From dcdb9b25e4578f51f6ec644f8174c0b0c0769b76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:30:55 -0700 Subject: [PATCH 04/25] feat(plugins): requires_hermes gate, config spec, ctx.plugin_config - plugin.yaml gains requires_hermes (version spec) and config: (list of {key, prompt, type, default, secret}) parsed onto PluginManifest - PluginManager skips loading (clean error, no traceback) when the running Hermes version does not satisfy requires_hermes; local _version_satisfies helper supports >=,>,<=,<,==,!= and comma specs - PluginContext.plugin_config merges config-spec defaults under plugins.entries. values from config.yaml --- hermes_cli/plugins.py | 169 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 6ca393fca5..1a5e93949f 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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..`` 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.`` 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, From dc4d9913732fb46c7863a7a6fef4b03f9a4242f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:30:55 -0700 Subject: [PATCH 05/25] feat(plugins): install-time ref checkout and removed-blocklist check - _install_plugin_core accepts ref= (full-depth clone + git checkout, PluginOperationError on failure) and skip_removed_check= - installs are refused when the identifier or resolved repo URL matches plugin-catalog/removed.yaml, with the recorded reason and date --- hermes_cli/plugins_cmd.py | 70 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index f5c57bb88f..731f35c6ef 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -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) From 6f5608bed31167b971bede98f85a3db87a61a84d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:30:55 -0700 Subject: [PATCH 06/25] test(plugins): cover plugin catalog, version gate, and installer - catalog loader validation, search, removed matching, capability summary - _version_satisfies operators and permissive fallbacks - requires_hermes load gate (skip vs normal load) - config spec parsing + ctx.plugin_config merge - _install_plugin_core ref checkout and removed-block via local file:// repos --- tests/hermes_cli/test_plugin_catalog.py | 594 ++++++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 tests/hermes_cli/test_plugin_catalog.py diff --git a/tests/hermes_cli/test_plugin_catalog.py b/tests/hermes_cli/test_plugin_catalog.py new file mode 100644 index 0000000000..18096bfb5e --- /dev/null +++ b/tests/hermes_cli/test_plugin_catalog.py @@ -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 /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 /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() From fe9dc0607103f236156530be03f23a06cb726f09 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:57:39 -0700 Subject: [PATCH 07/25] =?UTF-8?q?feat(dashboard):=20plugin=20catalog=20sur?= =?UTF-8?q?face=20=E2=80=94=20browse,=20capability-confirm=20install,=20re?= =?UTF-8?q?moved=20banners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/dashboard/plugins/catalog: catalog entries merged with installed-state (sidecar SHA, update_available, runtime_status) + removed blocklist + generated_at - agent-plugins/install accepts catalog_name: resolves the catalog entry, refuses removed plugins (400, no dashboard bypass), installs at the pinned SHA and writes the .hermes-catalog.json sidecar - plugins/hub rows annotated with removed_reason - PluginsPage: Catalog section with search, tier badges, capability chips, sha/docs links, capability-summary confirm dialog install flow, and red removed banners on catalog + installed rows - en i18n keys with en-only optional-key fallback convention --- hermes_cli/plugins_cmd.py | 101 ++++++- hermes_cli/web_server.py | 145 ++++++++- tests/hermes_cli/test_web_plugins_catalog.py | 296 +++++++++++++++++++ web/src/i18n/en.ts | 15 + web/src/i18n/types.ts | 14 + web/src/lib/api-plugins-catalog.test.ts | 58 ++++ web/src/lib/api.ts | 49 +++ web/src/pages/PluginsPage.tsx | 275 ++++++++++++++++- 8 files changed, 932 insertions(+), 21 deletions(-) create mode 100644 tests/hermes_cli/test_web_plugins_catalog.py create mode 100644 web/src/lib/api-plugins-catalog.test.ts diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 731f35c6ef..8a5d2f39d3 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1825,31 +1825,114 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, print() +_CATALOG_SIDECAR_FILENAME = ".hermes-catalog.json" + + +def write_catalog_sidecar(target: Path, entry: Any) -> None: + """Record catalog provenance next to an installed plugin. + + The ``.hermes-catalog.json`` sidecar lets ``hermes plugins list`` and the + dashboard tell a catalog-pinned install apart from a raw git install and + detect when the catalog has moved to a newer pinned SHA. + """ + from datetime import datetime, timezone + + payload = { + "catalog_name": entry.name, + "repo": entry.repo, + "sha": entry.sha, + "installed_at": datetime.now(timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + "tier": entry.tier, + } + (target / _CATALOG_SIDECAR_FILENAME).write_text( + json.dumps(payload, indent=2) + "\n", encoding="utf-8" + ) + + +def read_catalog_sidecar(plugin_dir: Path) -> Optional[dict]: + """Read a plugin dir's ``.hermes-catalog.json`` sidecar, or ``None``. + + Returns ``None`` for missing, unreadable, or non-mapping sidecars — + callers degrade to "installed, provenance unknown". + """ + path = plugin_dir / _CATALOG_SIDECAR_FILENAME + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + def dashboard_install_plugin( identifier: str, *, force: bool, enable: bool, + catalog_name: Optional[str] = None, ) -> dict[str, Any]: - """Non-interactive install for the web dashboard. Returns a JSON-serializable dict.""" + """Non-interactive install for the web dashboard. Returns a JSON-serializable dict. + + When *catalog_name* is given the identifier is resolved from the plugin + catalog and the pinned commit SHA is checked out (``ref=``). Removed + (blocklisted) plugins are refused with the recorded reason — the + dashboard deliberately has no bypass flag (CLI-only decision). + """ warnings: list[str] = [] - try: - git_url, _subdir = _resolve_git_url(identifier) - if git_url.startswith(("http://", "file://")): - warnings.append( - "Insecure URL scheme; prefer https:// or git@ for production installs.", - ) - except ValueError: - pass + entry = None + ref: Optional[str] = None + + if catalog_name: + from hermes_cli.plugin_catalog import find_removed, get_catalog_entry + + removed = find_removed(catalog_name) + if removed is not None: + detail = removed.reason or "no reason recorded" + if removed.date: + detail += f" (removed {removed.date})" + return { + "ok": False, + "error": ( + f"Plugin '{removed.name}' was removed from the Hermes " + f"plugin catalog and is blocked from installation: {detail}" + ), + } + entry = get_catalog_entry(catalog_name) + if entry is None: + return { + "ok": False, + "error": f"'{catalog_name}' is not in the Hermes plugin catalog.", + } + identifier = f"{entry.repo}#{entry.subdir}" if entry.subdir else entry.repo + ref = entry.sha + else: + try: + git_url, _subdir = _resolve_git_url(identifier) + if git_url.startswith(("http://", "file://")): + warnings.append( + "Insecure URL scheme; prefer https:// or git@ for production installs.", + ) + except ValueError: + pass try: target, installed_manifest, installed_name = _install_plugin_core( identifier, force=force, + ref=ref, ) except PluginOperationError as exc: return {"ok": False, "error": str(exc)} + if entry is not None: + try: + write_catalog_sidecar(target, entry) + except OSError as exc: + warnings.append(f"Could not record catalog provenance: {exc}") + missing_env = _missing_requires_env_names(installed_manifest) if enable: en = _get_enabled_set() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 238b4acf88..e40ed6a473 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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( diff --git a/tests/hermes_cli/test_web_plugins_catalog.py b/tests/hermes_cli/test_web_plugins_catalog.py new file mode 100644 index 0000000000..26cd3ef6ac --- /dev/null +++ b/tests/hermes_cli/test_web_plugins_catalog.py @@ -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 diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 38c61a15ca..14f61938c3 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 52fd86acf6..4257591d90 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -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 ── diff --git a/web/src/lib/api-plugins-catalog.test.ts b/web/src/lib/api-plugins-catalog.test.ts new file mode 100644 index 0000000000..d2cc219f90 --- /dev/null +++ b/web/src/lib/api-plugins-catalog.test.ts @@ -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( + 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); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 7474a8322a..af15d9bb40 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -921,6 +921,9 @@ export const api = { getPluginsHub: () => fetchJSON("/api/dashboard/plugins/hub"), + getPluginsCatalog: () => + fetchJSON("/api/dashboard/plugins/catalog"), + installAgentPlugin: (body: AgentPluginInstallRequest) => fetchJSON("/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; diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index 52baf89cbf..92be066830 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -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(null); const [loading, setLoading] = useState(true); + const [catalog, setCatalog] = useState(null); + const [catalogLoading, setCatalogLoading] = useState(true); + const [catalogSearch, setCatalogSearch] = useState(""); + const [catalogConfirm, setCatalogConfirm] = useState(null); + const [catalogBusy, setCatalogBusy] = useState(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(); + 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() { +

+ +

+ {t.pluginsPage.catalogHeading ?? "Plugin catalog"} +

+ +

+ {t.pluginsPage.catalogHint ?? + "Curated, Nous-reviewed plugins pinned to exact commits."} +

+ + setCatalogSearch(e.target.value)} + aria-label={t.pluginsPage.catalogSearchPlaceholder ?? "Search catalog..."} + /> + + {catalogLoading ? ( +
+ + {t.common.loading} +
+ ) : catalogEntries.length === 0 ? ( +

+ {t.pluginsPage.catalogEmpty ?? "No catalog entries match."}{" "} + + {t.pluginsPage.catalogEmptyDocsLink ?? "Learn about Hermes plugins"} + +

+ ) : ( +
    + {catalogEntries.map((entry) => ( +
  • + setCatalogConfirm(entry)} + removed={removedByName.get(entry.name) ?? null} + t={t} + /> +
  • + ))} +
+ )} +
+

@@ -896,6 +1007,30 @@ export default function PluginsPage() { + + 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"} + />

); } @@ -961,6 +1096,12 @@ function PluginRowCard(props: PluginRowCardProps) { {row.auth_required ? ( {t.pluginsPage.authRequired} ) : null} + + {row.removed_reason ? ( + + {t.pluginsPage.catalogRemovedBadge ?? "Removed"} + + ) : null}
@@ -1070,6 +1211,12 @@ function PluginRowCard(props: PluginRowCardProps) {

) : null} + {row.removed_reason ? ( +

+ {t.pluginsPage.removedFromCatalog ?? "Removed from catalog"}: {row.removed_reason} +

+ ) : null} + {dm?.slots?.length ? (

@@ -1111,3 +1258,127 @@ function PluginRowCard(props: PluginRowCardProps) { ); } + +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 ( + + +

+
+ {entry.name} + + + {entry.tier} + + + {entry.installed && entry.runtime_status ? ( + {entry.runtime_status} + ) : null} + + {isRemoved ? ( + + {t.pluginsPage.catalogRemovedBadge ?? "Removed"} + + ) : null} +
+ +
+ {isRemoved ? null : entry.installed && !entry.update_available ? ( + + {t.pluginsPage.catalogInstalledBadge ?? "Installed ✓"} + + ) : ( + + )} +
+
+ + {isRemoved ? ( +

+ {t.pluginsPage.removedFromCatalog ?? "Removed from catalog"} + {removed.reason ? `: ${removed.reason}` : ""} + {removed.date ? ` (${removed.date})` : ""} +

+ ) : null} + + {entry.description ? ( +

+ {entry.description} +

+ ) : null} + + {chips.length ? ( +
+ {chips.map((chip) => ( + + {chip} + + ))} +
+ ) : null} + +
+ {entry.maintainer} + + + {entry.sha_short} + + + + {entry.docs_url ? ( + + docs + + + ) : null} + + {entry.requires_hermes ? ( + hermes {entry.requires_hermes} + ) : null} + + {entry.platforms.length ? ( + {entry.platforms.join(", ")} + ) : null} +
+ + + ); +} From 8b70bf4c40b1f0d28fd5a9b9f3da4ad676bd68b9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:14:51 -0700 Subject: [PATCH 08/25] feat(plugins): live catalog index with 6h cache and fallback Add fetch_live_catalog()/load_catalog_live() to hermes_cli.plugin_catalog: list plugin-catalog/*.yaml via the GitHub contents API (unauthenticated, 5s timeout), raw-fetch each entry, and cache under /cache/plugin-catalog/ with a 6h TTL. Any network failure falls back silently to the in-tree catalog. Also expose filter_entries() so callers can reuse search semantics on a live entry list. --- hermes_cli/plugin_catalog.py | 113 ++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index 3de7c1ca9e..448f2c4684 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -23,6 +23,7 @@ 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 @@ -176,7 +177,11 @@ def load_catalog() -> List[PluginCatalogEntry]: Invalid entries are skipped with a logged warning; this function never raises for a malformed entry. """ - root = get_catalog_dir() + 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] = [] @@ -200,7 +205,17 @@ def get_catalog_entry(name: str) -> Optional[PluginCatalogEntry]: def search_catalog(query: str) -> List[PluginCatalogEntry]: """Case-insensitive substring search over name, description, and declared tools. An empty query returns the whole catalog.""" - entries = load_catalog() + 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 @@ -274,6 +289,100 @@ def find_removed(name_or_repo: str) -> Optional[RemovedEntry]: 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 ``/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 ───────────────────────────────────────────────────────── From 44624631bf0547a3836d2d6e84d85a62619ca246 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:14:51 -0700 Subject: [PATCH 09/25] feat(plugins): subprocess-isolated plugin validation for catalog CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New hermes_cli/plugin_validate.py — the checks behind 'hermes plugins validate ': manifest fields, strict requires_hermes spec parsing, config: shape, UPPER_SNAKE requires_env, a capability probe that imports the plugin and calls register(ctx) against a recording stub in a scratch subprocess (throwaway HERMES_HOME, 30s timeout) and diffs actual registrations against provides_* (undeclared = fail, unregistered = warn), plus built-in tool collision checks via the discovered tool registry. --- hermes_cli/plugin_validate.py | 434 ++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 hermes_cli/plugin_validate.py diff --git a/hermes_cli/plugin_validate.py b/hermes_cli/plugin_validate.py new file mode 100644 index 0000000000..74d0849a05 --- /dev/null +++ b/hermes_cli/plugin_validate.py @@ -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 From f1b30414d5fd1ef49ef70d368d57da1e6b87abcb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:14:51 -0700 Subject: [PATCH 10/25] =?UTF-8?q?feat(plugins):=20catalog=20CLI=20surface?= =?UTF-8?q?=20=E2=80=94=20search/browse/info/install/update/doctor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install: catalog names resolve to the pinned SHA (ref= checkout), print tier + capability summary before the enable prompt, and write a .hermes-catalog.json provenance sidecar; raw git URLs get a 'custom (unreviewed) source' banner; --allow-removed loudly bypasses the removed blocklist (skip_removed_check=True) - update: sidecar installs compare against the current catalog pin and force-reinstall at the new SHA (enabled state preserved); plain git installs keep the git-pull flow - list: catalog:@ annotation + red 'REMOVED from catalog' lines for blocklisted installs (table and --json) - search/browse/info: live-index catalog tables and full entry detail with removed-list warnings - validate: human ✓/✗ output, --json for CI, exit 0/1 - doctor: per-plugin manifest/enabled/load-error/env/requires_hermes/ provenance/pin/removed diagnosis, compact table or single-name detail --- hermes_cli/plugins_cmd.py | 607 +++++++++++++++++++++++++++++- hermes_cli/subcommands/plugins.py | 45 +++ 2 files changed, 645 insertions(+), 7 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 731f35c6ef..7ec4f9e1f4 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -613,12 +613,98 @@ def _install_plugin_core( 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). @@ -627,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: @@ -648,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(): @@ -700,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() @@ -712,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 " @@ -739,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 @@ -1188,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:@`` 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 @@ -1205,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 @@ -1235,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": @@ -1243,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") @@ -1254,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 ") + console.print("[dim]Install:[/dim] hermes plugins install ") + + +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 # --------------------------------------------------------------------------- @@ -2083,7 +2665,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"}: diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index 5355fbec34..636b22ed72 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -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 `", ) + 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" From 373ad70f4ca4391f06cb26bb481f942b594232fc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:14:51 -0700 Subject: [PATCH 11/25] test(plugins): cover catalog CLI surface and plugin validation Behavior contracts for catalog-name install resolution (ref pin + sidecar), custom-URL banner, --allow-removed wiring, catalog-pin updates, list annotations, live-index fetch/fallback/TTL cache, search/browse/info rendering, doctor, argparse dispatch, and the validate checks incl. undeclared-capability diffs, crash containment, and built-in tool collisions. --- tests/hermes_cli/test_plugin_validate.py | 214 +++++++ tests/hermes_cli/test_plugins_cmd_catalog.py | 626 +++++++++++++++++++ 2 files changed, 840 insertions(+) create mode 100644 tests/hermes_cli/test_plugin_validate.py create mode 100644 tests/hermes_cli/test_plugins_cmd_catalog.py diff --git a/tests/hermes_cli/test_plugin_validate.py b/tests/hermes_cli/test_plugin_validate.py new file mode 100644 index 0000000000..d1051cdbc7 --- /dev/null +++ b/tests/hermes_cli/test_plugin_validate.py @@ -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 diff --git a/tests/hermes_cli/test_plugins_cmd_catalog.py b/tests/hermes_cli/test_plugins_cmd_catalog.py new file mode 100644 index 0000000000..809b1de3a6 --- /dev/null +++ b/tests/hermes_cli/test_plugins_cmd_catalog.py @@ -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] From ab1d9f4c15796f955283ea214efb4b93943464c0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:17:11 -0700 Subject: [PATCH 12/25] fix: dedupe catalog sidecar helpers into public aliases Lane 2 (CLI) and lane 5 (dashboard) each shipped a sidecar read/write pair with identical format; keep the CLI pair and alias the public names the dashboard imports. --- hermes_cli/plugins_cmd.py | 44 ++++----------------------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 3c9ee467e6..363d647a6d 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -2407,46 +2407,10 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, print() -_CATALOG_SIDECAR_FILENAME = ".hermes-catalog.json" - - -def write_catalog_sidecar(target: Path, entry: Any) -> None: - """Record catalog provenance next to an installed plugin. - - The ``.hermes-catalog.json`` sidecar lets ``hermes plugins list`` and the - dashboard tell a catalog-pinned install apart from a raw git install and - detect when the catalog has moved to a newer pinned SHA. - """ - from datetime import datetime, timezone - - payload = { - "catalog_name": entry.name, - "repo": entry.repo, - "sha": entry.sha, - "installed_at": datetime.now(timezone.utc) - .isoformat() - .replace("+00:00", "Z"), - "tier": entry.tier, - } - (target / _CATALOG_SIDECAR_FILENAME).write_text( - json.dumps(payload, indent=2) + "\n", encoding="utf-8" - ) - - -def read_catalog_sidecar(plugin_dir: Path) -> Optional[dict]: - """Read a plugin dir's ``.hermes-catalog.json`` sidecar, or ``None``. - - Returns ``None`` for missing, unreadable, or non-mapping sidecars — - callers degrade to "installed, provenance unknown". - """ - path = plugin_dir / _CATALOG_SIDECAR_FILENAME - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - except Exception: - return None - return data if isinstance(data, dict) else None +# 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( From f3c6282dae2422cbdf02fa6c7656fa3d38fe1f39 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:23:57 -0700 Subject: [PATCH 13/25] fix(catalog): point seed entry at a real plugin subdir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example-plugins repo is a multi-plugin repo with no root plugin.yaml; the admission CI correctly rejected the root-level seed entry. Pin the plugin-llm-example subdir instead — the admission gate catching its own seed entry is the E2E proof it works. --- .../{example-plugin.yaml => plugin-llm-example.yaml} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename plugin-catalog/{example-plugin.yaml => plugin-llm-example.yaml} (66%) diff --git a/plugin-catalog/example-plugin.yaml b/plugin-catalog/plugin-llm-example.yaml similarity index 66% rename from plugin-catalog/example-plugin.yaml rename to plugin-catalog/plugin-llm-example.yaml index 236b6fa9a7..e423099b72 100644 --- a/plugin-catalog/example-plugin.yaml +++ b/plugin-catalog/plugin-llm-example.yaml @@ -1,8 +1,8 @@ -name: example-plugin +name: plugin-llm-example repo: https://github.com/NousResearch/hermes-example-plugins sha: 38fe0fb53eff98d477f807432e965429e665ca33 -subdir: "" -description: Reference example plugins for the Hermes plugin system. +subdir: "plugin-llm-example" +description: Reference plugin showing host-owned structured LLM access via ctx.llm. maintainer: NousResearch tier: official docs_url: "" From ce6d9e62cd520d6880c50aac9cf3257c860727ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:37:16 -0700 Subject: [PATCH 14/25] fix(catalog): reconcile catalog layer with landed index/manifest-v2/portable tracks - cmd_install: curated catalog resolution first, community-index fallback with unreviewed warning - _install_plugin_core: removed-blocklist gate on top of main pinned-metadata installer - cmd_update: catalog sidecar re-pins to catalog SHA (never git pull) - validator: config_schema (v2) shape check; portable Agent Plugins v1 plugin.json admission - admission CI: accept plugin.json-only packages at the pinned SHA - cmd_list: catalog:@ provenance + removed annotations (table + JSON) --- .github/workflows/plugin-catalog-ci.yml | 6 +- hermes_cli/plugin_validate.py | 44 +++++++++- hermes_cli/plugins_cmd.py | 37 +++++--- tests/hermes_cli/test_plugin_index_search.py | 6 +- tests/hermes_cli/test_plugin_validate.py | 16 ++-- tests/hermes_cli/test_plugins_cmd_catalog.py | 84 ++++--------------- .../user-guide/features/plugin-catalog.md | 14 ++++ 7 files changed, 113 insertions(+), 94 deletions(-) diff --git a/.github/workflows/plugin-catalog-ci.yml b/.github/workflows/plugin-catalog-ci.yml index 32fb597c16..84efbc0761 100644 --- a/.github/workflows/plugin-catalog-ci.yml +++ b/.github/workflows/plugin-catalog-ci.yml @@ -118,8 +118,10 @@ jobs: 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" + # Native plugin.yaml OR portable Agent Plugins v1 plugin.json + # (#81196; native manifest wins when both exist). + if [ ! -f "$PLUGIN_DIR/plugin.yaml" ] && [ ! -f "$PLUGIN_DIR/plugin.yml" ] && [ ! -f "$PLUGIN_DIR/plugin.json" ]; then + echo "::error file=$entry::no plugin.yaml or plugin.json at subdir '$SUBDIR' of $REPO@$SHA" FAILED=1; echo "::endgroup::"; continue fi diff --git a/hermes_cli/plugin_validate.py b/hermes_cli/plugin_validate.py index 6b3be69367..5aff1c4d84 100644 --- a/hermes_cli/plugin_validate.py +++ b/hermes_cli/plugin_validate.py @@ -414,7 +414,17 @@ def validate_plugin_dir(plugin_dir: Path) -> ValidationReport: 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") + # Portable Agent Plugins v1 (#81196): a plugin.json-only package is + # admissible — validated through the portable manifest reader. When a + # package carries both manifests the native plugin.yaml always wins + # (this branch is only reached when no native manifest exists). + portable_file = plugin_dir / "plugin.json" + if portable_file.is_file(): + return _validate_portable_plugin(report, plugin_dir) + report.add( + "manifest", False, + "no plugin.yaml (or portable plugin.json) in the plugin directory", + ) return report import yaml @@ -438,3 +448,35 @@ def validate_plugin_dir(plugin_dir: Path) -> ValidationReport: recorded = _check_capabilities(report, manifest, plugin_dir) _check_builtin_collisions(report, manifest, recorded) return report + + +def _validate_portable_plugin(report: ValidationReport, plugin_dir: Path) -> ValidationReport: + """Admission checks for a portable Agent Plugins v1 (plugin.json) package. + + Portable packages have no register() entry point, so the capability + probe does not apply; validation is the manifest reader's own + diagnostics (schema shape, name, supported subset). + """ + try: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + manifest, diagnostics = read_agent_plugin_manifest(plugin_dir) + except Exception as exc: + report.add("portable manifest", False, f"plugin.json failed validation: {exc}") + return report + + # The portable reader raises on hard failures; surviving diagnostics are + # advisory (unsupported-subset notes etc.) — surface them as warnings. + for diag in diagnostics: + scope = getattr(diag, "scope", "") + message = getattr(diag, "message", str(diag)) + report.warnings.append(f"{scope}: {message}" if scope else message) + + report.add("portable manifest", True, "plugin.json parses (Agent Plugins v1)") + name = str(manifest.get("name") or "").strip() + report.add( + "manifest fields", + bool(name), + "name present" if name else "plugin.json missing required 'name'", + ) + return report diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 00ddbe65e1..c099d4692b 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1007,6 +1007,21 @@ write_catalog_sidecar = _write_catalog_sidecar read_catalog_sidecar = _read_catalog_sidecar +def _catalog_annotation(dir_path) -> Optional[str]: + """Return ``catalog:@`` 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: @@ -2213,16 +2228,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_note = _catalog_annotation(_dir) + if catalog_note: + row["catalog"] = catalog_note + 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 @@ -2252,13 +2273,7 @@ def cmd_list(args: Any | None = None) -> None: status = "[green]enabled[/green]" else: status = "[yellow]not enabled[/yellow]" - source_label = source - if _dir: - sidecar = _read_catalog_sidecar(Path(_dir)) - if sidecar and sidecar.get("catalog_name"): - tier = str(sidecar.get("tier") or "community") - sha = str(sidecar.get("sha") or "") - source_label = f"catalog:{tier}@{sha[:8]}" + source_label = _catalog_annotation(_dir) or source removed_reason = _removed_annotation(name, _dir) if removed_reason is not None: removed_lines.append( diff --git a/tests/hermes_cli/test_plugin_index_search.py b/tests/hermes_cli/test_plugin_index_search.py index 5492b887ca..9efc5f174d 100644 --- a/tests/hermes_cli/test_plugin_index_search.py +++ b/tests/hermes_cli/test_plugin_index_search.py @@ -329,7 +329,7 @@ class TestInstallResolution: ) captured = {} - def fake_core(identifier, *, force, ref=None, scan_decision_cb=None): + def fake_core(identifier, *, force, ref=None, scan_decision_cb=None, skip_removed_check=False): captured["identifier"] = identifier captured["ref"] = ref raise plugins_cmd.PluginOperationError("stop here") @@ -348,7 +348,7 @@ class TestInstallResolution: ) captured = {} - def fake_core(identifier, *, force, ref=None, scan_decision_cb=None): + def fake_core(identifier, *, force, ref=None, scan_decision_cb=None, skip_removed_check=False): captured["ref"] = ref raise plugins_cmd.PluginOperationError("stop here") @@ -401,7 +401,7 @@ class TestInstallResolution: monkeypatch.setattr(plugin_index, "load_index", boom) captured = {} - def fake_core(identifier, *, force, ref=None, scan_decision_cb=None): + def fake_core(identifier, *, force, ref=None, scan_decision_cb=None, skip_removed_check=False): captured["identifier"] = identifier captured["ref"] = ref raise plugins_cmd.PluginOperationError("stop here") diff --git a/tests/hermes_cli/test_plugin_validate.py b/tests/hermes_cli/test_plugin_validate.py index d1051cdbc7..a728e9eed0 100644 --- a/tests/hermes_cli/test_plugin_validate.py +++ b/tests/hermes_cli/test_plugin_validate.py @@ -72,20 +72,22 @@ class TestStaticChecks: 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"}]) + def test_invalid_config_schema_fails(self, tmp_path): + manifest = dict( + BASE_MANIFEST, config_schema={"endpoint": {"type": "no-such-type"}} + ) 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): + def test_valid_config_schema_passes(self, tmp_path): manifest = dict( BASE_MANIFEST, - config=[ - {"key": "endpoint", "prompt": "Endpoint?", "type": "str"}, - {"key": "token", "secret": True, "type": "str"}, - ], + config_schema={ + "endpoint": {"type": "str", "description": "Endpoint?"}, + "retries": {"type": "int", "default": 3}, + }, ) d = _make_plugin(tmp_path, manifest=manifest) report = validate_plugin_dir(d) diff --git a/tests/hermes_cli/test_plugins_cmd_catalog.py b/tests/hermes_cli/test_plugins_cmd_catalog.py index 809b1de3a6..d990158cb6 100644 --- a/tests/hermes_cli/test_plugins_cmd_catalog.py +++ b/tests/hermes_cli/test_plugins_cmd_catalog.py @@ -86,7 +86,8 @@ def fake_core(monkeypatch, tmp_path): calls: list[dict] = [] target = tmp_path / "fake-installed" - def fake(identifier, *, force, ref=None, skip_removed_check=False): + def fake(identifier, *, force, ref=None, skip_removed_check=False, + scan_decision_cb=None): target.mkdir(parents=True, exist_ok=True) calls.append( { @@ -443,7 +444,7 @@ class TestSearchBrowseInfo: 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 + assert "No plugins matched" in out def test_info_shows_full_detail(self, catalog_dir, offline, capsys): _write_entry( @@ -486,69 +487,6 @@ class TestSearchBrowseInfo: # ── 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 = {} @@ -566,7 +504,6 @@ class TestDispatch: "cmd_browse", "cmd_info", "cmd_validate", - "cmd_doctor", "cmd_install", ): monkeypatch.setattr(plugins_cmd, fn, record(fn)) @@ -575,9 +512,9 @@ class TestDispatch: return recorded def test_search_dispatch(self, monkeypatch): - rec = self._dispatch(monkeypatch, "search", query="foo") + rec = self._dispatch(monkeypatch, "search", term="foo") assert rec["fn"] == "cmd_search" - assert "foo" in rec["args"] or rec["kwargs"].get("query") == "foo" + assert "foo" in rec["args"] or rec["kwargs"].get("term") == "foo" def test_browse_dispatch(self, monkeypatch): rec = self._dispatch(monkeypatch, "browse") @@ -592,8 +529,15 @@ class TestDispatch: assert rec["fn"] == "cmd_validate" def test_doctor_dispatch(self, monkeypatch): - rec = self._dispatch(monkeypatch, "doctor", name=None) - assert rec["fn"] == "cmd_doctor" + # `doctor` is owned by the runtime-contract dev doctor on main. + recorded = {} + monkeypatch.setattr( + plugins_cmd, "cmd_plugin_doctor", + lambda target, *, ci=False: recorded.setdefault("target", target), + ) + ns = argparse.Namespace(plugins_action="doctor", target=".", ci=False) + plugins_cmd.plugins_command(ns) + assert recorded["target"] == "." def test_install_allow_removed_dispatch(self, monkeypatch): rec = self._dispatch( diff --git a/website/docs/user-guide/features/plugin-catalog.md b/website/docs/user-guide/features/plugin-catalog.md index d443a014d0..0b18382b2f 100644 --- a/website/docs/user-guide/features/plugin-catalog.md +++ b/website/docs/user-guide/features/plugin-catalog.md @@ -81,6 +81,20 @@ hermes plugins enable The install prompt shows the entry's capability summary — declared tools, hooks, and required env vars — before anything is cloned. +### Updating a catalog install + +`hermes plugins update ` never runs `git pull` for catalog installs — +it compares your installed pin against the current catalog pin and, when the +catalog moved (via a reviewed PR), force-reinstalls at the new SHA. Your +enabled/disabled state is preserved. `hermes plugins list` shows catalog +installs as `catalog:@` so you can see provenance at a glance. + +### Names not in the catalog + +A bare name that isn't a catalog entry falls back to the +[community plugin index](plugins.md) with a warning — those entries are +indexed, not reviewed. Catalog names always win when both exist. + ### Custom git URLs are different `hermes plugins install ` still works for any repository, but it From f7a26d8f9da0cf7e517d3005694c0aa614f235aa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:44:56 -0700 Subject: [PATCH 15/25] fix(catalog): move catalog_name onto the shared _AgentPluginInstallBody in web_models The install body model was extracted to web_models.py on main; the branch re-declared it locally in web_server.py, shadowing the import and leaving the shared model without catalog_name (hub perf-guard test red). --- hermes_cli/web_models.py | 2 ++ hermes_cli/web_server.py | 7 ------- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py index fa5dd37243..32dcc815f6 100644 --- a/hermes_cli/web_models.py +++ b/hermes_cli/web_models.py @@ -739,6 +739,8 @@ class _AgentPluginInstallBody(BaseModel): identifier: str force: bool = False enable: bool = True + # Install by curated-catalog name (resolves repo + pinned SHA server-side). + catalog_name: Optional[str] = None # --- from web_server.py (originally lines 19896-19898) --- diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 869596de16..bdbdb2f7a4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -18691,13 +18691,6 @@ async def rescan_dashboard_plugins(): return {"ok": True, "count": len(plugins)} -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("_")} From cbf86f4265413aed1304425daea7a5c971ec1943 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:52:33 -0700 Subject: [PATCH 16/25] =?UTF-8?q?feat(desktop):=20plugin=20catalog=20in=20?= =?UTF-8?q?Capabilities=20=E2=80=94=20embedded=20picker,=20one-click=20dua?= =?UTF-8?q?l-target=20install,=20drift=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Capabilities gains a 4th tab (Plugins): the scoped (connection, profile)'s installed agent plugins with toggles, and the live docs-site plugin catalog embedded underneath (?embed=picker) — same shape as the Skills hub. - '+ Add to this Agent' on a catalog card opens the existing dual-target install modal: the agent half installs at the catalog's reviewed pin into the scoped profile (plugins.manage catalog_name= passthrough), the desktop half installs locally; bundled agent+desktop packages offer both in one flow. - Settings > Plugins: desktop halves of bundled packages get an 'agent half missing here' badge when the currently connected backend/profile lacks the agent component — the one-app/N-agents drift is now visible. - Docs /plugins page: picker embed mode (chrome hidden, pick button posts hermes-plugin-pick), subdir carried through the extractor. - Gateway plugins.manage install accepts catalog_name (resolved server-side against the backend's own catalog; removed-blocklist enforced, no bypass). --- .../src/app/settings/plugin-install-modal.tsx | 11 +- .../src/app/settings/plugins-settings.tsx | 46 +++- apps/desktop/src/app/skills/index.tsx | 15 +- .../src/app/skills/plugins-tab.test.tsx | 136 ++++++++++ apps/desktop/src/app/skills/plugins-tab.tsx | 254 ++++++++++++++++++ apps/desktop/src/i18n/en.ts | 19 ++ apps/desktop/src/i18n/types.ts | 16 ++ apps/desktop/src/i18n/zh.ts | 19 ++ apps/desktop/src/store/agent-plugins.ts | 18 +- .../src/store/plugin-install-request.ts | 8 + .../test_plugins_manage_install.py | 29 ++ tui_gateway/methods_tools.py | 10 +- website/scripts/extract-plugins.py | 1 + website/src/pages/plugins/index.tsx | 48 +++- website/src/pages/plugins/styles.module.css | 36 +++ 15 files changed, 649 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/app/skills/plugins-tab.test.tsx create mode 100644 apps/desktop/src/app/skills/plugins-tab.tsx diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index 40012f238c..d88734a0f2 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -145,7 +145,7 @@ export function PluginInstallModal() { void runProbe(request) }, [request, resetState, runProbe]) - const profileLabel = activeProfile || profileScope || 'default' + const profileLabel = request?.profile || activeProfile || profileScope || 'default' const agentTargetHint = connection?.mode === 'remote' ? m.agentTargetRemote(profileLabel) : m.agentTargetLocal(profileLabel) @@ -183,7 +183,9 @@ export function PluginInstallModal() { const result = await installAgentPlugin(requestGateway, { identifier: request.repo, force: forceReinstall, - enable: enableAgent + enable: enableAgent, + catalogName: request.catalogName, + profile: request.profile }) if (result.ok) { @@ -273,6 +275,11 @@ export function PluginInstallModal() {
{request.repo}
+ {request.catalogName && ( +

+ {m.catalogPinned(request.catalogName, request.sha?.slice(0, 8) ?? '')} +

+ )}
diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 69598afe43..ace8f45b07 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -310,7 +310,21 @@ function AgentPluginsSection() { ) } -function PluginRow({ record }: { record: PluginRecord }) { +/** Folder name when a desktop plugin entry lives in the UNIFIED agent-plugins + * root (`~/.hermes/plugins//desktop/plugin.js`) — i.e. it is the + * desktop half of a bundled agent+desktop package. Null for standalone + * desktop plugins. */ +function unifiedPackageName(file?: string): null | string { + if (!file) { + return null + } + + const match = /[\\/]plugins[\\/]([^\\/]+)[\\/]desktop[\\/]plugin\.js$/.exec(file) + + return match ? match[1] : null +} + +function PluginRow({ record, agentHalfMissing }: { record: PluginRecord; agentHalfMissing?: boolean }) { const { t } = useI18n() const p = t.settings.plugins @@ -348,6 +362,13 @@ function PluginRow({ record }: { record: PluginRecord }) { {record.name} {p.kinds[record.kind]} {record.status === 'error' && {p.failed}} + {agentHalfMissing && ( + + + {p.agentHalfMissing} + + + )} } /> @@ -358,6 +379,13 @@ export function PluginsSettings() { const { t } = useI18n() const p = t.settings.plugins const records = useStore($pluginRecords) + // The agent-plugin list for the CURRENTLY scoped backend/profile — used to + // flag bundled packages whose desktop half is local but whose agent half is + // not installed where the app is now pointing (one desktop app, N agents: + // switching gateway/profile makes this drift visible instead of silent). + const agentRows = useStore($agentPlugins) + const agentStatus = useStore($agentPluginsStatus) + const agentNames = new Set(agentRows.flatMap(row => [row.name, row.key ?? row.name])) // Deep-link from settings search (?plugin=): rows render as soon // as their store hydrates, so "ready" is simply target-present; the polling @@ -400,9 +428,19 @@ export function PluginsSettings() { ) : (
- {rows.map(record => ( - - ))} + {rows.map(record => { + const packageName = unifiedPackageName(record.file) + + return ( + + ) + })}
)} diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 464d84e928..50e88f28cb 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -63,12 +63,13 @@ import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { EmbeddedHubPicker } from './embedded-hub-picker' import { McpTab } from './mcp-tab' +import { PluginsTab } from './plugins-tab' import { $skillsSortDesc, $toolsetsSortDesc } from './store' // 'hub' is gone as a top-level tab — the Skills Hub browser lives inside the // Skills tab now (EmbeddedHubPicker below the installed list). Legacy // `?tab=hub` links fall back to 'skills' via useRouteEnumParam. -const SKILLS_MODES = ['skills', 'toolsets', 'mcp'] as const +const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const // Skills + toolsets live in the RQ cache so switching tabs/pages paints the // cached lists instantly (no reload flash) and mount only fires a deduped @@ -771,14 +772,15 @@ export function SkillsView({ onTabChange={id => setMode(id as (typeof SKILLS_MODES)[number])} // MCP manages a handful of entries with the editor right there — // searching it is noise. - searchHidden={mode === 'mcp'} + searchHidden={mode === 'mcp' || mode === 'plugins'} searchHints={searchHints} searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets} searchValue={query} tabs={[ { id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null }, { id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null }, - { id: 'mcp', label: t.skills.tabMcp } + { id: 'mcp', label: t.skills.tabMcp }, + { id: 'plugins', label: t.skills.tabPlugins } ]} > {/* One shared column: the scope selector sits above whichever tab is @@ -788,7 +790,12 @@ export function SkillsView({ {profileScopeSelector}
- {mode === 'mcp' ? ( + {mode === 'plugins' ? ( + // Agent plugins for the scoped profile: installed list on top, + // the live catalog picker underneath (same shape as Skills). + // Keyed on scope so a profile/connection switch reloads the list. + + ) : mode === 'mcp' ? ( // The gateway instance backs ONLY the live `reload.mcp` RPC, and // it is the ACTIVE gateway's socket — for a scope pinned to a // different backend that RPC would hot-reload the wrong diff --git a/apps/desktop/src/app/skills/plugins-tab.test.tsx b/apps/desktop/src/app/skills/plugins-tab.test.tsx new file mode 100644 index 0000000000..04009dfe6b --- /dev/null +++ b/apps/desktop/src/app/skills/plugins-tab.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins' +import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request' + +import { PluginsTab } from './plugins-tab' + +const requestGateway = vi.fn(async () => ({ plugins: [] })) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +describe('PluginsTab', () => { + beforeEach(() => { + $agentPlugins.set([]) + $agentPluginsStatus.set('ready') + closePluginInstallRequest() + requestGateway.mockClear() + }) + + afterEach(cleanup) + + it('lists the scoped profile agent plugins with toggles', () => { + $agentPlugins.set([ + { + description: 'A test plugin', + key: 'demo-plugin', + name: 'demo-plugin', + source: 'git', + status: 'enabled', + version: '1.0.0' + } + ]) + + render() + + expect(screen.getByText('demo-plugin')).toBeTruthy() + expect(screen.getByRole('switch', { name: 'demo-plugin' }).getAttribute('aria-checked')).toBe('true') + }) + + it('hides bundled plugins (managed from their own surfaces)', () => { + $agentPlugins.set([ + { + description: '', + key: 'image_gen/fal', + name: 'fal', + source: 'bundled', + status: 'enabled', + version: '' + } + ]) + + render() + + expect(screen.queryByText('fal')).toBeNull() + expect(screen.getByText(/No agent plugins installed/)).toBeTruthy() + }) + + it('loads the plugin list scoped to the selected profile', () => { + render() + + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'list', profile: 'workbot' }) + ) + }) + + it('opens the dual-target install modal from a catalog pick message', async () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'weather-plugin', + repo: 'https://github.com/example/weather-plugin', + sha: 'a'.repeat(40), + subdir: '', + tier: 'community', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => { + const request = $pluginInstallRequest.get() + + expect(request).not.toBeNull() + expect(request?.catalogName).toBe('weather-plugin') + expect(request?.repo).toBe('https://github.com/example/weather-plugin') + expect(request?.profile).toBe('workbot') + expect(request?.sha).toBe('a'.repeat(40)) + }) + }) + + it('ignores pick messages from foreign origins', () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'evil-plugin', + repo: 'https://github.com/evil/evil-plugin', + type: 'hermes-plugin-pick' + }, + origin: 'https://evil.example.com' + }) + ) + + expect($pluginInstallRequest.get()).toBeNull() + }) + + it('appends the subdir fragment for multi-plugin repos', async () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'nested-plugin', + repo: 'https://github.com/example/plugins-monorepo', + subdir: 'nested-plugin', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => { + expect($pluginInstallRequest.get()?.repo).toBe( + 'https://github.com/example/plugins-monorepo#nested-plugin' + ) + }) + }) +}) diff --git a/apps/desktop/src/app/skills/plugins-tab.tsx b/apps/desktop/src/app/skills/plugins-tab.tsx new file mode 100644 index 0000000000..94da93f140 --- /dev/null +++ b/apps/desktop/src/app/skills/plugins-tab.tsx @@ -0,0 +1,254 @@ +import { useStore } from '@nanostores/react' +import { memo, useEffect, useMemo, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' +import type { ProfileScope } from '@/hermes' +import { useI18n } from '@/i18n' +import { Loader2, Package } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $agentPluginBusy, + $agentPlugins, + $agentPluginsError, + $agentPluginsStatus, + type AgentPluginRow, + isDesktopRelevantPlugin, + loadAgentPlugins, + toggleAgentPlugin +} from '@/store/agent-plugins' +import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes' +import { openPluginInstallRequest } from '@/store/plugin-install-request' + +import { PanelEmpty } from '../overlays/panel' + +// The REAL Plugin Catalog page (docs site) embedded as a one-click picker — +// the same pattern as the Skills tab's EmbeddedHubPicker. `?embed=picker` +// hides the docs chrome and adds "+ Add to this Agent" per card, which posts +// { type: 'hermes-plugin-pick', name, repo, sha, subdir, tier, installCmd } +// to the parent window. We validate the origin and open the shared +// dual-target install modal (agent half → catalog-pinned install into the +// scoped profile; desktop half → local app), so bundled agent+desktop +// packages install both halves in one flow. +const CATALOG_ORIGIN = 'https://hermes-agent.nousresearch.com' +const CATALOG_PICKER_URL = `${CATALOG_ORIGIN}/docs/plugins?embed=picker` + +const CATALOG_PANE_ID = 'capabilities-plugin-catalog' +const CATALOG_DEFAULT_PX = 380 +const CATALOG_COLLAPSED_PX = 4 + +interface PluginPickMessage { + installCmd?: string + name?: string + repo?: string + sha?: string + subdir?: string + tier?: string + type?: string +} + +/** Derive the bare profile name a `plugins.manage` call should target. */ +function profileParam(scope: ProfileScope): null | string { + if (!scope) { + return null + } + + return typeof scope === 'string' ? scope : (scope.profile ?? null) +} + +function PluginRow({ + row, + busy, + onToggle +}: { + row: AgentPluginRow + busy: boolean + onToggle: (enable: boolean) => void +}) { + const { t } = useI18n() + const address = row.key ?? '' + const canToggle = Boolean(address) + const enabled = row.status === 'enabled' + + return ( +
+ +
+
+ {row.name} + {row.version && v{row.version}} + {row.portable && ( + + {t.skills.plugins.portableBadge} + + )} +
+ {row.description && ( +
+ {row.description} +
+ )} +
+
+ {busy && } + {canToggle ? ( + + ) : ( + + + + + + )} +
+
+ ) +} + +/** Agent plugins for the Capabilities page: the scoped profile's installed + * plugins on top (toggleable), the live catalog picker underneath — same + * management-plus-discovery shape as the Skills tab. */ +export const PluginsTab = memo(function PluginsTab({ profile }: { profile: ProfileScope }) { + const { t } = useI18n() + const p = t.skills.plugins + const { requestGateway } = useGatewayRequest() + + const rows = useStore($agentPlugins) + const status = useStore($agentPluginsStatus) + const error = useStore($agentPluginsError) + const busyKey = useStore($agentPluginBusy) + + const scope = profileParam(profile) + + useEffect(() => { + void loadAgentPlugins(requestGateway, scope) + }, [requestGateway, scope]) + + const visible = useMemo(() => rows.filter(isDesktopRelevantPlugin), [rows]) + + // Catalog picker viewport (persisted height, collapse toggle) — same pane + // store contract as EmbeddedHubPicker. + const heightOverride = useStore($paneHeightOverride(CATALOG_PANE_ID)) + const height = heightOverride ?? CATALOG_DEFAULT_PX + const open = height > CATALOG_COLLAPSED_PX + const [pickerMounted, setPickerMounted] = useState(open) + + if (open && !pickerMounted) { + setPickerMounted(true) + } + + useEffect(() => { + if (!open) { + return undefined + } + + const onMessage = (event: MessageEvent) => { + if (event.origin !== CATALOG_ORIGIN) { + return + } + + const data = event.data as null | PluginPickMessage + + if (!data || data.type !== 'hermes-plugin-pick' || !data.name || !data.repo) { + return + } + + // Open the shared dual-target install modal: it probes the repo for + // agent/desktop halves, installs the agent half at the catalog pin + // into the scoped profile, and offers the desktop half locally. + openPluginInstallRequest({ + catalogName: String(data.name), + profile: scope, + repo: data.subdir ? `${String(data.repo)}#${String(data.subdir)}` : String(data.repo), + sha: data.sha ? String(data.sha) : undefined + }) + } + + window.addEventListener('message', onMessage) + + return () => window.removeEventListener('message', onMessage) + }, [open, scope]) + + return ( +
+
+ {status === 'error' ? ( + void loadAgentPlugins(requestGateway, scope)} size="sm"> + {t.skills.refresh} + + } + description={error ?? undefined} + icon="error" + title={p.loadFailed} + /> + ) : visible.length === 0 && status === 'ready' ? ( + + ) : ( +
+ {visible.map(row => ( + { + if (!row.key) { + return + } + + void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope) + }} + row={row} + /> + ))} +
+ )} +
+ +
+
+ {p.catalogTitle} + +
+ {pickerMounted && ( +
+
+