feat(plugin-catalog): shelve the catalog by category (Memory, Desktop, Platforms, …)
The catalog page was one undifferentiated grid filtered only by tier, so a memory provider sat between two Desktop panes. Entries now carry an optional ``category`` (memory | desktop | platform | web | tools | voice | automation | models | other, default other) that the loader, the admission validator and the site extractor all understand. /docs/plugins renders one shelf per category in browse mode, a category pill row under the tier pills, a clickable category chip on every card, and a results bar (active category, count, clear) when a filter or search flattens the view. ``hermes plugins catalog`` gains a Category column and groups by it. All 18 shipped entries are categorised. Unknown categories fail admission (same contract as tier) so a typo cannot create a phantom shelf.
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,9 +14,10 @@ can install by name with a single command:
|
||||
hermes plugins install <name>
|
||||
```
|
||||
|
||||
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) |
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, number>;
|
||||
byCategory?: Record<string, number>;
|
||||
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<string, { label: string; icon: string; blurb: string }> = {
|
||||
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({
|
||||
|
||||
<div className={styles.cardInner}>
|
||||
<div className={styles.cardTop}>
|
||||
<span className={styles.cardIcon}>{"\u{1F50C}"}</span>
|
||||
<span className={styles.cardIcon} title={category.label}>{category.icon}</span>
|
||||
<div className={styles.cardTitleGroup}>
|
||||
<h3 className={styles.cardTitle}>{highlightMatch(plugin.name, query)}</h3>
|
||||
<span
|
||||
@@ -190,6 +211,16 @@ function PluginCard({
|
||||
</p>
|
||||
|
||||
<div className={styles.cardMeta}>
|
||||
<button
|
||||
className={styles.categoryChip}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCategoryClick?.(plugin.category);
|
||||
}}
|
||||
title={`Filter by ${category.label}`}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
{toolCount > 0 && (
|
||||
<span className={styles.capChip}>
|
||||
{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<string | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(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<string, CatalogPlugin[]>();
|
||||
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<string, number> = {};
|
||||
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 (
|
||||
<PluginCard
|
||||
key={key}
|
||||
plugin={plugin}
|
||||
query={search}
|
||||
expanded={expandedCard === key}
|
||||
onToggle={() => setExpandedCard(expandedCard === key ? null : key)}
|
||||
onPick={pickerMode ? pickPlugin : undefined}
|
||||
onCategoryClick={pickCategory}
|
||||
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Plugin Catalog"
|
||||
@@ -482,6 +562,11 @@ export default function PluginCatalogPage() {
|
||||
label="Community"
|
||||
color="#94a3b8"
|
||||
/>
|
||||
<StatCard
|
||||
value={Object.keys(categoryCounts).length}
|
||||
label="Categories"
|
||||
color="#7dd3fc"
|
||||
/>
|
||||
<StatCard value={meta.removedCount ?? 0} label="Removed" color="#f87171" />
|
||||
</div>
|
||||
)}
|
||||
@@ -554,6 +639,34 @@ export default function PluginCatalogPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.categoryPills} role="tablist" aria-label="Plugin categories">
|
||||
<button
|
||||
className={`${styles.categoryBtn} ${categoryFilter === "all" ? styles.categoryBtnActive : ""}`}
|
||||
onClick={() => setCategoryFilter("all")}
|
||||
role="tab"
|
||||
aria-selected={categoryFilter === "all"}
|
||||
>
|
||||
All categories
|
||||
</button>
|
||||
{CATEGORY_ORDER.filter((c) => categoryCounts[c]).map((c) => {
|
||||
const conf = CATEGORY_CONFIG[c];
|
||||
const active = categoryFilter === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
className={`${styles.categoryBtn} ${active ? styles.categoryBtnActive : ""}`}
|
||||
onClick={() => pickCategory(c)}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
title={conf.blurb}
|
||||
>
|
||||
<span aria-hidden="true">{conf.icon}</span> {conf.label}
|
||||
<span className={styles.tierCount}>{categoryCounts[c]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -586,23 +699,51 @@ export default function PluginCatalogPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : filtered.length > 0 && grouped ? (
|
||||
grouped.map(([cat, plugins]) => {
|
||||
const conf = CATEGORY_CONFIG[cat];
|
||||
return (
|
||||
<section key={cat} className={styles.categorySection} aria-labelledby={`cat-${cat}`}>
|
||||
<header className={styles.categoryHeader}>
|
||||
<h2 id={`cat-${cat}`} className={styles.categoryTitle}>
|
||||
<span aria-hidden="true">{conf.icon}</span> {conf.label}
|
||||
<span className={styles.tierCount}>{plugins.length}</span>
|
||||
</h2>
|
||||
<p className={styles.categoryBlurb}>{conf.blurb}</p>
|
||||
<button className={styles.categoryViewAll} onClick={() => pickCategory(cat)}>
|
||||
View only {conf.label} →
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.grid}>
|
||||
{plugins.map((plugin, i) => renderCard(plugin, i))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
) : filtered.length > 0 ? (
|
||||
<div className={styles.grid}>
|
||||
{filtered.map((plugin, i) => {
|
||||
const key = `${plugin.tier}-${plugin.name}`;
|
||||
return (
|
||||
<PluginCard
|
||||
key={key}
|
||||
plugin={plugin}
|
||||
query={search}
|
||||
expanded={expandedCard === key}
|
||||
onToggle={() => setExpandedCard(expandedCard === key ? null : key)}
|
||||
onPick={pickerMode ? pickPlugin : undefined}
|
||||
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<div className={styles.resultsBar} role="status">
|
||||
{categoryFilter !== "all" && CATEGORY_CONFIG[categoryFilter] && (
|
||||
<span>
|
||||
<span aria-hidden="true">{CATEGORY_CONFIG[categoryFilter].icon}</span>{" "}
|
||||
<strong>{CATEGORY_CONFIG[categoryFilter].label}</strong>
|
||||
<span style={{ opacity: 0.7 }}> · {CATEGORY_CONFIG[categoryFilter].blurb}</span>
|
||||
</span>
|
||||
)}
|
||||
{search.trim() && (
|
||||
<span>
|
||||
Results for <strong>“{search.trim()}”</strong>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{filtered.length} plugin{filtered.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<button className={styles.resultsClear} onClick={clearAll}>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.grid}>{filtered.map((plugin, i) => renderCard(plugin, i))}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>{"\u{1F50D}"}</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user