mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
d358280ad7
commit
fb40a768fc
12 changed files with 1836 additions and 0 deletions
3
.github/workflows/deploy-site.yml
vendored
3
.github/workflows/deploy-site.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
195
tests/website/test_extract_plugins.py
Normal file
195
tests/website/test_extract_plugins.py
Normal 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()
|
||||
120
website/docs/user-guide/features/plugin-catalog.md
Normal file
120
website/docs/user-guide/features/plugin-catalog.md
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
178
website/scripts/extract-plugins.py
Normal file
178
website/scripts/extract-plugins.py
Normal 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))
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
576
website/src/pages/plugins/index.tsx
Normal file
576
website/src/pages/plugins/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
691
website/src/pages/plugins/styles.module.css
Normal file
691
website/src/pages/plugins/styles.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue