feat(plugins): community plugin index + hermes plugins search (#64181)
Static machine-readable community plugin index with fuzzy search and index-resolved installs, mirroring the Skills Hub catalog pattern (fetch → HERMES_HOME/cache with 24h TTL → bundled seed fallback). - hermes_cli/plugin_index.py: index fetch/cache/seed chain, fuzzy search (name/description/tags/author + typo tolerance), capability filter, bare-name resolution. Canonical URL overridable via plugins.index_url config key. - hermes_cli/data/plugin_index.json: bundled seed (offline fallback + format reference) with 5 real ecosystem plugins, each pinned to an exact commit SHA. - hermes plugins search [term] [--json] [--capability] [--refresh]: Rich table or JSON output, offline-safe, with an explicit 'indexed ≠ audited' footer. - hermes plugins install <name>: bare names (no slash, no URL scheme) resolve through the index to owner/repo[/subdir] @ pinned ref and hand off to the existing install path (ref wired through the #82029 exact-ref support). Ambiguous names list candidates and exit; explicit owner/repo and Git URL installs are untouched, and an explicit --ref always beats the index pin. - Docs: discovery section in user-guide plugins.md (format, submission workflow via PR to hermes-plugin-index, security framing) and reference/cli-commands.md rows. - Tests: tests/hermes_cli/test_plugin_index_search.py (38 tests, no live network) covering parsing, search, remote→cache→seed fallback, TTL, install resolution/ambiguity/passthrough, and --json output.
This commit is contained in:
parent
88ab589f68
commit
2e0183169c
|
|
@ -31,6 +31,10 @@ __pycache__/model_tools.cpython-310.pyc
|
|||
__pycache__/web_tools.cpython-310.pyc
|
||||
logs/
|
||||
data/
|
||||
# Bundled community plugin index seed (shipped as package data) — the bare
|
||||
# `data/` pattern above would otherwise swallow it.
|
||||
!hermes_cli/data/
|
||||
!hermes_cli/data/plugin_index.json
|
||||
.pytest_cache/
|
||||
test_durations.json
|
||||
.pytest-cache/
|
||||
|
|
|
|||
|
|
@ -4756,6 +4756,11 @@ _SCHEMA_DEFINED_DICT_KEYS = frozenset({
|
|||
"email", "sms", "dingtalk",
|
||||
# MCP server template / dynamic auth dicts
|
||||
"sessions", "checkpoints",
|
||||
# Plugin settings — enable/disable lists plus index_url override
|
||||
# (hermes_cli/plugins_cmd.py, hermes_cli/plugin_index.py). Absent from
|
||||
# DEFAULT_CONFIG (written only when used), so listed here for
|
||||
# `hermes config set plugins.index_url ...` validation.
|
||||
"plugins",
|
||||
})
|
||||
|
||||
# Top-level keys that can be ANY user-supplied name (platform/provider dict
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-08-12T00:00:00Z",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "hermes-media-studio",
|
||||
"description": "Media Studio — generative media workspace plugin for Hermes Desktop (fal + Krea, durable job queue, library).",
|
||||
"author": "NousResearch",
|
||||
"tags": ["media", "image-gen", "video-gen", "dashboard", "desktop"],
|
||||
"repo": "NousResearch/hermes-media-studio",
|
||||
"ref": "e8d59971d2b7901405b39dac7b03bdd616272d0d",
|
||||
"homepage": "https://github.com/NousResearch/hermes-media-studio",
|
||||
"capabilities": ["tools", "dashboard"],
|
||||
"api_version": 1,
|
||||
"added_at": "2026-08-12"
|
||||
},
|
||||
{
|
||||
"name": "hermes-telegram-business",
|
||||
"description": "Observe-with-approval Telegram Business Mode (secretary bot) plugin — every drafted reply requires owner approval before it reaches the customer.",
|
||||
"author": "NousResearch",
|
||||
"tags": ["telegram", "gateway", "approvals", "messaging"],
|
||||
"repo": "NousResearch/hermes-telegram-business",
|
||||
"ref": "e905f3bc5eeaa5a9dab9bc5155601b3ebec75757",
|
||||
"homepage": "https://github.com/NousResearch/hermes-telegram-business",
|
||||
"capabilities": ["platform"],
|
||||
"api_version": 1,
|
||||
"added_at": "2026-08-12"
|
||||
},
|
||||
{
|
||||
"name": "plugin-llm-example",
|
||||
"description": "Reference plugin showing host-owned structured LLM access via ctx.llm.complete_structured(). Registers a /receipt-extract slash command.",
|
||||
"author": "NousResearch",
|
||||
"tags": ["example", "llm", "reference", "slash-command"],
|
||||
"repo": "NousResearch/hermes-example-plugins",
|
||||
"subdir": "plugin-llm-example",
|
||||
"ref": "38fe0fb53eff98d477f807432e965429e665ca33",
|
||||
"homepage": "https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example",
|
||||
"capabilities": ["commands", "llm"],
|
||||
"api_version": 1,
|
||||
"added_at": "2026-08-12"
|
||||
},
|
||||
{
|
||||
"name": "plugin-llm-async-example",
|
||||
"description": "Reference plugin demonstrating async host-owned LLM access from plugin code — the asyncio counterpart to plugin-llm-example.",
|
||||
"author": "NousResearch",
|
||||
"tags": ["example", "llm", "async", "reference"],
|
||||
"repo": "NousResearch/hermes-example-plugins",
|
||||
"subdir": "plugin-llm-async-example",
|
||||
"ref": "38fe0fb53eff98d477f807432e965429e665ca33",
|
||||
"homepage": "https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example",
|
||||
"capabilities": ["commands", "llm"],
|
||||
"api_version": 1,
|
||||
"added_at": "2026-08-12"
|
||||
},
|
||||
{
|
||||
"name": "hermes-plugin-chrome-profiles",
|
||||
"description": "Switch Hermes browser tools between Chrome profiles via CDP.",
|
||||
"author": "anpicasso",
|
||||
"tags": ["browser", "chrome", "cdp", "tools"],
|
||||
"repo": "anpicasso/hermes-plugin-chrome-profiles",
|
||||
"ref": "5b9c3257b464c0f926d4355149a8aed9c8f307b4",
|
||||
"homepage": "https://github.com/anpicasso/hermes-plugin-chrome-profiles",
|
||||
"capabilities": ["tools"],
|
||||
"api_version": 1,
|
||||
"added_at": "2026-08-12"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
"""Community plugin index — fetch, cache, search, and name resolution.
|
||||
|
||||
Mirrors the Skills Hub catalog pattern (``tools/skills_hub.py``): a static
|
||||
machine-readable JSON index hosted at a canonical URL, cached locally under
|
||||
``HERMES_HOME/cache/`` with a TTL, with a bundled seed file as the offline
|
||||
fallback and format reference.
|
||||
|
||||
Fallback chain: remote index → cached copy (fresh or stale) → bundled seed.
|
||||
|
||||
The index is discovery metadata ONLY. **Indexed ≠ audited** — inclusion in
|
||||
the index means the entry's metadata was reviewed, not that the plugin's code
|
||||
was audited. Install keeps its existing consent/review flow, and index
|
||||
entries pin an immutable ref (tag or commit SHA).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Canonical index location. Override via config key ``plugins.index_url``.
|
||||
DEFAULT_INDEX_URL = (
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-plugin-index/main/index.json"
|
||||
)
|
||||
|
||||
# Cache the fetched index for 24 hours; a stale cache is still preferred over
|
||||
# the bundled seed when the remote is unreachable.
|
||||
INDEX_CACHE_TTL = 24 * 3600
|
||||
|
||||
# Bundled seed — offline fallback and the machine-readable format reference.
|
||||
SEED_INDEX_PATH = Path(__file__).parent / "data" / "plugin_index.json"
|
||||
|
||||
_FETCH_TIMEOUT = 10.0
|
||||
_MAX_INDEX_BYTES = 5 * 1024 * 1024 # refuse absurdly large index payloads
|
||||
|
||||
SECURITY_FOOTER = (
|
||||
"Indexed \u2260 audited: inclusion in the index is a metadata review only, "
|
||||
"not a code audit. Review a plugin before enabling it."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginIndexEntry:
|
||||
"""One community plugin index entry."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
author: str = ""
|
||||
tags: List[str] = field(default_factory=list)
|
||||
repo: str = "" # "owner/name"
|
||||
ref: str = "" # pinned tag or commit SHA
|
||||
subdir: Optional[str] = None # path within the repo (monorepos)
|
||||
homepage: Optional[str] = None
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
api_version: Optional[int] = None
|
||||
added_at: Optional[str] = None
|
||||
|
||||
@property
|
||||
def install_identifier(self) -> str:
|
||||
"""Identifier accepted by the existing install path (owner/repo[/subdir])."""
|
||||
return f"{self.repo}/{self.subdir}" if self.subdir else self.repo
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
d: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"author": self.author,
|
||||
"tags": list(self.tags),
|
||||
"repo": self.repo,
|
||||
"ref": self.ref,
|
||||
}
|
||||
if self.subdir:
|
||||
d["subdir"] = self.subdir
|
||||
if self.homepage:
|
||||
d["homepage"] = self.homepage
|
||||
if self.capabilities:
|
||||
d["capabilities"] = list(self.capabilities)
|
||||
if self.api_version is not None:
|
||||
d["api_version"] = self.api_version
|
||||
if self.added_at:
|
||||
d["added_at"] = self.added_at
|
||||
return d
|
||||
|
||||
|
||||
def _cache_path() -> Path:
|
||||
return get_hermes_home() / "cache" / "plugin_index.json"
|
||||
|
||||
|
||||
def get_index_url() -> str:
|
||||
"""Resolve the index URL: config override ``plugins.index_url`` or default."""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config_readonly
|
||||
|
||||
override = cfg_get(load_config_readonly(), "plugins", "index_url", default=None)
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.strip()
|
||||
except Exception: # pragma: no cover - config loading must never break search
|
||||
logger.debug("plugin index: config override lookup failed", exc_info=True)
|
||||
return DEFAULT_INDEX_URL
|
||||
|
||||
|
||||
def _parse_entries(raw: Any) -> List[PluginIndexEntry]:
|
||||
"""Parse a decoded index document into entries, skipping malformed items."""
|
||||
if isinstance(raw, dict):
|
||||
items = raw.get("plugins", [])
|
||||
elif isinstance(raw, list): # bare-list form also accepted
|
||||
items = raw
|
||||
else:
|
||||
raise ValueError("Plugin index must be a JSON object or list.")
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("Plugin index 'plugins' field must be a list.")
|
||||
|
||||
entries: List[PluginIndexEntry] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
repo = item.get("repo")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
if not isinstance(repo, str) or repo.count("/") != 1 or not all(repo.split("/")):
|
||||
logger.debug("plugin index: skipping entry %r with invalid repo %r", name, repo)
|
||||
continue
|
||||
subdir = item.get("subdir")
|
||||
api_version = item.get("api_version")
|
||||
entries.append(
|
||||
PluginIndexEntry(
|
||||
name=name.strip(),
|
||||
description=str(item.get("description") or ""),
|
||||
author=str(item.get("author") or ""),
|
||||
tags=[str(t) for t in item.get("tags") or [] if isinstance(t, (str, int))],
|
||||
repo=repo.strip(),
|
||||
ref=str(item.get("ref") or ""),
|
||||
subdir=str(subdir).strip("/") if isinstance(subdir, str) and subdir.strip("/") else None,
|
||||
homepage=str(item["homepage"]) if item.get("homepage") else None,
|
||||
capabilities=[str(c) for c in item.get("capabilities") or []],
|
||||
api_version=int(api_version) if isinstance(api_version, (int, str)) and str(api_version).isdigit() else None,
|
||||
added_at=str(item["added_at"]) if item.get("added_at") else None,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _load_seed_entries() -> List[PluginIndexEntry]:
|
||||
try:
|
||||
return _parse_entries(json.loads(SEED_INDEX_PATH.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError) as exc: # pragma: no cover - bundled file
|
||||
logger.warning("plugin index: bundled seed unreadable: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _read_cache(*, max_age: Optional[float]) -> Optional[List[PluginIndexEntry]]:
|
||||
"""Return cached entries if the cache exists (and is younger than *max_age*)."""
|
||||
cache = _cache_path()
|
||||
try:
|
||||
if not cache.is_file():
|
||||
return None
|
||||
if max_age is not None:
|
||||
age = time.time() - cache.stat().st_mtime
|
||||
if age > max_age:
|
||||
return None
|
||||
return _parse_entries(json.loads(cache.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("plugin index: cache read failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(text: str) -> None:
|
||||
try:
|
||||
cache = _cache_path()
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
from utils import atomic_write_text
|
||||
|
||||
atomic_write_text(cache, text)
|
||||
except OSError as exc: # pragma: no cover - best effort
|
||||
logger.debug("plugin index: cache write failed: %s", exc)
|
||||
|
||||
|
||||
def _fetch_remote() -> Optional[List[PluginIndexEntry]]:
|
||||
"""Fetch and parse the remote index; cache the raw payload on success."""
|
||||
url = get_index_url()
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(url, timeout=_FETCH_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
text = resp.text
|
||||
if len(text.encode("utf-8", errors="ignore")) > _MAX_INDEX_BYTES:
|
||||
raise ValueError("Plugin index payload exceeds size limit.")
|
||||
entries = _parse_entries(json.loads(text))
|
||||
_write_cache(text)
|
||||
return entries
|
||||
except Exception as exc:
|
||||
logger.debug("plugin index: remote fetch failed (%s): %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def load_index(*, refresh: bool = False, offline: bool = False) -> tuple[List[PluginIndexEntry], str]:
|
||||
"""Load the plugin index.
|
||||
|
||||
Returns ``(entries, source)`` where *source* is one of ``"remote"``,
|
||||
``"cache"``, or ``"seed"``.
|
||||
|
||||
Order: fresh cache (unless *refresh*) → remote → stale cache → bundled seed.
|
||||
``offline=True`` skips the network entirely.
|
||||
"""
|
||||
if not refresh:
|
||||
cached = _read_cache(max_age=INDEX_CACHE_TTL)
|
||||
if cached is not None:
|
||||
return cached, "cache"
|
||||
|
||||
if not offline:
|
||||
remote = _fetch_remote()
|
||||
if remote is not None:
|
||||
return remote, "remote"
|
||||
|
||||
stale = _read_cache(max_age=None)
|
||||
if stale is not None:
|
||||
return stale, "cache"
|
||||
|
||||
return _load_seed_entries(), "seed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _score_entry(entry: PluginIndexEntry, term: str) -> float:
|
||||
"""Fuzzy relevance score for *entry* against lowercase *term* (0 = no match)."""
|
||||
import difflib
|
||||
|
||||
name = entry.name.lower()
|
||||
desc = entry.description.lower()
|
||||
tags = [t.lower() for t in entry.tags]
|
||||
|
||||
if term == name:
|
||||
return 100.0
|
||||
score = 0.0
|
||||
if term in name:
|
||||
score = max(score, 80.0)
|
||||
if any(term == t for t in tags):
|
||||
score = max(score, 70.0)
|
||||
if any(term in t for t in tags):
|
||||
score = max(score, 55.0)
|
||||
if term in desc:
|
||||
score = max(score, 50.0)
|
||||
if term in entry.author.lower():
|
||||
score = max(score, 40.0)
|
||||
# Fuzzy close-match on the name for typo tolerance.
|
||||
ratio = difflib.SequenceMatcher(None, term, name).ratio()
|
||||
if ratio >= 0.6:
|
||||
score = max(score, ratio * 60.0)
|
||||
return score
|
||||
|
||||
|
||||
def search_index(
|
||||
entries: List[PluginIndexEntry], term: str, *, capability: Optional[str] = None
|
||||
) -> List[PluginIndexEntry]:
|
||||
"""Rank *entries* against *term* (fuzzy on name/description/tags/author).
|
||||
|
||||
An empty *term* matches everything (browse mode). ``capability`` filters
|
||||
entries by declared capability.
|
||||
"""
|
||||
pool = entries
|
||||
if capability:
|
||||
cap = capability.lower()
|
||||
pool = [e for e in pool if any(cap == c.lower() for c in e.capabilities)]
|
||||
|
||||
term = (term or "").strip().lower()
|
||||
if not term:
|
||||
return sorted(pool, key=lambda e: e.name)
|
||||
|
||||
scored = [(e, _score_entry(e, term)) for e in pool]
|
||||
matched = [(e, s) for e, s in scored if s > 0]
|
||||
matched.sort(key=lambda pair: (-pair[1], pair[0].name))
|
||||
return [e for e, _s in matched]
|
||||
|
||||
|
||||
def resolve_name(
|
||||
entries: List[PluginIndexEntry], name: str
|
||||
) -> tuple[Optional[PluginIndexEntry], List[PluginIndexEntry]]:
|
||||
"""Resolve a bare plugin *name* against the index.
|
||||
|
||||
Returns ``(entry, candidates)``: an exact (case-insensitive) unique match
|
||||
in ``entry``, otherwise ``entry is None`` and ``candidates`` holds any
|
||||
partial matches (empty = nothing similar, >1 on exact = ambiguous).
|
||||
"""
|
||||
lowered = name.strip().lower()
|
||||
exact = [e for e in entries if e.name.lower() == lowered]
|
||||
if len(exact) == 1:
|
||||
return exact[0], exact
|
||||
if len(exact) > 1:
|
||||
return None, exact
|
||||
partial = [e for e in entries if lowered in e.name.lower()]
|
||||
if len(partial) == 1:
|
||||
return partial[0], partial
|
||||
return None, partial
|
||||
|
|
@ -809,13 +809,74 @@ def _install_plugin_core(
|
|||
return target, installed_manifest, installed_name
|
||||
|
||||
|
||||
def _looks_like_bare_index_name(identifier: str) -> bool:
|
||||
"""True when *identifier* is a bare plugin name (no slash, not a URL).
|
||||
|
||||
Bare names are resolved through the community plugin index; anything with
|
||||
a slash or URL scheme keeps the existing owner/repo / Git URL semantics.
|
||||
"""
|
||||
if "/" in identifier or "\\" in identifier:
|
||||
return False
|
||||
return not identifier.startswith(("https://", "http://", "git@", "ssh://", "file://"))
|
||||
|
||||
|
||||
def _resolve_index_name(identifier: str, console) -> tuple[str, Optional[str]]:
|
||||
"""Resolve a bare plugin name to ``(install_identifier, pinned_ref)``.
|
||||
|
||||
Exits with an error when the name is unknown, or lists candidates and
|
||||
exits when the name is ambiguous. The returned ref is only used when it
|
||||
is an exact 40-character commit SHA (the pin format the installer
|
||||
accepts); tag refs are surfaced as advisory output instead.
|
||||
"""
|
||||
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}")
|
||||
console.print("Re-run with the exact name or the owner/repo identifier.")
|
||||
else:
|
||||
console.print(
|
||||
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."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
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(
|
||||
identifier: str,
|
||||
force: bool = False,
|
||||
enable: Optional[bool] = None,
|
||||
ref: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Install a plugin from a Git URL or owner/repo shorthand.
|
||||
"""Install a plugin from a Git URL, owner/repo shorthand, or index name.
|
||||
|
||||
Bare names (no slash, no URL scheme) are resolved through the community
|
||||
plugin index to ``owner/repo`` plus the index-pinned ref. An explicit
|
||||
``--ref`` always wins over the index pin.
|
||||
|
||||
After install, prompt "Enable now? [y/N]" unless *enable* is provided
|
||||
(True = auto-enable without prompting, False = install disabled).
|
||||
|
|
@ -824,6 +885,11 @@ def cmd_install(
|
|||
|
||||
console = Console()
|
||||
|
||||
if _looks_like_bare_index_name(identifier):
|
||||
identifier, index_ref = _resolve_index_name(identifier, console)
|
||||
if ref is None:
|
||||
ref = index_ref
|
||||
|
||||
try:
|
||||
git_url, _subdir = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
|
|
@ -2667,6 +2733,64 @@ def cmd_plugin_doctor(target: str = ".", *, ci: bool = False) -> None:
|
|||
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 rich.console import Console
|
||||
|
||||
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] "
|
||||
f"[dim](index source: {source})[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
table = Table(title=f"Community plugins ({len(results)} match{'es' if len(results) != 1 else ''})")
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Description")
|
||||
table.add_column("Author")
|
||||
table.add_column("Tags", style="dim")
|
||||
for e in results:
|
||||
desc = e.description
|
||||
if len(desc) > 70:
|
||||
desc = desc[: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 plugins_command(args) -> None:
|
||||
"""Dispatch hermes plugins subcommands."""
|
||||
action = getattr(args, "plugins_action", None)
|
||||
|
|
@ -2684,6 +2808,13 @@ def plugins_command(args) -> None:
|
|||
enable=enable_arg,
|
||||
ref=getattr(args, "ref", None),
|
||||
)
|
||||
elif action == "search":
|
||||
cmd_search(
|
||||
getattr(args, "term", "") or "",
|
||||
json_output=getattr(args, "json", False),
|
||||
capability=getattr(args, "capability", None),
|
||||
refresh=getattr(args, "refresh", False),
|
||||
)
|
||||
elif action == "update":
|
||||
cmd_update(args.name)
|
||||
elif action in {"remove", "rm", "uninstall"}:
|
||||
|
|
|
|||
|
|
@ -22,11 +22,15 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
|||
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
|
||||
|
||||
plugins_install = plugins_subparsers.add_parser(
|
||||
"install", help="Install a plugin from a Git URL or owner/repo"
|
||||
"install", help="Install a plugin from a Git URL, owner/repo, or index name"
|
||||
)
|
||||
plugins_install.add_argument(
|
||||
"identifier",
|
||||
help="Git URL or owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)",
|
||||
help=(
|
||||
"Git URL, owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles), "
|
||||
"or a bare plugin name resolved through the community index "
|
||||
"(see `hermes plugins search`)"
|
||||
),
|
||||
)
|
||||
plugins_install.add_argument(
|
||||
"--force",
|
||||
|
|
@ -51,6 +55,32 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
|
|||
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
|
||||
)
|
||||
|
||||
plugins_search = plugins_subparsers.add_parser(
|
||||
"search", help="Search the community plugin index"
|
||||
)
|
||||
plugins_search.add_argument(
|
||||
"term",
|
||||
nargs="?",
|
||||
default="",
|
||||
help="Search term matched fuzzily against name, description, and tags "
|
||||
"(omit to browse the full index)",
|
||||
)
|
||||
plugins_search.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print machine-readable JSON",
|
||||
)
|
||||
plugins_search.add_argument(
|
||||
"--capability",
|
||||
metavar="CAP",
|
||||
help="Filter by declared capability (e.g. tools, platform, commands)",
|
||||
)
|
||||
plugins_search.add_argument(
|
||||
"--refresh",
|
||||
action="store_true",
|
||||
help="Bypass the local cache and re-fetch the index",
|
||||
)
|
||||
|
||||
plugins_update = plugins_subparsers.add_parser(
|
||||
"update", help="Pull latest changes for an installed plugin"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ py-modules = [
|
|||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["observability/schemas/*.json"]
|
||||
hermes_cli = ["observability/schemas/*.json", "data/*.json"]
|
||||
# gateway/assets/ ships status_phrases.yaml and the Telegram BotFather
|
||||
# screenshot. Without this, sealed venvs (uv2nix) silently lose both —
|
||||
# status phrases fall back to the tiny hardcoded set and the Telegram
|
||||
|
|
|
|||
|
|
@ -0,0 +1,498 @@
|
|||
"""Tests for the community plugin index (#64181).
|
||||
|
||||
Covers: index parsing, fuzzy search, cache TTL + fallback chain
|
||||
(remote → cache → seed), `hermes plugins search --json`, and install-time
|
||||
name resolution (unique / ambiguous / passthrough of owner/repo).
|
||||
No live network — every remote fetch is mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import plugin_index
|
||||
from hermes_cli.plugin_index import (
|
||||
PluginIndexEntry,
|
||||
_parse_entries,
|
||||
load_index,
|
||||
resolve_name,
|
||||
search_index,
|
||||
)
|
||||
|
||||
|
||||
def _entry(name, repo="owner/repo", **kw):
|
||||
return PluginIndexEntry(name=name, repo=repo, **kw)
|
||||
|
||||
|
||||
def _index_doc(entries):
|
||||
return {"schema_version": 1, "plugins": entries}
|
||||
|
||||
|
||||
SAMPLE = _index_doc(
|
||||
[
|
||||
{
|
||||
"name": "hermes-media-studio",
|
||||
"description": "Generative media workspace plugin.",
|
||||
"author": "NousResearch",
|
||||
"tags": ["media", "image-gen"],
|
||||
"repo": "NousResearch/hermes-media-studio",
|
||||
"ref": "e" * 40,
|
||||
},
|
||||
{
|
||||
"name": "hermes-telegram-business",
|
||||
"description": "Telegram secretary bot with owner approval.",
|
||||
"author": "NousResearch",
|
||||
"tags": ["telegram", "gateway"],
|
||||
"repo": "NousResearch/hermes-telegram-business",
|
||||
"ref": "f" * 40,
|
||||
"capabilities": ["platform"],
|
||||
},
|
||||
{
|
||||
"name": "plugin-llm-example",
|
||||
"description": "Reference plugin for structured LLM access.",
|
||||
"author": "NousResearch",
|
||||
"tags": ["example", "llm"],
|
||||
"repo": "NousResearch/hermes-example-plugins",
|
||||
"subdir": "plugin-llm-example",
|
||||
"ref": "a" * 40,
|
||||
"capabilities": ["commands", "llm"],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(plugin_index, "get_hermes_home", lambda: tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_cache(home: Path, doc, *, age_seconds: float = 0) -> Path:
|
||||
cache = home / "cache" / "plugin_index.json"
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(doc), encoding="utf-8")
|
||||
if age_seconds:
|
||||
stamp = time.time() - age_seconds
|
||||
import os
|
||||
|
||||
os.utime(cache, (stamp, stamp))
|
||||
return cache
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParsing:
|
||||
def test_parses_object_form(self):
|
||||
entries = _parse_entries(SAMPLE)
|
||||
assert [e.name for e in entries] == [
|
||||
"hermes-media-studio",
|
||||
"hermes-telegram-business",
|
||||
"plugin-llm-example",
|
||||
]
|
||||
assert entries[2].subdir == "plugin-llm-example"
|
||||
assert entries[2].install_identifier == (
|
||||
"NousResearch/hermes-example-plugins/plugin-llm-example"
|
||||
)
|
||||
assert entries[0].install_identifier == "NousResearch/hermes-media-studio"
|
||||
|
||||
def test_parses_bare_list_form(self):
|
||||
entries = _parse_entries(SAMPLE["plugins"])
|
||||
assert len(entries) == 3
|
||||
|
||||
def test_skips_malformed_entries(self):
|
||||
doc = _index_doc(
|
||||
[
|
||||
{"name": "good", "repo": "o/r"},
|
||||
{"name": "", "repo": "o/r"}, # empty name
|
||||
{"name": "norepo"}, # missing repo
|
||||
{"name": "badrepo", "repo": "not-a-repo"}, # no slash
|
||||
{"name": "deep", "repo": "a/b/c"}, # too many slashes
|
||||
"not-a-dict",
|
||||
]
|
||||
)
|
||||
entries = _parse_entries(doc)
|
||||
assert [e.name for e in entries] == ["good"]
|
||||
|
||||
def test_rejects_non_container(self):
|
||||
with pytest.raises(ValueError):
|
||||
_parse_entries("nope")
|
||||
|
||||
def test_bundled_seed_parses(self):
|
||||
raw = json.loads(plugin_index.SEED_INDEX_PATH.read_text(encoding="utf-8"))
|
||||
entries = _parse_entries(raw)
|
||||
assert len(entries) >= 3
|
||||
for e in entries:
|
||||
assert e.repo.count("/") == 1
|
||||
assert e.ref, f"seed entry {e.name} must pin a ref"
|
||||
assert len(e.ref) == 40, f"seed entry {e.name} must pin a commit SHA"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearch:
|
||||
entries = _parse_entries(SAMPLE)
|
||||
|
||||
def test_exact_name_ranks_first(self):
|
||||
results = search_index(self.entries, "hermes-media-studio")
|
||||
assert results[0].name == "hermes-media-studio"
|
||||
|
||||
def test_matches_tags(self):
|
||||
results = search_index(self.entries, "telegram")
|
||||
assert results and results[0].name == "hermes-telegram-business"
|
||||
|
||||
def test_matches_description(self):
|
||||
results = search_index(self.entries, "secretary")
|
||||
assert [e.name for e in results] == ["hermes-telegram-business"]
|
||||
|
||||
def test_fuzzy_typo_tolerance(self):
|
||||
results = search_index(self.entries, "hermes-media-studo")
|
||||
assert results and results[0].name == "hermes-media-studio"
|
||||
|
||||
def test_no_match(self):
|
||||
assert search_index(self.entries, "zzzzqqqq") == []
|
||||
|
||||
def test_empty_term_browses_all_sorted(self):
|
||||
results = search_index(self.entries, "")
|
||||
assert [e.name for e in results] == sorted(e.name for e in self.entries)
|
||||
|
||||
def test_capability_filter(self):
|
||||
results = search_index(self.entries, "", capability="platform")
|
||||
assert [e.name for e in results] == ["hermes-telegram-business"]
|
||||
|
||||
def test_capability_filter_with_term(self):
|
||||
results = search_index(self.entries, "llm", capability="commands")
|
||||
assert [e.name for e in results] == ["plugin-llm-example"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback chain: remote → cache → seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadIndex:
|
||||
def test_fresh_cache_wins_without_network(self, hermes_home, monkeypatch):
|
||||
_write_cache(hermes_home, SAMPLE)
|
||||
|
||||
def boom(): # pragma: no cover - must not be called
|
||||
raise AssertionError("network hit despite fresh cache")
|
||||
|
||||
monkeypatch.setattr(plugin_index, "_fetch_remote", boom)
|
||||
entries, source = load_index()
|
||||
assert source == "cache"
|
||||
assert len(entries) == 3
|
||||
|
||||
def test_expired_cache_triggers_remote(self, hermes_home, monkeypatch):
|
||||
_write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60)
|
||||
remote_doc = _index_doc([{"name": "fresh-plugin", "repo": "o/r", "ref": "b" * 40}])
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc)
|
||||
)
|
||||
entries, source = load_index()
|
||||
assert source == "remote"
|
||||
assert [e.name for e in entries] == ["fresh-plugin"]
|
||||
|
||||
def test_remote_failure_falls_back_to_stale_cache(self, hermes_home, monkeypatch):
|
||||
_write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60)
|
||||
monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
|
||||
entries, source = load_index()
|
||||
assert source == "cache"
|
||||
assert len(entries) == 3
|
||||
|
||||
def test_no_cache_no_remote_falls_back_to_seed(self, hermes_home, monkeypatch):
|
||||
monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
|
||||
entries, source = load_index()
|
||||
assert source == "seed"
|
||||
assert len(entries) >= 3
|
||||
|
||||
def test_refresh_bypasses_fresh_cache(self, hermes_home, monkeypatch):
|
||||
_write_cache(hermes_home, SAMPLE)
|
||||
remote_doc = _index_doc([{"name": "newer", "repo": "o/r", "ref": "c" * 40}])
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc)
|
||||
)
|
||||
entries, source = load_index(refresh=True)
|
||||
assert source == "remote"
|
||||
assert [e.name for e in entries] == ["newer"]
|
||||
|
||||
def test_offline_skips_network(self, hermes_home, monkeypatch):
|
||||
def boom(): # pragma: no cover
|
||||
raise AssertionError("network hit in offline mode")
|
||||
|
||||
monkeypatch.setattr(plugin_index, "_fetch_remote", boom)
|
||||
entries, source = load_index(offline=True)
|
||||
assert source == "seed"
|
||||
|
||||
def test_corrupt_cache_ignored(self, hermes_home, monkeypatch):
|
||||
cache = hermes_home / "cache" / "plugin_index.json"
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text("{not json", encoding="utf-8")
|
||||
monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
|
||||
entries, source = load_index()
|
||||
assert source == "seed"
|
||||
|
||||
def test_remote_fetch_writes_cache(self, hermes_home, monkeypatch):
|
||||
payload = json.dumps(SAMPLE)
|
||||
|
||||
class FakeResponse:
|
||||
text = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
import httpx
|
||||
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: FakeResponse())
|
||||
entries, source = load_index()
|
||||
assert source == "remote"
|
||||
cache = hermes_home / "cache" / "plugin_index.json"
|
||||
assert cache.is_file()
|
||||
assert json.loads(cache.read_text(encoding="utf-8")) == SAMPLE
|
||||
|
||||
def test_index_url_config_override(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
plugin_index,
|
||||
"get_index_url",
|
||||
plugin_index.get_index_url, # keep real fn, patch config below
|
||||
)
|
||||
from hermes_cli import config as config_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"load_config_readonly",
|
||||
lambda: {"plugins": {"index_url": "https://example.com/custom.json"}},
|
||||
)
|
||||
assert plugin_index.get_index_url() == "https://example.com/custom.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveName:
|
||||
entries = _parse_entries(SAMPLE)
|
||||
|
||||
def test_exact_unique(self):
|
||||
entry, candidates = resolve_name(self.entries, "hermes-media-studio")
|
||||
assert entry is not None and entry.repo == "NousResearch/hermes-media-studio"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
entry, _ = resolve_name(self.entries, "Hermes-Media-Studio")
|
||||
assert entry is not None
|
||||
|
||||
def test_unique_partial(self):
|
||||
entry, _ = resolve_name(self.entries, "telegram")
|
||||
assert entry is not None and entry.name == "hermes-telegram-business"
|
||||
|
||||
def test_ambiguous_partial(self):
|
||||
entry, candidates = resolve_name(self.entries, "hermes")
|
||||
assert entry is None
|
||||
assert len(candidates) == 2
|
||||
|
||||
def test_unknown(self):
|
||||
entry, candidates = resolve_name(self.entries, "nonexistent-thing")
|
||||
assert entry is None and candidates == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstallResolution:
|
||||
def test_bare_name_detection(self):
|
||||
from hermes_cli.plugins_cmd import _looks_like_bare_index_name
|
||||
|
||||
assert _looks_like_bare_index_name("hermes-media-studio")
|
||||
assert not _looks_like_bare_index_name("owner/repo")
|
||||
assert not _looks_like_bare_index_name("https://github.com/o/r.git")
|
||||
assert not _looks_like_bare_index_name("git@github.com:o/r.git")
|
||||
assert not _looks_like_bare_index_name("ssh://git@github.com/o/r.git")
|
||||
assert not _looks_like_bare_index_name("file:///tmp/x")
|
||||
|
||||
def test_install_resolves_name_and_pins_ref(self, hermes_home, monkeypatch):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_core(identifier, *, force, ref=None):
|
||||
captured["identifier"] = identifier
|
||||
captured["ref"] = ref
|
||||
raise plugins_cmd.PluginOperationError("stop here")
|
||||
|
||||
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_install("hermes-media-studio", enable=False)
|
||||
assert captured["identifier"] == "NousResearch/hermes-media-studio"
|
||||
assert captured["ref"] == "e" * 40
|
||||
|
||||
def test_install_explicit_ref_beats_index_pin(self, hermes_home, monkeypatch):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def fake_core(identifier, *, force, ref=None):
|
||||
captured["ref"] = ref
|
||||
raise plugins_cmd.PluginOperationError("stop here")
|
||||
|
||||
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_install("hermes-media-studio", enable=False, ref="d" * 40)
|
||||
assert captured["ref"] == "d" * 40
|
||||
|
||||
def test_install_ambiguous_name_lists_candidates_and_exits(
|
||||
self, hermes_home, monkeypatch, capsys
|
||||
):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
|
||||
)
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
plugins_cmd,
|
||||
"_install_plugin_core",
|
||||
lambda *a, **k: called.append(1),
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
plugins_cmd.cmd_install("hermes", enable=False)
|
||||
assert exc.value.code == 1
|
||||
assert not called
|
||||
out = capsys.readouterr().out
|
||||
assert "ambiguous" in out
|
||||
assert "hermes-media-studio" in out
|
||||
assert "hermes-telegram-business" in out
|
||||
|
||||
def test_install_unknown_name_exits(self, hermes_home, monkeypatch, capsys):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
plugins_cmd.cmd_install("totally-unknown", enable=False)
|
||||
assert exc.value.code == 1
|
||||
assert "not found" in capsys.readouterr().out
|
||||
|
||||
def test_owner_repo_passthrough_skips_index(self, hermes_home, monkeypatch):
|
||||
"""Explicit owner/repo installs never consult the index."""
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
def boom(**kw): # pragma: no cover
|
||||
raise AssertionError("index consulted for owner/repo identifier")
|
||||
|
||||
monkeypatch.setattr(plugin_index, "load_index", boom)
|
||||
captured = {}
|
||||
|
||||
def fake_core(identifier, *, force, ref=None):
|
||||
captured["identifier"] = identifier
|
||||
captured["ref"] = ref
|
||||
raise plugins_cmd.PluginOperationError("stop here")
|
||||
|
||||
monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
|
||||
with pytest.raises(SystemExit):
|
||||
plugins_cmd.cmd_install("someowner/somerepo", enable=False)
|
||||
assert captured["identifier"] == "someowner/somerepo"
|
||||
assert captured["ref"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI search command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCmdSearch:
|
||||
def test_json_output(self, hermes_home, monkeypatch, capsys):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
|
||||
)
|
||||
plugins_cmd.cmd_search("telegram", json_output=True)
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["source"] == "seed"
|
||||
assert payload["query"] == "telegram"
|
||||
assert payload["results"][0]["name"] == "hermes-telegram-business"
|
||||
assert payload["results"][0]["repo"] == "NousResearch/hermes-telegram-business"
|
||||
assert payload["results"][0]["ref"] == "f" * 40
|
||||
assert "audited" in payload["note"]
|
||||
|
||||
def test_table_output_includes_security_footer(
|
||||
self, hermes_home, monkeypatch, capsys
|
||||
):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
|
||||
)
|
||||
plugins_cmd.cmd_search("media")
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes-media-studio" in out
|
||||
assert "audited" in out
|
||||
|
||||
def test_no_results_message(self, hermes_home, monkeypatch, capsys):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
|
||||
)
|
||||
plugins_cmd.cmd_search("zzzznope")
|
||||
assert "No plugins matched" in capsys.readouterr().out
|
||||
|
||||
def test_parser_accepts_search(self):
|
||||
import argparse
|
||||
|
||||
from hermes_cli.subcommands.plugins import build_plugins_parser
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
build_plugins_parser(sub, cmd_plugins=lambda args: None)
|
||||
args = parser.parse_args(
|
||||
["plugins", "search", "media", "--json", "--capability", "tools", "--refresh"]
|
||||
)
|
||||
assert args.plugins_action == "search"
|
||||
assert args.term == "media"
|
||||
assert args.json is True
|
||||
assert args.capability == "tools"
|
||||
assert args.refresh is True
|
||||
|
||||
def test_dispatch_routes_search(self, hermes_home, monkeypatch):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_search(term, *, json_output, capability, refresh):
|
||||
captured.update(
|
||||
term=term, json_output=json_output, capability=capability, refresh=refresh
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plugins_cmd, "cmd_search", fake_search)
|
||||
import argparse
|
||||
|
||||
args = argparse.Namespace(
|
||||
plugins_action="search", term="llm", json=True, capability=None, refresh=False
|
||||
)
|
||||
plugins_cmd.plugins_command(args)
|
||||
assert captured == {
|
||||
"term": "llm",
|
||||
"json_output": True,
|
||||
"capability": None,
|
||||
"refresh": False,
|
||||
}
|
||||
|
|
@ -1374,7 +1374,8 @@ Unified plugin management — general plugins, memory providers, and context eng
|
|||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| *(none)* | Composite interactive UI — general plugin toggles + provider plugin configuration. |
|
||||
| `install <identifier> [--force] [--ref COMMIT_SHA]` | Install a plugin from a Git URL or `owner/repo`. `--ref` accepts only a full 40-character commit SHA and installs that exact immutable revision. |
|
||||
| `install <identifier> [--force] [--ref COMMIT_SHA]` | Install a plugin from a Git URL, `owner/repo`, or a bare index name. Bare names (no slash) are resolved through the community plugin index to `owner/repo` plus the index-pinned commit; ambiguous names list candidates and exit. `--ref` accepts only a full 40-character commit SHA, installs that exact immutable revision, and overrides any index pin. |
|
||||
| `search [term] [--json] [--capability CAP] [--refresh]` | Search the community plugin index (fuzzy match on name/description/tags; omit `term` to browse). Fetched from `plugins.index_url` (default: the NousResearch plugin index), cached under `~/.hermes/cache/` for 24h, falling back to the stale cache and then the bundled seed when offline. Indexed ≠ audited — inclusion is a metadata review only. |
|
||||
| `update <name>` | Pull latest changes for an unpinned installed plugin. Pinned plugins must be reinstalled with `--force --ref <new-commit>` to move. |
|
||||
| `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |
|
||||
| `enable <name>` | Enable a disabled plugin. |
|
||||
|
|
|
|||
|
|
@ -331,6 +331,8 @@ Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist
|
|||
```bash
|
||||
hermes plugins # unified interactive UI
|
||||
hermes plugins list # table: enabled / disabled / not enabled
|
||||
hermes plugins search <term> # search the community plugin index
|
||||
hermes plugins install <name> # install by index name (resolved to repo @ pinned ref)
|
||||
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 --no-enable # install but leave disabled (no prompt)
|
||||
|
|
@ -400,6 +402,75 @@ not a code audit, and Hermes has not reviewed the plugin's code. Only install
|
|||
plugins from sources you trust.
|
||||
:::
|
||||
|
||||
### Discovering community plugins
|
||||
|
||||
`hermes plugins search <term>` searches the **community plugin index** — a
|
||||
static, machine-readable JSON catalog of community plugins. Matching is fuzzy
|
||||
across name, description, and tags:
|
||||
|
||||
```bash
|
||||
hermes plugins search telegram # fuzzy search
|
||||
hermes plugins search # browse the whole index
|
||||
hermes plugins search --capability platform # filter by declared capability
|
||||
hermes plugins search media --json # machine-readable output
|
||||
hermes plugins search --refresh # bypass the 24h local cache
|
||||
```
|
||||
|
||||
Once you've found a plugin, install it by bare name — the name is resolved
|
||||
through the index to its `owner/repo` plus the index-pinned commit:
|
||||
|
||||
```bash
|
||||
hermes plugins install hermes-media-studio
|
||||
```
|
||||
|
||||
If a name matches more than one entry, the candidates are listed and nothing
|
||||
is installed. Explicit `owner/repo` or Git-URL identifiers never touch the
|
||||
index and keep working exactly as before. An explicit `--ref <sha>` always
|
||||
overrides the index pin.
|
||||
|
||||
**How the index is fetched.** The index lives at a canonical URL
|
||||
(`https://raw.githubusercontent.com/NousResearch/hermes-plugin-index/main/index.json`,
|
||||
overridable via `hermes config set plugins.index_url <url>`). Fetches are
|
||||
cached under `~/.hermes/cache/plugin_index.json` for 24 hours; when the
|
||||
remote is unreachable the stale cache is used, and when there is no cache at
|
||||
all a bundled seed copy ships with Hermes — so search works fully offline.
|
||||
|
||||
**Index entry format.** Each entry is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hermes-media-studio",
|
||||
"description": "Generative media workspace plugin.",
|
||||
"author": "NousResearch",
|
||||
"tags": ["media", "image-gen"],
|
||||
"repo": "NousResearch/hermes-media-studio",
|
||||
"ref": "<40-char commit SHA>",
|
||||
"subdir": null,
|
||||
"homepage": "https://github.com/NousResearch/hermes-media-studio",
|
||||
"capabilities": ["tools", "dashboard"],
|
||||
"api_version": 1,
|
||||
"added_at": "2026-08-12"
|
||||
}
|
||||
```
|
||||
|
||||
`repo` is the `owner/name` GitHub identifier, `ref` pins an immutable commit
|
||||
SHA, and optional `subdir` supports monorepos. The bundled seed file
|
||||
(`hermes_cli/data/plugin_index.json` in the repo) is the format reference.
|
||||
|
||||
**Submitting a plugin.** The index is maintained as a plain JSON file —
|
||||
submit a pull request to the
|
||||
[hermes-plugin-index](https://github.com/NousResearch/hermes-plugin-index)
|
||||
repository adding your entry (name, description, author, tags, `owner/repo`,
|
||||
and a pinned commit SHA). Review covers the entry's *metadata* only.
|
||||
|
||||
:::warning Indexed ≠ audited
|
||||
Inclusion in the community index means the entry's metadata was reviewed —
|
||||
**it is not a code audit**. Installing still goes through the normal
|
||||
consent/review flow (plugins install disabled by default, enabling is an
|
||||
explicit step, and tool-override rights require a separate grant). Review a
|
||||
plugin's source before enabling it.
|
||||
:::
|
||||
|
||||
### Interactive UI
|
||||
|
||||
Running `hermes plugins` with no arguments opens a composite interactive screen:
|
||||
|
|
|
|||
Loading…
Reference in New Issue