1010"""
1111
1212import argparse
13+ import json
1314import os
1415import shutil
1516import sys
17+ import urllib .request
18+ from concurrent .futures import ThreadPoolExecutor
1619from pathlib import Path
1720
1821import yaml
@@ -421,6 +424,7 @@ def generate_plugin_page(plugin: dict, registry: dict, enrichment: dict | None,
421424
422425 # Skills table
423426 if skills :
427+ is_discovered = plugin .get ("_discovered" , False )
424428 lines .append ("## Skills" )
425429 lines .append ("" )
426430 lines .append ("| Skill | Description | Invocable |" )
@@ -430,7 +434,13 @@ def generate_plugin_page(plugin: dict, registry: dict, enrichment: dict | None,
430434 sdesc = " " .join (skill .get ("description" , "" ).split ())
431435 invocable = skill .get ("user-invocable" , True )
432436 badge = ":material-check:" if invocable else ":material-close: internal"
433- lines .append (f"| [`/{ sname } `]({ sname } .md) | { sdesc } | { badge } |" )
437+ if is_discovered and repo :
438+ ref = plugin .get ("source" , {}).get ("ref" , "main" )
439+ skill_path = skill .get ("path" , sname )
440+ link = f"https://github.com/{ repo } /tree/{ ref } /{ skill_path } "
441+ lines .append (f"| [`/{ sname } `]({ link } ) | { sdesc } | { badge } |" )
442+ else :
443+ lines .append (f"| [`/{ sname } `]({ sname } .md) | { sdesc } | { badge } |" )
434444 lines .append ("" )
435445
436446 # Agents table
@@ -770,6 +780,93 @@ def load_enrichment(plugin_dir: Path) -> dict | None:
770780 return None
771781
772782
783+ def _parse_frontmatter (content : str ) -> dict :
784+ """Parse YAML frontmatter from a SKILL.md file."""
785+ content = content .lstrip ()
786+ if not content .startswith ("---" ):
787+ return {}
788+ end = content .find ("\n ---" , 3 )
789+ if end == - 1 :
790+ return {}
791+ try :
792+ return yaml .safe_load (content [3 :end ]) or {}
793+ except yaml .YAMLError :
794+ return {}
795+
796+
797+ def _github_get (url : str ) -> bytes :
798+ """HTTP GET with optional GitHub token auth."""
799+ headers = {}
800+ token = os .environ .get ("GITHUB_TOKEN" ) or os .environ .get ("GH_TOKEN" )
801+ if token :
802+ headers ["Authorization" ] = f"Bearer { token } "
803+ req = urllib .request .Request (url , headers = headers )
804+ with urllib .request .urlopen (req , timeout = 30 ) as resp :
805+ return resp .read ()
806+
807+
808+ def discover_remote_skills (repo : str , ref : str = "main" ) -> list [dict ]:
809+ """Discover skills from a GitHub repo by fetching SKILL.md frontmatter."""
810+ tree_url = f"https://api.github.com/repos/{ repo } /git/trees/{ ref } ?recursive=1"
811+ tree_data = json .loads (_github_get (tree_url ))
812+
813+ if tree_data .get ("truncated" ):
814+ print (f" Warning: tree for { repo } was truncated, some skills may be missed" )
815+
816+ skill_entries = []
817+ for item in tree_data .get ("tree" , []):
818+ if item ["type" ] == "blob" and item ["path" ].endswith ("/SKILL.md" ):
819+ parent = item ["path" ].rsplit ("/" , 1 )[0 ]
820+ skill_name = parent .rsplit ("/" , 1 )[- 1 ]
821+ skill_entries .append ((skill_name , parent , item ["path" ]))
822+
823+ if not skill_entries :
824+ return []
825+
826+ def fetch_one (entry ):
827+ skill_name , parent_path , full_path = entry
828+ raw_url = f"https://raw.githubusercontent.com/{ repo } /{ ref } /{ full_path } "
829+ try :
830+ content = _github_get (raw_url ).decode ("utf-8" , errors = "replace" )
831+ fm = _parse_frontmatter (content )
832+ return {
833+ "name" : fm .get ("name" , skill_name ),
834+ "description" : fm .get ("description" , "" ),
835+ "user-invocable" : fm .get ("user-invocable" , True ),
836+ "path" : parent_path ,
837+ }
838+ except Exception :
839+ return {
840+ "name" : skill_name ,
841+ "description" : "" ,
842+ "user-invocable" : True ,
843+ "path" : parent_path ,
844+ }
845+
846+ with ThreadPoolExecutor (max_workers = 8 ) as pool :
847+ skills = list (pool .map (fetch_one , sorted (skill_entries )))
848+
849+ return skills
850+
851+
852+ def load_discovered (plugin_dir : Path ) -> list [dict ] | None :
853+ """Load cached discovered skills from _discovered.yaml."""
854+ path = plugin_dir / "_discovered.yaml"
855+ if path .exists ():
856+ with open (path ) as f :
857+ data = yaml .safe_load (f )
858+ if isinstance (data , list ):
859+ return data
860+ return None
861+
862+
863+ def save_discovered (plugin_dir : Path , skills : list [dict ]) -> None :
864+ """Write discovered skills cache."""
865+ path = plugin_dir / "_discovered.yaml"
866+ with open (path , "w" ) as f :
867+ yaml .dump (skills , f , default_flow_style = False , sort_keys = False )
868+
869+
773870def generate_mkdocs_yml (registry : dict , categories : dict ,
774871 cat_plugins : dict [str , list ]) -> str :
775872 """Generate complete mkdocs.yml with dynamic nav."""
@@ -787,9 +884,10 @@ def generate_mkdocs_yml(registry: dict, categories: dict,
787884 skills = plugin .get ("skills" , [])
788885 nav_lines .append (f" - { name } :" )
789886 nav_lines .append (f" - plugins/{ name } /index.md" )
790- for skill in skills :
791- sname = skill ["name" ]
792- nav_lines .append (f" - { sname } : plugins/{ name } /{ sname } .md" )
887+ if not plugin .get ("_discovered" ):
888+ for skill in skills :
889+ sname = skill ["name" ]
890+ nav_lines .append (f" - { sname } : plugins/{ name } /{ sname } .md" )
793891
794892 # Categories section — with index page
795893 nav_lines .append (" - Categories:" )
@@ -823,21 +921,37 @@ def generate_llms_txt(registry: dict, site_url: str) -> str:
823921 lines .append ("" )
824922 for p in plugins :
825923 pname = p ["name" ]
924+ is_discovered = p .get ("_discovered" , False )
925+ repo = p .get ("source" , {}).get ("repo" , "" )
926+ ref = p .get ("source" , {}).get ("ref" , "main" )
826927 for s in p .get ("skills" , []):
827928 sname = s ["name" ]
828929 sdesc = s .get ("description" , "" ).strip ()
829930 if s .get ("user-invocable" , True ):
830- lines .append (f"- [{ sname } ]({ site_url } /plugins/{ pname } /{ sname } /): { sdesc } " )
931+ if is_discovered and repo :
932+ skill_path = s .get ("path" , sname )
933+ link = f"https://github.com/{ repo } /tree/{ ref } /{ skill_path } "
934+ else :
935+ link = f"{ site_url } /plugins/{ pname } /{ sname } /"
936+ lines .append (f"- [{ sname } ]({ link } ): { sdesc } " )
831937 lines .append ("" )
832938 lines .append ("## Optional" )
833939 lines .append ("" )
834940 for p in plugins :
835941 pname = p ["name" ]
942+ is_discovered = p .get ("_discovered" , False )
943+ repo = p .get ("source" , {}).get ("repo" , "" )
944+ ref = p .get ("source" , {}).get ("ref" , "main" )
836945 for s in p .get ("skills" , []):
837946 if not s .get ("user-invocable" , True ):
838947 sname = s ["name" ]
839948 sdesc = s .get ("description" , "" ).strip ()
840- lines .append (f"- [{ sname } ]({ site_url } /plugins/{ pname } /{ sname } /): { sdesc } (internal)" )
949+ if is_discovered and repo :
950+ skill_path = s .get ("path" , sname )
951+ link = f"https://github.com/{ repo } /tree/{ ref } /{ skill_path } "
952+ else :
953+ link = f"{ site_url } /plugins/{ pname } /{ sname } /"
954+ lines .append (f"- [{ sname } ]({ link } ): { sdesc } (internal)" )
841955 lines .append ("" )
842956 return "\n " .join (lines )
843957
@@ -915,11 +1029,36 @@ def generate_llms_full_txt(registry: dict, docs_dir: Path) -> str:
9151029 return "\n " .join (lines )
9161030
9171031
918- def generate_site (registry : dict , output_dir : Path ):
1032+ def generate_site (registry : dict , output_dir : Path , * ,
1033+ fetch_remote : bool = False ):
9191034 docs = output_dir / "docs"
9201035 categories = registry .get ("categories" , {})
9211036 cat_plugins = build_category_plugins (registry )
9221037
1038+ # Auto-discover skills before generating any pages (counts need to be accurate)
1039+ for plugin in registry .get ("plugins" , []):
1040+ if not plugin .get ("skills" ):
1041+ name = plugin ["name" ]
1042+ plugin_dir = docs / "plugins" / name
1043+ plugin_dir .mkdir (parents = True , exist_ok = True )
1044+ repo = plugin .get ("source" , {}).get ("repo" , "" )
1045+ ref = plugin .get ("source" , {}).get ("ref" , "main" )
1046+ discovered = load_discovered (plugin_dir )
1047+ if fetch_remote and repo :
1048+ print (f" Discovering skills for { name } from { repo } ..." )
1049+ try :
1050+ discovered = discover_remote_skills (repo , ref )
1051+ save_discovered (plugin_dir , discovered )
1052+ print (f" Found { len (discovered )} skills" )
1053+ except Exception as e :
1054+ print (f" Warning: { e } " )
1055+ if discovered :
1056+ plugin ["skills" ] = discovered
1057+ plugin ["_discovered" ] = True
1058+
1059+ # Rebuild category groups after discovery so counts are accurate
1060+ cat_plugins = build_category_plugins (registry )
1061+
9231062 # Clean generated content
9241063 clean_generated (output_dir )
9251064
@@ -945,6 +1084,8 @@ def generate_site(registry: dict, output_dir: Path):
9451084 generate_plugin_page (plugin , registry , enrichment , plugin_dir ))
9461085
9471086 for skill in plugin .get ("skills" , []):
1087+ if plugin .get ("_discovered" ):
1088+ continue
9481089 sname = skill ["name" ]
9491090 (plugin_dir / f"{ sname } .md" ).write_text (
9501091 generate_skill_page (skill , plugin , enrichment , plugin_dir ))
@@ -978,11 +1119,14 @@ def main() -> None:
9781119 formatter_class = argparse .RawDescriptionHelpFormatter )
9791120 parser .add_argument ("--registry" , default = "registry.yaml" )
9801121 parser .add_argument ("--output-dir" , default = "site" )
1122+ parser .add_argument ("--fetch-remote-skills" , action = "store_true" ,
1123+ help = "Discover skills from source repos via GitHub API" )
9811124 args = parser .parse_args ()
9821125
9831126 registry = load_registry (args .registry )
9841127 output_dir = Path (args .output_dir )
985- generate_site (registry , output_dir )
1128+ generate_site (registry , output_dir ,
1129+ fetch_remote = args .fetch_remote_skills )
9861130
9871131 plugins = registry .get ("plugins" , [])
9881132 skills = sum (len (p .get ("skills" , [])) for p in plugins )
0 commit comments