mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +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
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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue