33Bundle Cursor or Copilot instruction component files into a single instruction file.
44Usage: python3 implode.py [cursor|github] [output_file]
55"""
6+
67import os
78import sys
89import argparse
1213sys .path .insert (0 , os .path .dirname (os .path .abspath (__file__ )))
1314from constants import SECTION_GLOBS , header_to_filename , filename_to_header
1415
16+
1517def get_ordered_files (file_list , section_globs_keys ):
1618 """Order files based on SECTION_GLOBS key order, with unmapped files at the end."""
1719 file_dict = {f .stem : f for f in file_list }
1820 ordered_files = []
19-
21+
2022 # Add files in SECTION_GLOBS order
2123 for section_name in section_globs_keys :
2224 filename = header_to_filename (section_name )
2325 if filename in file_dict :
2426 ordered_files .append (file_dict [filename ])
2527 del file_dict [filename ]
26-
28+
2729 # Add any remaining files (not in SECTION_GLOBS) sorted alphabetically
2830 remaining_files = sorted (file_dict .values (), key = lambda p : p .name )
2931 ordered_files .extend (remaining_files )
30-
32+
3133 return ordered_files
3234
35+
3336def get_ordered_files_github (file_list , section_globs_keys ):
3437 """Order GitHub instruction files based on SECTION_GLOBS key order, with unmapped files at the end.
3538 Handles .instructions suffix by stripping it for ordering purposes."""
3639 # Create dict mapping base filename (without .instructions) to the actual file
3740 file_dict = {}
3841 for f in file_list :
39- base_stem = f .stem .replace (' .instructions' , '' )
42+ base_stem = f .stem .replace (" .instructions" , "" )
4043 file_dict [base_stem ] = f
41-
44+
4245 ordered_files = []
43-
46+
4447 # Add files in SECTION_GLOBS order
4548 for section_name in section_globs_keys :
4649 filename = header_to_filename (section_name )
4750 if filename in file_dict :
4851 ordered_files .append (file_dict [filename ])
4952 del file_dict [filename ]
50-
53+
5154 # Add any remaining files (not in SECTION_GLOBS) sorted alphabetically
5255 remaining_files = sorted (file_dict .values (), key = lambda p : p .name )
5356 ordered_files .extend (remaining_files )
54-
57+
5558 return ordered_files
5659
60+
5761def bundle_cursor_rules (rules_dir , output_file ):
5862 rule_files = list (Path (rules_dir ).glob ("*.mdc" ))
5963 general = [f for f in rule_files if f .stem == "general" ]
6064 others = [f for f in rule_files if f .stem != "general" ]
61-
65+
6266 # Order the non-general files based on SECTION_GLOBS
6367 ordered_others = get_ordered_files (others , SECTION_GLOBS .keys ())
6468 ordered = general + ordered_others
65-
69+
70+ def resolve_header_from_stem (stem ):
71+ """Return the canonical header for a given filename stem.
72+
73+ Prefer exact header names from SECTION_GLOBS (preserves acronyms like FastAPI, TypeScript).
74+ Fallback to title-casing the filename when not found in SECTION_GLOBS.
75+ """
76+ for section_name in SECTION_GLOBS .keys ():
77+ if header_to_filename (section_name ) == stem :
78+ return section_name
79+ return filename_to_header (stem )
80+
6681 with open (output_file , "w" ) as out :
6782 for rule_file in ordered :
6883 with open (rule_file , "r" ) as f :
@@ -71,41 +86,55 @@ def bundle_cursor_rules(rules_dir, output_file):
7186 continue
7287 content = strip_yaml_frontmatter (content )
7388 content = strip_header (content )
74- # Convert dash-separated names to title case with spaces
75- header = filename_to_header (rule_file .stem )
89+ # Use canonical header names from SECTION_GLOBS when available
90+ header = resolve_header_from_stem (rule_file .stem )
7691 if rule_file .stem != "general" :
7792 out .write (f"## { header } \n \n " )
7893 out .write (content )
7994 out .write ("\n \n " )
8095
96+
8197def strip_yaml_frontmatter (text ):
8298 lines = text .splitlines ()
83- if lines and lines [0 ].strip () == ' ---' :
99+ if lines and lines [0 ].strip () == " ---" :
84100 # Find the next '---' after the first
85101 for i in range (1 , len (lines )):
86- if lines [i ].strip () == ' ---' :
87- return ' \n ' .join (lines [i + 1 :]).lstrip (' \n ' )
102+ if lines [i ].strip () == " ---" :
103+ return " \n " .join (lines [i + 1 :]).lstrip (" \n " )
88104 return text
89105
106+
90107def strip_header (text ):
91108 """Remove the first markdown header (## Header) from text if present."""
92109 lines = text .splitlines ()
93- if lines and lines [0 ].startswith (' ## ' ):
110+ if lines and lines [0 ].startswith (" ## " ):
94111 # Remove the header line and any immediately following empty lines
95112 remaining_lines = lines [1 :]
96113 while remaining_lines and not remaining_lines [0 ].strip ():
97114 remaining_lines = remaining_lines [1 :]
98- return ' \n ' .join (remaining_lines )
115+ return " \n " .join (remaining_lines )
99116 return text
100117
118+
101119def bundle_github_instructions (instructions_dir , output_file ):
102120 copilot_general = Path (".github/copilot-instructions.md" )
103121 instr_files = list (Path (instructions_dir ).glob ("*.instructions.md" ))
104-
122+
105123 # Order the instruction files based on SECTION_GLOBS
106124 # We need to create a modified version that strips .instructions from stems for ordering
107125 ordered_files = get_ordered_files_github (instr_files , SECTION_GLOBS .keys ())
108-
126+
127+ def resolve_header_from_stem (stem ):
128+ """Return the canonical header for a given filename stem.
129+
130+ Prefer exact header names from SECTION_GLOBS (preserves acronyms like FastAPI, TypeScript).
131+ Fallback to title-casing the filename when not found in SECTION_GLOBS.
132+ """
133+ for section_name in SECTION_GLOBS .keys ():
134+ if header_to_filename (section_name ) == stem :
135+ return section_name
136+ return filename_to_header (stem )
137+
109138 with open (output_file , "w" ) as out :
110139 # Write general copilot instructions if present
111140 if copilot_general .exists ():
@@ -120,16 +149,24 @@ def bundle_github_instructions(instructions_dir, output_file):
120149 continue
121150 content = strip_yaml_frontmatter (content )
122151 content = strip_header (content )
123- # Convert dash-separated names to title case with spaces
124- header = filename_to_header (instr_file .stem .replace ('.instructions' ,'' ))
152+ # Use canonical header names from SECTION_GLOBS when available
153+ base_stem = instr_file .stem .replace (".instructions" , "" )
154+ header = resolve_header_from_stem (base_stem )
125155 out .write (f"## { header } \n \n " )
126156 out .write (content )
127157 out .write ("\n \n " )
128158
159+
129160def main ():
130- parser = argparse .ArgumentParser (description = "Bundle Cursor or Copilot rules into a single file." )
131- parser .add_argument ("mode" , choices = ["cursor" , "github" ], help = "Which rules to bundle" )
132- parser .add_argument ("output" , nargs = "?" , default = "instructions.md" , help = "Output file" )
161+ parser = argparse .ArgumentParser (
162+ description = "Bundle Cursor or Copilot rules into a single file."
163+ )
164+ parser .add_argument (
165+ "mode" , choices = ["cursor" , "github" ], help = "Which rules to bundle"
166+ )
167+ parser .add_argument (
168+ "output" , nargs = "?" , default = "instructions.md" , help = "Output file"
169+ )
133170 args = parser .parse_args ()
134171
135172 if args .mode == "cursor" :
@@ -138,5 +175,6 @@ def main():
138175 bundle_github_instructions (".github/instructions" , args .output )
139176 print (f"Bundled { args .mode } rules into { args .output } " )
140177
178+
141179if __name__ == "__main__" :
142180 main ()
0 commit comments