llm-ide-rules 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: llm-ide-rules
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: CLI tool for managing LLM IDE prompts and rules
5
5
  Keywords: llm,ide,prompts,cursor,copilot
6
6
  Author: Michael Bianco
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "llm-ide-rules"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "CLI tool for managing LLM IDE prompts and rules"
5
5
  keywords = ["llm", "ide", "prompts", "cursor", "copilot"]
6
6
  readme = "README.md"
@@ -4,11 +4,11 @@ import typer
4
4
  from typing_extensions import Annotated
5
5
 
6
6
  from llm_ide_rules.commands.explode import explode_main
7
- from llm_ide_rules.commands.implode import cursor, github
7
+ from llm_ide_rules.commands.implode import cursor, github, claude, gemini
8
8
  from llm_ide_rules.commands.download import download_main
9
9
  from llm_ide_rules.commands.delete import delete_main
10
10
 
11
- __version__ = "0.4.0"
11
+ __version__ = "0.5.0"
12
12
 
13
13
  app = typer.Typer(
14
14
  name="llm_ide_rules",
@@ -23,8 +23,10 @@ app.command("delete", help="Remove downloaded LLM instruction files")(delete_mai
23
23
 
24
24
  # Create implode sub-typer
25
25
  implode_app = typer.Typer(help="Bundle rule files into a single instruction file")
26
- implode_app.command("cursor", help="Bundle Cursor rules into a single file")(cursor)
27
- implode_app.command("github", help="Bundle GitHub/Copilot instructions into a single file")(github)
26
+ implode_app.command("cursor", help="Bundle Cursor rules and commands into a single file")(cursor)
27
+ implode_app.command("github", help="Bundle GitHub/Copilot instructions and prompts into a single file")(github)
28
+ implode_app.command("claude", help="Bundle Claude Code commands into a single file")(claude)
29
+ implode_app.command("gemini", help="Bundle Gemini CLI commands into a single file")(gemini)
28
30
  app.add_typer(implode_app, name="implode")
29
31
 
30
32
  def main():
@@ -192,8 +192,87 @@ description: '{description}'
192
192
  f.write(line)
193
193
 
194
194
 
195
- def process_unmapped_section(lines, section_name, rules_dir, github_prompts_dir):
196
- """Process an unmapped section as a manually applied rule (prompt)."""
195
+ def write_cursor_command(content_lines, filename, commands_dir, section_name=None):
196
+ """Write a Cursor command file (plain markdown, no frontmatter)."""
197
+ filepath = os.path.join(commands_dir, filename + ".md")
198
+
199
+ trimmed = trim_content(content_lines)
200
+
201
+ # Strip the header from content (first line starting with ##)
202
+ filtered_content = []
203
+ found_header = False
204
+ for line in trimmed:
205
+ if not found_header and line.startswith("## "):
206
+ found_header = True
207
+ continue
208
+ filtered_content.append(line)
209
+
210
+ # Trim again after removing header
211
+ filtered_content = trim_content(filtered_content)
212
+
213
+ with open(filepath, "w") as f:
214
+ for line in filtered_content:
215
+ f.write(line)
216
+
217
+
218
+ def write_claude_command(content_lines, filename, commands_dir, section_name=None):
219
+ """Write a Claude Code command file (plain markdown, no frontmatter)."""
220
+ filepath = os.path.join(commands_dir, filename + ".md")
221
+
222
+ trimmed = trim_content(content_lines)
223
+
224
+ # Strip the header from content (first line starting with ##)
225
+ filtered_content = []
226
+ found_header = False
227
+ for line in trimmed:
228
+ if not found_header and line.startswith("## "):
229
+ found_header = True
230
+ continue
231
+ filtered_content.append(line)
232
+
233
+ # Trim again after removing header
234
+ filtered_content = trim_content(filtered_content)
235
+
236
+ with open(filepath, "w") as f:
237
+ for line in filtered_content:
238
+ f.write(line)
239
+
240
+
241
+ def write_gemini_command(content_lines, filename, commands_dir, section_name=None):
242
+ """Write a Gemini CLI command file (TOML format)."""
243
+ filepath = os.path.join(commands_dir, filename + ".toml")
244
+
245
+ description, filtered_content = extract_description_and_filter_content(
246
+ content_lines, ""
247
+ )
248
+
249
+ # Strip the header from content (first line starting with ##)
250
+ final_content = []
251
+ found_header = False
252
+ for line in filtered_content:
253
+ if not found_header and line.startswith("## "):
254
+ found_header = True
255
+ continue
256
+ final_content.append(line)
257
+
258
+ # Trim and convert to string
259
+ final_content = trim_content(final_content)
260
+ content_str = "".join(final_content).strip()
261
+
262
+ with open(filepath, "w") as f:
263
+ f.write(f'name = "{filename}"\n')
264
+ if description:
265
+ f.write(f'description = "{description}"\n')
266
+ else:
267
+ f.write(f'description = "{section_name or filename}"\n')
268
+ f.write('\n[command]\n')
269
+ f.write('shell = """\n')
270
+ f.write(content_str)
271
+ f.write('\n"""\n')
272
+
273
+
274
+ def process_unmapped_section(lines, section_name, cursor_commands_dir, github_prompts_dir, claude_commands_dir, gemini_commands_dir):
275
+ """Process an unmapped section as a manually applied rule (command)."""
197
276
  section_content = extract_section(lines, f"## {section_name}")
198
277
  if any(line.strip() for line in section_content):
199
278
  filename = header_to_filename(section_name)
@@ -203,9 +282,11 @@ def process_unmapped_section(lines, section_name, rules_dir, github_prompts_dir)
203
282
  section_content, section_name
204
283
  )
205
284
 
206
- # Create prompt files (same as None case in SECTION_GLOBS)
207
- write_cursor_prompt(section_content, filename, rules_dir, section_name)
285
+ # Create command files (same as None case in SECTION_GLOBS)
286
+ write_cursor_command(section_content, filename, cursor_commands_dir, section_name)
208
287
  write_github_prompt(section_content, filename, github_prompts_dir, section_name)
288
+ write_claude_command(section_content, filename, claude_commands_dir, section_name)
289
+ write_gemini_command(section_content, filename, gemini_commands_dir, section_name)
209
290
  return True
210
291
  return False
211
292
 
@@ -235,12 +316,18 @@ def explode_main(
235
316
 
236
317
  # Work in current directory ($PWD)
237
318
  rules_dir = os.path.join(os.getcwd(), ".cursor", "rules")
319
+ cursor_commands_dir = os.path.join(os.getcwd(), ".cursor", "commands")
238
320
  copilot_dir = os.path.join(os.getcwd(), ".github", "instructions")
239
321
  github_prompts_dir = os.path.join(os.getcwd(), ".github", "prompts")
322
+ claude_commands_dir = os.path.join(os.getcwd(), ".claude", "commands")
323
+ gemini_commands_dir = os.path.join(os.getcwd(), ".gemini", "commands")
240
324
 
241
325
  os.makedirs(rules_dir, exist_ok=True)
326
+ os.makedirs(cursor_commands_dir, exist_ok=True)
242
327
  os.makedirs(copilot_dir, exist_ok=True)
243
328
  os.makedirs(github_prompts_dir, exist_ok=True)
329
+ os.makedirs(claude_commands_dir, exist_ok=True)
330
+ os.makedirs(gemini_commands_dir, exist_ok=True)
244
331
 
245
332
  input_path = os.path.join(os.getcwd(), input_file)
246
333
 
@@ -295,18 +382,20 @@ alwaysApply: true
295
382
  section_content,
296
383
  )
297
384
  else:
298
- # It's a prompt - create prompt files using the original section name for header
299
- write_cursor_prompt(section_content, filename, rules_dir, section_name)
385
+ # It's a command - create command files using the original section name for header
386
+ write_cursor_command(section_content, filename, cursor_commands_dir, section_name)
300
387
  write_github_prompt(
301
388
  section_content, filename, github_prompts_dir, section_name
302
389
  )
390
+ write_claude_command(section_content, filename, claude_commands_dir, section_name)
391
+ write_gemini_command(section_content, filename, gemini_commands_dir, section_name)
303
392
 
304
393
  # Check for sections in mapping that don't exist in the file
305
394
  for section_name in SECTION_GLOBS:
306
395
  if section_name not in found_sections:
307
396
  logger.warning("Section not found in file", section=section_name)
308
397
 
309
- # Process unmapped sections as manually applied rules (prompts)
398
+ # Process unmapped sections as manually applied rules (commands)
310
399
  processed_unmapped = set()
311
400
  for line in lines:
312
401
  if line.startswith("## "):
@@ -321,16 +410,19 @@ alwaysApply: true
321
410
  and section_name not in processed_unmapped
322
411
  ):
323
412
  if process_unmapped_section(
324
- lines, section_name, rules_dir, github_prompts_dir
413
+ lines, section_name, cursor_commands_dir, github_prompts_dir, claude_commands_dir, gemini_commands_dir
325
414
  ):
326
415
  processed_unmapped.add(section_name)
327
416
 
328
417
  logger.info(
329
418
  "Explode operation completed",
330
419
  cursor_rules=rules_dir,
420
+ cursor_commands=cursor_commands_dir,
331
421
  copilot_instructions=copilot_dir,
332
422
  github_prompts=github_prompts_dir,
423
+ claude_commands=claude_commands_dir,
424
+ gemini_commands=gemini_commands_dir,
333
425
  )
334
426
  typer.echo(
335
- "Created Cursor rules in .cursor/rules/, Copilot instructions in .github/instructions/, and prompts in respective directories"
427
+ "Created rules and commands in .cursor/, .claude/, .github/, and .gemini/ directories"
336
428
  )
@@ -0,0 +1,400 @@
1
+ """Implode command: Bundle rule files into a single instruction file."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing_extensions import Annotated
6
+ import logging
7
+
8
+ import typer
9
+ import structlog
10
+
11
+ from llm_ide_rules.constants import load_section_globs, header_to_filename, filename_to_header
12
+
13
+ logger = structlog.get_logger()
14
+
15
+
16
+ def get_ordered_files(file_list, section_globs_keys):
17
+ """Order files based on SECTION_GLOBS key order, with unmapped files at the end."""
18
+ file_dict = {f.stem: f for f in file_list}
19
+ ordered_files = []
20
+
21
+ # Add files in SECTION_GLOBS order
22
+ for section_name in section_globs_keys:
23
+ filename = header_to_filename(section_name)
24
+ if filename in file_dict:
25
+ ordered_files.append(file_dict[filename])
26
+ del file_dict[filename]
27
+
28
+ # Add any remaining files (not in SECTION_GLOBS) sorted alphabetically
29
+ remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
30
+ ordered_files.extend(remaining_files)
31
+
32
+ return ordered_files
33
+
34
+
35
+ def get_ordered_files_github(file_list, section_globs_keys):
36
+ """Order GitHub instruction files based on SECTION_GLOBS key order, with unmapped files at the end.
37
+ Handles .instructions suffix by stripping it for ordering purposes."""
38
+ # Create dict mapping base filename (without .instructions) to the actual file
39
+ file_dict = {}
40
+ for f in file_list:
41
+ base_stem = f.stem.replace(".instructions", "")
42
+ file_dict[base_stem] = f
43
+
44
+ ordered_files = []
45
+
46
+ # Add files in SECTION_GLOBS order
47
+ for section_name in section_globs_keys:
48
+ filename = header_to_filename(section_name)
49
+ if filename in file_dict:
50
+ ordered_files.append(file_dict[filename])
51
+ del file_dict[filename]
52
+
53
+ # Add any remaining files (not in SECTION_GLOBS) sorted alphabetically
54
+ remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
55
+ ordered_files.extend(remaining_files)
56
+
57
+ return ordered_files
58
+
59
+
60
+ def bundle_cursor_rules(rules_dir, commands_dir, output_file, section_globs):
61
+ """Bundle Cursor rule and command files into a single file."""
62
+ rule_files = list(Path(rules_dir).glob("*.mdc"))
63
+ command_files = list(Path(commands_dir).glob("*.md"))
64
+
65
+ general = [f for f in rule_files if f.stem == "general"]
66
+ others = [f for f in rule_files if f.stem != "general"]
67
+
68
+ # Order the non-general files based on section_globs
69
+ ordered_others = get_ordered_files(others, section_globs.keys())
70
+ ordered_commands = get_ordered_files(command_files, section_globs.keys())
71
+ ordered = general + ordered_others + ordered_commands
72
+
73
+ def resolve_header_from_stem(stem):
74
+ """Return the canonical header for a given filename stem.
75
+
76
+ Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
77
+ Fallback to title-casing the filename when not found in section_globs.
78
+ """
79
+ for section_name in section_globs.keys():
80
+ if header_to_filename(section_name) == stem:
81
+ return section_name
82
+ return filename_to_header(stem)
83
+
84
+ with open(output_file, "w") as out:
85
+ for rule_file in ordered:
86
+ with open(rule_file, "r") as f:
87
+ content = f.read().strip()
88
+ if not content:
89
+ continue
90
+ content = strip_yaml_frontmatter(content)
91
+ content = strip_header(content)
92
+ # Use canonical header names from SECTION_GLOBS when available
93
+ header = resolve_header_from_stem(rule_file.stem)
94
+ if rule_file.stem != "general":
95
+ out.write(f"## {header}\n\n")
96
+ out.write(content)
97
+ out.write("\n\n")
98
+
99
+
100
+ def strip_yaml_frontmatter(text):
101
+ """Strip YAML frontmatter from text."""
102
+ lines = text.splitlines()
103
+ if lines and lines[0].strip() == "---":
104
+ # Find the next '---' after the first
105
+ for i in range(1, len(lines)):
106
+ if lines[i].strip() == "---":
107
+ return "\n".join(lines[i + 1 :]).lstrip("\n")
108
+ return text
109
+
110
+
111
+ def strip_header(text):
112
+ """Remove the first markdown header (## Header) from text if present."""
113
+ lines = text.splitlines()
114
+ if lines and lines[0].startswith("## "):
115
+ # Remove the header line and any immediately following empty lines
116
+ remaining_lines = lines[1:]
117
+ while remaining_lines and not remaining_lines[0].strip():
118
+ remaining_lines = remaining_lines[1:]
119
+ return "\n".join(remaining_lines)
120
+ return text
121
+
122
+
123
+ def bundle_github_instructions(instructions_dir, prompts_dir, output_file, section_globs):
124
+ """Bundle GitHub instruction and prompt files into a single file."""
125
+ copilot_general = Path(os.getcwd()) / ".github" / "copilot-instructions.md"
126
+ instr_files = list(Path(instructions_dir).glob("*.instructions.md"))
127
+ prompt_files = list(Path(prompts_dir).glob("*.prompt.md"))
128
+
129
+ # Order the instruction files based on section_globs
130
+ # We need to create a modified version that strips .instructions from stems for ordering
131
+ ordered_instructions = get_ordered_files_github(instr_files, section_globs.keys())
132
+
133
+ # For prompts, we need to handle .prompt suffix similarly
134
+ ordered_prompts = []
135
+ prompt_dict = {}
136
+ for f in prompt_files:
137
+ base_stem = f.stem.replace(".prompt", "")
138
+ prompt_dict[base_stem] = f
139
+
140
+ for section_name in section_globs.keys():
141
+ filename = header_to_filename(section_name)
142
+ if filename in prompt_dict:
143
+ ordered_prompts.append(prompt_dict[filename])
144
+ del prompt_dict[filename]
145
+
146
+ # Add remaining prompts alphabetically
147
+ remaining_prompts = sorted(prompt_dict.values(), key=lambda p: p.name)
148
+ ordered_prompts.extend(remaining_prompts)
149
+
150
+ def resolve_header_from_stem(stem):
151
+ """Return the canonical header for a given filename stem.
152
+
153
+ Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
154
+ Fallback to title-casing the filename when not found in section_globs.
155
+ """
156
+ for section_name in section_globs.keys():
157
+ if header_to_filename(section_name) == stem:
158
+ return section_name
159
+ return filename_to_header(stem)
160
+
161
+ with open(output_file, "w") as out:
162
+ # Write general copilot instructions if present
163
+ if copilot_general.exists():
164
+ content = copilot_general.read_text().strip()
165
+ if content:
166
+ out.write(content)
167
+ out.write("\n\n")
168
+ for instr_file in ordered_instructions:
169
+ with open(instr_file, "r") as f:
170
+ content = f.read().strip()
171
+ if not content:
172
+ continue
173
+ content = strip_yaml_frontmatter(content)
174
+ content = strip_header(content)
175
+ # Use canonical header names from SECTION_GLOBS when available
176
+ base_stem = instr_file.stem.replace(".instructions", "")
177
+ header = resolve_header_from_stem(base_stem)
178
+ out.write(f"## {header}\n\n")
179
+ out.write(content)
180
+ out.write("\n\n")
181
+ for prompt_file in ordered_prompts:
182
+ with open(prompt_file, "r") as f:
183
+ content = f.read().strip()
184
+ if not content:
185
+ continue
186
+ content = strip_yaml_frontmatter(content)
187
+ content = strip_header(content)
188
+ # Use canonical header names from SECTION_GLOBS when available
189
+ base_stem = prompt_file.stem.replace(".prompt", "")
190
+ header = resolve_header_from_stem(base_stem)
191
+ out.write(f"## {header}\n\n")
192
+ out.write(content)
193
+ out.write("\n\n")
194
+
195
+
196
+ def bundle_claude_commands(commands_dir, output_file, section_globs):
197
+ """Bundle Claude Code command files into a single file."""
198
+ command_files = list(Path(commands_dir).glob("*.md"))
199
+ ordered_commands = get_ordered_files(command_files, section_globs.keys())
200
+
201
+ def resolve_header_from_stem(stem):
202
+ """Return the canonical header for a given filename stem.
203
+
204
+ Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
205
+ Fallback to title-casing the filename when not found in section_globs.
206
+ """
207
+ for section_name in section_globs.keys():
208
+ if header_to_filename(section_name) == stem:
209
+ return section_name
210
+ return filename_to_header(stem)
211
+
212
+ with open(output_file, "w") as out:
213
+ for command_file in ordered_commands:
214
+ with open(command_file, "r") as f:
215
+ content = f.read().strip()
216
+ if not content:
217
+ continue
218
+ # Claude commands don't have frontmatter, just content
219
+ header = resolve_header_from_stem(command_file.stem)
220
+ out.write(f"## {header}\n\n")
221
+ out.write(content)
222
+ out.write("\n\n")
223
+
224
+
225
+ def strip_toml_metadata(text):
226
+ """Extract content from TOML command.shell block."""
227
+ lines = text.splitlines()
228
+ in_shell_block = False
229
+ content_lines = []
230
+
231
+ for line in lines:
232
+ if line.strip() == '[command]':
233
+ in_shell_block = True
234
+ continue
235
+ if in_shell_block:
236
+ if line.strip().startswith('shell = """'):
237
+ # Start of shell content
238
+ # Check if content is on same line
239
+ after_start = line.split('"""', 1)[1] if '"""' in line else ""
240
+ if after_start.strip():
241
+ content_lines.append(after_start)
242
+ continue
243
+ if line.strip() == '"""' or line.strip().endswith('"""'):
244
+ # End of shell content
245
+ if line.strip() != '"""':
246
+ # Content on same line as closing
247
+ content_lines.append(line.rsplit('"""', 1)[0])
248
+ break
249
+ content_lines.append(line)
250
+
251
+ return "\n".join(content_lines).strip()
252
+
253
+
254
+ def bundle_gemini_commands(commands_dir, output_file, section_globs):
255
+ """Bundle Gemini CLI command files into a single file."""
256
+ command_files = list(Path(commands_dir).glob("*.toml"))
257
+ ordered_commands = get_ordered_files(command_files, section_globs.keys())
258
+
259
+ def resolve_header_from_stem(stem):
260
+ """Return the canonical header for a given filename stem.
261
+
262
+ Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
263
+ Fallback to title-casing the filename when not found in section_globs.
264
+ """
265
+ for section_name in section_globs.keys():
266
+ if header_to_filename(section_name) == stem:
267
+ return section_name
268
+ return filename_to_header(stem)
269
+
270
+ with open(output_file, "w") as out:
271
+ for command_file in ordered_commands:
272
+ with open(command_file, "r") as f:
273
+ content = f.read().strip()
274
+ if not content:
275
+ continue
276
+ # Extract content from TOML shell block
277
+ content = strip_toml_metadata(content)
278
+ header = resolve_header_from_stem(command_file.stem)
279
+ out.write(f"## {header}\n\n")
280
+ out.write(content)
281
+ out.write("\n\n")
282
+
283
+
284
+ def cursor(
285
+ output: Annotated[str, typer.Argument(help="Output file")] = "instructions.md",
286
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
287
+ config: Annotated[str, typer.Option("--config", "-c", help="Custom configuration file path")] = None,
288
+ ):
289
+ """Bundle Cursor rules and commands into a single file."""
290
+ if verbose:
291
+ logging.basicConfig(level=logging.DEBUG)
292
+ structlog.configure(
293
+ wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
294
+ )
295
+
296
+ # Load section globs (with optional custom config)
297
+ SECTION_GLOBS = load_section_globs(config)
298
+
299
+ rules_dir = os.path.join(os.getcwd(), ".cursor", "rules")
300
+ commands_dir = os.path.join(os.getcwd(), ".cursor", "commands")
301
+ output_path = os.path.join(os.getcwd(), output)
302
+
303
+ logger.info("Bundling Cursor rules and commands", rules_dir=rules_dir, commands_dir=commands_dir, output_file=output_path, config=config)
304
+
305
+ if not Path(rules_dir).exists():
306
+ logger.error("Cursor rules directory not found", rules_dir=rules_dir)
307
+ raise typer.Exit(1)
308
+
309
+ bundle_cursor_rules(rules_dir, commands_dir, output_path, SECTION_GLOBS)
310
+ logger.info("Cursor rules and commands bundled successfully", output_file=output_path)
311
+ typer.echo(f"Bundled cursor rules and commands into {output}")
312
+
313
+
314
+ def github(
315
+ output: Annotated[str, typer.Argument(help="Output file")] = "instructions.md",
316
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
317
+ config: Annotated[str, typer.Option("--config", "-c", help="Custom configuration file path")] = None,
318
+ ):
319
+ """Bundle GitHub/Copilot instructions and prompts into a single file."""
320
+ if verbose:
321
+ logging.basicConfig(level=logging.DEBUG)
322
+ structlog.configure(
323
+ wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
324
+ )
325
+
326
+ # Load section globs (with optional custom config)
327
+ SECTION_GLOBS = load_section_globs(config)
328
+
329
+ instructions_dir = os.path.join(os.getcwd(), ".github", "instructions")
330
+ prompts_dir = os.path.join(os.getcwd(), ".github", "prompts")
331
+ output_path = os.path.join(os.getcwd(), output)
332
+
333
+ logger.info("Bundling GitHub instructions and prompts", instructions_dir=instructions_dir, prompts_dir=prompts_dir, output_file=output_path, config=config)
334
+
335
+ if not Path(instructions_dir).exists():
336
+ logger.error("GitHub instructions directory not found", instructions_dir=instructions_dir)
337
+ raise typer.Exit(1)
338
+
339
+ bundle_github_instructions(instructions_dir, prompts_dir, output_path, SECTION_GLOBS)
340
+ logger.info("GitHub instructions and prompts bundled successfully", output_file=output_path)
341
+ typer.echo(f"Bundled github instructions and prompts into {output}")
342
+
343
+
344
+ def claude(
345
+ output: Annotated[str, typer.Argument(help="Output file")] = "instructions.md",
346
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
347
+ config: Annotated[str, typer.Option("--config", "-c", help="Custom configuration file path")] = None,
348
+ ):
349
+ """Bundle Claude Code commands into a single file."""
350
+ if verbose:
351
+ logging.basicConfig(level=logging.DEBUG)
352
+ structlog.configure(
353
+ wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
354
+ )
355
+
356
+ # Load section globs (with optional custom config)
357
+ SECTION_GLOBS = load_section_globs(config)
358
+
359
+ commands_dir = os.path.join(os.getcwd(), ".claude", "commands")
360
+ output_path = os.path.join(os.getcwd(), output)
361
+
362
+ logger.info("Bundling Claude Code commands", commands_dir=commands_dir, output_file=output_path, config=config)
363
+
364
+ if not Path(commands_dir).exists():
365
+ logger.error("Claude Code commands directory not found", commands_dir=commands_dir)
366
+ raise typer.Exit(1)
367
+
368
+ bundle_claude_commands(commands_dir, output_path, SECTION_GLOBS)
369
+ logger.info("Claude Code commands bundled successfully", output_file=output_path)
370
+ typer.echo(f"Bundled claude commands into {output}")
371
+
372
+
373
+ def gemini(
374
+ output: Annotated[str, typer.Argument(help="Output file")] = "instructions.md",
375
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
376
+ config: Annotated[str, typer.Option("--config", "-c", help="Custom configuration file path")] = None,
377
+ ):
378
+ """Bundle Gemini CLI commands into a single file."""
379
+ if verbose:
380
+ logging.basicConfig(level=logging.DEBUG)
381
+ structlog.configure(
382
+ wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
383
+ )
384
+
385
+ # Load section globs (with optional custom config)
386
+ SECTION_GLOBS = load_section_globs(config)
387
+
388
+ commands_dir = os.path.join(os.getcwd(), ".gemini", "commands")
389
+ output_path = os.path.join(os.getcwd(), output)
390
+
391
+ logger.info("Bundling Gemini CLI commands", commands_dir=commands_dir, output_file=output_path, config=config)
392
+
393
+ if not Path(commands_dir).exists():
394
+ logger.error("Gemini CLI commands directory not found", commands_dir=commands_dir)
395
+ raise typer.Exit(1)
396
+
397
+ bundle_gemini_commands(commands_dir, output_path, SECTION_GLOBS)
398
+ logger.info("Gemini CLI commands bundled successfully", output_file=output_path)
399
+ typer.echo(f"Bundled gemini commands into {output}")
400
+
@@ -13,6 +13,15 @@
13
13
  "Shell": "**/*.sh",
14
14
  "TypeScript": "**/*.ts,**/*.tsx",
15
15
  "TypeScript DocString": null,
16
- "Secrets": null
16
+ "Secrets": null,
17
+ "Dev In Browser": null,
18
+ "Fastapi Stripe": null,
19
+ "Fix Tests": null,
20
+ "Implement Fastapi Routes": null,
21
+ "Plan Only": null,
22
+ "Python Command": null,
23
+ "Refactor On Instructions": null,
24
+ "Standalone Python Scripts": null,
25
+ "Stripe Backend": null
17
26
  }
18
27
  }
@@ -1,218 +0,0 @@
1
- """Implode command: Bundle rule files into a single instruction file."""
2
-
3
- import os
4
- from pathlib import Path
5
- from typing_extensions import Annotated
6
- import logging
7
-
8
- import typer
9
- import structlog
10
-
11
- from llm_ide_rules.constants import load_section_globs, header_to_filename, filename_to_header
12
-
13
- logger = structlog.get_logger()
14
-
15
-
16
- def get_ordered_files(file_list, section_globs_keys):
17
- """Order files based on SECTION_GLOBS key order, with unmapped files at the end."""
18
- file_dict = {f.stem: f for f in file_list}
19
- ordered_files = []
20
-
21
- # Add files in SECTION_GLOBS order
22
- for section_name in section_globs_keys:
23
- filename = header_to_filename(section_name)
24
- if filename in file_dict:
25
- ordered_files.append(file_dict[filename])
26
- del file_dict[filename]
27
-
28
- # Add any remaining files (not in SECTION_GLOBS) sorted alphabetically
29
- remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
30
- ordered_files.extend(remaining_files)
31
-
32
- return ordered_files
33
-
34
-
35
- def get_ordered_files_github(file_list, section_globs_keys):
36
- """Order GitHub instruction files based on SECTION_GLOBS key order, with unmapped files at the end.
37
- Handles .instructions suffix by stripping it for ordering purposes."""
38
- # Create dict mapping base filename (without .instructions) to the actual file
39
- file_dict = {}
40
- for f in file_list:
41
- base_stem = f.stem.replace(".instructions", "")
42
- file_dict[base_stem] = f
43
-
44
- ordered_files = []
45
-
46
- # Add files in SECTION_GLOBS order
47
- for section_name in section_globs_keys:
48
- filename = header_to_filename(section_name)
49
- if filename in file_dict:
50
- ordered_files.append(file_dict[filename])
51
- del file_dict[filename]
52
-
53
- # Add any remaining files (not in SECTION_GLOBS) sorted alphabetically
54
- remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
55
- ordered_files.extend(remaining_files)
56
-
57
- return ordered_files
58
-
59
-
60
- def bundle_cursor_rules(rules_dir, output_file, section_globs):
61
- """Bundle Cursor rule files into a single file."""
62
- rule_files = list(Path(rules_dir).glob("*.mdc"))
63
- general = [f for f in rule_files if f.stem == "general"]
64
- others = [f for f in rule_files if f.stem != "general"]
65
-
66
- # Order the non-general files based on section_globs
67
- ordered_others = get_ordered_files(others, section_globs.keys())
68
- ordered = general + ordered_others
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
-
81
- with open(output_file, "w") as out:
82
- for rule_file in ordered:
83
- with open(rule_file, "r") as f:
84
- content = f.read().strip()
85
- if not content:
86
- continue
87
- content = strip_yaml_frontmatter(content)
88
- content = strip_header(content)
89
- # Use canonical header names from SECTION_GLOBS when available
90
- header = resolve_header_from_stem(rule_file.stem)
91
- if rule_file.stem != "general":
92
- out.write(f"## {header}\n\n")
93
- out.write(content)
94
- out.write("\n\n")
95
-
96
-
97
- def strip_yaml_frontmatter(text):
98
- """Strip YAML frontmatter from text."""
99
- lines = text.splitlines()
100
- if lines and lines[0].strip() == "---":
101
- # Find the next '---' after the first
102
- for i in range(1, len(lines)):
103
- if lines[i].strip() == "---":
104
- return "\n".join(lines[i + 1 :]).lstrip("\n")
105
- return text
106
-
107
-
108
- def strip_header(text):
109
- """Remove the first markdown header (## Header) from text if present."""
110
- lines = text.splitlines()
111
- if lines and lines[0].startswith("## "):
112
- # Remove the header line and any immediately following empty lines
113
- remaining_lines = lines[1:]
114
- while remaining_lines and not remaining_lines[0].strip():
115
- remaining_lines = remaining_lines[1:]
116
- return "\n".join(remaining_lines)
117
- return text
118
-
119
-
120
- def bundle_github_instructions(instructions_dir, output_file, section_globs):
121
- """Bundle GitHub instruction files into a single file."""
122
- copilot_general = Path(os.getcwd()) / ".github" / "copilot-instructions.md"
123
- instr_files = list(Path(instructions_dir).glob("*.instructions.md"))
124
-
125
- # Order the instruction files based on section_globs
126
- # We need to create a modified version that strips .instructions from stems for ordering
127
- ordered_files = get_ordered_files_github(instr_files, section_globs.keys())
128
-
129
- def resolve_header_from_stem(stem):
130
- """Return the canonical header for a given filename stem.
131
-
132
- Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
133
- Fallback to title-casing the filename when not found in section_globs.
134
- """
135
- for section_name in section_globs.keys():
136
- if header_to_filename(section_name) == stem:
137
- return section_name
138
- return filename_to_header(stem)
139
-
140
- with open(output_file, "w") as out:
141
- # Write general copilot instructions if present
142
- if copilot_general.exists():
143
- content = copilot_general.read_text().strip()
144
- if content:
145
- out.write(content)
146
- out.write("\n\n")
147
- for instr_file in ordered_files:
148
- with open(instr_file, "r") as f:
149
- content = f.read().strip()
150
- if not content:
151
- continue
152
- content = strip_yaml_frontmatter(content)
153
- content = strip_header(content)
154
- # Use canonical header names from SECTION_GLOBS when available
155
- base_stem = instr_file.stem.replace(".instructions", "")
156
- header = resolve_header_from_stem(base_stem)
157
- out.write(f"## {header}\n\n")
158
- out.write(content)
159
- out.write("\n\n")
160
-
161
-
162
- def cursor(
163
- output: Annotated[str, typer.Argument(help="Output file")] = "instructions.md",
164
- verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
165
- config: Annotated[str, typer.Option("--config", "-c", help="Custom configuration file path")] = None,
166
- ):
167
- """Bundle Cursor rules into a single file."""
168
- if verbose:
169
- logging.basicConfig(level=logging.DEBUG)
170
- structlog.configure(
171
- wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
172
- )
173
-
174
- # Load section globs (with optional custom config)
175
- SECTION_GLOBS = load_section_globs(config)
176
-
177
- rules_dir = os.path.join(os.getcwd(), ".cursor", "rules")
178
- output_path = os.path.join(os.getcwd(), output)
179
-
180
- logger.info("Bundling Cursor rules", rules_dir=rules_dir, output_file=output_path, config=config)
181
-
182
- if not Path(rules_dir).exists():
183
- logger.error("Cursor rules directory not found", rules_dir=rules_dir)
184
- raise typer.Exit(1)
185
-
186
- bundle_cursor_rules(rules_dir, output_path, SECTION_GLOBS)
187
- logger.info("Cursor rules bundled successfully", output_file=output_path)
188
- typer.echo(f"Bundled cursor rules into {output}")
189
-
190
-
191
- def github(
192
- output: Annotated[str, typer.Argument(help="Output file")] = "instructions.md",
193
- verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose logging")] = False,
194
- config: Annotated[str, typer.Option("--config", "-c", help="Custom configuration file path")] = None,
195
- ):
196
- """Bundle GitHub/Copilot instructions into a single file."""
197
- if verbose:
198
- logging.basicConfig(level=logging.DEBUG)
199
- structlog.configure(
200
- wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
201
- )
202
-
203
- # Load section globs (with optional custom config)
204
- SECTION_GLOBS = load_section_globs(config)
205
-
206
- instructions_dir = os.path.join(os.getcwd(), ".github", "instructions")
207
- output_path = os.path.join(os.getcwd(), output)
208
-
209
- logger.info("Bundling GitHub instructions", instructions_dir=instructions_dir, output_file=output_path, config=config)
210
-
211
- if not Path(instructions_dir).exists():
212
- logger.error("GitHub instructions directory not found", instructions_dir=instructions_dir)
213
- raise typer.Exit(1)
214
-
215
- bundle_github_instructions(instructions_dir, output_path, SECTION_GLOBS)
216
- logger.info("GitHub instructions bundled successfully", output_file=output_path)
217
- typer.echo(f"Bundled github instructions into {output}")
218
-
File without changes