generate-skill-docs.py
1 #!/usr/bin/env python3 2 """Generate per-skill Docusaurus pages from skills/ and optional-skills/ SKILL.md files. 3 4 Each skill gets website/docs/user-guide/skills/<source>/<category>/<skill-name>.md 5 where <source> is "bundled" or "optional". 6 7 Also regenerates: 8 - website/docs/reference/skills-catalog.md 9 - website/docs/reference/optional-skills-catalog.md 10 (so their table rows link to the new dedicated pages) 11 12 Sidebar is updated to nest all per-skill pages under Skills → Bundled / Optional. 13 """ 14 15 from __future__ import annotations 16 import re 17 import sys 18 from collections import defaultdict 19 from pathlib import Path 20 from textwrap import dedent 21 from typing import Any 22 23 import yaml 24 25 REPO = Path(__file__).resolve().parent.parent.parent 26 DOCS = REPO / "website" / "docs" 27 SKILLS_PAGES = DOCS / "user-guide" / "skills" 28 29 SKILL_SOURCES = [ 30 ("bundled", REPO / "skills"), 31 ("optional", REPO / "optional-skills"), 32 ] 33 34 # Pages the user had previously hand-written in user-guide/skills/. 35 # We leave these alone (they get first-class sidebar treatment separately). 36 HAND_WRITTEN = {"godmode.md", "google-workspace.md"} 37 38 39 _FENCE_RE = re.compile(r"^(?P<indent>\s*)(?P<fence>```+|~~~+)", re.MULTILINE) 40 41 # Unicode box-drawing characters. If a generated fenced code block contains any 42 # of these, wrap it in `<!-- ascii-guard-ignore -->` so the docs-site-checks 43 # lint (which scans inside code fences) can't reject the page for a skill's 44 # own ASCII diagram. Skill authors shouldn't need to remember to add the 45 # ignore markers in every SKILL.md — the generator handles it defensively. 46 _BOX_DRAWING_CHARS = frozenset("┌┐└┘─│═║╔╗╚╝╠╣╦╩╬├┤┬┴┼╭╮╯╰▶◀▲▼") 47 48 49 def _wrap_ascii_art_code_blocks(code_segment: str) -> str: 50 """Wrap a fenced code segment in ascii-guard-ignore markers if it contains 51 box-drawing characters. No-op otherwise, so plain bash/python code blocks 52 stay uncluttered. 53 54 Already-wrapped segments (the SKILL.md source added its own markers) are 55 left alone — double-wrapping is harmless but we'd rather keep the output 56 clean. 57 """ 58 if not any(ch in _BOX_DRAWING_CHARS for ch in code_segment): 59 return code_segment 60 return ( 61 "<!-- ascii-guard-ignore -->\n" 62 f"{code_segment}\n" 63 "<!-- ascii-guard-ignore-end -->" 64 ) 65 66 67 def mdx_escape_body(body: str) -> str: 68 """Escape MDX-dangerous characters in markdown body, leaving fenced code blocks alone. 69 70 Outside fenced code blocks: 71 * `{` -> `{` (prevents MDX from parsing JSX expressions) 72 * `}` -> `}` 73 * `<tag>` for bare tags that aren't whitelisted HTML get HTML-entity-escaped 74 * inline `` `code` `` content is preserved (backticks handled naturally) 75 Inside fenced code blocks: untouched. 76 77 We also preserve `<br>`, `<br/>`, `<img ...>`, `<a ...>`, and a handful of 78 other markup-safe tags because Docusaurus/MDX accepts them as HTML. 79 """ 80 # Split the body into segments by fenced code blocks, alternating 81 # (text, code, text, code, ...). A line like ``` or ~~~ opens a fence; 82 # a matching marker closes it. 83 lines = body.split("\n") 84 segments: list[tuple[str, str]] = [] # ("text"|"code", content) 85 buf: list[str] = [] 86 mode = "text" 87 fence_char: str | None = None 88 fence_len = 0 89 for line in lines: 90 stripped = line.lstrip() 91 if mode == "text": 92 if stripped.startswith("```") or stripped.startswith("~~~"): 93 # Opening fence 94 if buf: 95 segments.append(("text", "\n".join(buf))) 96 buf = [] 97 buf.append(line) 98 # Detect fence char + length 99 m = re.match(r"(`{3,}|~{3,})", stripped) 100 if m: 101 fence_char = m.group(1)[0] 102 fence_len = len(m.group(1)) 103 mode = "code" 104 else: 105 buf.append(line) 106 else: # code mode 107 buf.append(line) 108 if fence_char is not None and stripped.startswith(fence_char * fence_len): 109 # Closing fence 110 segments.append(("code", "\n".join(buf))) 111 buf = [] 112 mode = "text" 113 fence_char = None 114 fence_len = 0 115 if buf: 116 segments.append((mode, "\n".join(buf))) 117 118 def escape_text(text: str) -> str: 119 # Walk inline-code runs (backticks) and leave them alone. 120 # Pattern matches runs of backticks, then the matched content, then the 121 # same number of backticks. 122 out: list[str] = [] 123 i = 0 124 while i < len(text): 125 ch = text[i] 126 if ch == "`": 127 # Find the run of backticks 128 j = i 129 while j < len(text) and text[j] == "`": 130 j += 1 131 run = text[i:j] 132 # Find matching run 133 end = text.find(run, j) 134 if end == -1: 135 # No closing -- just keep as-is 136 out.append(text[i:]) 137 i = len(text) 138 continue 139 out.append(text[i : end + len(run)]) 140 i = end + len(run) 141 else: 142 # Escape MDX metacharacters 143 if ch == "{": 144 out.append("{") 145 elif ch == "}": 146 out.append("}") 147 elif ch == "<": 148 # Preserve full HTML comments (e.g. ascii-guard ignore markers) — they 149 # are not HTML tags, so the tag regex below would escape the leading <. 150 if text[i:].startswith("<!--"): 151 end = text.find("-->", i) 152 if end != -1: 153 out.append(text[i : end + 3]) 154 i = end + 3 155 continue 156 # Look ahead to see if this is a valid HTML-ish tag. 157 # If it looks like a tag name then alnum/-/_ chars, leave it. 158 # Otherwise escape. 159 m = re.match( 160 r"<(/?)([A-Za-z][A-Za-z0-9]*)([^<>]*)>", 161 text[i:], 162 ) 163 if m: 164 tag = m.group(2).lower() 165 # Whitelist known-safe HTML tags 166 safe_tags = { 167 "br", 168 "hr", 169 "img", 170 "a", 171 "b", 172 "i", 173 "em", 174 "strong", 175 "code", 176 "kbd", 177 "sup", 178 "sub", 179 "span", 180 "div", 181 "p", 182 "ul", 183 "ol", 184 "li", 185 "table", 186 "thead", 187 "tbody", 188 "tr", 189 "td", 190 "th", 191 "details", 192 "summary", 193 "blockquote", 194 "pre", 195 "mark", 196 "small", 197 "u", 198 "s", 199 "del", 200 "ins", 201 "h1", 202 "h2", 203 "h3", 204 "h4", 205 "h5", 206 "h6", 207 } 208 if tag in safe_tags: 209 out.append(m.group(0)) 210 i += len(m.group(0)) 211 continue 212 # Escape the `<` 213 out.append("<") 214 else: 215 out.append(ch) 216 i += 1 217 return "".join(out) 218 219 processed: list[str] = [] 220 for kind, content in segments: 221 if kind == "code": 222 processed.append(_wrap_ascii_art_code_blocks(content)) 223 else: 224 processed.append(escape_text(content)) 225 return "\n".join(processed) 226 227 228 def rewrite_relative_links(body: str, meta: dict[str, Any]) -> str: 229 """Rewrite references/foo.md style links in the SKILL.md body. 230 231 The source SKILL.md lives in `skills/<...>` and references sibling files 232 with paths like `references/foo.md` or `./templates/bar.md`. Those files 233 are NOT copied into docs/, so we rewrite these to absolute GitHub URLs 234 pointing to the file in the repo. 235 """ 236 source_dir = "skills" if meta["source_kind"] == "bundled" else "optional-skills" 237 base = f"https://github.com/NousResearch/hermes-agent/blob/main/{source_dir}/{meta['rel_path']}" 238 239 def sub_link(m: re.Match) -> str: 240 text = m.group(1) 241 url = m.group(2).strip() 242 # Skip URLs that already start with a scheme or // 243 if re.match(r"^[a-z]+://", url) or url.startswith("#") or url.startswith("/"): 244 return m.group(0) 245 # Skip mailto 246 if url.startswith("mailto:"): 247 return m.group(0) 248 # Strip leading ./ 249 url_clean = url[2:] if url.startswith("./") else url 250 full = f"{base}/{url_clean}" 251 return f"[{text}]({full})" 252 253 return re.sub(r"\[([^\]]+)\]\(([^)]+)\)", sub_link, body) 254 255 256 def parse_skill_md(path: Path) -> dict[str, Any]: 257 text = path.read_text(encoding="utf-8") 258 if not text.startswith("---"): 259 raise ValueError(f"{path}: no frontmatter") 260 parts = text.split("---", 2) 261 if len(parts) < 3: 262 raise ValueError(f"{path}: malformed frontmatter") 263 fm_text, body = parts[1], parts[2] 264 try: 265 fm = yaml.safe_load(fm_text) or {} 266 except yaml.YAMLError as exc: 267 raise ValueError(f"{path}: YAML error: {exc}") from exc 268 return {"frontmatter": fm, "body": body.lstrip("\n")} 269 270 271 def sanitize_yaml_string(s: str) -> str: 272 """Make a string safe to embed in a YAML double-quoted scalar.""" 273 s = s.replace("\\", "\\\\").replace('"', '\\"') 274 # Collapse newlines to spaces. 275 s = re.sub(r"\s+", " ", s).strip() 276 return s 277 278 279 def derive_skill_meta(skill_path: Path, source_dir: Path, source_kind: str) -> dict[str, Any]: 280 """Extract category + skill slug from filesystem layout. 281 282 skills/<cat>/<skill>/SKILL.md -> cat=<cat>, slug=<skill> 283 skills/<cat>/<sub>/<skill>/SKILL.md -> cat=<cat>, sub=<sub>, slug=<skill> 284 optional-skills/<cat>/<skill>/SKILL.md -> cat=<cat>, slug=<skill> 285 """ 286 rel = skill_path.parent.relative_to(source_dir) 287 parts = rel.parts 288 if len(parts) == 1: 289 # Top-level skill (e.g. skills/dogfood/SKILL.md) -- rare 290 category = parts[0] 291 sub = None 292 slug = parts[0] 293 elif len(parts) == 2: 294 category, slug = parts 295 sub = None 296 elif len(parts) == 3: 297 category, sub, slug = parts 298 else: 299 raise ValueError(f"Unexpected skill layout: {skill_path}") 300 return { 301 "source_kind": source_kind, # bundled | optional 302 "category": category, 303 "sub": sub, 304 "slug": slug, 305 "rel_path": str(rel), 306 } 307 308 309 def page_id(meta: dict[str, Any]) -> str: 310 """Stable slug used for filename + sidebar id.""" 311 if meta["sub"]: 312 return f"{meta['category']}-{meta['sub']}-{meta['slug']}" 313 return f"{meta['category']}-{meta['slug']}" 314 315 316 def page_output_path(meta: dict[str, Any]) -> Path: 317 return ( 318 SKILLS_PAGES 319 / meta["source_kind"] 320 / meta["category"] 321 / f"{page_id(meta)}.md" 322 ) 323 324 325 def sidebar_doc_id(meta: dict[str, Any]) -> str: 326 """Docusaurus sidebar id, relative to docs/.""" 327 return f"user-guide/skills/{meta['source_kind']}/{meta['category']}/{page_id(meta)}" 328 329 330 def render_skill_page( 331 meta: dict[str, Any], 332 fm: dict[str, Any], 333 body: str, 334 skill_index: dict[str, dict[str, Any]] | None = None, 335 ) -> str: 336 name = fm.get("name", meta["slug"]) 337 description = fm.get("description", "").strip() 338 short_desc = description.split(".")[0].strip() if description else name 339 if len(short_desc) > 160: 340 short_desc = short_desc[:157] + "..." 341 342 title = f"{name}" 343 # Heuristic nicer title from name 344 display_name = name.replace("-", " ").replace("_", " ").title() 345 346 hermes_meta = (fm.get("metadata") or {}).get("hermes") or {} 347 tags = hermes_meta.get("tags") or [] 348 related = hermes_meta.get("related_skills") or [] 349 platforms = fm.get("platforms") 350 version = fm.get("version") 351 author = fm.get("author") 352 license_ = fm.get("license") 353 deps = fm.get("dependencies") 354 355 # Build metadata info block 356 info_rows: list[tuple[str, str]] = [] 357 if meta["source_kind"] == "bundled": 358 info_rows.append(("Source", "Bundled (installed by default)")) 359 else: 360 info_rows.append( 361 ( 362 "Source", 363 "Optional — install with `hermes skills install official/" 364 + meta["category"] 365 + "/" 366 + meta["slug"] 367 + "`", 368 ) 369 ) 370 source_dir = "skills" if meta["source_kind"] == "bundled" else "optional-skills" 371 info_rows.append(("Path", f"`{source_dir}/{meta['rel_path']}`")) 372 if version: 373 info_rows.append(("Version", f"`{version}`")) 374 if author: 375 info_rows.append(("Author", str(author))) 376 if license_: 377 info_rows.append(("License", str(license_))) 378 if deps: 379 if isinstance(deps, list): 380 deps_str = ", ".join(f"`{d}`" for d in deps) if deps else "None" 381 else: 382 deps_str = f"`{deps}`" 383 info_rows.append(("Dependencies", deps_str)) 384 if platforms: 385 if isinstance(platforms, list): 386 plat_str = ", ".join(platforms) 387 else: 388 plat_str = str(platforms) 389 info_rows.append(("Platforms", plat_str)) 390 if tags: 391 info_rows.append(("Tags", ", ".join(f"`{t}`" for t in tags))) 392 if related: 393 # link to sibling pages when possible -- fall back to plain code 394 link_parts = [] 395 for r in related: 396 target_meta = None 397 if skill_index is not None: 398 target_meta = skill_index.get(r) 399 if target_meta is not None: 400 href = ( 401 f"/docs/user-guide/skills/{target_meta['source_kind']}" 402 f"/{target_meta['category']}/{page_id(target_meta)}" 403 ) 404 link_parts.append(f"[`{r}`]({href})") 405 else: 406 link_parts.append(f"`{r}`") 407 info_rows.append(("Related skills", ", ".join(link_parts))) 408 409 info_block = "\n".join(f"| {k} | {v} |" for k, v in info_rows) 410 info_table = ( 411 "| | |\n|---|---|\n" + info_block 412 ) 413 414 # Frontmatter for Docusaurus 415 fm_title = sanitize_yaml_string(display_name + " — " + (short_desc or name)) 416 if len(fm_title) > 120: 417 fm_title = sanitize_yaml_string(display_name) 418 fm_desc = sanitize_yaml_string(short_desc or description or name) 419 sidebar_label = sanitize_yaml_string(display_name) 420 421 body_clean = mdx_escape_body(rewrite_relative_links(body.strip(), meta)) 422 423 # Guard against the first heading in body being `# Xxx Skill` which would 424 # duplicate the page title -- Docusaurus handles this fine because the 425 # frontmatter `title` drives the page header and TOC. 426 427 return ( 428 "---\n" 429 f'title: "{fm_title}"\n' 430 f'sidebar_label: "{sidebar_label}"\n' 431 f'description: "{fm_desc}"\n' 432 "---\n" 433 "\n" 434 "{/* 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" 435 "\n" 436 f"# {display_name}\n" 437 "\n" 438 f"{mdx_escape_body(description)}\n" 439 "\n" 440 "## Skill metadata\n" 441 "\n" 442 f"{info_table}\n" 443 "\n" 444 "## Reference: full SKILL.md\n" 445 "\n" 446 ":::info\n" 447 "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" 448 ":::\n" 449 "\n" 450 f"{body_clean}\n" 451 ) 452 453 454 def discover_skills() -> list[tuple[dict[str, Any], dict[str, Any]]]: 455 results: list[tuple[dict[str, Any], dict[str, Any]]] = [] 456 for kind, source_dir in SKILL_SOURCES: 457 for skill_md in sorted(source_dir.rglob("SKILL.md")): 458 meta = derive_skill_meta(skill_md, source_dir, kind) 459 parsed = parse_skill_md(skill_md) 460 results.append((meta, parsed)) 461 return results 462 463 464 def build_catalog_md_bundled(entries: list[tuple[dict[str, Any], dict[str, Any]]]) -> str: 465 by_cat: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list) 466 for meta, parsed in entries: 467 if meta["source_kind"] != "bundled": 468 continue 469 by_cat[meta["category"]].append((meta, parsed)) 470 for k in by_cat: 471 by_cat[k].sort(key=lambda e: e[0]["slug"]) 472 473 lines = [ 474 "---", 475 "sidebar_position: 5", 476 'title: "Bundled Skills Catalog"', 477 'description: "Catalog of bundled skills that ship with Hermes Agent"', 478 "---", 479 "", 480 "# Bundled Skills Catalog", 481 "", 482 "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.", 483 "", 484 "If a skill is missing from this list but present in the repo, the catalog is regenerated by `website/scripts/generate-skill-docs.py`.", 485 "", 486 ] 487 for category in sorted(by_cat): 488 lines.append(f"## {category}") 489 lines.append("") 490 lines.append("| Skill | Description | Path |") 491 lines.append("|-------|-------------|------|") 492 for meta, parsed in by_cat[category]: 493 fm = parsed["frontmatter"] 494 name = fm.get("name", meta["slug"]) 495 desc = (fm.get("description") or "").strip() 496 if len(desc) > 240: 497 desc = desc[:237].rstrip() + "..." 498 link_target = f"/docs/user-guide/skills/bundled/{meta['category']}/{page_id(meta)}" 499 path = f"`{meta['rel_path']}`" 500 desc_esc = mdx_escape_body(desc).replace("|", "\\|").replace("\n", " ") 501 lines.append( 502 f"| [`{name}`]({link_target}) | {desc_esc} | {path} |" 503 ) 504 lines.append("") 505 return "\n".join(lines).rstrip() + "\n" 506 507 508 def build_catalog_md_optional(entries: list[tuple[dict[str, Any], dict[str, Any]]]) -> str: 509 by_cat: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list) 510 for meta, parsed in entries: 511 if meta["source_kind"] != "optional": 512 continue 513 by_cat[meta["category"]].append((meta, parsed)) 514 for k in by_cat: 515 by_cat[k].sort(key=lambda e: e[0]["slug"]) 516 517 lines = [ 518 "---", 519 "sidebar_position: 9", 520 'title: "Optional Skills Catalog"', 521 'description: "Official optional skills shipped with hermes-agent — install via hermes skills install official/<category>/<skill>"', 522 "---", 523 "", 524 "# Optional Skills Catalog", 525 "", 526 "Optional skills ship with hermes-agent under `optional-skills/` but are **not active by default**. Install them explicitly:", 527 "", 528 "```bash", 529 "hermes skills install official/<category>/<skill>", 530 "```", 531 "", 532 "For example:", 533 "", 534 "```bash", 535 "hermes skills install official/blockchain/solana", 536 "hermes skills install official/mlops/flash-attention", 537 "```", 538 "", 539 "Each skill below links to a dedicated page with its full definition, setup, and usage.", 540 "", 541 "To uninstall:", 542 "", 543 "```bash", 544 "hermes skills uninstall <skill-name>", 545 "```", 546 "", 547 ] 548 for category in sorted(by_cat): 549 lines.append(f"## {category}") 550 lines.append("") 551 lines.append("| Skill | Description |") 552 lines.append("|-------|-------------|") 553 for meta, parsed in by_cat[category]: 554 fm = parsed["frontmatter"] 555 name = fm.get("name", meta["slug"]) 556 desc = (fm.get("description") or "").strip() 557 if len(desc) > 240: 558 desc = desc[:237].rstrip() + "..." 559 link_target = f"/docs/user-guide/skills/optional/{meta['category']}/{page_id(meta)}" 560 desc_esc = mdx_escape_body(desc).replace("|", "\\|").replace("\n", " ") 561 lines.append(f"| [**{name}**]({link_target}) | {desc_esc} |") 562 lines.append("") 563 564 lines.extend( 565 [ 566 "---", 567 "", 568 "## Contributing Optional Skills", 569 "", 570 "To add a new optional skill to the repository:", 571 "", 572 "1. Create a directory under `optional-skills/<category>/<skill-name>/`", 573 "2. Add a `SKILL.md` with standard frontmatter (name, description, version, author)", 574 "3. Include any supporting files in `references/`, `templates/`, or `scripts/` subdirectories", 575 "4. Submit a pull request — the skill will appear in this catalog and get its own docs page once merged", 576 ] 577 ) 578 return "\n".join(lines).rstrip() + "\n" 579 580 581 def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) -> dict: 582 """Build a dict representing the Skills sidebar tree. 583 584 Structure: 585 Skills 586 ├── (hand-written pages first: godmode, google-workspace) 587 ├── Bundled 588 │ ├── apple 589 │ │ ├── apple-apple-notes 590 │ │ └── ... 591 │ └── ... 592 └── Optional 593 └── ... 594 """ 595 bundled = defaultdict(list) 596 optional = defaultdict(list) 597 for meta, _ in entries: 598 if meta["source_kind"] == "bundled": 599 bundled[meta["category"]].append(meta) 600 else: 601 optional[meta["category"]].append(meta) 602 603 def cat_section(bucket: dict[str, list[dict[str, Any]]]) -> list[dict]: 604 result = [] 605 for category in sorted(bucket): 606 items = sorted(bucket[category], key=lambda m: m["slug"]) 607 result.append( 608 { 609 "type": "category", 610 "label": category, 611 "collapsed": True, 612 "items": [sidebar_doc_id(m) for m in items], 613 } 614 ) 615 return result 616 617 return { 618 "bundled_categories": cat_section(bundled), 619 "optional_categories": cat_section(optional), 620 } 621 622 623 def write_sidebar(entries): 624 # The per-skill pages (`build_sidebar_items(entries)`) are still generated 625 # as standalone docs under `website/docs/user-guide/skills/{bundled,optional}/` 626 # and reachable via the catalog pages in Reference — but we intentionally 627 # do NOT explode them into the left sidebar. Two hundred-plus skill entries 628 # drown the actual product docs and make the site feel overwhelming to 629 # first-time visitors. 630 # 631 # Sidebar now shows: 632 # Skills 633 # ├── Bundled catalog → (link to reference/skills-catalog) 634 # └── Optional catalog → (link to reference/optional-skills-catalog) 635 # 636 # The catalog pages are auto-regenerated tables with a link to every skill. 637 # Individual skill pages (including the two formerly hand-written guides, 638 # godmode and google-workspace) are still reachable at their URLs and are 639 # linked from the catalog tables and from the Skills overview page — they 640 # just aren't promoted in the left sidebar, because there's no principled 641 # rule for which skills would get promoted and which wouldn't. 642 _ = build_sidebar_items(entries) # still called for any side effects / validation 643 644 skills_subtree = ( 645 " {\n" 646 " type: 'category',\n" 647 " label: 'Skills',\n" 648 " collapsed: true,\n" 649 " items: [\n" 650 " 'reference/skills-catalog',\n" 651 " 'reference/optional-skills-catalog',\n" 652 " ],\n" 653 " },\n" 654 ) 655 656 sidebar_path = REPO / "website" / "sidebars.ts" 657 text = sidebar_path.read_text(encoding="utf-8") 658 # Replace the existing Skills block. 659 pattern = re.compile( 660 r" \{\n" 661 r" type: 'category',\n" 662 r" label: 'Skills',\n" 663 r"(?:.*?\n)*?" 664 r" \},\n", 665 re.DOTALL, 666 ) 667 # Safer: match the exact current block shape. 668 old_block_start = " {\n type: 'category',\n label: 'Skills',\n" 669 i = text.find(old_block_start) 670 if i == -1: 671 raise RuntimeError("Could not find Skills sidebar block to replace") 672 # Find matching closing of this block -- walk brace depth 673 depth = 0 674 j = i 675 while j < len(text): 676 ch = text[j] 677 if ch == "{": 678 depth += 1 679 elif ch == "}": 680 depth -= 1 681 if depth == 0: 682 # Include the trailing ,\n after the closing brace 683 end = text.find("\n", j) + 1 684 break 685 j += 1 686 else: 687 raise RuntimeError("Could not find end of Skills sidebar block") 688 689 new_text = text[:i] + skills_subtree + text[end:] 690 sidebar_path.write_text(new_text, encoding="utf-8") 691 print(f"Updated sidebar: {sidebar_path}") 692 693 694 def main(): 695 entries = discover_skills() 696 print(f"Discovered {len(entries)} skills") 697 698 # Build name -> meta index for related-skill cross-linking 699 skill_index: dict[str, dict[str, Any]] = {} 700 for meta, parsed in entries: 701 name = parsed["frontmatter"].get("name", meta["slug"]) 702 # Prefer bundled over optional if a name collision exists 703 if name not in skill_index or meta["source_kind"] == "bundled": 704 skill_index[name] = meta 705 706 # Write per-skill pages 707 written = 0 708 for meta, parsed in entries: 709 out_path = page_output_path(meta) 710 out_path.parent.mkdir(parents=True, exist_ok=True) 711 content = render_skill_page( 712 meta, parsed["frontmatter"], parsed["body"], skill_index=skill_index 713 ) 714 out_path.write_text(content, encoding="utf-8") 715 written += 1 716 print(f"Wrote {written} per-skill pages under {SKILLS_PAGES}") 717 718 # Regenerate catalogs 719 bundled_catalog = build_catalog_md_bundled(entries) 720 (DOCS / "reference" / "skills-catalog.md").write_text(bundled_catalog, encoding="utf-8") 721 print("Updated reference/skills-catalog.md") 722 723 optional_catalog = build_catalog_md_optional(entries) 724 (DOCS / "reference" / "optional-skills-catalog.md").write_text(optional_catalog, encoding="utf-8") 725 print("Updated reference/optional-skills-catalog.md") 726 727 # Update sidebar 728 write_sidebar(entries) 729 730 731 if __name__ == "__main__": 732 main()