feat(docs): add /docs/plugins catalog page fed by plugin-catalog/ extractor

- website/scripts/extract-plugins.py: reads plugin-catalog/*.yaml (+removed.yaml),
  emits static/api/plugins.json + plugins-meta.json; degrades to an empty
  catalog with exit 0 when plugin-catalog/ does not exist yet
- website/src/pages/plugins/: catalog page with search, tier tabs
  (All/Official/Community), capability chips, pinned-SHA repo links,
  copyable install commands, and an empty-state submission CTA
- cross-nav between Skills Hub and Plugin Catalog pages + navbar item
- user docs: user-guide/features/plugin-catalog.md (trust model, install,
  submission checklist, custom git-URL contrast), registered in sidebars.ts
- wired into deploy-site.yml and prebuild.mjs; artifacts gitignored
- tests: tests/website/test_extract_plugins.py
This commit is contained in:
Teknium 2026-07-22 07:28:53 -07:00
parent d358280ad7
commit fb40a768fc
No known key found for this signature in database
12 changed files with 1836 additions and 0 deletions

View file

@ -157,6 +157,9 @@ jobs:
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Extract plugin catalog for the Plugins page
run: python3 website/scripts/extract-plugins.py
- name: Regenerate per-skill docs pages + catalogs
run: python3 website/scripts/generate-skill-docs.py

4
.gitignore vendored
View file

@ -113,6 +113,10 @@ website/static/api/skills-index.json
# every build).
website/static/api/skills.json
website/static/api/skills-meta.json
# plugins.json + plugins-meta.json are build artifacts emitted by
# website/scripts/extract-plugins.py during prebuild (Plugin Catalog page).
website/static/api/plugins.json
website/static/api/plugins-meta.json
# automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/automation-blueprints-index.json

View file

@ -0,0 +1,195 @@
"""Tests for website/scripts/extract-plugins.py.
Behavioral contracts for the /docs/plugins catalog extractor:
1. Reads ``plugin-catalog/*.yaml`` entries (skipping ``removed.yaml``) and
emits ``plugins.json`` rows carrying name/repo/sha/tier/capabilities plus
a synthesized ``hermes plugins install <name>`` command.
2. Entries missing any of name/repo/sha are skipped (logged, not fatal).
3. A missing ``plugin-catalog/`` directory degrades gracefully: empty
catalog list, zero counts in the meta sidecar, exit 0 the docs build
must stay green before the catalog directory lands on main.
"""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
EXTRACT = REPO_ROOT / "website" / "scripts" / "extract-plugins.py"
@pytest.fixture(scope="module")
def mod():
spec = importlib.util.spec_from_file_location("extract_plugins", EXTRACT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _write_entry(catalog_dir: Path, name: str, **overrides) -> Path:
import yaml
entry = {
"name": name,
"repo": f"https://github.com/example/{name}",
"sha": "38fe0fb53eff98d477f807432e965429e665ca33",
"description": f"{name} does things.",
"maintainer": "Example",
"tier": "community",
}
entry.update(overrides)
# Drop keys explicitly set to None so tests can simulate missing fields.
entry = {k: v for k, v in entry.items() if v is not None}
path = catalog_dir / f"{name}.yaml"
path.write_text(yaml.safe_dump(entry), encoding="utf-8")
return path
# --------------------------------------------------------------------------
# Entry loading + validation
# --------------------------------------------------------------------------
def test_valid_entry_is_extracted_with_install_command(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(
catalog,
"example-plugin",
tier="official",
docs_url="https://example.com/docs",
requires_hermes=">=0.19",
platforms=["linux"],
capabilities={
"provides_tools": ["do_thing"],
"provides_hooks": ["on_start"],
"provides_middleware": [],
"requires_env": ["EXAMPLE_TOKEN"],
},
)
entries = mod.load_catalog_entries(catalog)
assert len(entries) == 1
e = entries[0]
assert e["name"] == "example-plugin"
assert e["repo"] == "https://github.com/example/example-plugin"
assert e["sha"] == "38fe0fb53eff98d477f807432e965429e665ca33"
assert e["shaShort"] == "38fe0fb"
assert e["tier"] == "official"
assert e["maintainer"] == "Example"
assert e["requiresHermes"] == ">=0.19"
assert e["platforms"] == ["linux"]
assert e["docsUrl"] == "https://example.com/docs"
assert e["capabilities"]["providesTools"] == ["do_thing"]
assert e["capabilities"]["providesHooks"] == ["on_start"]
assert e["capabilities"]["requiresEnv"] == ["EXAMPLE_TOKEN"]
assert e["installCommand"] == "hermes plugins install example-plugin"
def test_entries_missing_required_fields_are_skipped(mod, tmp_path, capsys):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "good-plugin")
_write_entry(catalog, "no-sha", sha=None)
_write_entry(catalog, "no-repo", repo=None)
entries = mod.load_catalog_entries(catalog)
assert [e["name"] for e in entries] == ["good-plugin"]
err = capsys.readouterr().err
assert "no-sha" in err
assert "no-repo" in err
def test_removed_yaml_is_not_treated_as_an_entry(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "kept-plugin")
(catalog / "removed.yaml").write_text(
"removed:\n - name: evil-plugin\n repo: https://github.com/evil/x\n"
' reason: "bad"\n date: "2026-07-02"\n',
encoding="utf-8",
)
entries = mod.load_catalog_entries(catalog)
assert [e["name"] for e in entries] == ["kept-plugin"]
assert mod.count_removed(catalog) == 1
def test_unknown_tier_normalizes_to_community(mod, tmp_path):
catalog = tmp_path / "plugin-catalog"
catalog.mkdir()
_write_entry(catalog, "weird-tier", tier="platinum")
entries = mod.load_catalog_entries(catalog)
assert entries[0]["tier"] == "community"
# --------------------------------------------------------------------------
# Full run: outputs + graceful degradation
# --------------------------------------------------------------------------
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")
(catalog / "removed.yaml").write_text(
"removed:\n - name: gone\n", encoding="utf-8"
)
out_dir = tmp_path / "api"
rc = mod.main(catalog_dir=catalog, output_dir=out_dir)
assert rc == 0
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
assert [p["name"] for p in plugins] == ["alpha", "beta"]
assert meta["total"] == 2
assert meta["byTier"] == {"official": 1, "community": 1}
assert meta["removedCount"] == 1
assert meta["generatedAt"]
def test_missing_catalog_dir_degrades_to_empty_outputs_exit_zero(mod, tmp_path):
out_dir = tmp_path / "api"
rc = mod.main(catalog_dir=tmp_path / "does-not-exist", output_dir=out_dir)
assert rc == 0
plugins = json.loads((out_dir / "plugins.json").read_text(encoding="utf-8"))
meta = json.loads((out_dir / "plugins-meta.json").read_text(encoding="utf-8"))
assert plugins == []
assert meta["total"] == 0
assert meta["byTier"] == {"official": 0, "community": 0}
assert meta["removedCount"] == 0
def test_script_exits_zero_as_subprocess_when_catalog_missing(tmp_path):
"""CLI contract: the deploy step runs the script hard (no `|| true`);
it must exit 0 even when plugin-catalog/ hasn't landed yet."""
out_dir = tmp_path / "api"
result = subprocess.run(
[
sys.executable,
str(EXTRACT),
"--catalog-dir",
str(tmp_path / "missing"),
"--output-dir",
str(out_dir),
],
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
assert (out_dir / "plugins.json").exists()
assert (out_dir / "plugins-meta.json").exists()

View file

@ -0,0 +1,120 @@
---
sidebar_position: 13
sidebar_label: "Plugin Catalog"
title: "Plugin Catalog"
description: "Browse and install reviewed, SHA-pinned Hermes plugins from the curated catalog"
---
# Plugin Catalog
The plugin catalog is a curated, human-reviewed directory of Hermes plugins you
can install by name with a single command:
```bash
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.
The catalog complements — it does not replace — the existing
[plugin system](plugins.md). Anything you can install from the catalog is a
normal plugin under the hood; the catalog just adds discovery and a review
layer on top.
## What's in an entry
Each catalog entry is a small YAML file in the
[`plugin-catalog/`](https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog)
directory of the hermes-agent repository, declaring:
| Field | Meaning |
|---|---|
| `name` | The catalog key you pass to `hermes plugins install` |
| `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` |
| `maintainer` | Who owns the plugin |
| `capabilities` | Declared tools, hooks, middleware, and required env vars |
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) |
| `platforms` | OS restrictions, empty = all (optional) |
| `docs_url` | External documentation link (optional) |
## Trust model
The catalog is designed so you know exactly what you're installing:
- **Human-merged admission.** Every entry (and every pin update) lands via a
pull request reviewed by a maintainer. Nothing enters the catalog
automatically.
- **Exact SHA pins.** Entries pin a specific commit, not a branch. A plugin
author pushing new code to their repo does **not** change what the catalog
installs — updating the pin requires another reviewed PR.
- **Capability declarations.** Entries state up front which tools, hooks, and
middleware the plugin provides and which environment variables (API keys
etc.) it needs, so you can judge its blast radius before installing.
- **Removed list.** Plugins pulled from the catalog (for example after a
security incident) go on `plugin-catalog/removed.yaml` with a reason and
date. The installer refuses to install anything on the removed list.
- **Installed ≠ enabled.** Installing a catalog plugin puts it on disk; like
any plugin it must still be enabled before it loads. See
[Plugins → Enabling and disabling](plugins.md).
:::warning Catalog review is a point-in-time review
A catalog entry means the pinned commit was looked at by a human, capability
declarations were checked, and the repo met the submission bar. It is not a
security audit, and it says nothing about other commits in the same
repository. Review the code of anything you give credentials to.
:::
## Installing from the catalog
```bash
# Install a reviewed catalog entry by name (checks out the pinned SHA)
hermes plugins install <name>
# Then enable it, as with any plugin
hermes plugins enable <name>
```
The install prompt shows the entry's capability summary — declared tools,
hooks, and required env vars — before anything is cloned.
### Custom git URLs are different
`hermes plugins install <git-url>` still works for any repository, but it
bypasses the catalog entirely:
- **No review** — you get whatever is at the branch tip, not a reviewed pin.
- **A warning banner** is shown to make clear the code is unvetted.
- The removed list is still consulted (a known-bad repo is refused by URL).
Use the git-URL path for your own plugins and repos you already trust; use the
catalog for discovery.
## Submitting a plugin to the catalog
Submissions are pull requests that add one `plugin-catalog/<name>.yaml` file.
The full checklist lives in the
[plugin-catalog README](https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog);
in short, an entry must be:
1. **Owner-submitted** — the PR author owns or maintains the plugin repo.
2. **A public repository** — the `repo` URL is publicly cloneable.
3. **Released** — the repo has real releases/tags, not just a default branch.
4. **Passing validation** — the catalog validation GitHub Action is green on
the PR (schema, SHA format, reachability).
5. **Pinned to settled code** — the pinned SHA is at least **2 weeks old**, so
the catalog never points at code pushed moments before review.
Pin updates (bumping `sha` to a newer commit) follow the same PR + review
process.
## See also
- [Plugins](plugins.md) — the plugin system itself: manifest format, enabling,
configuration
- [Built-in Plugins](built-in-plugins.md) — plugins that ship with Hermes
- [Build a Hermes Plugin](/developer-guide/plugins) — write your own
- [Plugin Catalog page](/plugins) — the browsable catalog

View file

@ -144,6 +144,11 @@ const config: Config = {
label: 'Skills',
position: 'left',
},
{
to: '/plugins',
label: 'Plugins',
position: 'left',
},
{
href: 'https://hermes-agent.nousresearch.com/',
label: 'Download',

View file

@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Extract plugin-catalog entries into website/static/api/plugins.json.
Feeds the Plugin Catalog page at /docs/plugins (website/src/pages/plugins/).
Data source: ``plugin-catalog/*.yaml`` at the repo root one YAML file per
catalog entry (see plugin-catalog/README.md for the entry schema), plus
``plugin-catalog/removed.yaml`` listing plugins pulled from the catalog.
No network, no crawling: the catalog is human-merged data in the checkout.
Graceful degradation: when ``plugin-catalog/`` does not exist yet (the
catalog PR may not have merged), we emit an EMPTY catalog list and zeroed
meta counts and exit 0 so the docs build stays green. The page renders a
"catalog is just getting started" state.
Outputs (both under website/static/api/, CDN-served at /docs/api/):
- ``plugins.json`` list of catalog entries for the page
- ``plugins-meta.json`` counts by tier + generatedAt + removedCount
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CATALOG_DIR = REPO_ROOT / "plugin-catalog"
DEFAULT_OUTPUT_DIR = REPO_ROOT / "website" / "static" / "api"
CATALOG_TIERS = ("official", "community")
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
def _log(msg: str) -> None:
print(f"[extract-plugins] {msg}", file=sys.stderr)
def _str_list(value) -> list[str]:
if isinstance(value, str):
return [value] if value.strip() else []
if isinstance(value, list):
return [str(x) for x in value if x]
return []
def _normalize_capabilities(raw) -> dict:
raw = raw if isinstance(raw, dict) else {}
return {
"providesTools": _str_list(raw.get("provides_tools")),
"providesHooks": _str_list(raw.get("provides_hooks")),
"providesMiddleware": _str_list(raw.get("provides_middleware")),
"requiresEnv": _str_list(raw.get("requires_env")),
}
def load_catalog_entries(catalog_dir: Path) -> list[dict]:
"""Parse all ``*.yaml`` files (except removed.yaml) into page entries.
Entries missing any of name/repo/sha are skipped with a stderr log
a malformed community entry must never break the docs deploy.
"""
entries: list[dict] = []
if not catalog_dir.is_dir():
return entries
for path in sorted(catalog_dir.glob("*.yaml")):
if path.name == "removed.yaml":
continue
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError) as e:
_log(f"skipping {path.name}: unreadable YAML ({e})")
continue
if not isinstance(raw, dict):
_log(f"skipping {path.name}: not a mapping")
continue
name = str(raw.get("name") or "").strip()
repo = str(raw.get("repo") or "").strip()
sha = str(raw.get("sha") or "").strip().lower()
missing = [
field
for field, value in (("name", name), ("repo", repo), ("sha", sha))
if not value
]
if missing:
_log(f"skipping {path.name}: missing required field(s) {', '.join(missing)}")
continue
if not SHA_RE.match(sha):
_log(f"skipping {path.name} ({name}): sha is not a 40-hex commit pin")
continue
tier = str(raw.get("tier") or "community").strip().lower()
if tier not in CATALOG_TIERS:
_log(f"{path.name} ({name}): unknown tier {tier!r}, treating as community")
tier = "community"
entries.append({
"name": name,
"description": str(raw.get("description") or "").strip(),
"repo": repo,
"sha": sha,
"shaShort": sha[:7],
"tier": tier,
"maintainer": str(raw.get("maintainer") or "").strip(),
"requiresHermes": str(raw.get("requires_hermes") or "").strip(),
"platforms": _str_list(raw.get("platforms")),
"capabilities": _normalize_capabilities(raw.get("capabilities")),
"docsUrl": str(raw.get("docs_url") or "").strip(),
"installCommand": f"hermes plugins install {name}",
})
entries.sort(key=lambda e: (0 if e["tier"] == "official" else 1, e["name"]))
return entries
def count_removed(catalog_dir: Path) -> int:
"""Number of entries in plugin-catalog/removed.yaml (``removed:`` list)."""
removed_path = catalog_dir / "removed.yaml"
if not removed_path.is_file():
return 0
try:
raw = yaml.safe_load(removed_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError) as e:
_log(f"could not read removed.yaml: {e}")
return 0
if not isinstance(raw, dict):
return 0
removed = raw.get("removed")
return len(removed) if isinstance(removed, list) else 0
def main(catalog_dir: Path = DEFAULT_CATALOG_DIR, output_dir: Path = DEFAULT_OUTPUT_DIR) -> int:
if not catalog_dir.is_dir():
_log(
f"plugin-catalog directory not found at {catalog_dir}; "
"emitting empty catalog (this is expected until the catalog lands)"
)
entries = load_catalog_entries(catalog_dir)
removed_count = count_removed(catalog_dir)
by_tier = Counter(e["tier"] 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},
"removedCount": removed_count,
}
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_dir / "plugins.json", "w", encoding="utf-8") as f:
json.dump(entries, f, separators=(",", ":"), ensure_ascii=False)
with open(output_dir / "plugins-meta.json", "w", encoding="utf-8") as f:
json.dump(meta, f, separators=(",", ":"), ensure_ascii=False)
print(
f"Extracted {len(entries)} plugin catalog entries "
f"({meta['byTier']['official']} official, {meta['byTier']['community']} community, "
f"{removed_count} removed) to {output_dir / 'plugins.json'}"
)
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog-dir", type=Path, default=DEFAULT_CATALOG_DIR)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
args = parser.parse_args()
sys.exit(main(catalog_dir=args.catalog_dir, output_dir=args.output_dir))

View file

@ -32,7 +32,10 @@ const websiteDir = resolve(scriptDir, "..");
const extractScript = join(scriptDir, "extract-skills.py");
const llmsScript = join(scriptDir, "generate-llms-txt.py");
const cronBlueprintsScript = join(scriptDir, "extract-automation-blueprints.py");
const pluginsScript = join(scriptDir, "extract-plugins.py");
const outputFile = join(websiteDir, "static", "api", "skills.json");
const pluginsOutputFile = join(websiteDir, "static", "api", "plugins.json");
const pluginsMetaOutputFile = join(websiteDir, "static", "api", "plugins-meta.json");
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
const UNIFIED_INDEX_URL =
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json";
@ -143,3 +146,22 @@ runPython(llmsScript, "generate-llms-txt.py");
// 3) automation-blueprints-index.json — Automation Blueprints catalog page. Non-fatal; the page
// renders an empty state if the generator can't run.
runPython(cronBlueprintsScript, "extract-automation-blueprints.py");
// 4) plugins.json + plugins-meta.json — Plugin Catalog page. The script itself
// degrades gracefully (empty catalog, exit 0) when plugin-catalog/ is absent;
// if python3 is missing entirely, write the same empty fallback so the page
// renders its "just getting started" state instead of a fetch error.
if (!runPython(pluginsScript, "extract-plugins.py")) {
mkdirSync(dirname(pluginsOutputFile), { recursive: true });
writeFileSync(pluginsOutputFile, "[]\n");
writeFileSync(
pluginsMetaOutputFile,
JSON.stringify({
generatedAt: new Date().toISOString(),
total: 0,
byTier: { official: 0, community: 0 },
removedCount: 0,
}) + "\n",
);
console.warn("[prebuild] wrote empty plugins.json fallback");
}

View file

@ -76,6 +76,7 @@ const sidebars: SidebarsConfig = {
'user-guide/features/skins',
'user-guide/features/plugins',
'user-guide/features/built-in-plugins',
'user-guide/features/plugin-catalog',
],
},
{

View file

@ -0,0 +1,576 @@
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import styles from "./styles.module.css";
interface PluginCapabilities {
providesTools?: string[];
providesHooks?: string[];
providesMiddleware?: string[];
requiresEnv?: string[];
}
interface CatalogPlugin {
name: string;
description: string;
repo: string;
sha: string;
shaShort: string;
tier: string;
maintainer: string;
requiresHermes?: string;
platforms?: string[];
capabilities?: PluginCapabilities;
docsUrl?: string;
installCommand: string;
/** Lowercase pre-joined haystack for the search filter (built at load). */
_search?: string;
}
interface CatalogMeta {
generatedAt?: string;
total?: number;
byTier?: Record<string, number>;
removedCount?: number;
}
// Routes Docusaurus serves the static API JSON from. `baseUrl` is `/docs/`,
// `static/api/` ends up at `/docs/api/` — same pattern as the Skills Hub.
const PLUGINS_URL = "/docs/api/plugins.json";
const META_URL = "/docs/api/plugins-meta.json";
const CATALOG_README_URL =
"https://github.com/NousResearch/hermes-agent/tree/main/plugin-catalog";
const TIER_CONFIG: Record<
string,
{ label: string; color: string; bg: string; border: string; icon: string }
> = {
official: {
label: "Official",
color: "#ffd700",
bg: "rgba(255, 215, 0, 0.08)",
border: "rgba(255, 215, 0, 0.25)",
icon: "\u{2713}",
},
community: {
label: "Community",
color: "#94a3b8",
bg: "rgba(148, 163, 184, 0.08)",
border: "rgba(148, 163, 184, 0.2)",
icon: "\u{2756}",
},
};
const TIER_ORDER = ["all", "official", "community"];
function formatRelativeTime(iso?: string): string | null {
if (!iso) return null;
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) return null;
const diffMs = Date.now() - then;
if (diffMs < 0) return "just now";
const mins = Math.floor(diffMs / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
const months = Math.floor(days / 30);
return `${months} month${months === 1 ? "" : "s"} ago`;
}
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query || !text) return text;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className={styles.highlight}>{text.slice(idx, idx + query.length)}</mark>
{text.slice(idx + query.length)}
</>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard?.writeText(text).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
() => {},
);
},
[text],
);
return (
<button
className={styles.copyBtn}
onClick={onCopy}
title="Copy install command"
aria-label="Copy install command"
>
{copied ? (
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path
fillRule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clipRule="evenodd"
/>
</svg>
) : (
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14">
<path d="M7 3.5A1.5 1.5 0 018.5 2h3.879a1.5 1.5 0 011.06.44l3.122 3.12A1.5 1.5 0 0117 6.622V12.5a1.5 1.5 0 01-1.5 1.5h-1v-3.379a3 3 0 00-.879-2.121L10.5 5.379A3 3 0 008.379 4.5H7v-1z" />
<path d="M4.5 6A1.5 1.5 0 003 7.5v9A1.5 1.5 0 004.5 18h7a1.5 1.5 0 001.5-1.5v-5.879a1.5 1.5 0 00-.44-1.06L9.44 6.439A1.5 1.5 0 008.378 6H4.5z" />
</svg>
)}
<span className={styles.copyBtnLabel}>{copied ? "Copied" : "Copy"}</span>
</button>
);
}
function PluginCard({
plugin,
query,
expanded,
onToggle,
style,
}: {
plugin: CatalogPlugin;
query: string;
expanded: boolean;
onToggle: () => void;
style?: React.CSSProperties;
}) {
const tier = TIER_CONFIG[plugin.tier] || TIER_CONFIG.community;
const caps = plugin.capabilities || {};
const toolCount = caps.providesTools?.length || 0;
const hookCount = caps.providesHooks?.length || 0;
const middlewareCount = caps.providesMiddleware?.length || 0;
const pinUrl = `${plugin.repo.replace(/\.git$/, "").replace(/\/$/, "")}/tree/${plugin.sha}`;
return (
<div
className={`${styles.card} ${expanded ? styles.cardExpanded : ""}`}
onClick={onToggle}
style={style}
>
<div className={styles.cardAccent} style={{ background: tier.color }} />
<div className={styles.cardInner}>
<div className={styles.cardTop}>
<span className={styles.cardIcon}>{"\u{1F50C}"}</span>
<div className={styles.cardTitleGroup}>
<h3 className={styles.cardTitle}>{highlightMatch(plugin.name, query)}</h3>
<span
className={styles.tierPill}
style={{
color: tier.color,
background: tier.bg,
borderColor: tier.border,
}}
>
{tier.icon} {tier.label}
</span>
</div>
</div>
<p className={`${styles.cardDesc} ${expanded ? styles.cardDescFull : ""}`}>
{highlightMatch(plugin.description || "No description available.", query)}
</p>
<div className={styles.cardMeta}>
{toolCount > 0 && (
<span className={styles.capChip}>
{toolCount} tool{toolCount === 1 ? "" : "s"}
</span>
)}
{hookCount > 0 && (
<span className={styles.capChip}>
{hookCount} hook{hookCount === 1 ? "" : "s"}
</span>
)}
{middlewareCount > 0 && (
<span className={styles.capChip}>
{middlewareCount} middleware
</span>
)}
{caps.requiresEnv?.map((v) => (
<code key={v} className={styles.envChip}>
{v}
</code>
))}
{plugin.platforms?.map((p) => (
<span key={p} className={styles.platformPill}>
{p === "macos" ? "\u{F8FF} macOS" : p === "linux" ? "\u{1F427} Linux" : p}
</span>
))}
</div>
{expanded && (
<div className={styles.cardDetail}>
{plugin.maintainer && (
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Maintainer</span>
<span className={styles.metaValue}>{plugin.maintainer}</span>
</div>
)}
{plugin.requiresHermes && (
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Requires</span>
<span className={styles.metaValue}>
<code>hermes {plugin.requiresHermes}</code>
</span>
</div>
)}
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Pinned</span>
<span className={styles.metaValue}>
<a
href={pinUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={styles.shaLink}
title={plugin.sha}
>
<code>{plugin.shaShort}</code>
</a>
</span>
</div>
{caps.providesTools?.length ? (
<div className={styles.metaRow}>
<span className={styles.metaLabel}>Tools</span>
<span className={styles.chipList}>
{caps.providesTools.map((t) => (
<code key={t} className={styles.envChip}>
{t}
</code>
))}
</span>
</div>
) : null}
<div className={styles.installHint}>
<code>{plugin.installCommand}</code>
<CopyButton text={plugin.installCommand} />
</div>
<div className={styles.cardLinks}>
<a
className={styles.docsLink}
href={plugin.repo}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Repository
</a>
{plugin.docsUrl ? (
<a
className={styles.docsLink}
href={plugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Documentation
</a>
) : null}
</div>
</div>
)}
</div>
</div>
);
}
function StatCard({ value, label, color }: { value: number; label: string; color: string }) {
return (
<div className={styles.stat}>
<span className={styles.statValue} style={{ color }}>
{value}
</span>
<span className={styles.statLabel}>{label}</span>
</div>
);
}
function buildSearchHaystack(p: CatalogPlugin): string {
return [
p.name,
p.description,
p.maintainer,
p.tier,
...(p.capabilities?.providesTools || []),
...(p.capabilities?.providesHooks || []),
...(p.capabilities?.requiresEnv || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
export default function PluginCatalogPage() {
const [data, setData] = useState<{ plugins: CatalogPlugin[]; meta: CatalogMeta } | null>(
null,
);
const [loadError, setLoadError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState("all");
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [pl, mt] = await Promise.all([
fetch(PLUGINS_URL).then((r) => {
if (!r.ok) throw new Error(`plugins.json HTTP ${r.status}`);
return r.json();
}),
fetch(META_URL).then((r) => (r.ok ? r.json() : {})).catch(() => ({})),
]);
if (cancelled) return;
const arr = Array.isArray(pl) ? (pl as CatalogPlugin[]) : [];
for (const p of arr) p._search = buildSearchHaystack(p);
setData({ plugins: arr, meta: mt || {} });
} catch (err) {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : String(err));
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
e.preventDefault();
searchRef.current?.focus();
}
if (e.key === "Escape") {
searchRef.current?.blur();
setExpandedCard(null);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const allPlugins: CatalogPlugin[] = data?.plugins ?? [];
const meta: CatalogMeta = data?.meta ?? {};
const filtered = useMemo(() => {
const q = search.toLowerCase().trim();
return allPlugins.filter((p) => {
if (tierFilter !== "all" && p.tier !== tierFilter) return false;
if (q) return (p._search || "").includes(q);
return true;
});
}, [search, tierFilter, allPlugins]);
useEffect(() => {
setExpandedCard(null);
}, [search, tierFilter]);
const clearAll = useCallback(() => {
setSearch("");
setTierFilter("all");
}, []);
const catalogEmpty = data !== null && allPlugins.length === 0;
return (
<Layout
title="Plugin Catalog"
description="Browse reviewed, SHA-pinned plugins for Hermes Agent"
>
<div className={styles.page}>
<header className={styles.hero}>
<div className={styles.heroGlow} />
<div className={styles.heroContent}>
<p className={styles.heroEyebrow}>Hermes Agent</p>
<h1 className={styles.heroTitle}>Plugin Catalog</h1>
<nav className={styles.crossNav} aria-label="Catalog pages">
<Link className={styles.crossNavLink} to="/skills">
Skills
</Link>
<span className={`${styles.crossNavLink} ${styles.crossNavActive}`}>
Plugins
</span>
</nav>
<p className={styles.heroSub}>
Reviewed, SHA-pinned plugins you can install with one command.
{loadError && (
<span style={{ color: "#f87171", marginLeft: 8 }}>
· failed to load catalog ({loadError})
</span>
)}
</p>
{meta.generatedAt && !catalogEmpty && (
<p className={styles.heroSub} style={{ fontSize: "0.85rem", opacity: 0.75 }}>
Catalog refreshed{" "}
<span title={meta.generatedAt}>
{formatRelativeTime(meta.generatedAt) || "recently"}
</span>
</p>
)}
{!catalogEmpty && (
<div className={styles.statsRow}>
<StatCard
value={allPlugins.filter((p) => p.tier === "official").length}
label="Official"
color="#ffd700"
/>
<StatCard
value={allPlugins.filter((p) => p.tier === "community").length}
label="Community"
color="#94a3b8"
/>
<StatCard value={meta.removedCount ?? 0} label="Removed" color="#f87171" />
</div>
)}
</div>
</header>
{!catalogEmpty && (
<div className={styles.controlsBar}>
<div className={styles.searchWrap}>
<svg
className={styles.searchIcon}
viewBox="0 0 20 20"
fill="currentColor"
width="18"
height="18"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
<input
ref={searchRef}
type="text"
placeholder='Search plugins... (press "/" to focus)'
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
{search && (
<button className={styles.clearBtn} onClick={() => setSearch("")}>
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
<div className={styles.tierPills}>
{TIER_ORDER.map((tier) => {
const active = tierFilter === tier;
const conf = TIER_CONFIG[tier];
const count =
tier === "all"
? allPlugins.length
: allPlugins.filter((p) => p.tier === tier).length;
return (
<button
key={tier}
className={`${styles.tierBtn} ${active ? styles.tierBtnActive : ""}`}
onClick={() => setTierFilter(tier)}
style={
active && conf
? ({
"--pill-color": conf.color,
"--pill-bg": conf.bg,
"--pill-border": conf.border,
} as React.CSSProperties)
: undefined
}
>
{tier === "all" ? "All" : conf?.label || tier}
<span className={styles.tierCount}>{count}</span>
</button>
);
})}
</div>
</div>
)}
<main className={styles.main}>
{!data && !loadError ? (
<div className={styles.empty}>
<div className={styles.loadingSpinner} />
<h3 className={styles.emptyTitle}>Loading the catalog</h3>
</div>
) : catalogEmpty ? (
<div className={styles.empty}>
<div className={styles.emptyIcon}>{"\u{1F331}"}</div>
<h3 className={styles.emptyTitle}>The catalog is just getting started</h3>
<p className={styles.emptyDesc}>
The plugin catalog is a curated, human-reviewed list of Hermes
plugins each entry pinned to an exact commit. Want yours listed?
Submissions are open.
</p>
<div className={styles.emptyActions}>
<a
className={styles.emptyCta}
href={CATALOG_README_URL}
target="_blank"
rel="noopener noreferrer"
>
How to submit a plugin
</a>
<Link className={styles.emptyCtaSecondary} to="/user-guide/features/plugin-catalog">
Read the catalog docs
</Link>
</div>
</div>
) : 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)}
style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }}
/>
);
})}
</div>
) : (
<div className={styles.empty}>
<div className={styles.emptyIcon}>{"\u{1F50D}"}</div>
<h3 className={styles.emptyTitle}>No plugins found</h3>
<p className={styles.emptyDesc}>
Try a different search term or clear your filters.
</p>
<button className={styles.emptyReset} onClick={clearAll}>
Reset all filters
</button>
</div>
)}
</main>
</div>
</Layout>
);
}

