diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index fd09205a055c..1fc7a39b27a4 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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 diff --git a/.gitignore b/.gitignore index b5d594e19430..42ce66680b68 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/tests/website/test_extract_plugins.py b/tests/website/test_extract_plugins.py new file mode 100644 index 000000000000..6edb3c1242e4 --- /dev/null +++ b/tests/website/test_extract_plugins.py @@ -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 `` 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() diff --git a/website/docs/user-guide/features/plugin-catalog.md b/website/docs/user-guide/features/plugin-catalog.md new file mode 100644 index 000000000000..d443a014d02c --- /dev/null +++ b/website/docs/user-guide/features/plugin-catalog.md @@ -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 +``` + +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 + +# Then enable it, as with any plugin +hermes plugins enable +``` + +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 ` 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/.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 diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 1c85d7693a4d..4630d0afcdd7 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -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', diff --git a/website/scripts/extract-plugins.py b/website/scripts/extract-plugins.py new file mode 100644 index 000000000000..72848de76506 --- /dev/null +++ b/website/scripts/extract-plugins.py @@ -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)) diff --git a/website/scripts/prebuild.mjs b/website/scripts/prebuild.mjs index b873a9b20883..44ab20795871 100644 --- a/website/scripts/prebuild.mjs +++ b/website/scripts/prebuild.mjs @@ -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"); +} diff --git a/website/sidebars.ts b/website/sidebars.ts index 76b948aa725e..4a1bb3ca09da 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -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', ], }, { diff --git a/website/src/pages/plugins/index.tsx b/website/src/pages/plugins/index.tsx new file mode 100644 index 000000000000..f1bfbc80f5f7 --- /dev/null +++ b/website/src/pages/plugins/index.tsx @@ -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; + 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)} + {text.slice(idx, idx + query.length)} + {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 ( + + ); +} + +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 ( +
+
+ +
+
+ {"\u{1F50C}"} +
+

{highlightMatch(plugin.name, query)}

+ + {tier.icon} {tier.label} + +
+
+ +

+ {highlightMatch(plugin.description || "No description available.", query)} +

+ +
+ {toolCount > 0 && ( + + {toolCount} tool{toolCount === 1 ? "" : "s"} + + )} + {hookCount > 0 && ( + + {hookCount} hook{hookCount === 1 ? "" : "s"} + + )} + {middlewareCount > 0 && ( + + {middlewareCount} middleware + + )} + {caps.requiresEnv?.map((v) => ( + + {v} + + ))} + {plugin.platforms?.map((p) => ( + + {p === "macos" ? "\u{F8FF} macOS" : p === "linux" ? "\u{1F427} Linux" : p} + + ))} +
+ + {expanded && ( +
+ {plugin.maintainer && ( +
+ Maintainer + {plugin.maintainer} +
+ )} + {plugin.requiresHermes && ( +
+ Requires + + hermes {plugin.requiresHermes} + +
+ )} + + {caps.providesTools?.length ? ( +
+ Tools + + {caps.providesTools.map((t) => ( + + {t} + + ))} + +
+ ) : null} +
+ {plugin.installCommand} + +
+ +
+ )} +
+
+ ); +} + +function StatCard({ value, label, color }: { value: number; label: string; color: string }) { + return ( +
+ + {value} + + {label} +
+ ); +} + +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(null); + + const [search, setSearch] = useState(""); + const [tierFilter, setTierFilter] = useState("all"); + const [expandedCard, setExpandedCard] = useState(null); + const searchRef = useRef(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 ( + +
+
+
+
+

Hermes Agent

+

Plugin Catalog

+ +

+ Reviewed, SHA-pinned plugins you can install with one command. + {loadError && ( + + · failed to load catalog ({loadError}) + + )} +

+ {meta.generatedAt && !catalogEmpty && ( +

+ Catalog refreshed{" "} + + {formatRelativeTime(meta.generatedAt) || "recently"} + +

+ )} + + {!catalogEmpty && ( +
+ p.tier === "official").length} + label="Official" + color="#ffd700" + /> + p.tier === "community").length} + label="Community" + color="#94a3b8" + /> + +
+ )} +
+
+ + {!catalogEmpty && ( +
+
+ + + + setSearch(e.target.value)} + className={styles.searchInput} + /> + {search && ( + + )} +
+ +
+ {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 ( + + ); + })} +
+
+ )} + +
+ {!data && !loadError ? ( +
+
+

Loading the catalog…

+
+ ) : catalogEmpty ? ( +
+
{"\u{1F331}"}
+

The catalog is just getting started

+

+ 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. +

+
+ + How to submit a plugin ↗ + + + Read the catalog docs + +
+
+ ) : filtered.length > 0 ? ( +
+ {filtered.map((plugin, i) => { + const key = `${plugin.tier}-${plugin.name}`; + return ( + setExpandedCard(expandedCard === key ? null : key)} + style={{ animationDelay: `${Math.min(i, 20) * 25}ms` }} + /> + ); + })} +
+ ) : ( +
+
{"\u{1F50D}"}
+

No plugins found

+

+ Try a different search term or clear your filters. +

+ +
+ )} +
+
+
+ ); +} diff --git a/website/src/pages/plugins/styles.module.css b/website/src/pages/plugins/styles.module.css new file mode 100644 index 000000000000..c7a80fbc6db1 --- /dev/null +++ b/website/src/pages/plugins/styles.module.css @@ -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; + } +} diff --git a/website/src/pages/skills/index.tsx b/website/src/pages/skills/index.tsx index 735ccafb5489..484e3714946d 100644 --- a/website/src/pages/skills/index.tsx +++ b/website/src/pages/skills/index.tsx @@ -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() {

Hermes Agent

Skills Hub

+

Discover, search, and install from{" "} diff --git a/website/src/pages/skills/styles.module.css b/website/src/pages/skills/styles.module.css index 018703c676de..d7b78e35f972 100644 --- a/website/src/pages/skills/styles.module.css +++ b/website/src/pages/skills/styles.module.css @@ -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;