From 5971a4e0925f88fc6afa4f6ebef743d852d46810 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 9 May 2026 18:17:39 -0700 Subject: [PATCH] feat(docs): richer info panels on the Skills Hub for built-in + optional skills (#22905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Skills Hub at /skills had cards that, when expanded, showed only the one-line description, tags, author, version, and an install command. For the 163 bundled and optional skills shipped with the repo, this was thinner than the data we already have on disk. Three changes, all under website/: 1. extract-skills.py now pulls four extra fields per local skill: - 'overview' — first non-heading body paragraph from SKILL.md (stripped of admonitions/code fences, capped at ~500 chars at a sentence boundary) - 'envVars' / 'commands' — from the prerequisites: block in frontmatter - 'license' — from the top-level frontmatter - 'docsPath' — slug to the per-skill /docs/user-guide/skills/.../* page, computed with the same logic as generate-skill-docs.py 162 of 163 local skills get a non-empty overview automatically. The remaining one (media/heartmula) has only headings/code in its body and falls through to the description. 2. Skill TS interface + SkillCard expanded-panel render the new fields: - Overview paragraph at the top of the panel - Prerequisites box (env vars + required commands) when frontmatter declares them - License row alongside author/version - 'View full documentation →' link to the per-skill docs page Search now covers the overview text too, so users can find skills by matching content from inside SKILL.md, not just the one-line description. 3. styles.module.css gains six new classes (overviewBlock, detailLabel, overviewText, prereqBlock/Row/Kind/List/Item, docsLink) styled to match the existing dark panel aesthetic. External / community skills (Anthropic, LobeHub, Claude Marketplace cached indexes) keep the old behavior — overview is empty, no prereqs, no docsPath. Validation: 'npm run build' clean (exit 0); broken-link count unchanged at 155 baseline; all 163 generated docsPath values resolve to existing pages under website/docs/user-guide/skills/. --- website/scripts/extract-skills.py | 85 ++++++++++++++++++++ website/src/pages/skills/index.tsx | 53 ++++++++++++- website/src/pages/skills/styles.module.css | 91 ++++++++++++++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index b106a9527b8..302fbe51c30 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -56,6 +56,67 @@ SOURCE_LABELS = { } +def _extract_overview(body: str) -> str: + """Pull the first non-heading paragraph from a SKILL.md body. + + Skips H1/H2/etc. lines so the overview is real prose, not a heading. + Strips markdown links/code-fence syntax to plain-ish text. Capped at + ~500 chars so the SkillCard panel stays a reasonable size. + """ + if not body: + return "" + paragraphs = [p.strip() for p in body.split("\n\n") if p.strip()] + for p in paragraphs[:6]: + # Skip pure heading paragraphs ("# Foo", "## Foo") + if p.startswith("#"): + # If a heading paragraph also has body text on later lines, take those + 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 + # Skip a leading admonition fence (:::tip / :::info / etc.) + if p.startswith(":::"): + continue + # Skip pure code fences and frontmatter-style blocks + if p.startswith("```") or p.startswith("~~~"): + continue + # Trim to roughly 500 chars at a sentence boundary + 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///SKILL.md -> bundled//- + bundled + skills////SKILL.md -> bundled//-- + optional + optional-skills///SKILL.md -> optional//- + """ + 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 extract_local_skills(): skills = [] @@ -87,6 +148,9 @@ def extract_local_skills(): 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] @@ -101,9 +165,26 @@ def extract_local_skills(): if isinstance(tags, str): tags = [tags] + # Optional structured prerequisites — surfaced in the SkillCard panel + 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, @@ -111,6 +192,10 @@ def extract_local_skills(): "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 diff --git a/website/src/pages/skills/index.tsx b/website/src/pages/skills/index.tsx index 7e2311a6cd2..0f01f7b683f 100644 --- a/website/src/pages/skills/index.tsx +++ b/website/src/pages/skills/index.tsx @@ -6,6 +6,7 @@ import styles from "./styles.module.css"; interface Skill { name: string; description: string; + overview?: string; category: string; categoryLabel: string; source: string; @@ -13,6 +14,10 @@ interface Skill { platforms: string[]; author: string; version: string; + license?: string; + envVars?: string[]; + commands?: string[]; + docsPath?: string; } const allSkills: Skill[] = skills as Skill[]; @@ -179,6 +184,37 @@ function SkillCard({ {expanded && (
+ {skill.overview && ( +
+ Overview +

{skill.overview}

+
+ )} + {(skill.envVars?.length || skill.commands?.length) ? ( +
+ Prerequisites + {skill.envVars?.length ? ( +
+ env + + {skill.envVars.map((v) => ( + {v} + ))} + +
+ ) : null} + {skill.commands?.length ? ( +
+ cmd + + {skill.commands.map((c) => ( + {c} + ))} + +
+ ) : null} +
+ ) : null} {skill.tags?.length > 0 && (
{skill.tags.map((tag) => ( @@ -207,9 +243,24 @@ function SkillCard({ {skill.version}
)} + {skill.license && ( +
+ License + {skill.license} +
+ )}
hermes skills install {skill.name}
+ {skill.docsPath && ( + e.stopPropagation()} + > + View full documentation → + + )}
)} @@ -289,7 +340,7 @@ export default function SkillsDashboard() { if (sourceFilter !== "all" && s.source !== sourceFilter) return false; if (categoryFilter !== "all" && s.category !== categoryFilter) return false; if (q) { - const haystack = [s.name, s.description, s.categoryLabel, s.author, ...(s.tags || [])] + const haystack = [s.name, s.description, s.overview, s.categoryLabel, s.author, ...(s.tags || [])] .join(" ") .toLowerCase(); return haystack.includes(q); diff --git a/website/src/pages/skills/styles.module.css b/website/src/pages/skills/styles.module.css index a1bbfd000a3..94dce0a7493 100644 --- a/website/src/pages/skills/styles.module.css +++ b/website/src/pages/skills/styles.module.css @@ -638,6 +638,97 @@ padding: 0; } +.overviewBlock { + margin-bottom: 0.75rem; +} + +.detailLabel { + display: block; + font-family: "JetBrains Mono", monospace; + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ifm-font-color-secondary); + opacity: 0.55; + margin-bottom: 0.3rem; +} + +.overviewText { + font-size: 0.82rem; + line-height: 1.5; + color: var(--ifm-font-color-base); + opacity: 0.92; + margin: 0; + white-space: pre-wrap; +} + +.prereqBlock { + margin-bottom: 0.75rem; + padding: 0.5rem 0.65rem; + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 5px; + background: rgba(255, 255, 255, 0.015); +} + +.prereqRow { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.prereqRow:first-of-type { + margin-top: 0; +} + +.prereqKind { + font-family: "JetBrains Mono", monospace; + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ifm-font-color-secondary); + opacity: 0.55; + min-width: 2.5rem; + padding-top: 0.15rem; +} + +.prereqList { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.prereqItem { + font-family: "JetBrains Mono", monospace; + font-size: 0.7rem; + padding: 0.1rem 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); +} + +.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;