diff --git a/.gitignore b/.gitignore index 283c8a9164dbe..1a374433d0e46 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3a1ed048719b9..8395df4af5ad8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/data/plugin_index.json b/hermes_cli/data/plugin_index.json new file mode 100644 index 0000000000000..e3801570fadc0 --- /dev/null +++ b/hermes_cli/data/plugin_index.json @@ -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" + } + ] +} diff --git a/hermes_cli/plugin_index.py b/hermes_cli/plugin_index.py new file mode 100644 index 0000000000000..2cc92a76b7a0b --- /dev/null +++ b/hermes_cli/plugin_index.py @@ -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 diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 56f54e683b0e2..370518154cc76 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -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 ` 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 [/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"}: diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index 87c3bb0b14ed6..b5b7831b2f396 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -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 `", ) + 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" ) diff --git a/pyproject.toml b/pyproject.toml index 25d16bef19ab5..ea5f259778d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/hermes_cli/test_plugin_index_search.py b/tests/hermes_cli/test_plugin_index_search.py new file mode 100644 index 0000000000000..d649d9357593a --- /dev/null +++ b/tests/hermes_cli/test_plugin_index_search.py @@ -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, + } diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 905bc840dcd8c..1a1e15e665d19 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -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 [--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 [--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 ` | Pull latest changes for an unpinned installed plugin. Pinned plugins must be reinstalled with `--force --ref ` to move. | | `remove ` (aliases: `rm`, `uninstall`) | Remove an installed plugin. | | `enable ` | Enable a disabled plugin. | diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 26af0960124c3..185a096ba17c7 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -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 # search the community plugin index +hermes plugins install # 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 ` 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 ` 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 `). 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: