diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index dea2f32ac2..560b3fd034 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -27,6 +27,8 @@ import yaml logger = logging.getLogger(__name__) CATALOG_TIERS = ("official", "community") +# Browse taxonomy for the catalog page / picker. Entries without one land in "other". +CATALOG_CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other") LIVE_CATALOG_URL = "https://hermes-agent.nousresearch.com/docs/api/plugin-catalog.json" LIVE_CATALOG_TTL_SECONDS = 6 * 60 * 60 _REQUEST_TIMEOUT = 5.0 @@ -60,6 +62,7 @@ class PluginCatalogEntry: description: str maintainer: str tier: str = "community" + category: str = "other" requires_hermes: str = "" subdir: str = "" docs_url: str = "" @@ -75,7 +78,8 @@ class PluginCatalogEntry: caps = self.capabilities return { "name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description, - "maintainer": self.maintainer, "tier": self.tier, "requires_hermes": self.requires_hermes, + "maintainer": self.maintainer, "tier": self.tier, "category": self.category, + "requires_hermes": self.requires_hermes, "subdir": self.subdir, "docs_url": self.docs_url, "platforms": list(self.platforms), "capabilities": { "provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks), @@ -104,11 +108,14 @@ def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]: repo = str(data.get("repo") or "") sha = str(data.get("sha") or "").strip().lower() tier = str(data.get("tier") or "community") + category = str(data.get("category") or "other") problem = ( f"invalid name {name!r} (must match [a-z0-9_-]{{1,64}})" if not _NAME_RE.match(name) else f"repo must be an https:// URL (got {repo!r})" if not repo.startswith("https://") else f"sha must be a full 40-character hex commit SHA (got {data.get('sha')!r})" if not _SHA_RE.match(sha) else f"tier must be one of {'/'.join(CATALOG_TIERS)} (got {tier!r})" if tier not in CATALOG_TIERS + else f"category must be one of {'/'.join(CATALOG_CATEGORIES)} (got {category!r})" + if category not in CATALOG_CATEGORIES else None) if problem: logger.warning("Plugin catalog: %s: %s", label, problem) @@ -118,7 +125,7 @@ def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]: return PluginCatalogEntry( name=name, repo=repo, sha=sha, description=str(data.get("description") or "").strip(), - maintainer=str(data.get("maintainer") or "").strip(), tier=tier, + maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category, requires_hermes=str(data.get("requires_hermes") or "").strip(), subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(), platforms=_str_list(data.get("platforms")), diff --git a/hermes_cli/plugins_cmd_catalog.py b/hermes_cli/plugins_cmd_catalog.py index 346f93441a..ec660642d6 100644 --- a/hermes_cli/plugins_cmd_catalog.py +++ b/hermes_cli/plugins_cmd_catalog.py @@ -162,12 +162,12 @@ def _capability_counts(entry: PluginCatalogEntry) -> str: def _render_entries(entries: List[PluginCatalogEntry], console) -> None: from hermes_cli.plugins_cmd import _table - table = _table(((("Name", "bold")), ("Tier", None), ("Description", None), ("Pinned", "dim"), - ("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)") - for e in entries: + table = _table(((("Name", "bold")), ("Category", None), ("Tier", None), ("Description", None), + ("Pinned", "dim"), ("Capabilities", "dim")), title="Hermes Plugin Catalog (curated)") + for e in sorted(entries, key=lambda e: (e.category, e.tier != "official", e.name)): tier = "[cyan]official[/cyan]" if e.tier == "official" else "[magenta]community[/magenta]" desc = e.description if len(e.description) <= 60 else e.description[:57] + "..." - table.add_row(e.name, tier, desc, e.sha[:8], _capability_counts(e)) + table.add_row(e.name, e.category, tier, desc, e.sha[:8], _capability_counts(e)) console.print() console.print(table) console.print() diff --git a/plugin-catalog/README.md b/plugin-catalog/README.md index 6ea912ad5b..b964c8333e 100644 --- a/plugin-catalog/README.md +++ b/plugin-catalog/README.md @@ -44,6 +44,8 @@ subdir: "" # optional path within the repo description: One-line description. maintainer: OwnerName tier: official # official | community (default community) +category: memory # memory | desktop | platform | web | tools | voice | automation | models | other + # (default other) — the shelf the entry sits on at /docs/plugins requires_hermes: ">=0.19" # optional docs_url: "" # optional platforms: [] # optional, e.g. [linux, macos]; empty = all diff --git a/plugin-catalog/herdr-auto-reconcile.yaml b/plugin-catalog/herdr-auto-reconcile.yaml index 9c5c1e2546..355f708f4b 100644 --- a/plugin-catalog/herdr-auto-reconcile.yaml +++ b/plugin-catalog/herdr-auto-reconcile.yaml @@ -5,6 +5,7 @@ description: Wake Hermes when allowlisted Herdr panes need reconciliation, witho pane work. maintainer: chris-yyau tier: community +category: automation docs_url: https://github.com/chris-yyau/hermes-herdr-auto-reconcile platforms: [] capabilities: diff --git a/plugin-catalog/hermes-ledgerline.yaml b/plugin-catalog/hermes-ledgerline.yaml index b59881be48..2e91fc41ea 100644 --- a/plugin-catalog/hermes-ledgerline.yaml +++ b/plugin-catalog/hermes-ledgerline.yaml @@ -4,6 +4,7 @@ sha: 9be4b850c874cebaa18b0077cdc5164b5e7b953b description: Inspect session costs and usage in Hermes Desktop. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-ledgerline#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/hermes-nous-prices.yaml b/plugin-catalog/hermes-nous-prices.yaml index 31b62e0403..b8c0b03fef 100644 --- a/plugin-catalog/hermes-nous-prices.yaml +++ b/plugin-catalog/hermes-nous-prices.yaml @@ -4,6 +4,7 @@ sha: 94f99436786e6fbbfcce161635d11c67bf094c8e description: Browse Nous Portal model prices and account balances in Hermes Desktop. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-nous-prices#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/hermes-office.yaml b/plugin-catalog/hermes-office.yaml index 110e299676..63f8cfd73b 100644 --- a/plugin-catalog/hermes-office.yaml +++ b/plugin-catalog/hermes-office.yaml @@ -4,6 +4,7 @@ sha: e1e0255a48fe7a7549d8d0a5ef0747a41a0fb7e7 description: View and manage Bot Mode agents in a shared office in Hermes Desktop. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-office#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/hermes-plugin-chrome-profiles.yaml b/plugin-catalog/hermes-plugin-chrome-profiles.yaml index b97cc4ee58..38736e7a30 100644 --- a/plugin-catalog/hermes-plugin-chrome-profiles.yaml +++ b/plugin-catalog/hermes-plugin-chrome-profiles.yaml @@ -4,6 +4,7 @@ sha: 5b9c3257b464c0f926d4355149a8aed9c8f307b4 description: Switch Hermes browser tools between local and remote Chrome/Edge profiles via CDP. maintainer: anpicasso tier: community +category: web docs_url: https://github.com/anpicasso/hermes-plugin-chrome-profiles platforms: [] capabilities: diff --git a/plugin-catalog/hermes-plugin-netbox.yaml b/plugin-catalog/hermes-plugin-netbox.yaml index 80146ddd9e..5e94d35496 100644 --- a/plugin-catalog/hermes-plugin-netbox.yaml +++ b/plugin-catalog/hermes-plugin-netbox.yaml @@ -5,6 +5,7 @@ description: 'NetBox change management: query DCIM/IPAM objects, build a reviewe with a journal and roll back.' maintainer: andrewvieyra tier: community +category: tools docs_url: https://github.com/andrewvieyra/hermes-plugin-netbox platforms: [] capabilities: diff --git a/plugin-catalog/hermes-resetwatch.yaml b/plugin-catalog/hermes-resetwatch.yaml index 805994edc2..0d23f8f333 100644 --- a/plugin-catalog/hermes-resetwatch.yaml +++ b/plugin-catalog/hermes-resetwatch.yaml @@ -4,6 +4,7 @@ sha: 3be783290fd66c6070af8eaf7a2286c7ef5e71c5 description: Track subscription quotas and reset times in Hermes Desktop. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-resetwatch#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/hermes-rss.yaml b/plugin-catalog/hermes-rss.yaml index 6418eb3e54..cba2ba014a 100644 --- a/plugin-catalog/hermes-rss.yaml +++ b/plugin-catalog/hermes-rss.yaml @@ -4,6 +4,7 @@ sha: 8053056094f8a232babe029e2336cb8118d8ba7a description: Read RSS and Atom feeds and discuss articles in Hermes Desktop. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-rss#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/hermes-rustpush-imessage.yaml b/plugin-catalog/hermes-rustpush-imessage.yaml index 6401a2f0e2..2b54be4cef 100644 --- a/plugin-catalog/hermes-rustpush-imessage.yaml +++ b/plugin-catalog/hermes-rustpush-imessage.yaml @@ -5,6 +5,7 @@ description: Local-only iMessage platform plugin backed by a launchd-supervised over a same-user Unix socket. maintainer: boobutler tier: community +category: platform docs_url: https://github.com/boobutler/hermes-rustpush-imessage platforms: [] capabilities: diff --git a/plugin-catalog/hermes-snapcompact.yaml b/plugin-catalog/hermes-snapcompact.yaml index 7f6170d407..9f839e26ce 100644 --- a/plugin-catalog/hermes-snapcompact.yaml +++ b/plugin-catalog/hermes-snapcompact.yaml @@ -5,6 +5,7 @@ description: 'Snapcompact context engine: archives conversation history as dense vision models read back at ~1/3 the token cost.' maintainer: vimona3ds tier: community +category: memory docs_url: https://github.com/vimona3ds/hermes-snapcompact platforms: [] capabilities: diff --git a/plugin-catalog/hermes-ssh.yaml b/plugin-catalog/hermes-ssh.yaml index 575479f439..92fcd7a33c 100644 --- a/plugin-catalog/hermes-ssh.yaml +++ b/plugin-catalog/hermes-ssh.yaml @@ -4,6 +4,7 @@ sha: 02c6b34ee604a30827df25a082cba03fbf6fdf17 description: Manage SSH connections and remote workspaces in Hermes Desktop. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-ssh#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/hermes-tailscale.yaml b/plugin-catalog/hermes-tailscale.yaml index f736557f1a..ed054c18fe 100644 --- a/plugin-catalog/hermes-tailscale.yaml +++ b/plugin-catalog/hermes-tailscale.yaml @@ -4,6 +4,7 @@ sha: 6dad1d5aac443a196f7ba83736a1f4c9e3a75048 description: Browse Tailscale devices and connect to remote Hermes instances in Hermes Desktop. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-tailscale#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/hermes-telegram-business.yaml b/plugin-catalog/hermes-telegram-business.yaml index 9814cf4ef0..dfc92f23af 100644 --- a/plugin-catalog/hermes-telegram-business.yaml +++ b/plugin-catalog/hermes-telegram-business.yaml @@ -5,6 +5,7 @@ description: 'Observe-with-approval Telegram Business Mode (secretary bot): ever needs owner approval before it is sent.' maintainer: NousResearch tier: official +category: platform docs_url: https://github.com/NousResearch/hermes-telegram-business platforms: [] capabilities: diff --git a/plugin-catalog/hermes-terminal.yaml b/plugin-catalog/hermes-terminal.yaml index 2888b7867b..4fd5088471 100644 --- a/plugin-catalog/hermes-terminal.yaml +++ b/plugin-catalog/hermes-terminal.yaml @@ -4,6 +4,7 @@ sha: 43658bf3c883178bf035d2163f7f541500d17407 description: Run the Hermes terminal interface in a Hermes Desktop workspace tab. maintainer: Adolanium tier: community +category: desktop docs_url: https://github.com/Adolanium/hermes-terminal#readme capabilities: provides_tools: [] diff --git a/plugin-catalog/jackal-verified.yaml b/plugin-catalog/jackal-verified.yaml index 6a4e37f69c..3c02960150 100644 --- a/plugin-catalog/jackal-verified.yaml +++ b/plugin-catalog/jackal-verified.yaml @@ -5,6 +5,7 @@ description: 'Typed adapter for the reproducible JACKAL v1.7.3 release: 41 catal tools (exact, numerical, Lean-checked bounded lanes, claim replay).' maintainer: AnubisQuantumCipher tier: community +category: tools docs_url: https://github.com/AnubisQuantumCipher/hermes-jackal-verified platforms: [] capabilities: diff --git a/plugin-catalog/shodan.yaml b/plugin-catalog/shodan.yaml index a1c4fed4ce..6b7721e444 100644 --- a/plugin-catalog/shodan.yaml +++ b/plugin-catalog/shodan.yaml @@ -4,6 +4,7 @@ sha: c7d56211420438bd300c3526b7536f55aafb0fce description: Shodan tools with complete capability declarations. maintainer: Adolanium tier: community +category: tools docs_url: https://github.com/Adolanium/hermes-plugin-shodan#readme capabilities: provides_tools: diff --git a/plugin-catalog/snyk.yaml b/plugin-catalog/snyk.yaml index 4b44d0554e..b0f54b4694 100644 --- a/plugin-catalog/snyk.yaml +++ b/plugin-catalog/snyk.yaml @@ -7,6 +7,7 @@ description: 'Scan code (SAST), dependencies, container images, IaC and SBOMs wi needs Node.js/npm on PATH and a free Snyk account (browser login via the snyk_auth tool).' maintainer: NousResearch tier: official +category: tools docs_url: https://github.com/NousResearch/hermes-plugin-snyk platforms: [] capabilities: diff --git a/plugin-catalog/touchdesigner.yaml b/plugin-catalog/touchdesigner.yaml index 3f890b4015..f5bbab4fd5 100644 --- a/plugin-catalog/touchdesigner.yaml +++ b/plugin-catalog/touchdesigner.yaml @@ -6,6 +6,7 @@ description: 'Drive a live TouchDesigner session through the twozero MCP server (mcp.json + skills/); TouchDesigner itself runs on macOS/Windows.' maintainer: NousResearch tier: official +category: tools docs_url: https://github.com/NousResearch/hermes-plugin-touchdesigner platforms: [] capabilities: diff --git a/scripts/validate_plugin_catalog.py b/scripts/validate_plugin_catalog.py index eb0078c7c0..8c3afacae4 100644 --- a/scripts/validate_plugin_catalog.py +++ b/scripts/validate_plugin_catalog.py @@ -42,6 +42,7 @@ except ImportError: # pragma: no cover - dependency guidance only NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$") SHA_RE = re.compile(r"^[0-9a-f]{40}$") TIERS = ("official", "community") +CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other") PLATFORMS = ("linux", "macos", "windows") CAPABILITY_KEYS = ( "provides_tools", @@ -59,6 +60,7 @@ KNOWN_KEYS = { "description", "maintainer", "tier", + "category", "requires_hermes", "docs_url", "platforms", @@ -125,6 +127,10 @@ def validate_entry(data: object) -> tuple[list[str], list[str]]: if tier not in TIERS: errors.append(f"tier {tier!r} must be one of {list(TIERS)}") + category = data.get("category", "other") + if category not in CATEGORIES: + errors.append(f"category {category!r} must be one of {list(CATEGORIES)}") + if "requires_hermes" in data: _check_requires_hermes(data["requires_hermes"], errors) diff --git a/tests/hermes_cli/test_plugin_catalog.py b/tests/hermes_cli/test_plugin_catalog.py index 19a3613515..fc69d053eb 100644 --- a/tests/hermes_cli/test_plugin_catalog.py +++ b/tests/hermes_cli/test_plugin_catalog.py @@ -30,6 +30,18 @@ def test_shipped_catalog_entries_are_all_valid_and_pinned(): assert all(e.install_identifier.startswith(e.repo) for e in entries) +def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path): + """``category`` is the browse shelf: absent → ``other``; a value outside CATALOG_CATEGORIES is an + invalid entry (skipped with a warning) so a typo cannot create a phantom shelf on the site.""" + (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("no-cat"))) + (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("mem", category="memory"))) + (tmp_path / "c.yaml").write_text(yaml.safe_dump(_entry("typo", category="memmory"))) + entries = {e.name: e for e in pc.load_catalog(tmp_path)} + assert set(entries) == {"no-cat", "mem"} + assert entries["no-cat"].category == "other" and entries["mem"].category == "memory" + assert entries["mem"].to_dict()["category"] == "memory" + + def test_invalid_entries_are_skipped_not_raised(tmp_path): (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok"))) (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123"))) diff --git a/tests/scripts/test_validate_plugin_catalog.py b/tests/scripts/test_validate_plugin_catalog.py index 7b369c96ec..52bbd80c3e 100644 --- a/tests/scripts/test_validate_plugin_catalog.py +++ b/tests/scripts/test_validate_plugin_catalog.py @@ -88,6 +88,15 @@ def _expect_error(tmp_path, mutation: dict, expected_substring: str, drop: str = assert "entry.yaml" in combined, combined +def test_unknown_category_fails(tmp_path): + path = write_entry(tmp_path, {**VALID_ENTRY, "category": "memmory"}) + result = run_validator(str(path)) + assert result.returncode != 0 + assert "category" in result.stdout + result.stderr + path = write_entry(tmp_path, {**VALID_ENTRY, "category": "memory"}, "ok.yaml") + assert run_validator(str(path)).returncode == 0 + + def test_bad_name_fails(tmp_path): _expect_error(tmp_path, {"name": "Bad Name!"}, "name") diff --git a/tests/website/test_extract_plugins.py b/tests/website/test_extract_plugins.py index c531ae0ff7..1fbc2c3d5b 100644 --- a/tests/website/test_extract_plugins.py +++ b/tests/website/test_extract_plugins.py @@ -140,8 +140,8 @@ def test_unknown_tier_normalizes_to_community(mod, tmp_path): def test_main_writes_catalog_and_meta(mod, tmp_path): catalog = tmp_path / "plugin-catalog" catalog.mkdir() - _write_entry(catalog, "alpha", tier="official") - _write_entry(catalog, "beta") + _write_entry(catalog, "alpha", tier="official", category="memory") + _write_entry(catalog, "beta") # no category → "other" shelf (catalog / "removed.yaml").write_text( "removed:\n - name: gone\n", encoding="utf-8" ) @@ -155,6 +155,8 @@ def test_main_writes_catalog_and_meta(mod, tmp_path): assert [p["name"] for p in plugins] == ["alpha", "beta"] assert meta["total"] == 2 assert meta["byTier"] == {"official": 1, "community": 1} + assert {p["name"]: p["category"] for p in plugins} == {"alpha": "memory", "beta": "other"} + assert meta["byCategory"] == {"memory": 1, "other": 1} assert meta["removedCount"] == 1 assert meta["generatedAt"] # The live-refresh document consumed by installed clients: loader-schema entries + the kill list. diff --git a/website/docs/user-guide/features/plugin-catalog.md b/website/docs/user-guide/features/plugin-catalog.md index 89913653d8..45f0dacff0 100644 --- a/website/docs/user-guide/features/plugin-catalog.md +++ b/website/docs/user-guide/features/plugin-catalog.md @@ -14,9 +14,10 @@ can install by name with a single command: hermes plugins install ``` -Browse it visually at **[/docs/plugins](/plugins)** — search, tier filters -(Official / Community), capability chips, and copyable install commands for -every entry. +Browse it visually at **[/docs/plugins](/plugins)** — entries are shelved by +category (Memory, Desktop, Platforms, Web & Browser, Tools, Voice, Automation, +Models), with search, tier filters (Official / Community), capability chips, and +copyable install commands for every entry. The catalog complements — it does not replace — the existing [plugin system](plugins.md). Anything you can install from the catalog is a @@ -35,6 +36,7 @@ directory of the hermes-agent repository, declaring: | `repo` | The plugin's public git repository | | `sha` | The **exact 40-hex commit** that was reviewed — installs check out this pin, not a branch tip | | `tier` | `official` (maintained by NousResearch) or `community` | +| `category` | Browse shelf: `memory`, `desktop`, `platform`, `web`, `tools`, `voice`, `automation`, `models` or `other` (default) | | `maintainer` | Who owns the plugin | | `capabilities` | Declared tools, hooks, middleware, and required env vars | | `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) | diff --git a/website/scripts/extract-plugins.py b/website/scripts/extract-plugins.py index 7bed3fabad..f592a9d0a2 100644 --- a/website/scripts/extract-plugins.py +++ b/website/scripts/extract-plugins.py @@ -40,6 +40,7 @@ DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog" DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api" CATALOG_TIERS = ("official", "community") +CATALOG_CATEGORIES = ("memory", "desktop", "platform", "web", "tools", "voice", "automation", "models", "other") SHA_RE = re.compile(r"^[0-9a-f]{40}$") @@ -106,6 +107,10 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]: if tier not in CATALOG_TIERS: _log(f"{path.name} ({name}): unknown tier {tier!r}, treating as community") tier = "community" + category = str(raw.get("category") or "other").strip().lower() + if category not in CATALOG_CATEGORIES: + _log(f"{path.name} ({name}): unknown category {category!r}, treating as other") + category = "other" entries.append({ "name": name, @@ -114,6 +119,7 @@ def load_catalog_entries(catalog_dir: Path) -> list[dict]: "sha": sha, "shaShort": sha[:7], "tier": tier, + "category": category, "maintainer": str(raw.get("maintainer") or "").strip(), "subdir": str(raw.get("subdir") or "").strip(), "requiresHermes": str(raw.get("requires_hermes") or "").strip(), @@ -173,10 +179,12 @@ def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUT removed_count = count_removed(catalog_dir) by_tier = Counter(e["tier"] for e in entries) + by_category = Counter(e["category"] for e in entries) meta = { "generatedAt": datetime.now(timezone.utc).isoformat(), "total": len(entries), "byTier": {tier: by_tier.get(tier, 0) for tier in CATALOG_TIERS}, + "byCategory": {c: by_category.get(c, 0) for c in CATALOG_CATEGORIES if by_category.get(c)}, "removedCount": removed_count, } diff --git a/website/src/pages/plugins/index.tsx b/website/src/pages/plugins/index.tsx index 134a0e3409..9d96de4d10 100644 --- a/website/src/pages/plugins/index.tsx +++ b/website/src/pages/plugins/index.tsx @@ -17,6 +17,7 @@ interface CatalogPlugin { sha: string; shaShort: string; tier: string; + category: string; maintainer: string; subdir?: string; requiresHermes?: string; @@ -32,6 +33,7 @@ interface CatalogMeta { generatedAt?: string; total?: number; byTier?: Record; + byCategory?: Record; removedCount?: number; } @@ -65,6 +67,22 @@ const TIER_CONFIG: Record< const TIER_ORDER = ["all", "official", "community"]; +// Browse taxonomy. Order here is the order of the filter pills and of the +// grouped sections; keep it in sync with CATALOG_CATEGORIES in +// hermes_cli/plugin_catalog.py and website/scripts/extract-plugins.py. +const CATEGORY_CONFIG: Record = { + memory: { label: "Memory", icon: "\u{1F9E0}", blurb: "Memory providers and context engines" }, + desktop: { label: "Desktop", icon: "\u{1F5A5}\u{FE0F}", blurb: "Panes, tabs and views for Hermes Desktop" }, + platform: { label: "Platforms", icon: "\u{1F4AC}", blurb: "Messaging and channel adapters" }, + web: { label: "Web & Browser", icon: "\u{1F310}", blurb: "Search backends, extraction and browser control" }, + tools: { label: "Tools", icon: "\u{1F6E0}\u{FE0F}", blurb: "New tools the agent can call" }, + voice: { label: "Voice", icon: "\u{1F399}\u{FE0F}", blurb: "Speech, TTS and realtime audio" }, + automation: { label: "Automation", icon: "\u{23F1}\u{FE0F}", blurb: "Hooks, wake triggers and session automation" }, + models: { label: "Models", icon: "\u{2728}", blurb: "Model and inference providers" }, + other: { label: "Other", icon: "\u{1F4E6}", blurb: "Everything else" }, +}; +const CATEGORY_ORDER = Object.keys(CATEGORY_CONFIG); + function formatRelativeTime(iso?: string): string | null { if (!iso) return null; const then = new Date(iso).getTime(); @@ -142,6 +160,7 @@ function PluginCard({ expanded, onToggle, onPick, + onCategoryClick, style, }: { plugin: CatalogPlugin; @@ -150,9 +169,11 @@ function PluginCard({ onToggle: () => void; /** Picker embed mode: render "+ Add to this Agent" and call this. */ onPick?: (plugin: CatalogPlugin) => void; + onCategoryClick?: (category: string) => void; style?: React.CSSProperties; }) { const tier = TIER_CONFIG[plugin.tier] || TIER_CONFIG.community; + const category = CATEGORY_CONFIG[plugin.category] || CATEGORY_CONFIG.other; const caps = plugin.capabilities || {}; const toolCount = caps.providesTools?.length || 0; const hookCount = caps.providesHooks?.length || 0; @@ -169,7 +190,7 @@ function PluginCard({
- {"\u{1F50C}"} + {category.icon}

{highlightMatch(plugin.name, query)}

+ {toolCount > 0 && ( {toolCount} tool{toolCount === 1 ? "" : "s"} @@ -322,6 +353,8 @@ function buildSearchHaystack(p: CatalogPlugin): string { p.description, p.maintainer, p.tier, + p.category, + CATEGORY_CONFIG[p.category]?.label, ...(p.capabilities?.providesTools || []), ...(p.capabilities?.providesHooks || []), ...(p.capabilities?.requiresEnv || []), @@ -368,6 +401,7 @@ export default function PluginCatalogPage() { const [search, setSearch] = useState(""); const [tierFilter, setTierFilter] = useState("all"); + const [categoryFilter, setCategoryFilter] = useState("all"); const [expandedCard, setExpandedCard] = useState(null); const searchRef = useRef(null); @@ -418,22 +452,68 @@ export default function PluginCatalogPage() { const q = search.toLowerCase().trim(); return allPlugins.filter((p) => { if (tierFilter !== "all" && p.tier !== tierFilter) return false; + if (categoryFilter !== "all" && p.category !== categoryFilter) return false; if (q) return (p._search || "").includes(q); return true; }); - }, [search, tierFilter, allPlugins]); + }, [search, tierFilter, categoryFilter, allPlugins]); + + // Browse mode (no search, no category picked): render one section per + // category so Memory, Desktop, Platforms… read as distinct shelves rather + // than one undifferentiated wall. Filtering or searching flattens to a grid. + const grouped = useMemo(() => { + if (search.trim() || categoryFilter !== "all") return null; + const buckets = new Map(); + for (const p of filtered) { + const key = CATEGORY_CONFIG[p.category] ? p.category : "other"; + (buckets.get(key) ?? buckets.set(key, []).get(key)!).push(p); + } + return CATEGORY_ORDER.filter((c) => buckets.has(c)).map((c) => [c, buckets.get(c)!] as const); + }, [filtered, search, categoryFilter]); + + const categoryCounts = useMemo(() => { + const counts: Record = {}; + for (const p of allPlugins) { + if (tierFilter !== "all" && p.tier !== tierFilter) continue; + const key = CATEGORY_CONFIG[p.category] ? p.category : "other"; + counts[key] = (counts[key] || 0) + 1; + } + return counts; + }, [allPlugins, tierFilter]); useEffect(() => { setExpandedCard(null); - }, [search, tierFilter]); + }, [search, tierFilter, categoryFilter]); const clearAll = useCallback(() => { setSearch(""); setTierFilter("all"); + setCategoryFilter("all"); + }, []); + + const pickCategory = useCallback((c: string) => { + setCategoryFilter((cur) => (cur === c ? "all" : c)); + setSearch(""); }, []); const catalogEmpty = data !== null && allPlugins.length === 0; + const renderCard = (plugin: CatalogPlugin, i: number) => { + const key = `${plugin.tier}-${plugin.name}`; + return ( + setExpandedCard(expandedCard === key ? null : key)} + onPick={pickerMode ? pickPlugin : undefined} + onCategoryClick={pickCategory} + style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }} + /> + ); + }; + return ( +
)} @@ -554,6 +639,34 @@ export default function PluginCatalogPage() { ); })}
+ +
+ + {CATEGORY_ORDER.filter((c) => categoryCounts[c]).map((c) => { + const conf = CATEGORY_CONFIG[c]; + const active = categoryFilter === c; + return ( + + ); + })} +
)} @@ -586,23 +699,51 @@ export default function PluginCatalogPage() {
+ ) : filtered.length > 0 && grouped ? ( + grouped.map(([cat, plugins]) => { + const conf = CATEGORY_CONFIG[cat]; + return ( +
+
+

+ {conf.label} + {plugins.length} +

+

{conf.blurb}

+ +
+
+ {plugins.map((plugin, i) => renderCard(plugin, i))} +
+
+ ); + }) ) : filtered.length > 0 ? ( -
- {filtered.map((plugin, i) => { - const key = `${plugin.tier}-${plugin.name}`; - return ( - setExpandedCard(expandedCard === key ? null : key)} - onPick={pickerMode ? pickPlugin : undefined} - style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }} - /> - ); - })} -
+ <> +
+ {categoryFilter !== "all" && CATEGORY_CONFIG[categoryFilter] && ( + + {" "} + {CATEGORY_CONFIG[categoryFilter].label} + · {CATEGORY_CONFIG[categoryFilter].blurb} + + )} + {search.trim() && ( + + Results for “{search.trim()}” + + )} + + {filtered.length} plugin{filtered.length === 1 ? "" : "s"} + + +
+
{filtered.map((plugin, i) => renderCard(plugin, i))}
+ ) : (
{"\u{1F50D}"}
diff --git a/website/src/pages/plugins/styles.module.css b/website/src/pages/plugins/styles.module.css index a396f4d819..d2c870cae3 100644 --- a/website/src/pages/plugins/styles.module.css +++ b/website/src/pages/plugins/styles.module.css @@ -244,6 +244,143 @@ background: rgba(255, 255, 255, 0.08); } +/* ── Category pills + grouped sections ─────────────────────────────────── */ +.categoryPills { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: center; + width: 100%; + padding-top: 0.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.categoryBtn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 20px; + background: transparent; + color: var(--ifm-color-emphasis-600); + font-size: 0.8rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.categoryBtn:hover { + border-color: rgba(255, 255, 255, 0.15); + color: var(--ifm-font-color-base); +} + +.categoryBtnActive { + border-color: rgba(125, 211, 252, 0.7); + background: rgba(125, 211, 252, 0.14); + color: #bae6fd; + font-weight: 600; + box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.35), 0 0 14px rgba(125, 211, 252, 0.18); +} + +.categoryBtnActive .tierCount { + background: rgba(125, 211, 252, 0.2); +} + +/* Header shown above the flat grid when a category filter or search is active */ +.resultsBar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.9rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.9rem; + color: var(--ifm-color-emphasis-700); +} + +.resultsBar strong { + color: var(--ifm-font-color-base); + font-weight: 650; +} + +.resultsClear { + margin-left: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + color: var(--ifm-color-emphasis-700); + border-radius: 14px; + padding: 0.2rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; +} + +.resultsClear:hover { + border-color: rgba(255, 255, 255, 0.2); + color: var(--ifm-font-color-base); +} + +.categorySection { + margin-bottom: 2.25rem; +} + +.categoryHeader { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: "title view" "blurb view"; + align-items: center; + gap: 0.1rem 1rem; + margin-bottom: 0.9rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.categoryTitle { + grid-area: title; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.15rem; + font-weight: 650; + letter-spacing: -0.01em; +} + +.categoryBlurb { + grid-area: blurb; + margin: 0; + font-size: 0.85rem; + color: var(--ifm-color-emphasis-600); +} + +.categoryViewAll { + grid-area: view; + border: none; + background: transparent; + color: #7dd3fc; + font-size: 0.82rem; + cursor: pointer; + padding: 0.25rem 0; + white-space: nowrap; +} + +.categoryViewAll:hover { + text-decoration: underline; +} + +.categoryChip { + border: 1px solid rgba(125, 211, 252, 0.25); + background: rgba(125, 211, 252, 0.07); + color: #7dd3fc; + border-radius: 6px; + padding: 0.1rem 0.45rem; + font-size: 0.72rem; + cursor: pointer; +} + +.categoryChip:hover { + background: rgba(125, 211, 252, 0.14); +} + .main { max-width: 1200px; margin: 0 auto;