fix(catalog): remove the parallel community plugin index — the catalog is the only discovery system

The interim index (#84919) merged while this PR was open, leaving two
side-by-side discovery systems. Rip the index out, keep only what the
catalog track uses:

- delete hermes_cli/plugin_index.py, the bundled seed JSON, its test
  file, the .gitignore exception, and the plugins.index_url surface
- cmd_search is catalog-only (term + --json); --capability/--refresh die
- cmd_install: unknown bare catalog-like names hard-error again instead
  of falling through the removed index fallback
- plugin packs bare-name resolution rewired onto plugin_catalog
  (load_catalog_live), the only shipped index consumer
- docs (features/plugins.md, cli-commands.md) rewritten for the catalog
This commit is contained in:
Teknium
2026-08-31 11:54:54 -07:00
parent dcb3e27b23
commit 46ab5aa365
12 changed files with 108 additions and 1100 deletions
-1
View File
@@ -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/
+4 -4
View File
@@ -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",
})
-68
View File
@@ -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"
}
]
}
-305
View File
@@ -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
+27 -20
View File
@@ -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
+21 -114
View File
@@ -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 <term>` to "
"browse, or install directly with an owner/repo identifier."
)
sys.exit(1)
pinned_ref: Optional[str] = None
if entry.ref and _EXACT_COMMIT_RE.fullmatch(entry.ref):
pinned_ref = entry.ref.lower()
elif entry.ref:
console.print(
f"[dim]Index pins ref '{entry.ref}' (not an exact commit SHA); "
"installing the default branch head instead.[/dim]"
)
console.print(
f"[dim]Resolved '{entry.name}' via community index ({source}) → "
f"{entry.install_identifier}"
+ (f" @ {pinned_ref[:12]}[/dim]" if pinned_ref else "[/dim]")
)
console.print(f"[dim]{SECURITY_FOOTER}[/dim]")
return entry.install_identifier, pinned_ref
def cmd_install(
identifier: str,
force: bool = False,
@@ -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 <name>[/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)
+4 -14
View File
@@ -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"
@@ -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,
}
+24 -15
View File
@@ -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
+1 -1
View File
@@ -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(
+2 -2
View File
@@ -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 <identifier> [--force] [--ref COMMIT_SHA]` | Install a plugin from a Git URL, `owner/repo`, or a bare index name. Bare names (no slash) are resolved through the community plugin index to `owner/repo` plus the index-pinned commit; ambiguous names list candidates and exit. `--ref` accepts only a full 40-character commit SHA, installs that exact immutable revision, and overrides any index pin. |
| `search [term] [--json] [--capability CAP] [--refresh]` | Search the community plugin index (fuzzy match on name/description/tags; omit `term` to browse). Fetched from `plugins.index_url` (default: the NousResearch plugin index), cached under `~/.hermes/cache/` for 24h, falling back to the stale cache and then the bundled seed when offline. Indexed ≠ audited — inclusion is a metadata review only. |
| `install <identifier> [--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 <name>` | Pull latest changes for an unpinned installed plugin. Pinned plugins must be reinstalled with `--force --ref <new-commit>` to move. |
| `remove <name>` (aliases: `rm`, `uninstall`) | Remove an installed plugin. |
| `enable <name>` | Enable a disabled plugin. |
+25 -58
View File
@@ -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 <term> # search the community plugin index
hermes plugins search <term> # search the Hermes plugin catalog
hermes plugins install <name> # install by index name (resolved to repo @ pinned ref)
hermes plugins install 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 <term>` 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 <term>` 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 <name> # 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 <catalog-name>
```
If a name matches more than one entry, the candidates are listed and nothing
is installed. Explicit `owner/repo` or Git-URL identifiers never touch the
index and keep working exactly as before. An explicit `--ref <sha>` always
overrides the index pin.
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 <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 <sha>` and record
the same provenance in `plugins/.install-metadata.json`, so two installs of
the same pack resolve identically. Packs build on the