feat(plugins): catalog is the sole discovery system — re-port onto main's layout
- hermes_cli/plugins_cmd_catalog.py: new sibling owning resolution, the
.hermes-catalog.json provenance sidecar, search/info/validate, re-pin on
update, and the dashboard/TUI payload builders. plugins_cmd.py only
gains the hooks (cmd_install catalog branch, cmd_update / dashboard
update re-pin, dashboard_install_plugin catalog_name + kill list,
dispatch entries); the community index (plugin_index.py) is gone.
- hermes_cli/plugin_catalog.py: catalog_dir parameter replaces the
test-only HERMES_PLUGIN_CATALOG_DIR env var; live refresh reads ONE
published document (/docs/api/plugin-catalog.json, 6h cache, in-tree
fallback) instead of the unauthenticated GitHub contents API (60 req/h,
1 request per entry); in-tree and live removals are unioned so a stale
cache can never un-block.
- Catalog route lives in web_routers/dashboard_ui.py (the facade is off
limits); _plugin_runtime_status shared from web_server_dashboard.py;
hub rows carry removed_reason. TUI plugins.manage gains catalog_name
install, catalog row fields and an update action.
- plugin_validate: the probe context honours ctx.get_config defaults
(real plugins do int(ctx.get_config("timeout", 180)) in register()).
- Installed-state merge matches through the sidecar's catalog_name
first — catalog names rarely equal manifest names.
- extract-plugins.py emits plugin-catalog.json; deploy-site triggers on
plugin-catalog/** so entry merges republish it.
This commit is contained in:
@@ -9,6 +9,9 @@ on:
|
|||||||
- 'website/**'
|
- 'website/**'
|
||||||
- 'skills/**'
|
- 'skills/**'
|
||||||
- 'optional-skills/**'
|
- 'optional-skills/**'
|
||||||
|
# Catalog entry/removal merges must republish /docs/api/plugin-catalog.json —
|
||||||
|
# installed clients fetch it for live catalog refresh.
|
||||||
|
- 'plugin-catalog/**'
|
||||||
- '.github/workflows/deploy-site.yml'
|
- '.github/workflows/deploy-site.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ website/static/api/skills-meta.json
|
|||||||
# plugins.json + plugins-meta.json are build artifacts emitted by
|
# plugins.json + plugins-meta.json are build artifacts emitted by
|
||||||
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
|
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
|
||||||
website/static/api/plugins.json
|
website/static/api/plugins.json
|
||||||
|
website/static/api/plugin-catalog.json
|
||||||
website/static/api/plugins-meta.json
|
website/static/api/plugins-meta.json
|
||||||
# automation-blueprints-index.json is a build artifact emitted by
|
# automation-blueprints-index.json is a build artifact emitted by
|
||||||
# website/scripts/extract-automation-blueprints.py during prebuild.
|
# website/scripts/extract-automation-blueprints.py during prebuild.
|
||||||
|
|||||||
+158
-287
@@ -1,52 +1,47 @@
|
|||||||
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
|
"""Plugin catalog — curated, Nous-approved Hermes plugins shipped with the repo.
|
||||||
|
|
||||||
Mirrors the ``optional-mcps/`` MCP-catalog pattern (see
|
Mirrors the ``optional-mcps/`` MCP-catalog pattern: one YAML file per entry under the in-tree
|
||||||
:mod:`hermes_cli.mcp_catalog`): each catalog entry is a single YAML file under
|
``plugin-catalog/`` directory, pinned to an exact 40-character commit SHA. Presence in the directory IS
|
||||||
the in-tree ``plugin-catalog/`` directory, pinned to an exact 40-character
|
the human-merged approval gate; SHA bumps are new, re-reviewed PRs; ``removed.yaml`` is the kill list
|
||||||
commit SHA. Users discover entries via ``hermes plugins catalog`` /
|
(installs of a removed name/repo are refused with the recorded reason). Full policy:
|
||||||
``hermes plugins search`` and install them with
|
``plugin-catalog/README.md``.
|
||||||
``hermes plugins install <name>``, which clones the pinned commit.
|
|
||||||
|
|
||||||
Catalog policy (see plugin-catalog/README.md for the full admission policy):
|
Live refresh: the docs build publishes the same data as ONE JSON document
|
||||||
- Entries are added only by merging a PR into hermes-agent — presence in the
|
(``website/scripts/extract-plugins.py`` → ``/docs/api/plugin-catalog.json``, like the skills index), so
|
||||||
``plugin-catalog/`` directory is the human-merged approval gate.
|
an installed Hermes sees new entries and removals without updating. Any fetch failure falls back to the
|
||||||
- Every entry pins an exact 40-hex commit SHA. SHA bumps are new PRs,
|
in-tree copy silently.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CATALOG_TIERS = ("official", "community")
|
CATALOG_TIERS = ("official", "community")
|
||||||
|
LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json"
|
||||||
|
LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60
|
||||||
|
_REQUEST_TIMEOUT = 5.0
|
||||||
|
_MAX_LIVE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
# ─── Data classes ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RemovedEntry:
|
class RemovedEntry:
|
||||||
name: str
|
name: str
|
||||||
repo: str = ""
|
repo: str = ""
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
date: str = "" # ISO date string
|
date: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -61,354 +56,230 @@ class CatalogCapabilities:
|
|||||||
class PluginCatalogEntry:
|
class PluginCatalogEntry:
|
||||||
name: str # catalog key, [a-z0-9_-]{1,64}
|
name: str # catalog key, [a-z0-9_-]{1,64}
|
||||||
repo: str # https:// git URL
|
repo: str # https:// git URL
|
||||||
sha: str # 40-hex pinned commit — MANDATORY, validated
|
sha: str # 40-hex pinned commit — mandatory
|
||||||
description: str
|
description: str
|
||||||
maintainer: str
|
maintainer: str
|
||||||
tier: str = "community" # one of CATALOG_TIERS
|
tier: str = "community"
|
||||||
requires_hermes: str = "" # e.g. ">=0.19" (optional)
|
requires_hermes: str = ""
|
||||||
subdir: str = "" # optional path within the repo
|
subdir: str = ""
|
||||||
docs_url: str = ""
|
docs_url: str = ""
|
||||||
platforms: List[str] = field(default_factory=list) # empty = all OSes
|
platforms: List[str] = field(default_factory=list) # empty = all OSes
|
||||||
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
|
capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def install_identifier(self) -> str:
|
||||||
|
"""``_install_plugin_core`` identifier (``repo#subdir`` for monorepo entries)."""
|
||||||
|
return f"{self.repo}#{self.subdir}" if self.subdir else self.repo
|
||||||
|
|
||||||
# ─── Directory resolution ────────────────────────────────────────────────────
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
caps = self.capabilities
|
||||||
|
return {
|
||||||
|
"name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description,
|
||||||
|
"maintainer": self.maintainer, "tier": self.tier, "requires_hermes": self.requires_hermes,
|
||||||
|
"subdir": self.subdir, "docs_url": self.docs_url, "platforms": list(self.platforms),
|
||||||
|
"capabilities": {
|
||||||
|
"provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks),
|
||||||
|
"provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_catalog_dir() -> Path:
|
def get_catalog_dir() -> Path:
|
||||||
"""Return the ``plugin-catalog/`` directory shipped with this checkout.
|
"""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"
|
return Path(__file__).resolve().parent.parent / "plugin-catalog"
|
||||||
|
|
||||||
|
|
||||||
# ─── Loading / validation ────────────────────────────────────────────────────
|
# ── Parsing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _str_list(raw: Any) -> List[str]:
|
def _str_list(raw: Any) -> List[str]:
|
||||||
"""Coerce a YAML value into a list of strings (drop non-strings)."""
|
return [str(x) for x in raw if isinstance(x, (str, int, float))] if isinstance(raw, list) else []
|
||||||
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]:
|
def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]:
|
||||||
"""Parse and validate one catalog YAML file.
|
"""Validate one entry mapping (YAML file or live-JSON element); ``None`` + warning on any failure."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("Plugin catalog: %s: entry must be a mapping", label)
|
||||||
|
return None
|
||||||
|
name = str(data.get("name") or "")
|
||||||
|
repo = str(data.get("repo") or "")
|
||||||
|
sha = str(data.get("sha") or "").strip().lower()
|
||||||
|
tier = str(data.get("tier") or "community")
|
||||||
|
problem = (
|
||||||
|
f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name)
|
||||||
|
else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://")
|
||||||
|
else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha)
|
||||||
|
else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS
|
||||||
|
else None)
|
||||||
|
if problem:
|
||||||
|
logger.warning("Plugin catalog: %s: %s", label, problem)
|
||||||
|
return None
|
||||||
|
caps_raw = data.get("capabilities")
|
||||||
|
caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {}
|
||||||
|
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=CatalogCapabilities(
|
||||||
|
provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")),
|
||||||
|
provides_middleware=_str_list(caps.get("provides_middleware")),
|
||||||
|
requires_env=_str_list(caps.get("requires_env"))),
|
||||||
|
)
|
||||||
|
|
||||||
Returns ``None`` (after logging a warning) on any validation failure —
|
|
||||||
the loader never raises for a bad entry.
|
def _read_yaml(path: Path) -> Any:
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
|
logger.warning("Plugin catalog: failed to read %s: %s", path, exc)
|
||||||
return None
|
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 "")
|
def _removed_from_list(raw_list: Any) -> List[RemovedEntry]:
|
||||||
if not _NAME_RE.match(name):
|
if not isinstance(raw_list, list):
|
||||||
logger.warning(
|
return []
|
||||||
"Plugin catalog: %s: invalid name %r (must match [a-z0-9_-]{1,64})",
|
return [
|
||||||
path, name,
|
RemovedEntry(name=str(r["name"]), repo=str(r.get("repo") or ""), reason=str(r.get("reason") or ""),
|
||||||
)
|
date=str(r.get("date") or ""))
|
||||||
return None
|
for r in raw_list if isinstance(r, dict) and r.get("name")]
|
||||||
|
|
||||||
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]:
|
# ── In-tree catalog ──────────────────────────────────────────────────────────
|
||||||
"""Return all valid catalog entries, sorted by name.
|
|
||||||
|
|
||||||
Parses every ``*.yaml`` in the catalog dir except ``removed.yaml``.
|
def load_catalog(catalog_dir: Optional[Path] = None) -> List[PluginCatalogEntry]:
|
||||||
Invalid entries are skipped with a logged warning; this function never
|
"""Every valid ``*.yaml`` entry in the catalog dir (``removed.yaml`` excluded), sorted by file name.
|
||||||
raises for a malformed entry.
|
Malformed entries are skipped with a warning — never raises."""
|
||||||
"""
|
root = catalog_dir or 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():
|
if not root.is_dir():
|
||||||
return []
|
return []
|
||||||
entries: List[PluginCatalogEntry] = []
|
entries = []
|
||||||
for path in sorted(root.glob("*.yaml")):
|
for path in sorted(root.glob("*.yaml")):
|
||||||
if path.name == "removed.yaml":
|
if path.name == "removed.yaml":
|
||||||
continue
|
continue
|
||||||
entry = _parse_entry(path)
|
data = _read_yaml(path)
|
||||||
|
entry = entry_from_mapping(data, str(path)) if data is not None else None
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def get_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
|
def load_removed_list(catalog_dir: Optional[Path] = None) -> List[RemovedEntry]:
|
||||||
"""Look up a single catalog entry by name."""
|
"""``removed.yaml``'s ``removed:`` list; missing/malformed → empty."""
|
||||||
for entry in load_catalog():
|
path = (catalog_dir or get_catalog_dir()) / "removed.yaml"
|
||||||
if entry.name == name:
|
data = _read_yaml(path) if path.is_file() else None
|
||||||
return entry
|
return _removed_from_list(data.get("removed")) if isinstance(data, dict) else []
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def search_catalog(query: str) -> List[PluginCatalogEntry]:
|
def get_catalog_entry(name: str, catalog_dir: Optional[Path] = None) -> Optional[PluginCatalogEntry]:
|
||||||
"""Case-insensitive substring search over name, description, and
|
return next((e for e in load_catalog(catalog_dir) if e.name == name), None)
|
||||||
declared tools. An empty query returns the whole catalog."""
|
|
||||||
return filter_entries(load_catalog(), query)
|
|
||||||
|
|
||||||
|
|
||||||
def filter_entries(
|
def filter_entries(entries: List[PluginCatalogEntry], query: str) -> List[PluginCatalogEntry]:
|
||||||
entries: List[PluginCatalogEntry], query: str
|
"""Case-insensitive substring match over name, description and declared tools; empty query = all."""
|
||||||
) -> 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()
|
q = (query or "").strip().lower()
|
||||||
if not q:
|
if not q:
|
||||||
return entries
|
return entries
|
||||||
results: List[PluginCatalogEntry] = []
|
return [e for e in entries
|
||||||
for entry in entries:
|
if any(q in h.lower() for h in (e.name, e.description, *e.capabilities.provides_tools))]
|
||||||
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 search_catalog(query: str) -> List[PluginCatalogEntry]:
|
||||||
|
return filter_entries(load_catalog(), query)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Removed / blocklist ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _normalize_repo(url: str) -> str:
|
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()
|
return url.strip().rstrip("/").removesuffix(".git").lower()
|
||||||
|
|
||||||
|
|
||||||
def load_removed_list() -> List[RemovedEntry]:
|
def find_removed(name_or_repo: str, catalog_dir: Optional[Path] = None) -> Optional[RemovedEntry]:
|
||||||
"""Load ``plugin-catalog/removed.yaml`` (the ``removed:`` list).
|
"""Match a catalog name or repo URL (``.git``/trailing-slash insensitive) against the kill list.
|
||||||
|
|
||||||
Missing or malformed files yield an empty list — never raises.
|
The in-tree list and the live-fetched list are UNIONED: a removal published after this checkout
|
||||||
"""
|
shipped must still block, and a stale live cache must not un-block an in-tree removal.
|
||||||
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:
|
if not name_or_repo:
|
||||||
return None
|
return None
|
||||||
candidate = name_or_repo.strip()
|
candidate = name_or_repo.strip()
|
||||||
candidate_repo = _normalize_repo(candidate)
|
candidate_repo = _normalize_repo(candidate)
|
||||||
for entry in load_removed_list():
|
for entry in load_removed_list(catalog_dir) + (live_removed_list() if catalog_dir is None else []):
|
||||||
if candidate == entry.name:
|
if candidate == entry.name or (entry.repo and candidate_repo == _normalize_repo(entry.repo)):
|
||||||
return entry
|
|
||||||
if entry.repo and candidate_repo == _normalize_repo(entry.repo):
|
|
||||||
return entry
|
return entry
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ─── Live index ──────────────────────────────────────────────────────────────
|
# ── Live catalog ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# GitHub contents API for the in-repo catalog dir. Unauthenticated (60 req/hr
|
def _live_cache_path() -> Path:
|
||||||
# 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
|
from hermes_constants import get_hermes_home
|
||||||
|
return get_hermes_home() / "cache" / "plugin-catalog.json"
|
||||||
return get_hermes_home() / "cache" / "plugin-catalog"
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_live_catalog(*, force: bool = False) -> Optional[Path]:
|
def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
|
||||||
"""Refresh the catalog cache from the GitHub repo; return the cache dir.
|
"""The published ``plugin-catalog.json`` (``{"entries": [...], "removed": [...]}``), cached under
|
||||||
|
``HERMES_HOME/cache`` for :data:`LIVE_CATALOG_TTL_SECONDS`. ``None`` on ANY failure — callers fall
|
||||||
Lists ``plugin-catalog/*.yaml`` via the GitHub contents API, raw-fetches
|
back to the in-tree catalog."""
|
||||||
each file, and stores them under ``<hermes_home>/cache/plugin-catalog/``
|
cache = _live_cache_path()
|
||||||
with a 6-hour TTL (repeat searches don't re-hit the API). Returns the
|
try:
|
||||||
cache directory on success (or fresh cache), or ``None`` on ANY network
|
if not force and cache.is_file() and time.time() - cache.stat().st_mtime < LIVE_CATALOG_TTL_SECONDS:
|
||||||
or parse failure — callers then fall back to the in-tree catalog.
|
return json.loads(cache.read_text(encoding="utf-8"))
|
||||||
"""
|
except Exception as exc:
|
||||||
cache = _live_cache_dir()
|
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
|
||||||
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:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
|
resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True)
|
||||||
resp = httpx.get(
|
|
||||||
_LIVE_INDEX_URL,
|
|
||||||
timeout=_REQUEST_TIMEOUT,
|
|
||||||
follow_redirects=True,
|
|
||||||
headers={"Accept": "application/vnd.github+json"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
listing = resp.json()
|
if len(resp.content) > _MAX_LIVE_BYTES:
|
||||||
if not isinstance(listing, list):
|
raise ValueError("live catalog payload too large")
|
||||||
raise ValueError("unexpected contents-API payload")
|
data = resp.json()
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
||||||
fetched: dict[str, str] = {}
|
raise ValueError("unexpected live catalog payload")
|
||||||
for item in listing:
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if not isinstance(item, dict):
|
cache.write_text(json.dumps(data), encoding="utf-8")
|
||||||
continue
|
return data
|
||||||
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:
|
except Exception as exc:
|
||||||
logger.debug("Plugin catalog: live index fetch failed: %s", exc)
|
logger.debug("Plugin catalog: live fetch failed: %s", exc)
|
||||||
return None
|
try: # stale cache still beats the in-tree copy when the network is down
|
||||||
|
return json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def load_catalog_live() -> List[PluginCatalogEntry]:
|
def load_catalog_live() -> List[PluginCatalogEntry]:
|
||||||
"""Return catalog entries, preferring a live-fetched (or cached) index.
|
"""Entries from the live (or cached) catalog, else the in-tree catalog."""
|
||||||
|
data = fetch_live_catalog()
|
||||||
Falls back silently to the in-tree catalog when the network is
|
if data is not None:
|
||||||
unavailable or the fetch fails.
|
entries = [e for i, raw in enumerate(data["entries"])
|
||||||
"""
|
if (e := entry_from_mapping(raw, f"{LIVE_CATALOG_URL}#{i}")) is not None]
|
||||||
cache = fetch_live_catalog()
|
if entries:
|
||||||
if cache is not None and any(
|
return entries
|
||||||
p.name != "removed.yaml" for p in cache.glob("*.yaml")
|
|
||||||
):
|
|
||||||
return _load_entries_from_dir(cache)
|
|
||||||
return load_catalog()
|
return load_catalog()
|
||||||
|
|
||||||
|
|
||||||
# ─── Human summaries ─────────────────────────────────────────────────────────
|
def live_removed_list() -> List[RemovedEntry]:
|
||||||
|
data = fetch_live_catalog()
|
||||||
|
return _removed_from_list(data.get("removed")) if data else []
|
||||||
|
|
||||||
|
|
||||||
|
def get_live_catalog_entry(name: str) -> Optional[PluginCatalogEntry]:
|
||||||
|
return next((e for e in load_catalog_live() if e.name == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Human summaries ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
|
def entry_capability_summary(entry: PluginCatalogEntry) -> str:
|
||||||
"""One-paragraph human summary of what an entry declares, shown at
|
"""One paragraph shown at install/enable prompts: what the user is granting."""
|
||||||
install prompts so the user knows what they're granting."""
|
|
||||||
caps = entry.capabilities
|
caps = entry.capabilities
|
||||||
parts: List[str] = []
|
parts = [f"{label} {', '.join(items)}" for label, items in (
|
||||||
if caps.provides_tools:
|
("registers tool(s):", caps.provides_tools), ("hook(s):", caps.provides_hooks),
|
||||||
parts.append(f"registers tool(s): {', '.join(caps.provides_tools)}")
|
("middleware:", caps.provides_middleware), ("requires env var(s):", caps.requires_env)) if items]
|
||||||
if caps.provides_hooks:
|
bits = [f"{entry.name} ({entry.tier}, maintained by {entry.maintainer})"]
|
||||||
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:
|
if entry.description:
|
||||||
bits.append(entry.description)
|
bits.append(entry.description)
|
||||||
bits.append(f"This plugin {capability_text}.")
|
bits.append(f"This plugin {'; '.join(parts) if parts else 'declares no tools, hooks, middleware, or env vars'}.")
|
||||||
if entry.platforms:
|
if entry.platforms:
|
||||||
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
|
bits.append(f"Platforms: {', '.join(entry.platforms)}.")
|
||||||
if entry.requires_hermes:
|
if entry.requires_hermes:
|
||||||
|
|||||||
+12
-12
@@ -45,7 +45,7 @@ class PackPluginEntry:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def install_identifier(self) -> Optional[str]:
|
def install_identifier(self) -> Optional[str]:
|
||||||
"""Identifier for the install path; None for bare names (resolved via the community index)."""
|
"""Identifier for the install path; None for bare names (resolved via the plugin catalog)."""
|
||||||
if self.repo:
|
if self.repo:
|
||||||
return f"{self.repo}/{self.subdir}" if self.subdir else self.repo
|
return f"{self.repo}/{self.subdir}" if self.subdir else self.repo
|
||||||
return None
|
return None
|
||||||
@@ -199,31 +199,31 @@ class ResolvedPackPlugin:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_pack_plugins(pack: PluginPack) -> List[ResolvedPackPlugin]:
|
def resolve_pack_plugins(pack: PluginPack) -> List[ResolvedPackPlugin]:
|
||||||
"""Resolve every entry; bare names go through the community index. Failures do not raise —
|
"""Resolve every entry; bare names go through the curated plugin catalog. Failures do not raise —
|
||||||
they are carried per-entry so the review screen shows them and install reports partial failure."""
|
they are carried per-entry so the review screen shows them and install reports partial failure."""
|
||||||
resolved: List[ResolvedPackPlugin] = []
|
resolved: List[ResolvedPackPlugin] = []
|
||||||
index_entries = None
|
catalog_entries = None
|
||||||
for entry in pack.plugins:
|
for entry in pack.plugins:
|
||||||
if entry.install_identifier is not None:
|
if entry.install_identifier is not None:
|
||||||
resolved.append(ResolvedPackPlugin(entry=entry, identifier=entry.install_identifier))
|
resolved.append(ResolvedPackPlugin(entry=entry, identifier=entry.install_identifier))
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
from hermes_cli.plugin_index import load_index, resolve_name
|
if catalog_entries is None:
|
||||||
if index_entries is None:
|
from hermes_cli.plugin_catalog import load_catalog_live
|
||||||
index_entries, _src = load_index()
|
catalog_entries = load_catalog_live()
|
||||||
match, candidates = resolve_name(index_entries, entry.name or "")
|
except Exception as exc: # catalog load must not crash pack handling
|
||||||
except Exception as exc: # index load must not crash pack handling
|
|
||||||
resolved.append(ResolvedPackPlugin(
|
resolved.append(ResolvedPackPlugin(
|
||||||
entry=entry, identifier=None, resolve_error=f"community index unavailable: {exc}"))
|
entry=entry, identifier=None, resolve_error=f"plugin catalog unavailable: {exc}"))
|
||||||
continue
|
continue
|
||||||
|
match = next((e for e in catalog_entries if e.name == (entry.name or "")), None)
|
||||||
if match is None:
|
if match is None:
|
||||||
detail = "ambiguous" if len(candidates) > 1 else "not found"
|
|
||||||
resolved.append(ResolvedPackPlugin(
|
resolved.append(ResolvedPackPlugin(
|
||||||
entry=entry, identifier=None, resolve_error=f"{detail} in the community index"))
|
entry=entry, identifier=None, resolve_error="not found in the plugin catalog"))
|
||||||
continue
|
continue
|
||||||
|
caps = match.capabilities
|
||||||
resolved.append(ResolvedPackPlugin(
|
resolved.append(ResolvedPackPlugin(
|
||||||
entry=entry, identifier=match.install_identifier,
|
entry=entry, identifier=match.install_identifier,
|
||||||
index_capabilities=list(match.capabilities)))
|
index_capabilities=[*caps.provides_tools, *caps.provides_hooks, *caps.provides_middleware]))
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,11 @@ class RecordingContext:
|
|||||||
def register_cli_command(self, name, *args, **kwargs):
|
def register_cli_command(self, name, *args, **kwargs):
|
||||||
recorded["commands"].append(str(name))
|
recorded["commands"].append(str(name))
|
||||||
|
|
||||||
|
def get_config(self, key, default=None):
|
||||||
|
# Mirrors PluginContext.get_config with no config on disk: the DEFAULT, never None —
|
||||||
|
# plugins do `int(ctx.get_config("timeout", 180))` in register().
|
||||||
|
return default
|
||||||
|
|
||||||
def __getattr__(self, _name):
|
def __getattr__(self, _name):
|
||||||
# Any other registration surface (platforms, providers, skills,
|
# Any other registration surface (platforms, providers, skills,
|
||||||
# context engines, ...) is accepted as a no-op — the probe only
|
# context engines, ...) is accepted as a no-op — the probe only
|
||||||
|
|||||||
+86
-96
@@ -660,65 +660,40 @@ def _install_plugin_core(
|
|||||||
return target, installed_manifest, installed_manifest.get("name") or target.name
|
return target, installed_manifest, installed_manifest.get("name") or target.name
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_bare_index_name(identifier: str) -> bool:
|
|
||||||
"""True for a bare plugin name (no slash, no URL scheme) — resolved via the community index."""
|
|
||||||
return "/" not in identifier and "\\" not in identifier and not identifier.startswith(_URL_SCHEMES)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_index_name(identifier: str, console) -> tuple[str, Optional[str]]:
|
|
||||||
"""Resolve a bare plugin name to ``(install_identifier, pinned_ref)``; exit 1 when unknown or
|
|
||||||
ambiguous. The ref is only pinned when it is an exact 40-char SHA; tags are advisory output."""
|
|
||||||
from hermes_cli.plugin_index import SECURITY_FOOTER, load_index, resolve_name
|
|
||||||
entries, source = load_index()
|
|
||||||
entry, candidates = resolve_name(entries, identifier)
|
|
||||||
if entry is None:
|
|
||||||
if len(candidates) > 1:
|
|
||||||
console.print(
|
|
||||||
f"[red]Error:[/red] Plugin name '{identifier}' is ambiguous in the "
|
|
||||||
f"community index ({source}). Candidates:")
|
|
||||||
for c in candidates:
|
|
||||||
console.print(f" {c.name} → {c.install_identifier}")
|
|
||||||
_fail(console, "Re-run with the exact name or the owner/repo identifier.")
|
|
||||||
_fail(console, (
|
|
||||||
f"[red]Error:[/red] Plugin '{identifier}' was not found in the "
|
|
||||||
f"community index ({source}). Use `hermes plugins search <term>` to "
|
|
||||||
"browse, or install directly with an owner/repo identifier."))
|
|
||||||
|
|
||||||
pinned_ref: Optional[str] = None
|
|
||||||
if entry.ref and _EXACT_COMMIT_RE.fullmatch(entry.ref):
|
|
||||||
pinned_ref = entry.ref.lower()
|
|
||||||
elif entry.ref:
|
|
||||||
console.print(
|
|
||||||
f"[dim]Index pins ref '{entry.ref}' (not an exact commit SHA); "
|
|
||||||
"installing the default branch head instead.[/dim]")
|
|
||||||
console.print(
|
|
||||||
f"[dim]Resolved '{entry.name}' via community index ({source}) → "
|
|
||||||
f"{entry.install_identifier}"
|
|
||||||
+ (f" @ {pinned_ref[:12]}[/dim]" if pinned_ref else "[/dim]"))
|
|
||||||
console.print(f"[dim]{SECURITY_FOOTER}[/dim]")
|
|
||||||
return entry.install_identifier, pinned_ref
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_install(
|
def cmd_install(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
enable: Optional[bool] = None,
|
enable: Optional[bool] = None,
|
||||||
ref: Optional[str] = None,
|
ref: Optional[str] = None,
|
||||||
|
allow_removed: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Install a plugin from a Git URL, owner/repo shorthand, or index name.
|
"""Install a plugin from the curated catalog (bare name), a Git URL, or owner/repo shorthand.
|
||||||
|
|
||||||
Bare names resolve through the community index (an explicit ``--ref`` beats the index pin).
|
A catalog hit installs the reviewed pinned SHA (an explicit ``--ref`` wins) and records provenance in
|
||||||
|
a ``.hermes-catalog.json`` sidecar; URLs/shorthand are flagged as custom (unreviewed) sources. Every
|
||||||
|
install is checked against the catalog kill list unless *allow_removed*.
|
||||||
*enable* None prompts "Enable now? [y/N]"; True/False skip the prompt.
|
*enable* None prompts "Enable now? [y/N]"; True/False skip the prompt.
|
||||||
"""
|
"""
|
||||||
|
from hermes_cli import plugins_cmd_catalog as catalog
|
||||||
console = _console()
|
console = _console()
|
||||||
if _looks_like_bare_index_name(identifier):
|
entry = None
|
||||||
identifier, index_ref = _resolve_index_name(identifier, console)
|
if catalog.looks_like_catalog_name(identifier):
|
||||||
if ref is None:
|
entry = catalog.resolve_catalog_name(identifier, console)
|
||||||
ref = index_ref
|
identifier = entry.install_identifier
|
||||||
|
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan] [dim]pinned @ {entry.sha[:8]}[/dim]")
|
||||||
|
console.print(catalog.entry_capability_summary(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 catalog kill-list check. "
|
||||||
|
"This plugin may have been removed for security reasons.[/red]")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
git_url, _subdir = _resolve_git_url(identifier)
|
git_url, _subdir = _resolve_git_url(identifier)
|
||||||
except ValueError as e:
|
if not allow_removed:
|
||||||
|
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
|
||||||
|
except (ValueError, PluginOperationError) as e:
|
||||||
_fail(console, f"[red]Error:[/red] {e}")
|
_fail(console, f"[red]Error:[/red] {e}")
|
||||||
if git_url.startswith(("http://", "file://")):
|
if git_url.startswith(("http://", "file://")):
|
||||||
console.print(
|
console.print(
|
||||||
@@ -736,8 +711,12 @@ def cmd_install(
|
|||||||
return _is_tty() and _ask_yes(" Install anyway? Only continue if you trust the source. [y/N]: ")
|
return _is_tty() and _ask_yes(" Install anyway? Only continue if you trust the source. [y/N]: ")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target, installed_manifest, installed_name = _install_plugin_core(
|
if entry is not None:
|
||||||
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision)
|
target, installed_manifest, installed_name = catalog.install_catalog_entry(
|
||||||
|
entry, force=force, ref=ref, allow_removed=True, scan_decision_cb=_interactive_scan_decision)
|
||||||
|
else:
|
||||||
|
target, installed_manifest, installed_name = _install_plugin_core(
|
||||||
|
identifier, force=force, ref=ref, scan_decision_cb=_interactive_scan_decision)
|
||||||
except PluginOperationError as e:
|
except PluginOperationError as e:
|
||||||
_fail(console, f"[red]{'Blocked' if isinstance(e, PluginScanBlocked) else 'Error'}:[/red] {e}")
|
_fail(console, f"[red]{'Blocked' if isinstance(e, PluginScanBlocked) else 'Error'}:[/red] {e}")
|
||||||
if not _looks_like_plugin_dir(target):
|
if not _looks_like_plugin_dir(target):
|
||||||
@@ -794,8 +773,13 @@ def _pull_plugin_update(target: Path, pinned_msg, not_git_msg, before_pull=None)
|
|||||||
def cmd_update(name: str) -> None:
|
def cmd_update(name: str) -> None:
|
||||||
"""Update an installed plugin by pulling latest from its git remote."""
|
"""Update an installed plugin by pulling latest from its git remote."""
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
|
from hermes_cli import plugins_cmd_catalog as catalog
|
||||||
console = _console()
|
console = _console()
|
||||||
target = _require_installed_plugin(name, _plugins_dir(), console)
|
target = _require_installed_plugin(name, _plugins_dir(), console)
|
||||||
|
sidecar = catalog.read_catalog_sidecar(target)
|
||||||
|
if sidecar: # catalog installs re-pin to the reviewed SHA — never `git pull`
|
||||||
|
catalog.cmd_update_catalog(name, target, sidecar, console)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
output = _pull_plugin_update(
|
output = _pull_plugin_update(
|
||||||
target,
|
target,
|
||||||
@@ -1321,18 +1305,21 @@ def cmd_list(args: Any | None = None) -> None:
|
|||||||
enabled = _get_enabled_set()
|
enabled = _get_enabled_set()
|
||||||
disabled = _get_disabled_set()
|
disabled = _get_disabled_set()
|
||||||
entries = _filter_plugin_entries(entries, args, enabled, disabled)
|
entries = _filter_plugin_entries(entries, args, enabled, disabled)
|
||||||
|
from hermes_cli import plugins_cmd_catalog as catalog
|
||||||
|
# Source shows catalog provenance (``catalog:<tier>@<sha8>``); a kill-listed install is flagged.
|
||||||
rows = [
|
rows = [
|
||||||
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description, source)
|
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description,
|
||||||
|
catalog.catalog_annotation(_dir) or source, catalog.removed_annotation(name, _dir))
|
||||||
for name, version, description, source, _dir, key in entries
|
for name, version, description, source, _dir, key in entries
|
||||||
]
|
]
|
||||||
|
|
||||||
if getattr(args, "json", False):
|
if getattr(args, "json", False):
|
||||||
keys = ("name", "status", "version", "description", "source")
|
keys = ("name", "status", "version", "description", "source", "removed")
|
||||||
print(json.dumps([dict(zip(keys, row)) for row in rows], indent=2))
|
print(json.dumps([dict(zip(keys, row)) for row in rows], indent=2))
|
||||||
return
|
return
|
||||||
|
|
||||||
if getattr(args, "plain", False):
|
if getattr(args, "plain", False):
|
||||||
for name, status, version, _description, source in rows:
|
for name, status, version, _description, source, _removed in rows:
|
||||||
print(f"{status:12} {source:8} {version:8} {name}")
|
print(f"{status:12} {source:8} {version:8} {name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1343,11 +1330,17 @@ def cmd_list(args: Any | None = None) -> None:
|
|||||||
table = _table(
|
table = _table(
|
||||||
(("Name", "bold"), ("Status", None), ("Version", "dim"), ("Description", None), ("Source", "dim")),
|
(("Name", "bold"), ("Status", None), ("Version", "dim"), ("Description", None), ("Source", "dim")),
|
||||||
title="Plugins", show_lines=False)
|
title="Plugins", show_lines=False)
|
||||||
for name, status_name, version, description, source in rows:
|
removed_lines = []
|
||||||
|
for name, status_name, version, description, source, removed in rows:
|
||||||
status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]")
|
status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]")
|
||||||
|
if removed:
|
||||||
|
name = f"[red]{name} ✗[/red]"
|
||||||
|
removed_lines.append(f"[red]✗ {name}[/red] was removed from the plugin catalog: {removed}")
|
||||||
table.add_row(name, status, version, description, source)
|
table.add_row(name, status, version, description, source)
|
||||||
console.print()
|
console.print()
|
||||||
console.print(table)
|
console.print(table)
|
||||||
|
for line in removed_lines:
|
||||||
|
console.print(line)
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
|
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
|
||||||
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
|
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
|
||||||
@@ -1684,16 +1677,36 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
def dashboard_install_plugin(identifier: str, *, force: bool, enable: bool) -> dict[str, Any]:
|
def dashboard_install_plugin(
|
||||||
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
|
identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its
|
||||||
|
pinned SHA (identifier may be empty); every path enforces the kill list (no GUI bypass)."""
|
||||||
|
from hermes_cli import plugins_cmd_catalog as catalog
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
entry = None
|
||||||
|
if catalog_name:
|
||||||
|
entry = catalog.get_live_catalog_entry(catalog_name)
|
||||||
|
if entry is None:
|
||||||
|
return {"ok": False, "error": f"'{catalog_name}' is not in the Hermes plugin catalog."}
|
||||||
|
identifier = entry.install_identifier
|
||||||
|
else:
|
||||||
|
warnings.append("Custom (unreviewed) source — not from the Hermes catalog.")
|
||||||
try:
|
try:
|
||||||
if _resolve_git_url(identifier)[0].startswith(("http://", "file://")):
|
git_url = _resolve_git_url(identifier)[0]
|
||||||
|
if git_url.startswith(("http://", "file://")):
|
||||||
warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.")
|
warnings.append("Insecure URL scheme; prefer https:// or git@ for production installs.")
|
||||||
|
catalog.raise_if_removed(identifier, git_url, *((entry.name,) if entry else ()))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
except PluginOperationError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
try:
|
try:
|
||||||
target, installed_manifest, installed_name = _install_plugin_core(identifier, force=force)
|
if entry is not None:
|
||||||
|
target, installed_manifest, installed_name = catalog.install_catalog_entry(
|
||||||
|
entry, force=force, allow_removed=True)
|
||||||
|
else:
|
||||||
|
target, installed_manifest, installed_name = _install_plugin_core(identifier, force=force)
|
||||||
except PluginScanBlocked as exc:
|
except PluginScanBlocked as exc:
|
||||||
fields = ("pattern_id", "severity", "category", "file", "line", "description")
|
fields = ("pattern_id", "severity", "category", "file", "line", "description")
|
||||||
return {
|
return {
|
||||||
@@ -1799,10 +1812,15 @@ def _user_installed_plugin_dir(name: str) -> Optional[Path]:
|
|||||||
|
|
||||||
def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
|
def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
|
||||||
"""``git pull`` inside ``~/.hermes/plugins/<name>``."""
|
"""``git pull`` inside ``~/.hermes/plugins/<name>``."""
|
||||||
|
from hermes_cli import plugins_cmd_catalog as catalog
|
||||||
target = _user_installed_plugin_dir(name)
|
target = _user_installed_plugin_dir(name)
|
||||||
if target is None:
|
if target is None:
|
||||||
return {"ok": False, "error": f"Plugin '{name}' was not found under {_plugins_dir()}."}
|
return {"ok": False, "error": f"Plugin '{name}' was not found under {_plugins_dir()}."}
|
||||||
|
sidecar = catalog.read_catalog_sidecar(target)
|
||||||
try:
|
try:
|
||||||
|
if sidecar:
|
||||||
|
sha, changed = catalog.repin_catalog_plugin(target, sidecar)
|
||||||
|
return {"ok": True, "name": name, "sha": sha, "unchanged": not changed}
|
||||||
msg = _pull_plugin_update(
|
msg = _pull_plugin_update(
|
||||||
target,
|
target,
|
||||||
lambda rec: (
|
lambda rec: (
|
||||||
@@ -1954,44 +1972,16 @@ def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
def cmd_search(
|
|
||||||
term: str = "",
|
|
||||||
*,
|
|
||||||
json_output: bool = False,
|
|
||||||
capability: Optional[str] = None,
|
|
||||||
refresh: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Search the community plugin index (fuzzy on name/description/tags)."""
|
|
||||||
from hermes_cli.plugin_index import SECURITY_FOOTER, load_index, search_index
|
|
||||||
console = _console()
|
|
||||||
entries, source = load_index(refresh=refresh)
|
|
||||||
results = search_index(entries, term, capability=capability)
|
|
||||||
if json_output:
|
|
||||||
print(json.dumps(
|
|
||||||
{"source": source, "query": term, "results": [e.to_dict() for e in results], "note": SECURITY_FOOTER},
|
|
||||||
indent=2))
|
|
||||||
return
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
console.print(f"[yellow]No plugins matched '{term}'[/yellow] [dim](index source: {source})[/dim]")
|
|
||||||
return
|
|
||||||
|
|
||||||
table = _table(
|
|
||||||
(("Name", "bold"), ("Description", None), ("Author", None), ("Tags", "dim")),
|
|
||||||
title=f"Community plugins ({len(results)} match{'es' if len(results) != 1 else ''})")
|
|
||||||
for e in results:
|
|
||||||
desc = e.description if len(e.description) <= 70 else e.description[:67] + "..."
|
|
||||||
table.add_row(e.name, desc, e.author, ", ".join(e.tags))
|
|
||||||
console.print(table)
|
|
||||||
console.print(f"[dim]Index source: {source}. Install: hermes plugins install <name>[/dim]")
|
|
||||||
console.print(f"[dim]{SECURITY_FOOTER}[/dim]")
|
|
||||||
|
|
||||||
|
|
||||||
def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]:
|
def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]:
|
||||||
"""Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given)."""
|
"""Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given)."""
|
||||||
return True if getattr(args, yes_attr, False) else (False if getattr(args, no_attr, False) else None)
|
return True if getattr(args, yes_attr, False) else (False if getattr(args, no_attr, False) else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog():
|
||||||
|
from hermes_cli import plugins_cmd_catalog
|
||||||
|
return plugins_cmd_catalog
|
||||||
|
|
||||||
|
|
||||||
def _action_pack(args):
|
def _action_pack(args):
|
||||||
from hermes_cli.plugin_packs import pack_command
|
from hermes_cli.plugin_packs import pack_command
|
||||||
pack_command(args)
|
pack_command(args)
|
||||||
@@ -2039,12 +2029,12 @@ _PLUGIN_ACTIONS = {
|
|||||||
args.identifier,
|
args.identifier,
|
||||||
force=getattr(args, "force", False),
|
force=getattr(args, "force", False),
|
||||||
enable=_tri_state_flag(args, "enable", "no_enable"),
|
enable=_tri_state_flag(args, "enable", "no_enable"),
|
||||||
ref=getattr(args, "ref", None)),
|
ref=getattr(args, "ref", None),
|
||||||
"search": lambda args: cmd_search(
|
allow_removed=getattr(args, "allow_removed", False)),
|
||||||
getattr(args, "term", "") or "",
|
"search": lambda args: _catalog().cmd_search(
|
||||||
json_output=getattr(args, "json", False),
|
getattr(args, "term", "") or "", json_output=getattr(args, "json", False)),
|
||||||
capability=getattr(args, "capability", None),
|
"browse": lambda args: _catalog().cmd_search(""),
|
||||||
refresh=getattr(args, "refresh", False)),
|
"validate": lambda args: _catalog().cmd_validate(args.path, as_json=getattr(args, "json", False)),
|
||||||
"update": lambda args: cmd_update(args.name),
|
"update": lambda args: cmd_update(args.name),
|
||||||
"remove": lambda args: cmd_remove(args.name),
|
"remove": lambda args: cmd_remove(args.name),
|
||||||
"rm": lambda args: cmd_remove(args.name),
|
"rm": lambda args: cmd_remove(args.name),
|
||||||
@@ -2060,7 +2050,7 @@ _PLUGIN_ACTIONS = {
|
|||||||
"compat": lambda args: cmd_compat(args),
|
"compat": lambda args: cmd_compat(args),
|
||||||
"pack": _action_pack,
|
"pack": _action_pack,
|
||||||
"show": lambda args: cmd_show(args.name),
|
"show": lambda args: cmd_show(args.name),
|
||||||
"info": lambda args: cmd_show(args.name),
|
"info": lambda args: _catalog().cmd_info(args.name),
|
||||||
None: lambda args: cmd_toggle(),
|
None: lambda args: cmd_toggle(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""``hermes plugins`` catalog surface: resolution, provenance sidecar, search/browse/info/validate,
|
||||||
|
catalog-aware update, plus the dashboard/TUI-facing catalog payload helpers.
|
||||||
|
|
||||||
|
Sibling of :mod:`hermes_cli.plugins_cmd` (the installer core, enable/disable state and console helpers
|
||||||
|
live there and are imported late — this module is imported BY ``plugins_cmd``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from hermes_cli.plugin_catalog import (
|
||||||
|
PluginCatalogEntry, entry_capability_summary, filter_entries, find_removed, get_live_catalog_entry,
|
||||||
|
load_catalog_live, load_removed_list, _NAME_RE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CATALOG_SIDECAR = ".hermes-catalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolution / provenance ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def looks_like_catalog_name(identifier: str) -> bool:
|
||||||
|
"""Bare ``[a-z0-9_-]`` token — not a URL, ``owner/repo`` or path."""
|
||||||
|
from hermes_cli.plugins_cmd import _URL_SCHEMES
|
||||||
|
return bool(identifier) and "/" not in identifier and "\\" not in identifier \
|
||||||
|
and not identifier.startswith(_URL_SCHEMES) and bool(_NAME_RE.match(identifier))
|
||||||
|
|
||||||
|
|
||||||
|
def raise_if_removed(*candidates: str) -> None:
|
||||||
|
"""``PluginOperationError`` when any candidate (name or repo URL) is on the kill list."""
|
||||||
|
from hermes_cli.plugins_cmd import PluginOperationError
|
||||||
|
for candidate in candidates:
|
||||||
|
removed = find_removed(candidate)
|
||||||
|
if removed is not None:
|
||||||
|
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 catalog and is blocked from "
|
||||||
|
f"installation: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_catalog_name(identifier: str, console) -> PluginCatalogEntry:
|
||||||
|
"""Bare name → live catalog entry, or exit 1 with a pointer to ``search``."""
|
||||||
|
from hermes_cli.plugins_cmd import _fail
|
||||||
|
entry = get_live_catalog_entry(identifier)
|
||||||
|
if entry is None:
|
||||||
|
_fail(console, (
|
||||||
|
f"[red]Error:[/red] '{identifier}' is not in the Hermes plugin catalog and is not a Git URL or "
|
||||||
|
"owner/repo shorthand. Browse entries with `hermes plugins search`."))
|
||||||
|
raise SystemExit(1) # _fail exits; keeps type-checkers honest
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def write_catalog_sidecar(target: Path, entry: PluginCatalogEntry) -> None:
|
||||||
|
"""``.hermes-catalog.json`` inside the install dir — how ``update``/``list``/dashboards know the plugin
|
||||||
|
came from the catalog and at which pin."""
|
||||||
|
sidecar = {
|
||||||
|
"catalog_name": entry.name, "repo": entry.repo, "sha": entry.sha, "tier": entry.tier,
|
||||||
|
"installed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
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) -> Optional[dict]:
|
||||||
|
"""Parsed sidecar, or ``None`` (absent/corrupt = a non-catalog install)."""
|
||||||
|
path = Path(plugin_dir) / CATALOG_SIDECAR if plugin_dir else None
|
||||||
|
if path is None or 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) and data.get("catalog_name") else None
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_annotation(dir_path) -> Optional[str]:
|
||||||
|
"""``catalog:<tier>@<sha8>`` for a catalog install (``list`` Source column), else ``None``."""
|
||||||
|
sidecar = read_catalog_sidecar(dir_path)
|
||||||
|
if not sidecar:
|
||||||
|
return None
|
||||||
|
return f"catalog:{sidecar.get('tier') or 'community'}@{str(sidecar.get('sha') or '')[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def removed_annotation(name: str, dir_path) -> Optional[str]:
|
||||||
|
"""Kill-list reason when an INSTALLED plugin matches by name, catalog name or repo, else ``None``."""
|
||||||
|
sidecar = read_catalog_sidecar(dir_path) or {}
|
||||||
|
for candidate in (name, sidecar.get("catalog_name"), sidecar.get("repo")):
|
||||||
|
removed = find_removed(str(candidate)) if candidate else None
|
||||||
|
if removed is not None:
|
||||||
|
return removed.reason or "no reason recorded"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Catalog-aware install / update ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,
|
||||||
|
allow_removed: bool = False, scan_decision_cb=None) -> tuple:
|
||||||
|
"""``_install_plugin_core`` at the catalog pin (an explicit *ref* wins) + provenance sidecar.
|
||||||
|
Returns the core's ``(target, manifest, installed_name)``."""
|
||||||
|
from hermes_cli.plugins_cmd import _install_plugin_core
|
||||||
|
if not allow_removed:
|
||||||
|
raise_if_removed(entry.name, entry.repo)
|
||||||
|
target, manifest, installed_name = _install_plugin_core(
|
||||||
|
entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb)
|
||||||
|
write_catalog_sidecar(target, entry)
|
||||||
|
return target, manifest, installed_name
|
||||||
|
|
||||||
|
|
||||||
|
def repin_catalog_plugin(target: Path, sidecar: dict) -> tuple[str, bool]:
|
||||||
|
"""Re-pin a catalog install to the current catalog SHA (never ``git pull``). Returns
|
||||||
|
``(new_sha, changed)``; raises ``PluginOperationError`` when the entry left the catalog."""
|
||||||
|
from hermes_cli.plugins_cmd import PluginOperationError, _get_enabled_set, _save_enabled_set
|
||||||
|
catalog_name = str(sidecar["catalog_name"])
|
||||||
|
entry = get_live_catalog_entry(catalog_name)
|
||||||
|
if entry is None:
|
||||||
|
raise PluginOperationError(
|
||||||
|
f"Plugin '{catalog_name}' is no longer in the catalog — it may have been removed. "
|
||||||
|
"See `hermes plugins info` and the removed blocklist.")
|
||||||
|
if str(sidecar.get("sha") or "").strip().lower() == entry.sha:
|
||||||
|
return entry.sha, False
|
||||||
|
was_enabled = _get_enabled_set() # the force reinstall must not flip activation state
|
||||||
|
install_catalog_entry(entry, force=True)
|
||||||
|
_save_enabled_set(was_enabled)
|
||||||
|
return entry.sha, True
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_update_catalog(name: str, target: Path, sidecar: dict, console) -> None:
|
||||||
|
from hermes_cli.plugins_cmd import PluginOperationError, _fail
|
||||||
|
console.print(f"[dim]Checking catalog pin for {name}...[/dim]")
|
||||||
|
try:
|
||||||
|
sha, changed = repin_catalog_plugin(target, sidecar)
|
||||||
|
except PluginOperationError as exc:
|
||||||
|
_fail(console, f"[red]Error:[/red] {exc}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
verb = "updated to" if changed else "is already at catalog pin"
|
||||||
|
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] {verb} {sha[:8]}.")
|
||||||
|
|
||||||
|
|
||||||
|
# ── search / browse / info / validate ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _capability_counts(entry: PluginCatalogEntry) -> str:
|
||||||
|
caps = entry.capabilities
|
||||||
|
parts = [f"{len(items)} {label}{'s' if len(items) != 1 and label != 'middleware' else ''}"
|
||||||
|
for items, label in ((caps.provides_tools, "tool"), (caps.provides_hooks, "hook"),
|
||||||
|
(caps.provides_middleware, "middleware")) if items]
|
||||||
|
if caps.requires_env:
|
||||||
|
parts.append(f"{len(caps.requires_env)} env")
|
||||||
|
return ", ".join(parts) or "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_entries(entries: List[PluginCatalogEntry], console) -> None:
|
||||||
|
from hermes_cli.plugins_cmd import _table
|
||||||
|
table = _table(((("Name", "bold")), ("Tier", None), ("Description", None), ("Pinned", "dim"),
|
||||||
|
("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)")
|
||||||
|
for e in entries:
|
||||||
|
tier = "[cyan]official[/cyan]" if e.tier == "official" else "[magenta]community[/magenta]"
|
||||||
|
desc = e.description if len(e.description) <= 60 else e.description[:57] + "..."
|
||||||
|
table.add_row(e.name, tier, desc, e.sha[:8], _capability_counts(e))
|
||||||
|
console.print()
|
||||||
|
console.print(table)
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Details:[/dim] hermes plugins info <name> [dim]Install:[/dim] hermes plugins install <name>")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_search(term: str = "", *, json_output: bool = False) -> None:
|
||||||
|
"""Search the curated catalog (name/description/declared tools); empty term = browse everything."""
|
||||||
|
from hermes_cli.plugins_cmd import _console
|
||||||
|
matches = filter_entries(load_catalog_live(), term)
|
||||||
|
if json_output:
|
||||||
|
print(json.dumps({"query": term, "results": [e.to_dict() for e in matches]}, indent=2))
|
||||||
|
return
|
||||||
|
console = _console()
|
||||||
|
if not matches:
|
||||||
|
console.print(f"[yellow]No catalog entries matched '{term}'[/yellow]" if term
|
||||||
|
else "[dim]No catalog entries available.[/dim]")
|
||||||
|
return
|
||||||
|
_render_entries(matches, console)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_info(name: str) -> None:
|
||||||
|
"""Full catalog entry for *name*; falls back to installed-plugin details for non-catalog names."""
|
||||||
|
from hermes_cli.plugins_cmd import _console, cmd_show
|
||||||
|
entry = get_live_catalog_entry(name)
|
||||||
|
if entry is None:
|
||||||
|
cmd_show(name)
|
||||||
|
return
|
||||||
|
console = _console()
|
||||||
|
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()
|
||||||
|
rows = [("Repo", entry.repo), ("Subdir", entry.subdir), ("Pinned SHA", entry.sha),
|
||||||
|
("Maintainer", entry.maintainer), ("Requires", f"hermes {entry.requires_hermes}" if entry.requires_hermes else ""),
|
||||||
|
("Platforms", ", ".join(entry.platforms)), ("Docs", entry.docs_url)]
|
||||||
|
for label, value in rows:
|
||||||
|
if value:
|
||||||
|
console.print(f"[dim]{label + ':':<12}[/dim] {value}")
|
||||||
|
console.print()
|
||||||
|
for label, items in (("Tools", caps.provides_tools), ("Hooks", caps.provides_hooks),
|
||||||
|
("Middleware", caps.provides_middleware), ("Env vars", caps.requires_env)):
|
||||||
|
console.print(f"[dim]{label + ':':<12}[/dim] {', '.join(items) or '(none)'}")
|
||||||
|
console.print()
|
||||||
|
removed = find_removed(entry.name) or find_removed(entry.repo)
|
||||||
|
if removed is not None:
|
||||||
|
console.print(f"[red bold]✗ REMOVED from catalog: {removed.reason or 'no reason recorded'}"
|
||||||
|
f"{f' ({removed.date})' if removed.date else ''}[/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:
|
||||||
|
"""Catalog-admission validation of a plugin directory (the CI gate); exits 0/1."""
|
||||||
|
from hermes_cli.plugin_validate import validate_plugin_dir
|
||||||
|
from hermes_cli.plugins_cmd import _console
|
||||||
|
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 check_name, ok, detail in report.checks:
|
||||||
|
console.print(f"{'[green]✓[/green]' if ok else '[red]✗[/red]'} {check_name}"
|
||||||
|
+ (f" [dim]— {detail}[/dim]" if detail else ""))
|
||||||
|
for warning in report.warnings:
|
||||||
|
console.print(f"[yellow]⚠ {warning}[/yellow]")
|
||||||
|
console.print()
|
||||||
|
console.print("[green bold]Validation passed.[/green bold]" if report.ok else "[red bold]Validation failed.[/red bold]")
|
||||||
|
sys.exit(report.exit_code)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dashboard / TUI payloads ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def installed_catalog_state(installed: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Catalog entries merged with local state for the dashboard. *installed* maps every alias (name
|
||||||
|
and registry key) of a discovered plugin to ``{"dir", "runtime_status"}``. A catalog name rarely
|
||||||
|
equals the manifest name (``hermes-plugin-x`` vs ``x``), so installs are matched through the
|
||||||
|
sidecar's ``catalog_name`` first and by name only as a fallback."""
|
||||||
|
by_catalog_name: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for local in installed.values():
|
||||||
|
sidecar = read_catalog_sidecar(local["dir"])
|
||||||
|
if sidecar:
|
||||||
|
by_catalog_name[str(sidecar["catalog_name"])] = {**local, "sidecar": sidecar}
|
||||||
|
entries = []
|
||||||
|
for entry in load_catalog_live():
|
||||||
|
local = by_catalog_name.get(entry.name) or installed.get(entry.name)
|
||||||
|
sidecar = local.get("sidecar") if local else None
|
||||||
|
installed_sha = str(sidecar["sha"]) if sidecar and sidecar.get("sha") else None
|
||||||
|
entries.append({
|
||||||
|
**entry.to_dict(), "sha_short": entry.sha[:7],
|
||||||
|
"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 else None,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"entries": entries,
|
||||||
|
"removed": [{"name": r.name, "repo": r.repo, "reason": r.reason, "date": r.date} for r in load_removed_list()],
|
||||||
|
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_row_fields(dir_path, pins: Dict[str, str]) -> Dict[str, Any]:
|
||||||
|
"""Provenance fields for one installed-plugin row (TUI/desktop ``plugins.manage list``): catalog
|
||||||
|
name/tier/installed SHA and, when *pins* has the entry, the current pin + ``update_available``."""
|
||||||
|
sidecar = read_catalog_sidecar(dir_path)
|
||||||
|
if not sidecar:
|
||||||
|
return {}
|
||||||
|
installed_sha = str(sidecar.get("sha") or "").lower()
|
||||||
|
row: Dict[str, Any] = {
|
||||||
|
"catalog_name": sidecar["catalog_name"], "catalog_tier": str(sidecar.get("tier") or "community"),
|
||||||
|
"installed_sha": installed_sha}
|
||||||
|
pin = pins.get(str(sidecar["catalog_name"]))
|
||||||
|
if pin:
|
||||||
|
row["catalog_sha"] = pin
|
||||||
|
row["update_available"] = bool(installed_sha) and installed_sha != pin
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_pins() -> Dict[str, str]:
|
||||||
|
"""``{catalog_name: pinned_sha}`` from the live catalog; empty on failure (best effort)."""
|
||||||
|
try:
|
||||||
|
return {e.name: e.sha for e in load_catalog_live()}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
@@ -16,17 +16,19 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
|||||||
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
|
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
|
||||||
|
|
||||||
plugins_install = plugins_subparsers.add_parser(
|
plugins_install = plugins_subparsers.add_parser(
|
||||||
"install", help="Install a plugin from a Git URL, owner/repo, or index name")
|
"install", help="Install a plugin from the curated catalog, a Git URL, or owner/repo")
|
||||||
plugins_install.add_argument(
|
plugins_install.add_argument(
|
||||||
"identifier",
|
"identifier",
|
||||||
help="Git URL, owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles), "
|
help="Bare plugin catalog entry name (see `hermes plugins search`), Git URL, or owner/repo "
|
||||||
"or a bare plugin name resolved through the community index "
|
"shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)")
|
||||||
"(see `hermes plugins search`)")
|
|
||||||
plugins_install.add_argument(
|
plugins_install.add_argument(
|
||||||
"--force", "-f", action="store_true", help="Remove existing plugin and reinstall")
|
"--force", "-f", action="store_true", help="Remove existing plugin and reinstall")
|
||||||
plugins_install.add_argument(
|
plugins_install.add_argument(
|
||||||
"--ref", metavar="COMMIT_SHA",
|
"--ref", metavar="COMMIT_SHA",
|
||||||
help="Install exactly one immutable 40-character Git commit SHA")
|
help="Install exactly one immutable 40-character Git commit SHA")
|
||||||
|
plugins_install.add_argument(
|
||||||
|
"--allow-removed", action="store_true",
|
||||||
|
help="DANGEROUS: bypass the catalog removed-plugin blocklist check")
|
||||||
_install_enable_group = plugins_install.add_mutually_exclusive_group()
|
_install_enable_group = plugins_install.add_mutually_exclusive_group()
|
||||||
_install_enable_group.add_argument(
|
_install_enable_group.add_argument(
|
||||||
"--enable", action="store_true",
|
"--enable", action="store_true",
|
||||||
@@ -37,17 +39,18 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
plugins_search = plugins_subparsers.add_parser(
|
plugins_search = plugins_subparsers.add_parser(
|
||||||
"search", help="Search the community plugin index")
|
"search", help="Search the curated Hermes plugin catalog")
|
||||||
plugins_search.add_argument(
|
plugins_search.add_argument(
|
||||||
"term", nargs="?", default="",
|
"term", nargs="?", default="",
|
||||||
help="Search term matched fuzzily against name, description, and tags "
|
help="Query matched against entry names, descriptions and declared tools (omit to list the whole catalog)")
|
||||||
"(omit to browse the full index)")
|
|
||||||
add_json_flag(plugins_search, "Print machine-readable JSON")
|
add_json_flag(plugins_search, "Print machine-readable JSON")
|
||||||
plugins_search.add_argument(
|
|
||||||
"--capability", metavar="CAP",
|
plugins_subparsers.add_parser("browse", help="List every curated plugin catalog entry")
|
||||||
help="Filter by declared capability (e.g. tools, platform, commands)")
|
|
||||||
plugins_search.add_argument(
|
plugins_validate = plugins_subparsers.add_parser(
|
||||||
"--refresh", action="store_true", help="Bypass the local cache and re-fetch the index")
|
"validate", help="Validate a plugin directory for catalog admission (CI gate)")
|
||||||
|
plugins_validate.add_argument("path", help="Path to the plugin directory")
|
||||||
|
add_json_flag(plugins_validate, "Print machine-readable JSON (for CI)")
|
||||||
|
|
||||||
plugins_update = plugins_subparsers.add_parser(
|
plugins_update = plugins_subparsers.add_parser(
|
||||||
"update", help="Pull latest changes for an installed plugin")
|
"update", help="Pull latest changes for an installed plugin")
|
||||||
|
|||||||
@@ -170,12 +170,41 @@ def _plugin_action(result: dict, fallback_error: str, *, rescan: bool) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/dashboard/plugins/catalog")
|
||||||
|
async def get_plugins_catalog(request: Request):
|
||||||
|
"""Curated plugin catalog merged with installed state (session protected)."""
|
||||||
|
_require_token(request)
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
from hermes_cli.plugins_cmd import _discover_all_plugins, _get_disabled_set, _get_enabled_set
|
||||||
|
from hermes_cli.plugins_cmd_catalog import installed_catalog_state
|
||||||
|
from hermes_cli.web_server_dashboard import _plugin_runtime_status
|
||||||
|
enabled, disabled = _get_enabled_set(), _get_disabled_set()
|
||||||
|
installed = {}
|
||||||
|
for name, _v, _d, _s, dir_str, key in _discover_all_plugins():
|
||||||
|
aliases = {name, key} - {""}
|
||||||
|
info = {"dir": dir_str, "runtime_status": _plugin_runtime_status(aliases, enabled, disabled)}
|
||||||
|
installed.update({alias: info for alias in aliases})
|
||||||
|
return installed_catalog_state(installed)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_run)
|
||||||
|
except Exception as exc:
|
||||||
|
_log.warning("plugins/catalog failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to build plugins catalog.") from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/dashboard/agent-plugins/install")
|
@router.post("/api/dashboard/agent-plugins/install")
|
||||||
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
|
async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody):
|
||||||
_require_token(request)
|
_require_token(request)
|
||||||
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
from hermes_cli.plugins_cmd import dashboard_install_plugin
|
||||||
|
|
||||||
result = dashboard_install_plugin(body.identifier.strip(), force=body.force, enable=body.enable)
|
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(
|
||||||
|
identifier, force=body.force, enable=body.enable, catalog_name=catalog_name or None)
|
||||||
result = _plugin_action(result, "Install failed.", rescan=True)
|
result = _plugin_action(result, "Install failed.", rescan=True)
|
||||||
# Strip internal paths from the response
|
# Strip internal paths from the response
|
||||||
result.pop("after_install_path", None)
|
result.pop("after_install_path", None)
|
||||||
|
|||||||
@@ -634,6 +634,11 @@ def _plugin_auth_hint(name: str, provides_tools: list) -> tuple:
|
|||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_runtime_status(aliases: set, enabled_set: set, disabled_set: set) -> str:
|
||||||
|
"""enabled / disabled / inactive for a plugin's name+key alias set (disabled wins)."""
|
||||||
|
return "disabled" if aliases & disabled_set else "enabled" if aliases & enabled_set else "inactive"
|
||||||
|
|
||||||
|
|
||||||
def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
|
def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
"""Agent discovery + dashboard manifests + provider picker metadata.
|
"""Agent discovery + dashboard manifests + provider picker metadata.
|
||||||
|
|
||||||
@@ -662,6 +667,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
_get_enabled_set,
|
_get_enabled_set,
|
||||||
_read_manifest as _read_plugin_manifest_at,
|
_read_manifest as _read_plugin_manifest_at,
|
||||||
)
|
)
|
||||||
|
from hermes_cli.plugins_cmd_catalog import removed_annotation
|
||||||
|
|
||||||
dashboard_list = _get_dashboard_plugins()
|
dashboard_list = _get_dashboard_plugins()
|
||||||
dash_by_name = {str(p["name"]): p for p in dashboard_list}
|
dash_by_name = {str(p["name"]): p for p in dashboard_list}
|
||||||
@@ -675,12 +681,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
# Both the path-derived key (nested category plugins) and the bare manifest name
|
# 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.
|
# count for enabled/disabled state, matching the runtime loader's back-compat lookup.
|
||||||
aliases = {name, key} if key else {name}
|
aliases = {name, key} if key else {name}
|
||||||
if aliases & disabled_set:
|
runtime_status = _plugin_runtime_status(aliases, enabled_set, disabled_set)
|
||||||
runtime_status = "disabled"
|
|
||||||
elif aliases & enabled_set:
|
|
||||||
runtime_status = "enabled"
|
|
||||||
else:
|
|
||||||
runtime_status = "inactive"
|
|
||||||
|
|
||||||
dir_path = Path(dir_str)
|
dir_path = Path(dir_str)
|
||||||
dm = dash_by_name.get(name)
|
dm = dash_by_name.get(name)
|
||||||
@@ -708,6 +709,7 @@ def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
"auth_required": auth_required,
|
"auth_required": auth_required,
|
||||||
"auth_command": auth_command,
|
"auth_command": auth_command,
|
||||||
"user_hidden": name in hidden_plugins,
|
"user_hidden": name in hidden_plugins,
|
||||||
|
"removed_reason": removed_annotation(name, dir_str),
|
||||||
})
|
})
|
||||||
|
|
||||||
agent_names = {r["name"] for r in rows}
|
agent_names = {r["name"] for r in rows}
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ command. A hook with no concrete consumer is speculative infrastructure and is r
|
|||||||
`plugin-llm-example`, `plugin-llm-async-example`) live in
|
`plugin-llm-example`, `plugin-llm-async-example`) live in
|
||||||
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins), not here.
|
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins), not here.
|
||||||
|
|
||||||
|
## Plugin catalog (`plugin-catalog/`, Sep 2026)
|
||||||
|
|
||||||
|
The ONLY discovery system for out-of-tree plugins. One YAML per entry, 40-hex SHA pin mandatory,
|
||||||
|
human-merged via PR (`plugin-catalog/README.md` = admission policy; `plugin-catalog-ci.yml` clones
|
||||||
|
each changed entry at its pin and runs `hermes plugins validate`). `removed.yaml` is the kill list —
|
||||||
|
every install path (CLI, dashboard, TUI) refuses matches; only the CLI has a loud `--allow-removed`.
|
||||||
|
Code: `hermes_cli/plugin_catalog.py` (loader, live refresh from
|
||||||
|
`/docs/api/plugin-catalog.json` published by the docs build, in-tree fallback),
|
||||||
|
`hermes_cli/plugins_cmd_catalog.py` (resolution, `.hermes-catalog.json` provenance sidecar,
|
||||||
|
search/info/validate, re-pin on `update`, dashboard/TUI payloads). Never add a second name index:
|
||||||
|
bare names resolve through the catalog or error.
|
||||||
|
|
||||||
## Plugin kinds and their discovery systems
|
## Plugin kinds and their discovery systems
|
||||||
|
|
||||||
| Kind | Where | Discovery | Notes |
|
| Kind | Where | Discovery | Notes |
|
||||||
|
|||||||
@@ -1328,7 +1328,9 @@ def _(rid, params: dict) -> dict:
|
|||||||
# ─── Plugins ─────────────────────────────────────────────────────────────────
|
# ─── Plugins ─────────────────────────────────────────────────────────────────
|
||||||
def _plugin_rows() -> list[dict]:
|
def _plugin_rows() -> list[dict]:
|
||||||
pc = _tools_mod("hermes_cli.plugins_cmd")
|
pc = _tools_mod("hermes_cli.plugins_cmd")
|
||||||
|
cat = _tools_mod("hermes_cli.plugins_cmd_catalog")
|
||||||
enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set()
|
enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set()
|
||||||
|
pins = cat.catalog_pins() # powers the desktop's "Update to <pin>" affordance
|
||||||
out = []
|
out = []
|
||||||
for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()):
|
for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()):
|
||||||
status = pc._plugin_status(name, enabled, disabled, key=key)
|
status = pc._plugin_status(name, enabled, disabled, key=key)
|
||||||
@@ -1339,7 +1341,8 @@ def _plugin_rows() -> list[dict]:
|
|||||||
# key = canonical registry key (names collide across category dirs); portable = Agent Plugins v1.
|
# key = canonical registry key (names collide across category dirs); portable = Agent Plugins v1.
|
||||||
out.append({
|
out.append({
|
||||||
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
|
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
|
||||||
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir)})
|
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir),
|
||||||
|
**cat.catalog_row_fields(_dir, pins)})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -1363,22 +1366,44 @@ def _plugins_toggle(rid, params):
|
|||||||
|
|
||||||
|
|
||||||
def _plugins_install(rid, params):
|
def _plugins_install(rid, params):
|
||||||
|
# ``catalog_name`` alone installs a curated entry at its pinned SHA (resolved server-side, kill list
|
||||||
|
# enforced, no bypass) — same contract as the dashboard endpoint.
|
||||||
ident = (params.get("identifier") or params.get("repo") or "").strip()
|
ident = (params.get("identifier") or params.get("repo") or "").strip()
|
||||||
if not ident:
|
catalog_name = str(params.get("catalog_name") or "").strip()
|
||||||
return _err(rid, 4019, "plugins.install requires 'identifier' or 'repo'")
|
if not ident and not catalog_name:
|
||||||
|
return _err(rid, 4019, "plugins.install requires 'identifier', 'repo', or 'catalog_name'")
|
||||||
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_install_plugin(
|
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_install_plugin(
|
||||||
ident, force=bool(params.get("force")), enable=params.get("enable", True))
|
ident, force=bool(params.get("force")), enable=params.get("enable", True), catalog_name=catalog_name or None)
|
||||||
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "install failed")
|
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "install failed")
|
||||||
|
|
||||||
|
|
||||||
_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install}
|
def _plugins_update(rid, params):
|
||||||
|
"""Catalog installs only: re-pin to the current catalog SHA (non-catalog installs update via the CLI)."""
|
||||||
|
name = (params.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return _err(rid, 4019, "plugins.update requires a 'name'")
|
||||||
|
pc, cat = _tools_mod("hermes_cli.plugins_cmd"), _tools_mod("hermes_cli.plugins_cmd_catalog")
|
||||||
|
target = pc._plugins_dir() / name
|
||||||
|
sidecar = cat.read_catalog_sidecar(target) if target.is_dir() else None
|
||||||
|
if not sidecar:
|
||||||
|
return _err(rid, 4020, f"'{name}' is not a catalog install — update it via the CLI")
|
||||||
|
try:
|
||||||
|
sha, changed = cat.repin_catalog_plugin(target, sidecar)
|
||||||
|
except pc.PluginOperationError as e:
|
||||||
|
return _err(rid, 4021, str(e))
|
||||||
|
return _ok(rid, {"ok": True, "unchanged": not changed, "sha": sha})
|
||||||
|
|
||||||
|
|
||||||
|
_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install,
|
||||||
|
"update": _plugins_update}
|
||||||
|
|
||||||
|
|
||||||
@_scoped_rpc("plugins.manage", 5026, catch_resolve=False)
|
@_scoped_rpc("plugins.manage", 5026, catch_resolve=False)
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
"""TUI Plugins Hub backend (shares primitives with ``hermes plugins`` / the dashboard):
|
"""TUI Plugins Hub backend (shares primitives with ``hermes plugins`` / the dashboard):
|
||||||
``list`` → {plugins, user_count, bundled_count}; ``toggle`` flips ``key``/``name`` per ``enable``;
|
``list`` → {plugins, user_count, bundled_count}; ``toggle`` flips ``key``/``name`` per ``enable``;
|
||||||
``install`` git-clones ``identifier``/``repo`` (``force``, ``enable`` default True)."""
|
``install`` git-clones ``identifier``/``repo`` or a curated ``catalog_name`` (``force``, ``enable``
|
||||||
|
default True); ``update`` re-pins a catalog install to the current catalog SHA."""
|
||||||
return _run_action(rid, params, _PLUGINS_ACTIONS, "plugins")
|
return _run_action(rid, params, _PLUGINS_ACTIONS, "plugins")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,9 +91,18 @@ installs as `catalog:<tier>@<sha>` so you can see provenance at a glance.
|
|||||||
|
|
||||||
### Names not in the catalog
|
### Names not in the catalog
|
||||||
|
|
||||||
A bare name that isn't a catalog entry falls back to the
|
A bare name that isn't a catalog entry is an error: there is no second,
|
||||||
[community plugin index](plugins.md) with a warning — those entries are
|
unreviewed name index. Install such plugins by `owner/repo` or Git URL instead
|
||||||
indexed, not reviewed. Catalog names always win when both exist.
|
(custom source, see below), or submit them to the catalog.
|
||||||
|
|
||||||
|
### Live refresh
|
||||||
|
|
||||||
|
The docs build publishes the catalog as one JSON document
|
||||||
|
(`https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json`).
|
||||||
|
`search`/`install`/`update` fetch it at most every six hours and cache it under
|
||||||
|
`~/.hermes/cache/`, so new entries and removals reach installed clients without
|
||||||
|
updating Hermes. Offline, the copy shipped with your checkout is used. Removals
|
||||||
|
from the in-tree list and the live list are always both enforced.
|
||||||
|
|
||||||
### Custom git URLs are different
|
### Custom git URLs are different
|
||||||
|
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist
|
|||||||
hermes plugins # unified interactive UI
|
hermes plugins # unified interactive UI
|
||||||
hermes plugins list # table: enabled / disabled / not enabled
|
hermes plugins list # table: enabled / disabled / not enabled
|
||||||
hermes plugins search <term> # search the Hermes plugin catalog
|
hermes plugins search <term> # search the Hermes plugin catalog
|
||||||
hermes plugins install <name> # install by index name (resolved to repo @ pinned ref)
|
hermes plugins install <name> # install a catalog entry (repo @ reviewed pinned SHA)
|
||||||
hermes plugins install user/repo # install from Git, then prompt Enable? [y/N]
|
hermes plugins install user/repo # install from Git, then prompt Enable? [y/N]
|
||||||
hermes plugins install user/repo --enable # install AND enable (no prompt)
|
hermes plugins install user/repo --enable # install AND enable (no prompt)
|
||||||
hermes plugins install user/repo --no-enable # install but leave disabled (no prompt)
|
hermes plugins install user/repo --no-enable # install but leave disabled (no prompt)
|
||||||
@@ -542,8 +542,8 @@ description: STT + streaming TTS + approval relay
|
|||||||
author: hyper
|
author: hyper
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
plugins:
|
plugins:
|
||||||
- name: hermes-media-studio # bare community-index name…
|
- name: hermes-telegram-business # bare plugin-catalog name…
|
||||||
ref: e8d59971d2b7901405b39dac7b03bdd616272d0d
|
ref: e905f3bc5eeaa5a9dab9bc5155601b3ebec75757
|
||||||
- repo: owner/approval-relay # …or explicit owner/repo (or git URL)
|
- repo: owner/approval-relay # …or explicit owner/repo (or git URL)
|
||||||
ref: 8f3c2d1a9b4e5f6071829304a5b6c7d8e9f00112
|
ref: 8f3c2d1a9b4e5f6071829304a5b6c7d8e9f00112
|
||||||
subdir: plugins/relay # optional monorepo path
|
subdir: plugins/relay # optional monorepo path
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ meta counts and exit 0 so the docs build stays green. The page renders a
|
|||||||
|
|
||||||
Outputs (both under website/static/api/, CDN-served at /docs/api/):
|
Outputs (both under website/static/api/, CDN-served at /docs/api/):
|
||||||
|
|
||||||
- ``plugins.json`` — list of catalog entries for the page
|
- ``plugins.json`` — list of catalog entries for the page (camelCase)
|
||||||
- ``plugins-meta.json`` — counts by tier + generatedAt + removedCount
|
- ``plugins-meta.json`` — counts by tier + generatedAt + removedCount
|
||||||
|
- ``plugin-catalog.json`` — ``{"entries": [raw YAML mappings], "removed": [...]}`` in the loader's own
|
||||||
|
schema; installed Hermes clients fetch this for live catalog refresh
|
||||||
|
(``hermes_cli.plugin_catalog.LIVE_CATALOG_URL``) so new entries and removals reach them without
|
||||||
|
updating. Emitting it here means the docs deploy IS the publish step — no second pipeline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -123,20 +127,39 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]:
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def count_removed(catalog_dir: Path) -> int:
|
def load_removed(catalog_dir: Path) -> list[dict]:
|
||||||
"""Number of entries in plugin-catalog/removed.yaml (``removed:`` list)."""
|
"""``removed:`` list from plugin-catalog/removed.yaml (mappings only)."""
|
||||||
removed_path = catalog_dir / "removed.yaml"
|
removed_path = catalog_dir / "removed.yaml"
|
||||||
if not removed_path.is_file():
|
if not removed_path.is_file():
|
||||||
return 0
|
return []
|
||||||
try:
|
try:
|
||||||
raw = yaml.safe_load(removed_path.read_text(encoding="utf-8"))
|
raw = yaml.safe_load(removed_path.read_text(encoding="utf-8"))
|
||||||
except (yaml.YAMLError, OSError) as e:
|
except (yaml.YAMLError, OSError) as e:
|
||||||
_log(f"could not read removed.yaml: {e}")
|
_log(f"could not read removed.yaml: {e}")
|
||||||
return 0
|
return []
|
||||||
if not isinstance(raw, dict):
|
removed = raw.get("removed") if isinstance(raw, dict) else None
|
||||||
return 0
|
return [r for r in removed if isinstance(r, dict)] if isinstance(removed, list) else []
|
||||||
removed = raw.get("removed")
|
|
||||||
return len(removed) if isinstance(removed, list) else 0
|
|
||||||
|
def count_removed(catalog_dir: Path) -> int:
|
||||||
|
return len(load_removed(catalog_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def load_raw_entries(catalog_dir: Path) -> list[dict]:
|
||||||
|
"""Raw entry mappings (loader schema, snake_case) for ``plugin-catalog.json``; the client re-validates."""
|
||||||
|
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):
|
||||||
|
continue
|
||||||
|
if isinstance(raw, dict) and raw.get("name") and raw.get("repo") and raw.get("sha"):
|
||||||
|
entries.append(raw)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUTPUT_DIR) -> int:
|
def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUTPUT_DIR) -> int:
|
||||||
@@ -162,6 +185,9 @@ def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUT
|
|||||||
json.dump(entries, f, separators=(",", ":"), ensure_ascii=False)
|
json.dump(entries, f, separators=(",", ":"), ensure_ascii=False)
|
||||||
with open(output_dir / "plugins-meta.json", "w", encoding="utf-8") as f:
|
with open(output_dir / "plugins-meta.json", "w", encoding="utf-8") as f:
|
||||||
json.dump(meta, f, separators=(",", ":"), ensure_ascii=False)
|
json.dump(meta, f, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
with open(output_dir / "plugin-catalog.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"generated_at": meta["generatedAt"], "entries": load_raw_entries(catalog_dir),
|
||||||
|
"removed": load_removed(catalog_dir)}, f, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"Extracted {len(entries)} plugin catalog entries "
|
f"Extracted {len(entries)} plugin catalog entries "
|
||||||
|
|||||||
Reference in New Issue
Block a user