View file

@ -0,0 +1,691 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
.page {
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, sans-serif;
min-height: 100vh;
}
.hero {
position: relative;
overflow: hidden;
padding: 4rem 2rem 2.5rem;
text-align: center;
}
.heroGlow {
position: absolute;
top: -120px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 400px;
background: radial-gradient(
ellipse at center,
rgba(255, 215, 0, 0.07) 0%,
transparent 70%
);
pointer-events: none;
}
.heroContent {
position: relative;
z-index: 1;
max-width: 720px;
margin: 0 auto;
}
.heroEyebrow {
font-family: "JetBrains Mono", monospace;
font-size: 0.75rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(255, 215, 0, 0.5);
margin-bottom: 0.75rem;
}
.heroTitle {
font-size: 3rem;
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.1;
margin: 0 0 0.75rem;
}
[data-theme="dark"] .heroTitle {
color: #fafaf6;
}
.heroSub {
font-size: 1.05rem;
color: var(--ifm-font-color-secondary, #9a968e);
line-height: 1.5;
margin: 0 0 1.5rem;
}
/* Cross-nav between the Skills Hub and Plugin Catalog pages. */
.crossNav {
display: inline-flex;
gap: 0.35rem;
margin: 0 0 1.25rem;
padding: 0.25rem;
border: 1px solid rgba(255, 215, 0, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.crossNavLink {
font-family: "DM Sans", sans-serif;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3rem 0.9rem;
border-radius: 7px;
color: var(--ifm-font-color-secondary, #9a968e);
text-decoration: none;
transition: all 0.15s;
}
.crossNavLink:hover {
color: #ffd700;
text-decoration: none;
}
.crossNavActive {
background: rgba(255, 215, 0, 0.08);
color: #ffd700;
}
.statsRow {
display: flex;
justify-content: center;
gap: 2.5rem;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.statValue {
font-family: "JetBrains Mono", monospace;
font-size: 1.6rem;
font-weight: 700;
line-height: 1;
}
.statLabel {
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ifm-font-color-secondary, #9a968e);
}
.controlsBar {
position: sticky;
top: 60px; /* below Docusaurus navbar */
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
padding: 1rem 2rem;
backdrop-filter: blur(16px) saturate(1.4);
border-bottom: 1px solid rgba(255, 215, 0, 0.06);
}
[data-theme="dark"] .controlsBar {
background: rgba(7, 7, 13, 0.85);
}
.searchWrap {
position: relative;
width: 100%;
max-width: 560px;
}
.searchIcon {
position: absolute;
left: 0.85rem;
top: 50%;
transform: translateY(-50%);
color: rgba(255, 215, 0, 0.35);
pointer-events: none;
}
.searchInput {
width: 100%;
padding: 0.7rem 2.5rem 0.7rem 2.6rem;
font-size: 0.95rem;
font-family: "DM Sans", sans-serif;
border: 1px solid rgba(255, 215, 0, 0.12);
border-radius: 10px;
background: rgba(15, 15, 24, 0.6);
color: var(--ifm-font-color-base, #e8e4dc);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.searchInput:focus {
border-color: rgba(255, 215, 0, 0.4);
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.06);
}
.searchInput::placeholder {
color: var(--ifm-font-color-secondary, #9a968e);
opacity: 0.5;
}
.clearBtn {
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--ifm-font-color-secondary);
cursor: pointer;
padding: 0.15rem;
display: flex;
opacity: 0.6;
transition: opacity 0.15s;
}
.clearBtn:hover {
opacity: 1;
color: #ffd700;
}
/* Tier tabs: All / Official / Community (skills page's source-pill pattern). */
.tierPills {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
}
.tierBtn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
background: transparent;
color: var(--ifm-font-color-secondary, #9a968e);
font-family: "DM Sans", sans-serif;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.tierBtn:hover {
border-color: rgba(255, 255, 255, 0.15);
color: var(--ifm-font-color-base);
}
.tierBtnActive {
border-color: var(--pill-border, rgba(255, 215, 0, 0.3));
background: var(--pill-bg, rgba(255, 215, 0, 0.06));
color: var(--pill-color, #ffd700);
}
.tierCount {
font-family: "JetBrains Mono", monospace;
font-size: 0.68rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.05rem 0.35rem;
border-radius: 8px;
}
.tierBtnActive .tierCount {
background: rgba(255, 255, 255, 0.08);
}
.main {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 2rem 3rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 0.75rem;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
animation: cardIn 0.35s ease both;
}
[data-theme="dark"] .card {
background: #0c0c16;
}
.card:hover {
border-color: rgba(255, 215, 0, 0.15);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 215, 0, 0.05);
transform: translateY(-1px);
}
.cardExpanded {
border-color: rgba(255, 215, 0, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 215, 0, 0.08);
}
.cardAccent {
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
opacity: 0.5;
transition: opacity 0.2s;
}
.card:hover .cardAccent {
opacity: 1;
}
.cardInner {
padding: 1rem 1rem 0.85rem 1.15rem;
}
.cardTop {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin-bottom: 0.5rem;
}
.cardIcon {
font-size: 1.15rem;
line-height: 1;
flex-shrink: 0;
margin-top: 0.1rem;
opacity: 0.7;
}
.cardTitleGroup {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.cardTitle {
font-size: 0.92rem;
font-weight: 600;
line-height: 1.3;
margin: 0;
word-break: break-word;
color: var(--ifm-font-color-base);
}
.tierPill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.62rem;
font-weight: 500;
padding: 0.15rem 0.45rem;
border-radius: 4px;
border: 1px solid;
white-space: nowrap;
flex-shrink: 0;
margin-top: 0.1rem;
}
.cardDesc {
font-size: 0.82rem;
line-height: 1.55;
color: var(--ifm-font-color-secondary, #9a968e);
margin: 0 0 0.6rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cardDescFull {
-webkit-line-clamp: unset;
}
.cardMeta {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
/* Capability chips: tools/hooks/middleware counts. */
.capChip {
font-family: "JetBrains Mono", monospace;
font-size: 0.66rem;
padding: 0.15rem 0.45rem;
border: 1px solid rgba(255, 215, 0, 0.12);
border-radius: 3px;
background: rgba(255, 215, 0, 0.04);
color: rgba(255, 215, 0, 0.7);
}
/* Required env var chips. */
.envChip {
font-family: "JetBrains Mono", monospace;
font-size: 0.66rem;
padding: 0.12rem 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 3px;
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 215, 0, 0.6);
}
.platformPill {
font-size: 0.66rem;
padding: 0.12rem 0.4rem;
border-radius: 3px;
background: rgba(96, 165, 250, 0.06);
color: rgba(96, 165, 250, 0.8);
border: 1px solid rgba(96, 165, 250, 0.1);
}
.cardDetail {
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid rgba(255, 255, 255, 0.04);
animation: cardIn 0.2s ease both;
}
.metaRow {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.metaLabel {
font-family: "JetBrains Mono", monospace;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ifm-font-color-secondary);
opacity: 0.5;
min-width: 4.5rem;
padding-top: 0.15rem;
}
.metaValue {
font-size: 0.78rem;
color: var(--ifm-font-color-base);
}
.metaValue code {
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.05rem 0.3rem;
border-radius: 3px;
}
.chipList {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.shaLink {
color: rgba(96, 165, 250, 0.9);
text-decoration: none;
}
.shaLink:hover {
color: rgba(96, 165, 250, 1);
text-decoration: none;
}
.installHint {
margin-top: 0.65rem;
padding: 0.45rem 0.65rem;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 215, 0, 0.06);
border-radius: 5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.installHint code {
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
color: rgba(255, 215, 0, 0.7);
background: none;
padding: 0;
flex: 1;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
}
.installHint code::-webkit-scrollbar {
display: none;
}
.copyBtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
padding: 0.2rem 0.45rem;
border: 1px solid rgba(255, 215, 0, 0.18);
border-radius: 4px;
background: rgba(255, 215, 0, 0.06);
color: rgba(255, 215, 0, 0.85);
font-size: 0.68rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.copyBtn:hover {
background: rgba(255, 215, 0, 0.14);
color: rgba(255, 215, 0, 1);
}
.copyBtnLabel {
line-height: 1;
}
.cardLinks {
display: flex;
gap: 0.5rem;
}
.cardLinks .docsLink {
flex: 1;
}
.docsLink {
display: block;
margin-top: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid rgba(96, 165, 250, 0.2);
border-radius: 5px;
background: rgba(96, 165, 250, 0.06);
color: rgba(96, 165, 250, 0.9);
font-size: 0.78rem;
text-decoration: none;
text-align: center;
transition: all 0.15s;
}
.docsLink:hover {
background: rgba(96, 165, 250, 0.12);
color: rgba(96, 165, 250, 1);
border-color: rgba(96, 165, 250, 0.35);
text-decoration: none;
}
.highlight {
background: rgba(255, 215, 0, 0.2);
color: #ffd700;
border-radius: 2px;
padding: 0 1px;
}
.loadingSpinner {
width: 2.25rem;
height: 2.25rem;
margin: 0 auto 1rem;
border: 3px solid rgba(255, 215, 0, 0.15);
border-top-color: rgba(255, 215, 0, 0.7);
border-radius: 50%;
animation: pluginsSpin 0.8s linear infinite;
}
@keyframes pluginsSpin {
to {
transform: rotate(360deg);
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5rem 2rem;
text-align: center;
}
.emptyIcon {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.6;
}
.emptyTitle {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: var(--ifm-font-color-base);
}
.emptyDesc {
font-size: 0.85rem;
color: var(--ifm-font-color-secondary);
margin: 0 0 1.25rem;
max-width: 480px;
line-height: 1.6;
}
.emptyActions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
justify-content: center;
}
.emptyCta {
font-family: "DM Sans", sans-serif;
font-size: 0.85rem;
font-weight: 600;
padding: 0.55rem 1.25rem;
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 6px;
background: rgba(255, 215, 0, 0.06);
color: #ffd700;
text-decoration: none;
transition: all 0.2s;
}
.emptyCta:hover {
background: rgba(255, 215, 0, 0.12);
color: #ffd700;
text-decoration: none;
}
.emptyCtaSecondary {
font-family: "DM Sans", sans-serif;
font-size: 0.85rem;
padding: 0.55rem 1.25rem;
border: 1px solid rgba(96, 165, 250, 0.25);
border-radius: 6px;
background: rgba(96, 165, 250, 0.05);
color: rgba(96, 165, 250, 0.9);
text-decoration: none;
transition: all 0.2s;
}
.emptyCtaSecondary:hover {
background: rgba(96, 165, 250, 0.1);
color: rgba(96, 165, 250, 1);
text-decoration: none;
}
.emptyReset {
font-family: "DM Sans", sans-serif;
font-size: 0.85rem;
padding: 0.5rem 1.25rem;
border: 1px solid rgba(255, 215, 0, 0.25);
border-radius: 6px;
background: transparent;
color: #ffd700;
cursor: pointer;
transition: all 0.2s;
}
.emptyReset:hover {
background: rgba(255, 215, 0, 0.08);
}
@media (max-width: 900px) {
.hero {
padding: 2.5rem 1.25rem 1.75rem;
}
.heroTitle {
font-size: 2rem;
}
.statsRow {
gap: 1.5rem;
}
.statValue {
font-size: 1.25rem;
}
.controlsBar {
padding: 0.75rem 1rem;
}
.main {
padding: 0.75rem 1rem 2rem;
}
.grid {
grid-template-columns: 1fr;
}
}

View file

@ -1,5 +1,6 @@
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import styles from "./styles.module.css";
interface Skill {
@ -650,6 +651,14 @@ export default function SkillsDashboard() {
<div className={styles.heroContent}>
<p className={styles.heroEyebrow}>Hermes Agent</p>
<h1 className={styles.heroTitle}>Skills Hub</h1>
<nav className={styles.crossNav} aria-label="Catalog pages">
<span className={`${styles.crossNavLink} ${styles.crossNavActive}`}>
Skills
</span>
<Link className={styles.crossNavLink} to="/plugins">
Plugins
</Link>
</nav>
<p className={styles.heroSub}>
Discover, search, and install from{" "}
<strong className={styles.heroAccent}>

View file

@ -69,6 +69,38 @@
font-variant-numeric: tabular-nums;
}
/* Cross-nav between the Skills Hub and Plugin Catalog pages. */
.crossNav {
display: inline-flex;
gap: 0.35rem;
margin: 0 0 1.25rem;
padding: 0.25rem;
border: 1px solid rgba(255, 215, 0, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.crossNavLink {
font-family: "DM Sans", sans-serif;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3rem 0.9rem;
border-radius: 7px;
color: var(--ifm-font-color-secondary, #9a968e);
text-decoration: none;
transition: all 0.15s;
}
.crossNavLink:hover {
color: #ffd700;
text-decoration: none;
}
.crossNavActive {
background: rgba(255, 215, 0, 0.08);
color: #ffd700;
}
.statsRow {
display: flex;
justify-content: center;