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] =?UTF-8?q?feat(dashboard):=20plugin=20catalog=20surface?= =?UTF-8?q?=20=E2=80=94=20browse,=20capability-confirm=20install,=20remove?= =?UTF-8?q?d=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 731f35c6efa4..8a5d2f39d3af 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 238b4acf88e7..e40ed6a473fb 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 000000000000..26cd3ef6ac9b --- /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 38c61a15ca3c..14f61938c3f1 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 52fd86acf6a4..4257591d9076 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 000000000000..d2cc219f9086 --- /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 7474a8322a54..af15d9bb40e8 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 52baf89cbf9c..92be0668304d 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} +
+ + + ); +}