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;