/ website / scripts / generate-skill-docs.py
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        * `{` -> `&#123;`  (prevents MDX from parsing JSX expressions)
 72        * `}` -> `&#125;`
 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("&#123;")
145                  elif ch == "}":
146                      out.append("&#125;")
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("&lt;")
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()