mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
feat(dashboard): plugin catalog surface — browse, capability-confirm install, removed banners
- GET /api/dashboard/plugins/catalog: catalog entries merged with installed-state (sidecar SHA, update_available, runtime_status) + removed blocklist + generated_at - agent-plugins/install accepts catalog_name: resolves the catalog entry, refuses removed plugins (400, no dashboard bypass), installs at the pinned SHA and writes the .hermes-catalog.json sidecar - plugins/hub rows annotated with removed_reason - PluginsPage: Catalog section with search, tier badges, capability chips, sha/docs links, capability-summary confirm dialog install flow, and red removed banners on catalog + installed rows - en i18n keys with en-only optional-key fallback convention
This commit is contained in:
parent
6f5608bed3
commit
fe9dc06071
8 changed files with 932 additions and 21 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
296
tests/hermes_cli/test_web_plugins_catalog.py
Normal file
296
tests/hermes_cli/test_web_plugins_catalog.py
Normal file
|
|
@ -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
|
||||
|
|
@ -402,6 +402,21 @@ export const en: Translations = {
|
|||
versionBadge: "Version",
|
||||
showInSidebar: "Show in sidebar",
|
||||
hideFromSidebar: "Hide from sidebar",
|
||||
catalogHeading: "Plugin catalog",
|
||||
catalogHint:
|
||||
"Curated, Nous-reviewed plugins pinned to exact commits. Install from here for supply-chain-safe versions.",
|
||||
catalogSearchPlaceholder: "Search catalog...",
|
||||
catalogEmpty: "No catalog entries match.",
|
||||
catalogEmptyDocsLink: "Learn about Hermes plugins",
|
||||
catalogInstallBtn: "Install",
|
||||
catalogInstalledBadge: "Installed ✓",
|
||||
catalogUpdateBtn: "Update available",
|
||||
catalogRemovedBadge: "Removed",
|
||||
catalogConfirmTitle: "Install this plugin?",
|
||||
catalogConfirmInstallNote:
|
||||
"Plugins install disabled; enable it after install to activate.",
|
||||
catalogRequiresEnv: "Requires env",
|
||||
removedFromCatalog: "Removed from catalog",
|
||||
},
|
||||
|
||||
skills: {
|
||||
|
|
|
|||
|
|
@ -351,6 +351,20 @@ export interface Translations {
|
|||
versionBadge: string;
|
||||
showInSidebar: string;
|
||||
hideFromSidebar: string;
|
||||
// Catalog section (en-only fallback convention — optional keys).
|
||||
catalogHeading?: string;
|
||||
catalogHint?: string;
|
||||
catalogSearchPlaceholder?: string;
|
||||
catalogEmpty?: string;
|
||||
catalogEmptyDocsLink?: string;
|
||||
catalogInstallBtn?: string;
|
||||
catalogInstalledBadge?: string;
|
||||
catalogUpdateBtn?: string;
|
||||
catalogRemovedBadge?: string;
|
||||
catalogConfirmTitle?: string;
|
||||
catalogConfirmInstallNote?: string;
|
||||
catalogRequiresEnv?: string;
|
||||
removedFromCatalog?: string;
|
||||
};
|
||||
|
||||
// ── Profiles page ──
|
||||
|
|
|
|||
58
web/src/lib/api-plugins-catalog.test.ts
Normal file
58
web/src/lib/api-plugins-catalog.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { api } from "./api";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonFetchMock(body: unknown = { ok: true }) {
|
||||
return vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("api.getPluginsCatalog", () => {
|
||||
it("fetches the dashboard plugins catalog endpoint", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
|
||||
const fetchMock = jsonFetchMock({ entries: [], removed: [], generated_at: "" });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await api.getPluginsCatalog();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/dashboard/plugins/catalog",
|
||||
expect.objectContaining({ credentials: "include" }),
|
||||
);
|
||||
expect(result.entries).toEqual([]);
|
||||
expect(result.removed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api.installAgentPlugin with catalog_name", () => {
|
||||
it("posts catalog_name through to the install endpoint", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
|
||||
const fetchMock = jsonFetchMock({ ok: true, plugin_name: "alpha-plugin" });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.installAgentPlugin({
|
||||
identifier: "",
|
||||
catalog_name: "alpha-plugin",
|
||||
enable: false,
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("/api/dashboard/agent-plugins/install");
|
||||
const body = JSON.parse(String((init as RequestInit).body));
|
||||
expect(body.catalog_name).toBe("alpha-plugin");
|
||||
expect(body.identifier).toBe("");
|
||||
expect(body.enable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -921,6 +921,9 @@ export const api = {
|
|||
|
||||
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
|
||||
|
||||
getPluginsCatalog: () =>
|
||||
fetchJSON<CatalogResponse>("/api/dashboard/plugins/catalog"),
|
||||
|
||||
installAgentPlugin: (body: AgentPluginInstallRequest) =>
|
||||
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
|
||||
method: "POST",
|
||||
|
|
@ -2506,6 +2509,8 @@ export interface HubAgentPluginRow {
|
|||
auth_required: boolean;
|
||||
auth_command: string;
|
||||
user_hidden: boolean;
|
||||
/** Reason string when this plugin is on the catalog removed blocklist. */
|
||||
removed_reason?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginsHubProviders {
|
||||
|
|
@ -2525,6 +2530,8 @@ export interface AgentPluginInstallRequest {
|
|||
identifier: string;
|
||||
force?: boolean;
|
||||
enable?: boolean;
|
||||
/** Install by curated-catalog name (resolves repo + pinned SHA server-side). */
|
||||
catalog_name?: string;
|
||||
}
|
||||
|
||||
export interface AgentPluginInstallResponse {
|
||||
|
|
@ -2537,6 +2544,48 @@ export interface AgentPluginInstallResponse {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
// ── Plugin catalog types ───────────────────────────────────────────────
|
||||
|
||||
export interface CatalogCapabilities {
|
||||
provides_tools: string[];
|
||||
provides_hooks: string[];
|
||||
provides_middleware: string[];
|
||||
requires_env: string[];
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
repo: string;
|
||||
sha: string;
|
||||
sha_short: string;
|
||||
tier: "official" | "community";
|
||||
maintainer: string;
|
||||
requires_hermes: string;
|
||||
platforms: string[];
|
||||
capabilities: CatalogCapabilities;
|
||||
docs_url: string;
|
||||
capability_summary: string;
|
||||
/** Installed-state merge (computed server-side). */
|
||||
installed: boolean;
|
||||
installed_sha: string | null;
|
||||
update_available: boolean;
|
||||
runtime_status: "disabled" | "enabled" | "inactive" | null;
|
||||
}
|
||||
|
||||
export interface CatalogRemovedEntry {
|
||||
name: string;
|
||||
repo: string;
|
||||
reason: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface CatalogResponse {
|
||||
entries: CatalogEntry[];
|
||||
removed: CatalogRemovedEntry[];
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface AgentPluginUpdateResponse {
|
||||
ok: boolean;
|
||||
name?: string;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
|
||||
import type { Translations } from "@/i18n/types";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
CatalogEntry,
|
||||
CatalogRemovedEntry,
|
||||
CatalogResponse,
|
||||
HubAgentPluginRow,
|
||||
MemoryProviderConfig,
|
||||
MemoryProviderField,
|
||||
|
|
@ -281,6 +284,11 @@ function MemoryProviderSetupHint({
|
|||
export default function PluginsPage() {
|
||||
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [catalogSearch, setCatalogSearch] = useState("");
|
||||
const [catalogConfirm, setCatalogConfirm] = useState<CatalogEntry | null>(null);
|
||||
const [catalogBusy, setCatalogBusy] = useState<string | null>(null);
|
||||
const [installId, setInstallId] = useState("");
|
||||
const [installForce, setInstallForce] = useState(false);
|
||||
const [installEnable, setInstallEnable] = useState(true);
|
||||
|
|
@ -316,9 +324,17 @@ export default function PluginsPage() {
|
|||
.catch(() => showToast(t.common.loading, "error"));
|
||||
}, [showToast, t.common.loading]);
|
||||
|
||||
const loadCatalog = useCallback(() => {
|
||||
return api
|
||||
.getPluginsCatalog()
|
||||
.then(setCatalog)
|
||||
.catch(() => setCatalog(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHub().finally(() => setLoading(false));
|
||||
}, [loadHub]);
|
||||
void loadCatalog().finally(() => setCatalogLoading(false));
|
||||
}, [loadHub, loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel;
|
||||
|
|
@ -391,6 +407,27 @@ export default function PluginsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const onCatalogInstall = async (entry: CatalogEntry) => {
|
||||
setCatalogConfirm(null);
|
||||
setCatalogBusy(entry.name);
|
||||
try {
|
||||
const r = await api.installAgentPlugin({
|
||||
identifier: "",
|
||||
catalog_name: entry.name,
|
||||
force: entry.installed,
|
||||
enable: false,
|
||||
});
|
||||
showToast(`${r.plugin_name ?? entry.name} installed`, "success");
|
||||
if ((r.missing_env?.length ?? 0) > 0)
|
||||
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
|
||||
await Promise.all([loadHub(), loadCatalog()]);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Install failed", "error");
|
||||
} finally {
|
||||
setCatalogBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onRescan = useCallback(async () => {
|
||||
setRescanBusy(true);
|
||||
try {
|
||||
|
|
@ -506,6 +543,27 @@ export default function PluginsPage() {
|
|||
|
||||
const rows = hub?.plugins ?? [];
|
||||
const providers = hub?.providers;
|
||||
|
||||
const catalogEntries = useMemo(() => {
|
||||
const entries = catalog?.entries ?? [];
|
||||
const q = catalogSearch.trim().toLowerCase();
|
||||
if (!q) return entries;
|
||||
return entries.filter((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.description,
|
||||
entry.maintainer,
|
||||
...entry.capabilities.provides_tools,
|
||||
].some((haystack) => haystack.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [catalog, catalogSearch]);
|
||||
|
||||
const removedByName = useMemo(() => {
|
||||
const map = new Map<string, CatalogRemovedEntry>();
|
||||
for (const r of catalog?.removed ?? []) map.set(r.name, r);
|
||||
return map;
|
||||
}, [catalog]);
|
||||
|
||||
const selectedMemoryName = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel;
|
||||
const selectedMemoryInfo = selectedMemoryName
|
||||
? providers?.memory_options.find((provider) => provider.name === selectedMemoryName)
|
||||
|
|
@ -822,6 +880,59 @@ export default function PluginsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-3" data-testid="plugin-catalog-section">
|
||||
|
||||
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
|
||||
{t.pluginsPage.catalogHeading ?? "Plugin catalog"}
|
||||
</h3>
|
||||
|
||||
<p className="text-xs tracking-[0.06em] text-text-tertiary">
|
||||
{t.pluginsPage.catalogHint ??
|
||||
"Curated, Nous-reviewed plugins pinned to exact commits."}
|
||||
</p>
|
||||
|
||||
<Input
|
||||
className="max-w-md"
|
||||
placeholder={t.pluginsPage.catalogSearchPlaceholder ?? "Search catalog..."}
|
||||
value={catalogSearch}
|
||||
onChange={(e) => setCatalogSearch(e.target.value)}
|
||||
aria-label={t.pluginsPage.catalogSearchPlaceholder ?? "Search catalog..."}
|
||||
/>
|
||||
|
||||
{catalogLoading ? (
|
||||
<div className="flex items-center gap-2 py-4 text-xs text-text-tertiary">
|
||||
<Spinner />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
) : catalogEntries.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{t.pluginsPage.catalogEmpty ?? "No catalog entries match."}{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://hermes-agent.nousresearch.com/docs/plugins"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t.pluginsPage.catalogEmptyDocsLink ?? "Learn about Hermes plugins"}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{catalogEntries.map((entry) => (
|
||||
<li key={entry.name}>
|
||||
<CatalogEntryCard
|
||||
busy={catalogBusy === entry.name}
|
||||
entry={entry}
|
||||
onInstall={() => setCatalogConfirm(entry)}
|
||||
removed={removedByName.get(entry.name) ?? null}
|
||||
t={t}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
<h3 className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
|
||||
|
|
@ -896,6 +1007,30 @@ export default function PluginsPage() {
|
|||
|
||||
<Toast toast={toast} />
|
||||
<PluginSlot name="plugins:bottom" />
|
||||
|
||||
<ConfirmDialog
|
||||
open={catalogConfirm !== null}
|
||||
onCancel={() => setCatalogConfirm(null)}
|
||||
onConfirm={() => {
|
||||
if (catalogConfirm) void onCatalogInstall(catalogConfirm);
|
||||
}}
|
||||
title={t.pluginsPage.catalogConfirmTitle ?? "Install this plugin?"}
|
||||
description={
|
||||
catalogConfirm
|
||||
? [
|
||||
catalogConfirm.capability_summary,
|
||||
catalogConfirm.capabilities.requires_env.length
|
||||
? `${t.pluginsPage.catalogRequiresEnv ?? "Requires env"}: ${catalogConfirm.capabilities.requires_env.join(", ")}`
|
||||
: "",
|
||||
t.pluginsPage.catalogConfirmInstallNote ??
|
||||
"Plugins install disabled; enable it after install to activate.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: ""
|
||||
}
|
||||
confirmLabel={t.pluginsPage.catalogInstallBtn ?? "Install"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -961,6 +1096,12 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
{row.auth_required ? (
|
||||
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||
) : null}
|
||||
|
||||
{row.removed_reason ? (
|
||||
<Badge tone="destructive">
|
||||
{t.pluginsPage.catalogRemovedBadge ?? "Removed"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
|
|
@ -1070,6 +1211,12 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{row.removed_reason ? (
|
||||
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
|
||||
{t.pluginsPage.removedFromCatalog ?? "Removed from catalog"}: {row.removed_reason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{dm?.slots?.length ? (
|
||||
|
||||
<p className="text-xs tracking-[0.05em] text-text-tertiary">
|
||||
|
|
@ -1111,3 +1258,127 @@ function PluginRowCard(props: PluginRowCardProps) {
|
|||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface CatalogEntryCardProps {
|
||||
busy: boolean;
|
||||
entry: CatalogEntry;
|
||||
onInstall: () => void;
|
||||
removed: CatalogRemovedEntry | null;
|
||||
t: Translations;
|
||||
}
|
||||
|
||||
function CatalogEntryCard(props: CatalogEntryCardProps) {
|
||||
const { busy, entry, onInstall, removed, t } = props;
|
||||
|
||||
const caps = entry.capabilities;
|
||||
const chips: string[] = [];
|
||||
if (caps.provides_tools.length) chips.push(`${caps.provides_tools.length} tools`);
|
||||
if (caps.provides_hooks.length) chips.push(`${caps.provides_hooks.length} hooks`);
|
||||
if (caps.provides_middleware.length)
|
||||
chips.push(`${caps.provides_middleware.length} middleware`);
|
||||
if (caps.requires_env.length) chips.push(`env: ${caps.requires_env.join(", ")}`);
|
||||
|
||||
const isRemoved = removed !== null;
|
||||
|
||||
return (
|
||||
<Card className={cn(busy ? "opacity-70" : undefined)}>
|
||||
<CardContent className="flex flex-col gap-3 px-6 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
||||
<span className="truncate font-semibold">{entry.name}</span>
|
||||
|
||||
<Badge tone={entry.tier === "official" ? "success" : "secondary"}>
|
||||
{entry.tier}
|
||||
</Badge>
|
||||
|
||||
{entry.installed && entry.runtime_status ? (
|
||||
<Badge tone="outline">{entry.runtime_status}</Badge>
|
||||
) : null}
|
||||
|
||||
{isRemoved ? (
|
||||
<Badge tone="destructive">
|
||||
{t.pluginsPage.catalogRemovedBadge ?? "Removed"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
{isRemoved ? null : entry.installed && !entry.update_available ? (
|
||||
<Badge tone="success">
|
||||
{t.pluginsPage.catalogInstalledBadge ?? "Installed ✓"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button disabled={busy} ghost size="sm" onClick={onInstall}>
|
||||
{busy ? <Spinner /> : null}
|
||||
{entry.update_available
|
||||
? t.pluginsPage.catalogUpdateBtn ?? "Update available"
|
||||
: t.pluginsPage.catalogInstallBtn ?? "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRemoved ? (
|
||||
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
|
||||
{t.pluginsPage.removedFromCatalog ?? "Removed from catalog"}
|
||||
{removed.reason ? `: ${removed.reason}` : ""}
|
||||
{removed.date ? ` (${removed.date})` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{entry.description ? (
|
||||
<p className="min-w-0 w-full text-xs tracking-[0.06em] text-text-secondary break-words">
|
||||
{entry.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{chips.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<code
|
||||
key={chip}
|
||||
className="border border-border bg-background/40 px-2 py-1 font-mono text-[0.6875rem]"
|
||||
>
|
||||
{chip}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-text-tertiary">
|
||||
<span>{entry.maintainer}</span>
|
||||
|
||||
<a
|
||||
className="inline-flex items-center gap-1 font-mono underline"
|
||||
href={`${entry.repo.replace(/\.git$/, "")}/tree/${entry.sha}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{entry.sha_short}
|
||||
<ExternalLink className="h-3 w-3 opacity-65" />
|
||||
</a>
|
||||
|
||||
{entry.docs_url ? (
|
||||
<a
|
||||
className="inline-flex items-center gap-1 underline"
|
||||
href={entry.docs_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs
|
||||
<ExternalLink className="h-3 w-3 opacity-65" />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{entry.requires_hermes ? (
|
||||
<span>hermes {entry.requires_hermes}</span>
|
||||
) : null}
|
||||
|
||||
{entry.platforms.length ? (
|
||||
<span>{entry.platforms.join(", ")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue