diff --git a/.gitignore b/.gitignore index cd7b2879b9..e0a1156590 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,6 @@ 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 5475d4289f..2cd6561ef3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5351,10 +5351,10 @@ _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. + # Plugin settings — enable/disable lists and per-plugin entries + # (hermes_cli/plugins_cmd.py). Absent from DEFAULT_CONFIG (written only + # when used), so listed here for `hermes config set plugins....` + # validation. "plugins", }) diff --git a/hermes_cli/data/plugin_index.json b/hermes_cli/data/plugin_index.json deleted file mode 100644 index e3801570fa..0000000000 --- a/hermes_cli/data/plugin_index.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "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 deleted file mode 100644 index 2cc92a76b7..0000000000 --- a/hermes_cli/plugin_index.py +++ /dev/null @@ -1,305 +0,0 @@ -"""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/plugin_packs.py b/hermes_cli/plugin_packs.py index 12f1ebe914..372b9ca70f 100644 --- a/hermes_cli/plugin_packs.py +++ b/hermes_cli/plugin_packs.py @@ -86,7 +86,7 @@ class PackPluginEntry: @property def install_identifier(self) -> Optional[str]: """Identifier for the existing install path (None for bare names, - which must first resolve through the community index).""" + which must first resolve through the plugin catalog).""" if self.repo: return f"{self.repo}/{self.subdir}" if self.subdir else self.repo return None @@ -305,50 +305,57 @@ class ResolvedPackPlugin: def resolve_pack_plugins(pack: PluginPack) -> List[ResolvedPackPlugin]: - """Resolve every entry; bare names go through the community index. + """Resolve every entry; bare names go through the plugin catalog. Resolution failures do not raise — they are carried per-entry so the review screen can show them and install can report partial failure. """ resolved: List[ResolvedPackPlugin] = [] - index_entries = None + catalog_entries = None for entry in pack.plugins: ident = entry.install_identifier if ident is not None: resolved.append(ResolvedPackPlugin(entry=entry, identifier=ident)) continue - # Bare community-index name. + # Bare catalog name. try: - if index_entries is None: - from hermes_cli.plugin_index import load_index + if catalog_entries is None: + from hermes_cli.plugin_catalog import load_catalog_live - index_entries, _src = load_index() - from hermes_cli.plugin_index import resolve_name - - match, candidates = resolve_name(index_entries, entry.name or "") - except Exception as exc: # index load must not crash pack handling + catalog_entries = load_catalog_live() + except Exception as exc: # catalog load must not crash pack handling resolved.append( ResolvedPackPlugin( entry=entry, identifier=None, - resolve_error=f"community index unavailable: {exc}", + resolve_error=f"plugin catalog unavailable: {exc}", ) ) continue + match = next( + (e for e in catalog_entries if e.name == (entry.name or "")), None + ) if match is None: - detail = ( - "ambiguous in the community index" - if len(candidates) > 1 - else "not found in the community index" - ) resolved.append( - ResolvedPackPlugin(entry=entry, identifier=None, resolve_error=detail) + ResolvedPackPlugin( + entry=entry, identifier=None, + resolve_error="not found in the plugin catalog", + ) ) continue + caps = match.capabilities + declared = ( + list(caps.provides_tools) + + list(caps.provides_hooks) + + list(caps.provides_middleware) + ) + install_identifier = ( + f"{match.repo}#{match.subdir}" if match.subdir else match.repo + ) resolved.append( ResolvedPackPlugin( entry=entry, - identifier=match.install_identifier, - index_capabilities=list(match.capabilities), + identifier=install_identifier, + index_capabilities=declared, ) ) return resolved diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index c099d4692b..ad5b55602b 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1045,63 +1045,6 @@ def _removed_annotation(name: str, dir_path) -> Optional[str]: return None -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, @@ -1130,6 +1073,14 @@ def cmd_install( entry = None if _looks_like_catalog_name(identifier): entry = _get_live_catalog_entry(identifier) + if entry is None: + console.print( + f"[red]Error:[/red] '{identifier}' is not in the Hermes " + "plugin catalog and is not a Git URL or owner/repo " + "shorthand.\n" + "Browse available entries with `hermes plugins search`." + ) + sys.exit(1) if entry is not None: from hermes_cli.plugin_catalog import ( entry_capability_summary, @@ -1153,14 +1104,6 @@ def cmd_install( identifier = _catalog_install_identifier(entry) if ref is None: ref = entry.sha - elif _looks_like_bare_index_name(identifier): - console.print( - "[yellow]Warning:[/yellow] not in the curated Hermes catalog — " - "resolving through the community index (unreviewed)." - ) - identifier, index_ref = _resolve_index_name(identifier, console) - if ref is None: - ref = index_ref else: console.print( "[yellow]Warning:[/yellow] custom (unreviewed) source — " @@ -3529,39 +3472,22 @@ 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).""" + """Search the curated plugin catalog (name/description/declared tools).""" from rich.console import Console - from hermes_cli.plugin_index import ( - SECURITY_FOOTER, - load_index, - search_index, - ) + from hermes_cli.plugin_catalog import filter_entries, load_catalog_live console = Console() - - # Curated catalog first — reviewed, SHA-pinned entries. - catalog_matches = [] - try: - from hermes_cli.plugin_catalog import filter_entries, load_catalog_live - - catalog_matches = filter_entries(load_catalog_live(), term) - except Exception as exc: - logger.debug("catalog search unavailable: %s", exc) - - entries, source = load_index(refresh=refresh) - results = search_index(entries, term, capability=capability) + entries = load_catalog_live() + matches = filter_entries(entries, term) if json_output: print( json.dumps( { - "source": source, "query": term, - "catalog_results": [ + "results": [ { "name": e.name, "description": e.description, @@ -3569,42 +3495,25 @@ def cmd_search( "sha": e.sha, "tier": e.tier, } - for e in catalog_matches + for e in matches ], - "results": [e.to_dict() for e in results], - "note": SECURITY_FOOTER, }, indent=2, ) ) return - if catalog_matches: - _render_catalog_entries(catalog_matches, console) - - if not results: - if not catalog_matches: + if not matches: + if term: console.print( - f"[yellow]No plugins matched '{term}'[/yellow] " - f"[dim](index source: {source})[/dim]" + f"[yellow]No catalog entries matched '{term}'[/yellow] " + "[dim](browse everything with `hermes plugins browse`)[/dim]" ) + else: + console.print("[dim]No catalog entries available.[/dim]") return + _render_catalog_entries(matches, console) - 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: @@ -3633,8 +3542,6 @@ def plugins_command(args) -> None: 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) diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index ea241fe3a0..bb0eff1830 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -28,7 +28,7 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None: "identifier", help=( "Git URL, owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles), " - "or a bare plugin name resolved through the community index " + "or a bare plugin catalog entry name " "(see `hermes plugins search`)" ), ) @@ -61,30 +61,20 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None: ) plugins_search = plugins_subparsers.add_parser( - "search", help="Search the community plugin index" + "search", help="Search the Hermes plugin catalog" ) plugins_search.add_argument( "term", nargs="?", default="", - help="Search term matched fuzzily against name, description, and tags " - "(omit to browse the full index)", + help="Query matched against entry names, descriptions, and declared " + "tools (omit to list the whole catalog)", ) 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_subparsers.add_parser( "browse", help="Browse every curated plugin catalog entry" diff --git a/tests/hermes_cli/test_plugin_index_search.py b/tests/hermes_cli/test_plugin_index_search.py deleted file mode 100644 index 9efc5f174d..0000000000 --- a/tests/hermes_cli/test_plugin_index_search.py +++ /dev/null @@ -1,498 +0,0 @@ -"""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, scan_decision_cb=None, skip_removed_check=False): - 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, scan_decision_cb=None, skip_removed_check=False): - 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, scan_decision_cb=None, skip_removed_check=False): - 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/tests/hermes_cli/test_plugin_packs.py b/tests/hermes_cli/test_plugin_packs.py index 16e03e36ab..5bf082b64a 100644 --- a/tests/hermes_cli/test_plugin_packs.py +++ b/tests/hermes_cli/test_plugin_packs.py @@ -182,37 +182,46 @@ def test_load_pack_missing_file_errors(): # --------------------------------------------------------------------------- -# Resolution (bare index names) — index mocked, no network +# Resolution (bare catalog names) — catalog mocked, no network # --------------------------------------------------------------------------- -def test_resolve_pack_plugins_uses_community_index_for_bare_names(): - pack = parse_pack(_pack_yaml()) - fake_entry = SimpleNamespace( - install_identifier="idx-owner/idx-repo", capabilities=["tools"] +def _fake_catalog_entry(name): + return SimpleNamespace( + name=name, + repo="https://github.com/idx-owner/idx-repo", + subdir="", + capabilities=SimpleNamespace( + provides_tools=["tools"], + provides_hooks=[], + provides_middleware=[], + requires_env=[], + ), ) + + +def test_resolve_pack_plugins_uses_catalog_for_bare_names(): + pack = parse_pack(_pack_yaml()) + bare_name = pack.plugins[1].name + fake_entry = _fake_catalog_entry(bare_name) with mock.patch( - "hermes_cli.plugin_index.load_index", return_value=([fake_entry], "seed") - ), mock.patch( - "hermes_cli.plugin_index.resolve_name", - return_value=(fake_entry, [fake_entry]), + "hermes_cli.plugin_catalog.load_catalog_live", + return_value=[fake_entry], ): resolved = resolve_pack_plugins(pack) - assert resolved[0].identifier == "owner/tts-plugin" # repo entries skip the index - assert resolved[1].identifier == "idx-owner/idx-repo" + assert resolved[0].identifier == "owner/tts-plugin" # repo entries skip the catalog + assert resolved[1].identifier == "https://github.com/idx-owner/idx-repo" assert resolved[1].index_capabilities == ["tools"] -def test_resolve_pack_plugins_carries_index_miss_as_error(): +def test_resolve_pack_plugins_carries_catalog_miss_as_error(): pack = parse_pack( yaml.safe_dump( {"name": "p", "plugins": [{"name": "ghost", "ref": SHA_A}]} ) ) with mock.patch( - "hermes_cli.plugin_index.load_index", return_value=([], "seed") - ), mock.patch( - "hermes_cli.plugin_index.resolve_name", return_value=(None, []) + "hermes_cli.plugin_catalog.load_catalog_live", return_value=[] ): resolved = resolve_pack_plugins(pack) assert resolved[0].identifier is None diff --git a/tests/hermes_cli/test_plugins_cmd_catalog.py b/tests/hermes_cli/test_plugins_cmd_catalog.py index d990158cb6..fdcd54ffa3 100644 --- a/tests/hermes_cli/test_plugins_cmd_catalog.py +++ b/tests/hermes_cli/test_plugins_cmd_catalog.py @@ -444,7 +444,7 @@ class TestSearchBrowseInfo: def test_search_no_results_message(self, catalog_dir, offline, capsys): plugins_cmd.cmd_search("zzz-nothing") out = capsys.readouterr().out - assert "No plugins matched" in out + assert "No catalog entries matched" in out def test_info_shows_full_detail(self, catalog_dir, offline, capsys): _write_entry( diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 6771a80d20..9144acda52 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1412,8 +1412,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, `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. | +| `install [--force] [--ref COMMIT_SHA] [--allow-removed]` | Install a plugin from the Hermes plugin catalog (bare entry name), a Git URL, or `owner/repo` shorthand. Catalog names resolve to the entry's repo at its pinned 40-hex commit SHA, show the declared capability summary, and record catalog provenance in a `.hermes-catalog.json` sidecar. Raw URLs are flagged as custom (unreviewed) sources; `--ref` (full 40-character commit SHA) pins them. `--allow-removed` (DANGEROUS) bypasses the removed-plugin blocklist. | +| `search [term] [--json]` | Search the Hermes plugin catalog (matches entry names, descriptions, and declared tools; omit `term` to list everything). The catalog is curated in-repo (`plugin-catalog/`), refreshed from the live repo with a 6-hour cache, and falls back to the in-tree copy offline. Cataloged ≠ audited — admission reviews the entry, not the code. | | `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 417850c1c7..34be36be44 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -340,7 +340,7 @@ 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 search # search the Hermes plugin catalog 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) @@ -494,73 +494,40 @@ capability (`gateway.raw_events`) with a "no stability guarantee" label and a separate design, and has not shipped. ::: -### Discovering community plugins +### Discovering plugins — the Hermes plugin catalog -`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: +`hermes plugins search ` searches the **Hermes plugin catalog** — the +curated, SHA-pinned catalog maintained in the hermes-agent repository +(`plugin-catalog/`). Matching covers entry names, descriptions, and declared +tools: ```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 +hermes plugins search telegram # search the catalog +hermes plugins browse # browse every entry +hermes plugins info # full details for one entry ``` -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: +Once you've found a plugin, install it by bare name — the name resolves to +the entry's repository at its **pinned commit SHA**, and catalog provenance is +recorded so `hermes plugins update` can re-pin when the catalog moves: ```bash -hermes plugins install hermes-media-studio +hermes plugins install ``` -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. +Explicit `owner/repo` or Git-URL identifiers never touch the catalog and are +flagged as custom (unreviewed) sources. An explicit +`--ref <40-char commit SHA>` pins a custom install. -**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. +See [Plugin Catalog](./plugin-catalog.md) for the full trust model, admission +CI, and submission workflow. -**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. +:::warning Cataloged ≠ audited +A catalog entry means the entry's metadata and declared capabilities were +reviewed at admission — **it is not a code audit**. Installing still goes +through the normal consent 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. ::: ### Plugin packs @@ -595,7 +562,7 @@ hermes plugins pack export --enabled-only # only plugins.enabled **Supply-chain posture.** Every entry's `ref` must be an exact 40-character commit SHA — tags and branch names are rejected with an error naming the -entry, the same rule as the community index. Pack installs ride the exact +entry, the same rule as the plugin catalog. Pack installs ride the exact same pinned install path as `hermes plugins install --ref ` and record the same provenance in `plugins/.install-metadata.json`, so two installs of the same pack resolve identically. Packs build on the