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:
Teknium 2026-08-12 17:13:09 -07:00
parent 88ab589f68
commit 2e0183169c
10 changed files with 1118 additions and 5 deletions

4
.gitignore vendored
View File

@ -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/

View File

@ -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

View File

@ -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"
}
]
}

305
hermes_cli/plugin_index.py Normal file
View File

@ -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

View File

@ -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"}:

View File

@ -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"
)

View File

@ -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

View File

@ -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,
}

View File

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

View File

@ -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: