feat(docs): richer info panels on the Skills Hub for built-in + optional skills (#22905)

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/.
This commit is contained in:
Teknium 2026-05-09 18:17:39 -07:00 committed by GitHub
parent da086a0154
commit 5971a4e092
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 228 additions and 1 deletions

View file

@ -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/<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 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

View file

@ -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 && (
<div className={styles.cardDetail}>
{skill.overview && (
<div className={styles.overviewBlock}>
<span className={styles.detailLabel}>Overview</span>
<p className={styles.overviewText}>{skill.overview}</p>
</div>
)}
{(skill.envVars?.length || skill.commands?.length) ? (
<div className={styles.prereqBlock}>
<span className={styles.detailLabel}>Prerequisites</span>
{skill.envVars?.length ? (
<div className={styles.prereqRow}>
<span className={styles.prereqKind}>env</span>
<span className={styles.prereqList}>
{skill.envVars.map((v) => (
<code key={v} className={styles.prereqItem}>{v}</code>
))}
</span>
</div>
) : null}
{skill.commands?.length ? (
<div className={styles.prereqRow}>
<span className={styles.prereqKind}>cmd</span>
<span className={styles.prereqList}>
{skill.commands.map((c) => (
<code key={c} className={styles.prereqItem}>{c}</code>
))}
</span>
</div>
) : null}
</div>
) : null}
{skill.tags?.length > 0 && (
<div className={styles.tagRow}>
{skill.tags.map((tag) => (
@ -207,9 +243,24 @@ function SkillCard({
<span className={styles.authorValue}>{skill.version}</span>
</div>
)}
{skill.license && (
<div className={styles.authorRow}>
<span className={styles.authorLabel}>License</span>
<span className={styles.authorValue}>{skill.license}</span>
</div>
)}
<div className={styles.installHint}>
<code>hermes skills install {skill.name}</code>
</div>
{skill.docsPath && (
<a
className={styles.docsLink}
href={`/docs/user-guide/skills/${skill.docsPath}`}
onClick={(e) => e.stopPropagation()}
>
View full documentation
</a>
)}
</div>
)}
</div>
@ -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);

View file

@ -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;