Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Generate the Automation Blueprints catalog JSON for the docs site.
Mirrors ``extract-skills.py``: imports the single-source-of-truth blueprint
definitions from ``cron/blueprint_catalog.py`` and emits a flat JSON array the
docs page renders into cards (description, schedule, copy-paste slash command,
and a ``hermes://`` "Send to App" deep-link).
Output: ``website/static/api/automation-blueprints-index.json`` (served at
``/docs/api/automation-blueprints-index.json``). Run automatically by
``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Repo root = two levels up from website/scripts/.
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
OUTPUT = REPO_ROOT / "website" / "static" / "api" / "automation-blueprints-index.json"
def build_index() -> list:
from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
return [blueprint_catalog_entry(r) for r in CATALOG]
def main() -> int:
try:
index = build_index()
except Exception as e: # pragma: no cover - import/build failure
# Match extract-skills.py's resilience: write an empty array so the
# docs build never hard-fails on a generator hiccup.
sys.stderr.write(f"extract-automation-blueprints: {e}; writing empty index\n")
index = []
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT, "w", encoding="utf-8") as f:
json.dump(index, f, separators=(",", ":"))
sys.stderr.write(f"extract-automation-blueprints: wrote {len(index)} blueprints -> {OUTPUT}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+683
View File
@@ -0,0 +1,683 @@
#!/usr/bin/env python3
"""Extract skill metadata into website/static/api/skills.json for the Skills Hub page.
Two data sources:
1. Local SKILL.md files under ``skills/`` (built-in) and ``optional-skills/``
(official optional). These give us full metadata — overview prose, version,
license, env vars, commands — that the unified index doesn't carry.
2. The unified Hermes Skills Index at ``website/static/api/skills-index.json``,
built twice daily by ``scripts/build_skills_index.py`` (workflow
``.github/workflows/skills-index.yml``). Covers skills.sh, ClawHub, browse.sh,
LobeHub, Claude Marketplace, well-known endpoints, and the GitHub taps
(openai/skills, anthropics/skills, huggingface/skills, VoltAgent, etc.).
Legacy fallback: if the unified index is missing AND ``skills/index-cache/``
contains pre-baked JSON dumps, we read those (preserves behaviour from before
the unified index existed).
"""
import json
import os
from collections import Counter
from datetime import datetime, timezone
import yaml
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LOCAL_SKILL_DIRS = [
("skills", "built-in"),
("optional-skills", "optional"),
]
UNIFIED_INDEX_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "skills-index.json")
LEGACY_INDEX_CACHE_DIR = os.path.join(REPO_ROOT, "skills", "index-cache")
# Output to static/api/ so the file is CDN-served at /api/skills.json
# rather than bundled into the page's JS chunk. At 50k+ skills the
# bundled payload was ~26 MB; lazy-fetch keeps the initial page load
# fast and shrinks the JS chunk back to a few hundred KB.
OUTPUT = os.path.join(REPO_ROOT, "website", "static", "api", "skills.json")
META_OUTPUT = os.path.join(REPO_ROOT, "website", "static", "api", "skills-meta.json")
CATEGORY_LABELS = {
"apple": "Apple",
"autonomous-ai-agents": "AI Agents",
"blockchain": "Blockchain",
"communication": "Communication",
"creative": "Creative",
"data-science": "Data Science",
"devops": "DevOps",
"dogfood": "Dogfood",
"domain": "Business & Finance",
"email": "Email",
"gaming": "Gaming",
"gifs": "GIFs",
"github": "GitHub",
"health": "Health",
"inference-sh": "Inference",
"leisure": "Leisure",
"mcp": "MCP",
"media": "Media",
"migration": "Migration",
"mlops": "MLOps",
"note-taking": "Note-Taking",
"productivity": "Productivity",
"red-teaming": "Red Teaming",
"research": "Research",
"security": "Security",
"smart-home": "Smart Home",
"social-media": "Social Media",
"software-development": "Software Dev",
"translation": "Translation",
"other": "Other",
}
# Map the source ids the unified index emits to the friendly labels the
# Skills Hub UI uses. Keep these in sync with the SOURCE_CONFIG dict in
# website/src/pages/skills/index.tsx.
UNIFIED_SOURCE_LABELS = {
"official": "official", # treated as our "optional" tier in the UI
"skills.sh": "skills.sh",
"skills-sh": "skills.sh",
"clawhub": "ClawHub",
"browse-sh": "browse.sh",
"lobehub": "LobeHub",
"claude-marketplace": "Claude Marketplace",
"well-known": "Well-Known",
"github": "GitHub", # default for non-named GitHub taps
}
# Repo-specific labels for the unified index's "github" source. Lets us
# call out the well-known taps with their vendor name instead of a generic
# "GitHub" pill. Match is checked against the leading "owner/repo/" prefix
# of the identifier.
GITHUB_TAP_LABELS = {
"openai/skills": "OpenAI",
"anthropics/skills": "Anthropic",
"huggingface/skills": "HuggingFace",
"NVIDIA/skills": "NVIDIA",
"VoltAgent/awesome-agent-skills": "VoltAgent",
"garrytan/gstack": "gstack",
"MiniMax-AI/cli": "MiniMax",
}
# Legacy filename -> label mapping for the deprecated skills/index-cache/
# fallback. Used only when website/static/api/skills-index.json is absent.
LEGACY_SOURCE_LABELS = {
"anthropics_skills": "Anthropic",
"openai_skills": "OpenAI",
"claude_marketplace": "Claude Marketplace",
"lobehub": "LobeHub",
}
def _extract_overview(body: str) -> str:
"""Pull the first non-heading paragraph from a SKILL.md body."""
if not body:
return ""
paragraphs = [p.strip() for p in body.split("\n\n") if p.strip()]
for p in paragraphs[:6]:
if p.startswith("#"):
lines = [ln for ln in p.split("\n") if ln.strip() and not ln.lstrip().startswith("#")]
if lines:
p = "\n".join(lines).strip()
else:
continue
if p.startswith(":::"):
continue
if p.startswith("```") or p.startswith("~~~"):
continue
if len(p) > 500:
cut = p[:500]
last_period = cut.rfind(". ")
if last_period > 200:
p = cut[: last_period + 1]
else:
p = cut.rstrip() + ""
return p
return ""
def _docs_page_path(rel_dir: str, source_label: str) -> str:
"""Compute the per-skill docs-site URL slug for a given SKILL.md location.
Mirrors the slug logic in website/scripts/generate-skill-docs.py:
bundled + skills/<cat>/<slug>/SKILL.md -> bundled/<cat>/<cat>-<slug>
bundled + skills/<cat>/<sub>/<slug>/SKILL.md -> bundled/<cat>/<cat>-<sub>-<slug>
optional + optional-skills/<cat>/<slug>/SKILL.md -> optional/<cat>/<cat>-<slug>
"""
parts = [p for p in rel_dir.split(os.sep) if p]
if not parts:
return ""
source_dir = "bundled" if source_label == "built-in" else "optional"
if len(parts) == 1:
category, slug = parts[0], parts[0]
return f"{source_dir}/{category}/{category}-{slug}"
if len(parts) == 2:
category, slug = parts
return f"{source_dir}/{category}/{category}-{slug}"
if len(parts) == 3:
category, sub, slug = parts
return f"{source_dir}/{category}/{category}-{sub}-{slug}"
return ""
def _install_command(source: str, identifier: str, name: str) -> str:
"""Build the ``hermes skills install …`` command for a unified-index entry.
These show up in the SkillCard panel so users can copy-paste them. We try
to use the most idiomatic identifier per source.
"""
if not identifier:
return f"hermes skills install {name}"
src = source.lower()
if src in {"official", "built-in", "optional"}:
# OptionalSkillSource emits identifiers like "official/security/1password"
return f"hermes skills install {identifier}"
if src in {"skills.sh", "skills-sh"}:
# Already wrapped as "skills-sh/owner/repo/skill" by the source
return f"hermes skills install {identifier}"
if src == "clawhub":
return f"hermes skills install clawhub/{identifier}"
if src == "browse-sh":
# Identifier already includes the "browse-sh/" prefix from BrowseShSource
return f"hermes skills install {identifier}"
if src == "lobehub":
return f"hermes skills install {identifier}"
if src == "claude-marketplace":
return f"hermes skills install {identifier}"
if src == "github":
return f"hermes skills install {identifier}"
if src == "well-known":
return f"hermes skills install {identifier}"
return f"hermes skills install {identifier}"
def _source_url(source: str, identifier: str, extra: dict) -> str:
"""Best-effort clickable URL to the skill's origin (repo / detail page).
Community skills have no generated docs page, so without this the
expanded card on the Skills Hub gives users nowhere to go to read the
actual SKILL.md before installing. We prefer an explicit URL the source
adapter already collected (``extra.detail_url`` / ``extra.repo_url``),
then fall back to synthesizing one from the identifier shape.
"""
extra = extra or {}
for key in ("detail_url", "source_url", "repo_url", "url", "index_url"):
val = extra.get(key)
if isinstance(val, str) and val.startswith("http"):
return val
if not identifier:
return ""
src = (source or "").lower()
# GitHub-backed taps (openai/anthropic/nvidia/hf/gstack/VoltAgent/...):
# identifier is "owner/repo/<path...>" — link to the directory on GitHub.
if src in {"github", "openai", "anthropic", "huggingface", "nvidia",
"gstack", "voltagent", "minimax", "claude marketplace",
"claude-marketplace"}:
parts = [p for p in identifier.split("/") if p]
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
sub = "/".join(parts[2:])
base = f"https://github.com/{owner}/{repo}"
return f"{base}/tree/main/{sub}" if sub else base
return ""
if src == "clawhub":
# identifier is a bare slug (the "clawhub/" prefix is added at install time)
slug = identifier[len("clawhub/"):] if identifier.startswith("clawhub/") else identifier
return f"https://clawhub.ai/skills/{slug}"
if src in {"skills.sh", "skills-sh"}:
# "skills-sh/owner/repo/skill" -> the skills.sh detail page
rest = identifier[len("skills-sh/"):] if identifier.startswith("skills-sh/") else identifier
return f"https://skills.sh/skills/{rest}"
if src == "lobehub":
slug = identifier[len("lobehub/"):] if identifier.startswith("lobehub/") else identifier
return f"https://lobehub.com/agent/{slug}"
if src in {"browse.sh", "browse-sh"}:
# "browse-sh/<hostname>/<task-id>" -> browse.sh task page
rest = identifier[len("browse-sh/"):] if identifier.startswith("browse-sh/") else identifier
return f"https://browse.sh/skills/{rest}"
return ""
def extract_local_skills():
skills = []
for base_dir, source_label in LOCAL_SKILL_DIRS:
base_path = os.path.join(REPO_ROOT, base_dir)
if not os.path.isdir(base_path):
continue
for root, _dirs, files in os.walk(base_path):
if "SKILL.md" not in files:
continue
skill_path = os.path.join(root, "SKILL.md")
with open(skill_path, encoding="utf-8") as f:
content = f.read()
if not content.startswith("---"):
continue
parts = content.split("---", 2)
if len(parts) < 3:
continue
try:
fm = yaml.safe_load(parts[1])
except yaml.YAMLError:
continue
if not fm or not isinstance(fm, dict):
continue
body = parts[2].strip()
overview = _extract_overview(body)
rel = os.path.relpath(root, base_path)
category = rel.split(os.sep)[0]
tags = []
metadata = fm.get("metadata")
if isinstance(metadata, dict):
hermes_meta = metadata.get("hermes", {})
if isinstance(hermes_meta, dict):
tags = hermes_meta.get("tags", [])
if not tags:
tags = fm.get("tags", [])
if isinstance(tags, str):
tags = [tags]
prereq = fm.get("prerequisites") or {}
env_vars = []
commands = []
if isinstance(prereq, dict):
ev = prereq.get("env_vars")
if isinstance(ev, list):
env_vars = [str(x) for x in ev if x]
elif isinstance(ev, str) and ev.strip():
env_vars = [ev.strip()]
cmds = prereq.get("commands")
if isinstance(cmds, list):
commands = [str(x) for x in cmds if x]
elif isinstance(cmds, str) and cmds.strip():
commands = [cmds.strip()]
skills.append({
"name": fm.get("name", os.path.basename(root)),
"description": fm.get("description", ""),
"overview": overview,
"category": category,
"categoryLabel": CATEGORY_LABELS.get(category, category.replace("-", " ").title()),
"source": source_label,
"tags": tags or [],
"platforms": fm.get("platforms", []),
"author": fm.get("author", ""),
"version": fm.get("version", ""),
"license": fm.get("license", ""),
"envVars": env_vars,
"commands": commands,
"docsPath": _docs_page_path(rel, source_label),
})
return skills
def _label_for_github_identifier(identifier: str) -> str:
"""Return a friendly source label for a unified-index 'github' entry."""
if not identifier:
return "GitHub"
for prefix, label in GITHUB_TAP_LABELS.items():
if identifier.startswith(prefix + "/") or identifier == prefix:
return label
return "GitHub"
def extract_unified_index_skills():
"""Read website/static/api/skills-index.json — the canonical multi-source index.
Returns ``(skills, meta)`` where ``meta`` carries the index's
``generated_at`` timestamp and total count so the Skills Hub page can
show a "Last refreshed …" badge. Returns ``(None, None)`` when the
index file is absent or malformed (caller falls back to the legacy
cache).
"""
if not os.path.isfile(UNIFIED_INDEX_PATH):
return None, None
try:
with open(UNIFIED_INDEX_PATH, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
print(f"[extract-skills] Failed to read unified index: {e}")
return None, None
if not isinstance(data, dict) or "skills" not in data:
return None, None
meta = {
"indexGeneratedAt": data.get("generated_at", ""),
"indexSkillCount": data.get("skill_count", 0),
"indexVersion": data.get("version", 0),
}
out = []
for entry in data.get("skills", []):
if not isinstance(entry, dict):
continue
source_id = (entry.get("source") or "").lower()
identifier = entry.get("identifier", "") or ""
name = entry.get("name") or identifier.split("/")[-1] or "unknown"
description = (entry.get("description") or "").split("\n")[0]
if len(description) > 280:
description = description[:277] + ""
tags = entry.get("tags", []) or []
if not isinstance(tags, list):
tags = []
# Skip official entries here — extract_local_skills() already covered
# those from optional-skills/ with full metadata (overview, version, etc.).
if source_id == "official":
continue
# Map source id -> display label
if source_id == "github":
source_label = _label_for_github_identifier(identifier)
else:
source_label = UNIFIED_SOURCE_LABELS.get(source_id, source_id or "community")
# Guess a category from tags so the UI's category filter has a chance.
category = _guess_category(tags)
extra = entry.get("extra", {}) or {}
# A skills.sh.json grouping sidecar (if the tap ships one) gives us a
# real, human-readable category — prefer it over the tag heuristic.
# extra["category"] holds the grouping title, e.g. "Inference AI".
sidecar_category = extra.get("category") if isinstance(extra, dict) else None
category_label_override = ""
if isinstance(sidecar_category, str) and sidecar_category.strip():
category_label_override = sidecar_category.strip()
category = category_label_override.lower().replace(" ", "-")
# Author hint from extras when available (skills.sh has installs;
# clawhub doesn't expose author).
author = ""
if source_id in {"skills.sh", "skills-sh"}:
repo = entry.get("repo", "")
if repo:
author = repo.split("/")[0]
install_cmd = _install_command(source_id, identifier, name)
source_url = _source_url(source_id, identifier, extra)
out.append({
"name": name,
"description": description,
"overview": "",
"category": category,
"categoryLabel": category_label_override, # set from sidecar, else filled in _consolidate_small_categories
"fixedCategory": bool(category_label_override), # sidecar categories are exempt from small-cat collapse
"source": source_label,
"tags": tags,
"platforms": [],
"author": author,
"version": "",
"license": "",
"envVars": [],
"commands": [],
"docsPath": "",
"identifier": identifier,
"installCmd": install_cmd,
"sourceUrl": source_url,
})
return out, meta
def extract_legacy_cache_skills():
"""Read the deprecated skills/index-cache/ snapshots — fallback only."""
skills = []
if not os.path.isdir(LEGACY_INDEX_CACHE_DIR):
return skills
for filename in os.listdir(LEGACY_INDEX_CACHE_DIR):
if not filename.endswith(".json"):
continue
filepath = os.path.join(LEGACY_INDEX_CACHE_DIR, filename)
try:
with open(filepath, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
continue
stem = filename.replace(".json", "")
source_label = "community"
for key, label in LEGACY_SOURCE_LABELS.items():
if key in stem:
source_label = label
break
if isinstance(data, dict) and "agents" in data:
for agent in data["agents"]:
if not isinstance(agent, dict):
continue
skills.append({
"name": agent.get("identifier", agent.get("meta", {}).get("title", "unknown")),
"description": (agent.get("meta", {}).get("description", "") or "").split("\n")[0][:200],
"category": _guess_category(agent.get("meta", {}).get("tags", [])),
"categoryLabel": "",
"source": source_label,
"tags": agent.get("meta", {}).get("tags", []),
"platforms": [],
"author": agent.get("author", ""),
"version": "",
})
continue
if isinstance(data, list):
for entry in data:
if not isinstance(entry, dict) or not entry.get("name"):
continue
if "skills" in entry and isinstance(entry["skills"], list):
continue
skills.append({
"name": entry.get("name", ""),
"description": entry.get("description", ""),
"category": "uncategorized",
"categoryLabel": "",
"source": source_label,
"tags": entry.get("tags", []),
"platforms": [],
"author": "",
"version": "",
})
for s in skills:
if not s["categoryLabel"]:
s["categoryLabel"] = CATEGORY_LABELS.get(
s["category"],
s["category"].replace("-", " ").title() if s["category"] else "Uncategorized",
)
return skills
TAG_TO_CATEGORY = {}
for _cat, _tags in {
"software-development": [
"programming", "code", "coding", "software-development",
"frontend-development", "backend-development", "web-development",
"react", "python", "typescript", "java", "rust", "cli",
"developer-tools", "development", "api", "database", "debugging",
"documentation", "testing", "test", "architecture",
],
"autonomous-ai-agents": [
"ai", "agent", "agents", "ai-agent", "ai-agents", "agentic",
"agentic-ai", "ai-assistant", "assistant", "multi-agent",
"autonomous", "llm", "rag", "prompt", "prompts", "a2a", "acp",
],
"creative": [
"writing", "design", "creative", "art", "image-generation",
"image", "content", "video-editing", "content-creation",
],
"research": ["education", "academic", "academic-writing", "research", "knowledge"],
"social-media": ["marketing", "seo", "social-media", "advertising", "creator"],
"productivity": [
"productivity", "business", "automation", "calendar", "email",
"document", "documents", "office", "notes", "note-taking",
"collaboration", "workflow", "crm",
],
"data-science": ["data", "data-science", "analytics", "analysis", "visualization"],
"mlops": ["machine-learning", "deep-learning", "mlops", "training", "fine-tuning"],
"devops": ["devops", "docker", "kubernetes", "infrastructure", "deployment", "monitoring", "ci-cd"],
"gaming": ["gaming", "game", "game-development"],
"media": ["music", "media", "video", "audio", "podcast", "youtube"],
"health": ["health", "fitness", "medical", "wellness"],
"translation": ["translation", "language-learning", "i18n", "localization"],
"security": ["security", "cybersecurity", "auth", "compliance", "audit", "privacy"],
"blockchain": [
"blockchain", "crypto", "cryptocurrency", "defi", "web3",
"bitcoin", "ethereum", "nft", "trading", "arbitrage",
],
"communication": ["communication", "chat", "messaging", "slack", "discord"],
"domain": [
"finance", "accounting", "banking", "ecommerce", "e-commerce",
"shopping", "travel", "booking", "real-estate", "legal",
"government", "b2b", "b2b-sales", "entrepreneur", "budget",
],
}.items():
for _t in _tags:
TAG_TO_CATEGORY[_t] = _cat
def _guess_category(tags: list) -> str:
"""Map a skill's tags to a curated category, or 'uncategorized'.
Previously this fell back to ``tags[0]`` verbatim, which produced
hundreds of junk one-off "categories" in the sidebar (e.g.
"Doramagic Crystal", "0.10.7 Dev", "Ap2") — version strings, brand
names, and tag noise. We now ONLY accept categories that map to a
known curated bucket; everything else becomes "uncategorized", which
_consolidate_small_categories folds into "Other". Sidecar-declared
categories (skills.sh groupings) bypass this entirely via fixedCategory.
"""
if not tags:
return "uncategorized"
for tag in tags:
if not isinstance(tag, str):
continue
cat = TAG_TO_CATEGORY.get(tag.lower())
if cat:
return cat
# Also accept a tag that's already a known curated category key
# (e.g. a skill tagged literally "security" or "devops").
normalized = tag.lower().replace(" ", "-")
if normalized in CATEGORY_LABELS and normalized != "other":
return normalized
return "uncategorized"
MIN_CATEGORY_SIZE = 4
def _consolidate_small_categories(skills: list) -> list:
for s in skills:
if s["category"] in {"uncategorized", ""}:
s["category"] = "other"
s["categoryLabel"] = "Other"
# Skills with a sidecar-declared category (skills.sh.json grouping) keep
# their category even if it's the only skill in it — the tap explicitly
# chose that label, so it's not a heuristic guess to collapse away.
counts = Counter(
s["category"] for s in skills if not s.get("fixedCategory")
)
small_cats = {cat for cat, n in counts.items() if n < MIN_CATEGORY_SIZE}
for s in skills:
if s.get("fixedCategory"):
continue
if s["category"] in small_cats:
s["category"] = "other"
s["categoryLabel"] = "Other"
elif not s["categoryLabel"]:
s["categoryLabel"] = CATEGORY_LABELS.get(
s["category"],
s["category"].replace("-", " ").title() if s["category"] else "Uncategorized",
)
return skills
def main():
local = extract_local_skills()
unified, index_meta = extract_unified_index_skills()
if unified is not None:
external = unified
external_source = "unified index"
else:
external = extract_legacy_cache_skills()
external_source = "legacy index-cache"
index_meta = None
print(
f"[extract-skills] WARNING: unified index not found at "
f"{UNIFIED_INDEX_PATH}; falling back to {external_source}. "
f"Run `python3 scripts/build_skills_index.py` to refresh."
)
all_skills = _consolidate_small_categories(local + external)
source_order = {"built-in": 0, "optional": 1}
all_skills.sort(key=lambda s: (
source_order.get(s["source"], 2),
1 if s["category"] == "other" else 0,
s["category"],
s["name"],
))
os.makedirs(os.path.dirname(OUTPUT), exist_ok=True)
with open(OUTPUT, "w", encoding="utf-8") as f:
# Minified — file is served over the wire, not read by humans.
# At 50k+ skills the indented version was ~30% larger.
json.dump(all_skills, f, separators=(",", ":"), ensure_ascii=False)
# Sidecar meta file so the page can render a "Last refreshed" badge
# without changing the shape of skills.json.
by_source = Counter(s["source"] for s in all_skills)
meta = {
"extractedAt": datetime.now(timezone.utc).isoformat(),
"totalSkills": len(all_skills),
"localSkills": len(local),
"externalSkills": len(external),
"externalSource": external_source,
"bySource": dict(by_source.most_common()),
}
if index_meta:
meta.update(index_meta)
with open(META_OUTPUT, "w", encoding="utf-8") as f:
json.dump(meta, f, separators=(",", ":"), ensure_ascii=False)
print(f"Extracted {len(all_skills)} skills to {OUTPUT}")
print(f" {len(local)} local ({sum(1 for s in local if s['source'] == 'built-in')} built-in, "
f"{sum(1 for s in local if s['source'] == 'optional')} optional)")
print(f" {len(external)} from {external_source}")
print("By source:")
for src, count in by_source.most_common():
print(f" {src}: {count}")
if index_meta and index_meta.get("indexGeneratedAt"):
print(f"Unified index built at: {index_meta['indexGeneratedAt']}")
if __name__ == "__main__":
main()
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Generate llms.txt and llms-full.txt for the Hermes docs site.
Outputs:
website/static/llms.txt — short curated index of the docs, one link per page,
grouped by section. Conforms to https://llmstxt.org.
website/static/llms-full.txt — every `.md` file under `website/docs/` concatenated,
with `# <title>` headings and `<!-- source: … -->`
comments separating files.
Both publish at:
https://hermes-agent.nousresearch.com/docs/llms.txt
https://hermes-agent.nousresearch.com/docs/llms-full.txt
The `/docs/` prefix is not a mistake — Docusaurus serves `website/static/`
at the `docs/` base path. Clients and IDE plugins that probe the classic
`/llms.txt` root will miss these. Document the canonical URLs in the docs
index and in the repo README.
Called from `website/scripts/prebuild.mjs` on every `npm run start` /
`npm run build` so the output stays in sync with the docs tree.
"""
from __future__ import annotations
import re
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
WEBSITE = SCRIPT_DIR.parent
DOCS = WEBSITE / "docs"
STATIC = WEBSITE / "static"
SITE_BASE = "https://hermes-agent.nousresearch.com/docs"
# Curated sections for llms.txt — mirrors the product story, not the filesystem.
# Each entry: (docs-relative path without .md, display title, optional short desc).
# `None` desc → pulled from frontmatter `description:` field.
SECTIONS: list[tuple[str, list[tuple[str, str, str | None]]]] = [
("Getting Started", [
("getting-started/installation", "Installation", None),
("getting-started/quickstart", "Quickstart", None),
("getting-started/learning-path", "Learning Path", None),
("getting-started/updating", "Updating", None),
("getting-started/termux", "Termux (Android)", None),
("getting-started/nix-setup", "Nix Setup", None),
]),
("Using Hermes", [
("user-guide/cli", "CLI", None),
("user-guide/tui", "TUI (Ink terminal UI)", None),
("user-guide/configuration", "Configuration", None),
("user-guide/configuring-models", "Configuring Models", None),
("user-guide/sessions", "Sessions", None),
("user-guide/profiles", "Profiles", None),
("user-guide/git-worktrees", "Git Worktrees", None),
("user-guide/docker", "Docker Backend", None),
("user-guide/security", "Security", None),
("user-guide/checkpoints-and-rollback", "Checkpoints & Rollback", None),
]),
("Core Features", [
("user-guide/features/overview", "Features Overview", None),
("user-guide/features/tools", "Tools", None),
("user-guide/features/skills", "Skills System", None),
("user-guide/features/curator", "Curator", None),
("user-guide/features/memory", "Memory", None),
("user-guide/features/memory-providers", "Memory Providers", None),
("user-guide/features/context-files", "Context Files", None),
("user-guide/features/context-references", "Context References", None),
("user-guide/features/personality", "Personality & SOUL.md", None),
("user-guide/features/plugins", "Plugins", None),
("user-guide/features/built-in-plugins", "Built-in Plugins", None),
]),
("Automation", [
("user-guide/features/cron", "Cron Jobs", None),
("user-guide/features/delegation", "Delegation", None),
("user-guide/features/kanban", "Kanban Multi-Agent", None),
("user-guide/features/kanban-tutorial", "Kanban Tutorial", None),
("user-guide/features/goals", "Persistent Goals", None),
("user-guide/features/code-execution", "Code Execution", None),
("user-guide/features/hooks", "Hooks", None),
("user-guide/features/batch-processing", "Batch Processing", None),
]),
("Media & Web", [
("user-guide/features/voice-mode", "Voice Mode", None),
("user-guide/features/browser", "Browser", None),
("user-guide/features/vision", "Vision", None),
("user-guide/features/image-generation", "Image Generation", None),
("user-guide/features/tts", "Text-to-Speech", None),
]),
("Messaging Platforms", [
("user-guide/messaging/index", "Overview", None),
("user-guide/messaging/telegram", "Telegram", None),
("user-guide/messaging/discord", "Discord", None),
("user-guide/messaging/slack", "Slack", None),
("user-guide/messaging/whatsapp", "WhatsApp", None),
("user-guide/messaging/signal", "Signal", None),
("user-guide/messaging/email", "Email", None),
("user-guide/messaging/sms", "SMS", None),
("user-guide/messaging/matrix", "Matrix", None),
("user-guide/messaging/mattermost", "Mattermost", None),
("user-guide/messaging/homeassistant", "Home Assistant", None),
("user-guide/messaging/webhooks", "Webhooks", None),
]),
("Integrations", [
("integrations/index", "Integrations Overview", None),
("integrations/providers", "Providers", None),
("user-guide/features/mcp", "MCP (Model Context Protocol)", None),
("user-guide/features/acp", "ACP (Agent Context Protocol)", None),
("user-guide/features/api-server", "API Server", None),
("user-guide/features/honcho", "Honcho Memory", None),
("user-guide/features/provider-routing", "Provider Routing", None),
("user-guide/features/fallback-providers", "Fallback Providers", None),
("user-guide/features/credential-pools", "Credential Pools", None),
]),
("Guides & Tutorials", [
("guides/tips", "Tips & Best Practices", None),
("guides/local-llm-on-mac", "Local LLMs on Mac", None),
("guides/daily-briefing-bot", "Daily Briefing Bot", None),
("guides/team-telegram-assistant", "Team Telegram Assistant", None),
("guides/python-library", "Use Hermes as a Python Library", None),
("guides/use-mcp-with-hermes", "Use MCP with Hermes", None),
("guides/use-voice-mode-with-hermes", "Use Voice Mode with Hermes", None),
("guides/use-soul-with-hermes", "Use SOUL.md with Hermes", None),
("guides/build-a-hermes-plugin", "Build a Hermes Plugin", None),
("guides/automate-with-cron", "Automate with Cron", None),
("guides/work-with-skills", "Work with Skills", None),
("guides/delegation-patterns", "Delegation Patterns", None),
("guides/github-pr-review-agent", "GitHub PR Review Agent", None),
]),
("Developer Guide", [
("developer-guide/contributing", "Contributing", None),
("developer-guide/architecture", "Architecture", None),
("developer-guide/agent-loop", "Agent Loop", None),
("developer-guide/prompt-assembly", "Prompt Assembly", None),
("developer-guide/context-compression-and-caching", "Context Compression & Caching", None),
("developer-guide/gateway-internals", "Gateway Internals", None),
("developer-guide/session-storage", "Session Storage", None),
("developer-guide/provider-runtime", "Provider Runtime", None),
("developer-guide/adding-tools", "Adding Tools", None),
("developer-guide/adding-providers", "Adding Providers", None),
("developer-guide/adding-platform-adapters", "Adding Platform Adapters", None),
("developer-guide/creating-skills", "Creating Skills", None),
("developer-guide/extending-the-cli", "Extending the CLI", None),
]),
("Reference", [
("reference/cli-commands", "CLI Commands", None),
("reference/slash-commands", "Slash Commands", None),
("reference/profile-commands", "Profile Commands", None),
("reference/environment-variables", "Environment Variables", None),
("reference/tools-reference", "Tools Reference", None),
("reference/toolsets-reference", "Toolsets Reference", None),
("reference/mcp-config-reference", "MCP Config Reference", None),
("reference/model-catalog", "Model Catalog", None),
("reference/skills-catalog", "Bundled Skills Catalog", "Table of all ~90 skills bundled with Hermes"),
("reference/optional-skills-catalog", "Optional Skills Catalog", "Table of ~60 additional installable skills"),
("reference/faq", "FAQ & Troubleshooting", None),
]),
]
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
DESC_RE = re.compile(r"^description:\s*[\"'](.+?)[\"']\s*$", re.MULTILINE)
TITLE_RE = re.compile(r"^title:\s*[\"'](.+?)[\"']\s*$", re.MULTILINE)
def read_frontmatter(path: Path) -> tuple[dict[str, str], str]:
"""Return ({title, description}, body-markdown) for a doc file."""
text = path.read_text(encoding="utf-8")
m = FRONTMATTER_RE.match(text)
meta: dict[str, str] = {}
body = text
if m:
fm = m.group(1)
body = text[m.end():]
dm = DESC_RE.search(fm)
if dm:
meta["description"] = dm.group(1)
tm = TITLE_RE.search(fm)
if tm:
meta["title"] = tm.group(1)
return meta, body
def resolve_desc(slug: str, provided: str | None) -> str:
"""Resolve short description for llms.txt entry."""
if provided:
return provided
path = DOCS / f"{slug}.md"
if not path.exists():
path = DOCS / slug / "index.md"
if not path.exists():
return ""
meta, _ = read_frontmatter(path)
return meta.get("description", "")
def emit_llms_index() -> str:
"""Build the short llms.txt index."""
lines: list[str] = []
lines.append("# Hermes Agent")
lines.append("")
lines.append(
"> The self-improving AI agent built by Nous Research. A terminal-native "
"autonomous coding and task agent with persistent memory, agent-created skills, "
"and a messaging gateway that lives on 21+ messaging platforms — 19 native to "
"the gateway plus IRC and Microsoft Teams via plugins (Telegram, Discord, Slack, "
"SMS, Matrix, ...). Runs on local, Docker, SSH, Daytona, Modal, or Singularity "
"backends. Works with Nous Portal, OpenRouter, OpenAI, Anthropic, Google, or any "
"OpenAI-compatible endpoint."
)
lines.append("")
lines.append(
"Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/"
"hermes-agent/main/scripts/install.sh | bash` "
"(Linux, macOS, WSL2, Termux)"
)
lines.append("")
lines.append("Repo: https://github.com/NousResearch/hermes-agent")
lines.append("")
for section, items in SECTIONS:
lines.append(f"## {section}")
lines.append("")
for slug, title, desc_override in items:
desc = resolve_desc(slug, desc_override)
url = f"{SITE_BASE}/{slug}"
if desc:
lines.append(f"- [{title}]({url}): {desc}")
else:
lines.append(f"- [{title}]({url})")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def emit_llms_full() -> str:
"""Concatenate every doc under website/docs/ into a single markdown file.
Order: mirrors the curated SECTIONS list first (so the most important
pages are front-loaded for agents that truncate on token budget), then
appends any remaining .md files sorted by path.
"""
seen: set[Path] = set()
chunks: list[str] = [
"# Hermes Agent — Full Documentation\n",
(
"This file is the entire Hermes Agent documentation concatenated for LLM "
"context ingestion. Section order reflects docs-site navigation: Getting "
"Started, Using Hermes, Features, Messaging, Integrations, Guides, "
"Developer Guide, Reference, then everything else.\n"
),
"Canonical site: https://hermes-agent.nousresearch.com/docs\n",
"Short index: https://hermes-agent.nousresearch.com/docs/llms.txt\n",
"\n---\n\n",
]
def emit_file(rel: str) -> None:
path = DOCS / f"{rel}.md"
if not path.exists():
path = DOCS / rel / "index.md"
if not path.exists() or path in seen:
return
seen.add(path)
meta, body = read_frontmatter(path)
title = meta.get("title") or rel
chunks.append(f"<!-- source: website/docs/{path.relative_to(DOCS)} -->\n")
chunks.append(f"# {title}\n\n")
chunks.append(body.rstrip() + "\n\n---\n\n")
# Curated order first
for _, items in SECTIONS:
for slug, _t, _d in items:
emit_file(slug)
# Everything else (sorted, skipping already emitted and auto-gen skill pages
# — those are covered by the two catalog reference pages, emitting every
# individual skill would add ~1.4 MB of largely duplicative material).
for path in sorted(DOCS.rglob("*.md")):
if path in seen:
continue
rel = path.relative_to(DOCS)
parts = rel.parts
if len(parts) >= 3 and parts[0] == "user-guide" and parts[1] == "skills" \
and parts[2] in {"bundled", "optional"}:
continue
seen.add(path)
meta, body = read_frontmatter(path)
title = meta.get("title") or str(rel)
chunks.append(f"<!-- source: website/docs/{rel} -->\n")
chunks.append(f"# {title}\n\n")
chunks.append(body.rstrip() + "\n\n---\n\n")
return "".join(chunks).rstrip() + "\n"
def main() -> None:
STATIC.mkdir(exist_ok=True)
index = emit_llms_index()
full = emit_llms_full()
(STATIC / "llms.txt").write_text(index, encoding="utf-8")
(STATIC / "llms-full.txt").write_text(full, encoding="utf-8")
print(f"Wrote {STATIC / 'llms.txt'} ({len(index):,} bytes)")
print(f"Wrote {STATIC / 'llms-full.txt'} ({len(full):,} bytes)")
if __name__ == "__main__":
main()
+773
View File
@@ -0,0 +1,773 @@
#!/usr/bin/env python3
"""Generate per-skill Docusaurus pages from skills/ and optional-skills/ SKILL.md files.
Each skill gets website/docs/user-guide/skills/<source>/<category>/<skill-name>.md
where <source> is "bundled" or "optional".
Also regenerates:
- website/docs/reference/skills-catalog.md
- website/docs/reference/optional-skills-catalog.md
(so their table rows link to the new dedicated pages)
Sidebar is updated to nest all per-skill pages under Skills → Bundled / Optional.
"""
from __future__ import annotations
import re
from collections import defaultdict
from pathlib import Path
from typing import Any
import yaml
REPO = Path(__file__).resolve().parent.parent.parent
DOCS = REPO / "website" / "docs"
SKILLS_PAGES = DOCS / "user-guide" / "skills"
SKILL_SOURCES = [
("bundled", REPO / "skills"),
("optional", REPO / "optional-skills"),
]
# Pages the user had previously hand-written in user-guide/skills/.
# We leave these alone (they get first-class sidebar treatment separately).
HAND_WRITTEN = {"google-workspace.md"}
_FENCE_RE = re.compile(r"^(?P<indent>\s*)(?P<fence>```+|~~~+)", re.MULTILINE)
# Unicode box-drawing characters. If a generated fenced code block contains any
# of these, wrap it in `<!-- ascii-guard-ignore -->` so the docs-site-checks
# lint (which scans inside code fences) can't reject the page for a skill's
# own ASCII diagram. Skill authors shouldn't need to remember to add the
# ignore markers in every SKILL.md — the generator handles it defensively.
_BOX_DRAWING_CHARS = frozenset("┌┐└┘─│═║╔╗╚╝╠╣╦╩╬├┤┬┴┼╭╮╯╰▶◀▲▼")
def _wrap_ascii_art_code_blocks(code_segment: str) -> str:
"""Wrap a fenced code segment in ascii-guard-ignore markers if it contains
box-drawing characters. No-op otherwise, so plain bash/python code blocks
stay uncluttered.
Already-wrapped segments (the SKILL.md source added its own markers) are
left alone — double-wrapping is harmless but we'd rather keep the output
clean.
"""
if not any(ch in _BOX_DRAWING_CHARS for ch in code_segment):
return code_segment
return (
"<!-- ascii-guard-ignore -->\n"
f"{code_segment}\n"
"<!-- ascii-guard-ignore-end -->"
)
def mdx_escape_body(body: str) -> str:
"""Escape MDX-dangerous characters in markdown body, leaving fenced code blocks alone.
Outside fenced code blocks:
* `{` -> `&#123;` (prevents MDX from parsing JSX expressions)
* `}` -> `&#125;`
* `<tag>` for bare tags that aren't whitelisted HTML get HTML-entity-escaped
* inline `` `code` `` content is preserved (backticks handled naturally)
Inside fenced code blocks: untouched.
We also preserve `<br>`, `<br/>`, `<img ...>`, `<a ...>`, and a handful of
other markup-safe tags because Docusaurus/MDX accepts them as HTML.
"""
# Split the body into segments by fenced code blocks, alternating
# (text, code, text, code, ...). A line like ``` or ~~~ opens a fence;
# a matching marker closes it.
lines = body.split("\n")
segments: list[tuple[str, str]] = [] # ("text"|"code", content)
buf: list[str] = []
mode = "text"
fence_char: str | None = None
fence_len = 0
for line in lines:
stripped = line.lstrip()
if mode == "text":
if stripped.startswith("```") or stripped.startswith("~~~"):
# Opening fence
if buf:
segments.append(("text", "\n".join(buf)))
buf = []
buf.append(line)
# Detect fence char + length
m = re.match(r"(`{3,}|~{3,})", stripped)
if m:
fence_char = m.group(1)[0]
fence_len = len(m.group(1))
mode = "code"
else:
buf.append(line)
else: # code mode
buf.append(line)
if fence_char is not None and stripped.startswith(fence_char * fence_len):
# Closing fence
segments.append(("code", "\n".join(buf)))
buf = []
mode = "text"
fence_char = None
fence_len = 0
if buf:
segments.append((mode, "\n".join(buf)))
def escape_text(text: str) -> str:
# Walk inline-code runs (backticks) and leave them alone.
# Pattern matches runs of backticks, then the matched content, then the
# same number of backticks.
out: list[str] = []
i = 0
while i < len(text):
ch = text[i]
if ch == "`":
# Find the run of backticks
j = i
while j < len(text) and text[j] == "`":
j += 1
run = text[i:j]
# Find matching run
end = text.find(run, j)
if end == -1:
# No closing -- just keep as-is
out.append(text[i:])
i = len(text)
continue
out.append(text[i : end + len(run)])
i = end + len(run)
else:
# Escape MDX metacharacters
if ch == "{":
out.append("&#123;")
elif ch == "}":
out.append("&#125;")
elif ch == "<":
# Preserve full HTML comments (e.g. ascii-guard ignore markers) — they
# are not HTML tags, so the tag regex below would escape the leading <.
if text[i:].startswith("<!--"):
end = text.find("-->", i)
if end != -1:
out.append(text[i : end + 3])
i = end + 3
continue
# Look ahead to see if this is a valid HTML-ish tag.
# If it looks like a tag name then alnum/-/_ chars, leave it.
# Otherwise escape.
m = re.match(
r"<(/?)([A-Za-z][A-Za-z0-9]*)([^<>]*)>",
text[i:],
)
if m:
tag = m.group(2).lower()
# Whitelist known-safe HTML tags
safe_tags = {
"br",
"hr",
"img",
"a",
"b",
"i",
"em",
"strong",
"code",
"kbd",
"sup",
"sub",
"span",
"div",
"p",
"ul",
"ol",
"li",
"table",
"thead",
"tbody",
"tr",
"td",
"th",
"details",
"summary",
"blockquote",
"pre",
"mark",
"small",
"u",
"s",
"del",
"ins",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
}
if tag in safe_tags:
out.append(m.group(0))
i += len(m.group(0))
continue
# Escape the `<`
out.append("&lt;")
else:
out.append(ch)
i += 1
return "".join(out)
processed: list[str] = []
for kind, content in segments:
if kind == "code":
processed.append(_wrap_ascii_art_code_blocks(content))
else:
processed.append(escape_text(content))
return "\n".join(processed)
def rewrite_relative_links(body: str, meta: dict[str, Any]) -> str:
"""Rewrite references/foo.md style links in the SKILL.md body.
The source SKILL.md lives in `skills/<...>` and references sibling files
with paths like `references/foo.md` or `./templates/bar.md`. Those files
are NOT copied into docs/, so we rewrite these to absolute GitHub URLs
pointing to the file in the repo.
"""
source_dir = "skills" if meta["source_kind"] == "bundled" else "optional-skills"
base = f"https://github.com/NousResearch/hermes-agent/blob/main/{source_dir}/{meta['rel_path']}"
def sub_link(m: re.Match) -> str:
text = m.group(1)
url = m.group(2).strip()
# Skip URLs that already start with a scheme or //
if re.match(r"^[a-z]+://", url) or url.startswith("#") or url.startswith("/"):
return m.group(0)
# Skip mailto
if url.startswith("mailto:"):
return m.group(0)
# Strip leading ./
url_clean = url[2:] if url.startswith("./") else url
full = f"{base}/{url_clean}"
return f"[{text}]({full})"
return re.sub(r"\[([^\]]+)\]\(([^)]+)\)", sub_link, body)
def parse_skill_md(path: Path) -> dict[str, Any]:
text = path.read_text(encoding="utf-8")
if not text.startswith("---"):
raise ValueError(f"{path}: no frontmatter")
parts = text.split("---", 2)
if len(parts) < 3:
raise ValueError(f"{path}: malformed frontmatter")
fm_text, body = parts[1], parts[2]
try:
fm = yaml.safe_load(fm_text) or {}
except yaml.YAMLError as exc:
raise ValueError(f"{path}: YAML error: {exc}") from exc
return {"frontmatter": fm, "body": body.lstrip("\n")}
def sanitize_yaml_string(s: str) -> str:
"""Make a string safe to embed in a YAML double-quoted scalar."""
s = s.replace("\\", "\\\\").replace('"', '\\"')
# Collapse newlines to spaces.
s = re.sub(r"\s+", " ", s).strip()
return s
def derive_skill_meta(skill_path: Path, source_dir: Path, source_kind: str) -> dict[str, Any]:
"""Extract category + skill slug from filesystem layout.
skills/<cat>/<skill>/SKILL.md -> cat=<cat>, slug=<skill>
skills/<cat>/<sub>/<skill>/SKILL.md -> cat=<cat>, sub=<sub>, slug=<skill>
optional-skills/<cat>/<skill>/SKILL.md -> cat=<cat>, slug=<skill>
"""
rel = skill_path.parent.relative_to(source_dir)
parts = rel.parts
if len(parts) == 1:
# Top-level skill (e.g. skills/dogfood/SKILL.md) -- rare
category = parts[0]
sub = None
slug = parts[0]
elif len(parts) == 2:
category, slug = parts
sub = None
elif len(parts) == 3:
category, sub, slug = parts
else:
raise ValueError(f"Unexpected skill layout: {skill_path}")
return {
"source_kind": source_kind, # bundled | optional
"category": category,
"sub": sub,
"slug": slug,
"rel_path": str(rel),
}
def page_id(meta: dict[str, Any]) -> str:
"""Stable slug used for filename + sidebar id."""
if meta["sub"]:
return f"{meta['category']}-{meta['sub']}-{meta['slug']}"
return f"{meta['category']}-{meta['slug']}"
def page_output_path(meta: dict[str, Any]) -> Path:
return (
SKILLS_PAGES
/ meta["source_kind"]
/ meta["category"]
/ f"{page_id(meta)}.md"
)
def sidebar_doc_id(meta: dict[str, Any]) -> str:
"""Docusaurus sidebar id, relative to docs/."""
return f"user-guide/skills/{meta['source_kind']}/{meta['category']}/{page_id(meta)}"
def render_skill_page(
meta: dict[str, Any],
fm: dict[str, Any],
body: str,
skill_index: dict[str, dict[str, Any]] | None = None,
) -> str:
name = fm.get("name", meta["slug"])
description = fm.get("description", "").strip()
short_desc = description.split(".")[0].strip() if description else name
if len(short_desc) > 160:
short_desc = short_desc[:157] + "..."
title = f"{name}"
# Heuristic nicer title from name
display_name = name.replace("-", " ").replace("_", " ").title()
hermes_meta = (fm.get("metadata") or {}).get("hermes") or {}
tags = hermes_meta.get("tags") or []
related = hermes_meta.get("related_skills") or []
platforms = fm.get("platforms")
version = fm.get("version")
author = fm.get("author")
license_ = fm.get("license")
deps = fm.get("dependencies")
# Build metadata info block
info_rows: list[tuple[str, str]] = []
if meta["source_kind"] == "bundled":
info_rows.append(("Source", "Bundled (installed by default)"))
else:
info_rows.append(
(
"Source",
"Optional — install with `hermes skills install official/"
+ meta["category"]
+ "/"
+ meta["slug"]
+ "`",
)
)
source_dir = "skills" if meta["source_kind"] == "bundled" else "optional-skills"
info_rows.append(("Path", f"`{source_dir}/{meta['rel_path']}`"))
if version:
info_rows.append(("Version", f"`{version}`"))
if author:
info_rows.append(("Author", str(author)))
if license_:
info_rows.append(("License", str(license_)))
if deps:
if isinstance(deps, list):
deps_str = ", ".join(f"`{d}`" for d in deps) if deps else "None"
else:
deps_str = f"`{deps}`"
info_rows.append(("Dependencies", deps_str))
if platforms:
if isinstance(platforms, list):
plat_str = ", ".join(platforms)
else:
plat_str = str(platforms)
info_rows.append(("Platforms", plat_str))
if tags:
info_rows.append(("Tags", ", ".join(f"`{t}`" for t in tags)))
if related:
# link to sibling pages when possible -- fall back to plain code
link_parts = []
for r in related:
target_meta = None
if skill_index is not None:
target_meta = skill_index.get(r)
if target_meta is not None:
href = (
f"/docs/user-guide/skills/{target_meta['source_kind']}"
f"/{target_meta['category']}/{page_id(target_meta)}"
)
link_parts.append(f"[`{r}`]({href})")
else:
link_parts.append(f"`{r}`")
info_rows.append(("Related skills", ", ".join(link_parts)))
info_block = "\n".join(f"| {k} | {v} |" for k, v in info_rows)
info_table = (
"| | |\n|---|---|\n" + info_block
)
# Frontmatter for Docusaurus
fm_title = sanitize_yaml_string(display_name + "" + (short_desc or name))
if len(fm_title) > 120:
fm_title = sanitize_yaml_string(display_name)
fm_desc = sanitize_yaml_string(short_desc or description or name)
sidebar_label = sanitize_yaml_string(display_name)
body_clean = mdx_escape_body(rewrite_relative_links(body.strip(), meta))
# Guard against the first heading in body being `# Xxx Skill` which would
# duplicate the page title -- Docusaurus handles this fine because the
# frontmatter `title` drives the page header and TOC.
return (
"---\n"
f'title: "{fm_title}"\n'
f'sidebar_label: "{sidebar_label}"\n'
f'description: "{fm_desc}"\n'
"---\n"
"\n"
"{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}\n"
"\n"
f"# {display_name}\n"
"\n"
f"{mdx_escape_body(description)}\n"
"\n"
"## Skill metadata\n"
"\n"
f"{info_table}\n"
"\n"
"## Reference: full SKILL.md\n"
"\n"
":::info\n"
"The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.\n"
":::\n"
"\n"
f"{body_clean}\n"
)
def discover_skills() -> list[tuple[dict[str, Any], dict[str, Any]]]:
results: list[tuple[dict[str, Any], dict[str, Any]]] = []
for kind, source_dir in SKILL_SOURCES:
for skill_md in sorted(source_dir.rglob("SKILL.md")):
meta = derive_skill_meta(skill_md, source_dir, kind)
parsed = parse_skill_md(skill_md)
results.append((meta, parsed))
return results
def build_catalog_md_bundled(entries: list[tuple[dict[str, Any], dict[str, Any]]]) -> str:
by_cat: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list)
for meta, parsed in entries:
if meta["source_kind"] != "bundled":
continue
by_cat[meta["category"]].append((meta, parsed))
for k in by_cat:
by_cat[k].sort(key=lambda e: e[0]["slug"])
lines = [
"---",
"sidebar_position: 5",
'title: "Bundled Skills Catalog"',
'description: "Catalog of bundled skills that ship with Hermes Agent"',
"---",
"",
"# Bundled Skills Catalog",
"",
"Hermes ships with a large built-in skill library copied into `~/.hermes/skills/` on install. Each skill below links to a dedicated page with its full definition, setup, and usage.",
"",
"Hermes also syncs bundled skills on `hermes update`, but the sync manifest respects local deletions and user edits. If a skill listed here is missing from your profile's `~/.hermes/skills/` tree, it is still shipped with Hermes; restore it with `hermes skills reset <name> --restore`.",
"",
"If a skill is missing from this list but present in the repo, the catalog is regenerated by `website/scripts/generate-skill-docs.py`.",
"",
]
for category in sorted(by_cat):
lines.append(f"## {category}")
lines.append("")
lines.append("| Skill | Description | Path |")
lines.append("|-------|-------------|------|")
for meta, parsed in by_cat[category]:
fm = parsed["frontmatter"]
name = fm.get("name", meta["slug"])
desc = (fm.get("description") or "").strip()
if len(desc) > 240:
desc = desc[:237].rstrip() + "..."
link_target = f"/docs/user-guide/skills/bundled/{meta['category']}/{page_id(meta)}"
path = f"`{meta['rel_path']}`"
desc_esc = mdx_escape_body(desc).replace("|", "\\|").replace("\n", " ")
lines.append(
f"| [`{name}`]({link_target}) | {desc_esc} | {path} |"
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def build_catalog_md_optional(entries: list[tuple[dict[str, Any], dict[str, Any]]]) -> str:
by_cat: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list)
for meta, parsed in entries:
if meta["source_kind"] != "optional":
continue
by_cat[meta["category"]].append((meta, parsed))
for k in by_cat:
by_cat[k].sort(key=lambda e: e[0]["slug"])
lines = [
"---",
"sidebar_position: 9",
'title: "Optional Skills Catalog"',
'description: "Official optional skills shipped with hermes-agent — install via hermes skills install official/<category>/<skill>"',
"---",
"",
"# Optional Skills Catalog",
"",
"Optional skills ship with hermes-agent under `optional-skills/` but are **not active by default**. Install them explicitly:",
"",
"```bash",
"hermes skills install official/<category>/<skill>",
"```",
"",
"For example:",
"",
"```bash",
"hermes skills install official/blockchain/solana",
"hermes skills install official/mlops/flash-attention",
"```",
"",
"Each skill below links to a dedicated page with its full definition, setup, and usage.",
"",
"To uninstall:",
"",
"```bash",
"hermes skills uninstall <skill-name>",
"```",
"",
]
for category in sorted(by_cat):
lines.append(f"## {category}")
lines.append("")
lines.append("| Skill | Description |")
lines.append("|-------|-------------|")
for meta, parsed in by_cat[category]:
fm = parsed["frontmatter"]
name = fm.get("name", meta["slug"])
desc = (fm.get("description") or "").strip()
if len(desc) > 240:
desc = desc[:237].rstrip() + "..."
link_target = f"/docs/user-guide/skills/optional/{meta['category']}/{page_id(meta)}"
desc_esc = mdx_escape_body(desc).replace("|", "\\|").replace("\n", " ")
lines.append(f"| [**{name}**]({link_target}) | {desc_esc} |")
lines.append("")
lines.extend(
[
"---",
"",
"## Contributing Optional Skills",
"",
"To add a new optional skill to the repository:",
"",
"1. Create a directory under `optional-skills/<category>/<skill-name>/`",
"2. Add a `SKILL.md` with standard frontmatter (name, description, version, author)",
"3. Include any supporting files in `references/`, `templates/`, or `scripts/` subdirectories",
"4. Submit a pull request — the skill will appear in this catalog and get its own docs page once merged",
]
)
return "\n".join(lines).rstrip() + "\n"
def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) -> dict:
"""Build a dict representing the Skills sidebar tree.
Structure:
Skills
├── (hand-written pages first: google-workspace)
├── Bundled
│ ├── apple
│ │ ├── apple-apple-notes
│ │ └── ...
│ └── ...
└── Optional
└── ...
"""
bundled = defaultdict(list)
optional = defaultdict(list)
for meta, _ in entries:
if meta["source_kind"] == "bundled":
bundled[meta["category"]].append(meta)
else:
optional[meta["category"]].append(meta)
def cat_section(bucket: dict[str, list[dict[str, Any]]], source: str) -> list[dict]:
result = []
for category in sorted(bucket):
items = sorted(bucket[category], key=lambda m: m["slug"])
result.append(
{
"type": "category",
"label": category,
# Docusaurus generates a translation key from the label by
# default (e.g. sidebar.docs.category.productivity). When
# the same category name appears under both Bundled and
# Optional, the duplicate keys break i18n extraction and
# fail the build. Scope each category by source to keep
# the keys unique.
"key": f"skills-{source}-{category}",
"collapsed": True,
"items": [sidebar_doc_id(m) for m in items],
}
)
return result
return {
"bundled_categories": cat_section(bundled, "bundled"),
"optional_categories": cat_section(optional, "optional"),
}
def _render_sidebar_item(item: Any, indent: int) -> list[str]:
"""Render one sidebar item (string doc id, or category dict) as ts lines."""
pad = " " * indent
lines: list[str] = []
if isinstance(item, str):
lines.append(f"{pad}'{item}',")
return lines
# category dict
lines.append(f"{pad}{{")
lines.append(f"{pad} type: 'category',")
lines.append(f"{pad} label: '{item['label']}',")
if item.get("key"):
lines.append(f"{pad} key: '{item['key']}',")
if item.get("collapsed", True):
lines.append(f"{pad} collapsed: true,")
lines.append(f"{pad} items: [")
for child in item.get("items", []):
lines.extend(_render_sidebar_item(child, indent + 4))
lines.append(f"{pad} ],")
lines.append(f"{pad}}},")
return lines
def write_sidebar(entries):
# Sidebar layout:
# Skills
# ├── reference/skills-catalog
# ├── reference/optional-skills-catalog
# ├── Bundled
# │ ├── apple/
# │ │ ├── apple-apple-notes
# │ │ └── ...
# │ └── ...
# └── Optional
# └── ...
#
# The two catalog index pages stay at the top of the Skills section so
# the at-a-glance table view is one click away, and the per-category
# subtrees give individual skill pages real sidebar navigation when
# users land on them directly.
tree = build_sidebar_items(entries)
skills_block: list[dict[str, Any]] = [
{
"label": "Bundled",
"collapsed": True,
"items": tree["bundled_categories"],
},
{
"label": "Optional",
"collapsed": True,
"items": tree["optional_categories"],
},
]
skills_items: list[Any] = [
"reference/skills-catalog",
"reference/optional-skills-catalog",
*skills_block,
]
skills_top = {
"label": "Skills",
"collapsed": True,
"items": skills_items,
}
skills_subtree = "\n".join(_render_sidebar_item(skills_top, 8)) + "\n"
sidebar_path = REPO / "website" / "sidebars.ts"
text = sidebar_path.read_text(encoding="utf-8")
# Replace the existing Skills block.
pattern = re.compile(
r" \{\n"
r" type: 'category',\n"
r" label: 'Skills',\n"
r"(?:.*?\n)*?"
r" \},\n",
re.DOTALL,
)
# Safer: match the exact current block shape.
old_block_start = " {\n type: 'category',\n label: 'Skills',\n"
i = text.find(old_block_start)
if i == -1:
raise RuntimeError("Could not find Skills sidebar block to replace")
# Find matching closing of this block -- walk brace depth
depth = 0
j = i
while j < len(text):
ch = text[j]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
# Include the trailing ,\n after the closing brace
end = text.find("\n", j) + 1
break
j += 1
else:
raise RuntimeError("Could not find end of Skills sidebar block")
new_text = text[:i] + skills_subtree + text[end:]
sidebar_path.write_text(new_text, encoding="utf-8")
print(f"Updated sidebar: {sidebar_path}")
def main():
entries = discover_skills()
print(f"Discovered {len(entries)} skills")
# Build name -> meta index for related-skill cross-linking
skill_index: dict[str, dict[str, Any]] = {}
for meta, parsed in entries:
name = parsed["frontmatter"].get("name", meta["slug"])
# Prefer bundled over optional if a name collision exists
if name not in skill_index or meta["source_kind"] == "bundled":
skill_index[name] = meta
# Write per-skill pages
written = 0
for meta, parsed in entries:
out_path = page_output_path(meta)
out_path.parent.mkdir(parents=True, exist_ok=True)
content = render_skill_page(
meta, parsed["frontmatter"], parsed["body"], skill_index=skill_index
)
out_path.write_text(content, encoding="utf-8")
written += 1
print(f"Wrote {written} per-skill pages under {SKILLS_PAGES}")
# Regenerate catalogs
bundled_catalog = build_catalog_md_bundled(entries)
(DOCS / "reference" / "skills-catalog.md").write_text(bundled_catalog, encoding="utf-8")
print("Updated reference/skills-catalog.md")
optional_catalog = build_catalog_md_optional(entries)
(DOCS / "reference" / "optional-skills-catalog.md").write_text(optional_catalog, encoding="utf-8")
print("Updated reference/optional-skills-catalog.md")
# Update sidebar
write_sidebar(entries)
if __name__ == "__main__":
main()
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
// Runs website/scripts/extract-skills.py and generate-llms-txt.py before
// docusaurus build/start so that:
// - website/static/api/skills.json (lazy-fetched by src/pages/skills/index.tsx)
// - website/static/api/skills-meta.json (sidecar metadata for the Skills Hub)
// - website/static/llms.txt (agent-friendly short docs index)
// - website/static/llms-full.txt (full docs concat for LLM context)
// all exist without contributors remembering to run Python scripts manually.
// CI workflows still run the extraction explicitly, which is a no-op duplicate
// but matches their historical behaviour.
//
// We also try to pull a fresh copy of skills-index.json (the unified
// multi-source catalog) from the live docs site if it's not already on disk.
// That way local `npm run build` doesn't have to wait on
// scripts/build_skills_index.py crawling every skill source — which takes
// several minutes and burns GitHub API quota — but still gets the same
// 2000+ external skills the deployed site has.
//
// If python3 or its deps (pyyaml) aren't available on the local machine, we
// fall back to writing an empty skills.json so `npm run build` still
// succeeds — the Skills Hub page just shows an empty state, and llms.txt
// generation is skipped. CI always has the deps installed, so production
// deploys get real data.
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync, existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
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 outputFile = join(websiteDir, "static", "api", "skills.json");
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
const UNIFIED_INDEX_URL =
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json";
const UNIFIED_INDEX_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
function writeEmptyFallback(reason) {
mkdirSync(dirname(outputFile), { recursive: true });
writeFileSync(outputFile, "[]\n");
console.warn(
`[prebuild] extract-skills.py skipped (${reason}); wrote empty skills.json. ` +
`Install python3 + pyyaml locally for a populated Skills Hub page.`,
);
}
function runPython(script, label) {
if (!existsSync(script)) {
console.warn(`[prebuild] ${label} skipped (script missing)`);
return false;
}
const r = spawnSync("python3", [script], { stdio: "inherit", cwd: websiteDir });
if (r.error && r.error.code === "ENOENT") {
console.warn(`[prebuild] ${label} skipped (python3 not found)`);
return false;
}
if (r.status !== 0) {
console.warn(`[prebuild] ${label} exited with status ${r.status}`);
return false;
}
return true;
}
async function ensureUnifiedIndex() {
// If we have a recent copy on disk, trust it.
if (existsSync(unifiedIndexFile)) {
try {
const age = Date.now() - statSync(unifiedIndexFile).mtimeMs;
if (age < UNIFIED_INDEX_MAX_AGE_MS) {
return true;
}
console.log(
`[prebuild] skills-index.json is ${(age / 3600000).toFixed(1)}h old; ` +
`refreshing from ${UNIFIED_INDEX_URL}`,
);
} catch {
// fall through to re-fetch
}
}
try {
const resp = await fetch(UNIFIED_INDEX_URL, {
headers: { accept: "application/json" },
});
if (!resp.ok) {
console.warn(
`[prebuild] skills-index.json fetch returned HTTP ${resp.status}; ` +
`using local copy if any`,
);
return existsSync(unifiedIndexFile);
}
const text = await resp.text();
// Sanity check: must be valid JSON with a skills array
try {
const parsed = JSON.parse(text);
if (!parsed || !Array.isArray(parsed.skills)) {
console.warn(
"[prebuild] skills-index.json from live site has no skills array; ignoring",
);
return existsSync(unifiedIndexFile);
}
} catch (e) {
console.warn(`[prebuild] skills-index.json from live site is not valid JSON: ${e}`);
return existsSync(unifiedIndexFile);
}
mkdirSync(dirname(unifiedIndexFile), { recursive: true });
writeFileSync(unifiedIndexFile, text);
console.log(
`[prebuild] downloaded skills-index.json from ${UNIFIED_INDEX_URL} ` +
`(${(text.length / 1024).toFixed(0)} KB)`,
);
return true;
} catch (e) {
console.warn(`[prebuild] skills-index.json fetch failed: ${e}`);
return existsSync(unifiedIndexFile);
}
}
// 0) Pull unified index if we don't have a fresh one.
await ensureUnifiedIndex();
// 1) skills.json — required for the Skills Hub page.
if (!existsSync(extractScript)) {
writeEmptyFallback("extract script missing");
} else {
const r = spawnSync("python3", [extractScript], {
stdio: "inherit",
cwd: websiteDir,
});
if (r.error && r.error.code === "ENOENT") {
writeEmptyFallback("python3 not found");
} else if (r.status !== 0) {
writeEmptyFallback(`extract-skills.py exited with status ${r.status}`);
}
}
// 2) llms.txt + llms-full.txt — agent-friendly docs entrypoints. Non-fatal.
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");