llm-ide-rules 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

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,52 +1,34 @@
1
1
  """Explode command: Convert instruction file to separate rule files."""
2
2
 
3
- import os
4
- import sys
5
3
  from pathlib import Path
6
4
  from typing_extensions import Annotated
7
5
 
8
6
  import typer
9
- import structlog
10
- import logging
11
7
 
12
- from llm_ide_rules.constants import load_section_globs, header_to_filename
8
+ from llm_ide_rules.agents import get_agent
9
+ from llm_ide_rules.agents.base import (
10
+ BaseAgent,
11
+ replace_header_with_proper_casing,
12
+ write_rule_file,
13
+ )
14
+ from llm_ide_rules.log import log
15
+ from llm_ide_rules.constants import load_section_globs, header_to_filename, VALID_AGENTS
13
16
 
14
- logger = structlog.get_logger()
15
17
 
16
-
17
- def generate_cursor_frontmatter(glob):
18
- """Generate Cursor rule frontmatter for a given glob pattern."""
19
- return f"""---
20
- description:
21
- globs: {glob}
22
- alwaysApply: false
23
- ---
24
- """
25
-
26
-
27
- def generate_copilot_frontmatter(glob):
28
- """Generate Copilot instruction frontmatter for a given glob pattern."""
29
- return f"""---
30
- applyTo: "{glob}"
31
- ---
32
- """
33
-
34
-
35
- def extract_general(lines):
36
- """
37
- Extract lines before the first section header '## '.
38
- """
18
+ def extract_general(lines: list[str]) -> list[str]:
19
+ """Extract lines before the first section header '## '."""
39
20
  general = []
40
21
  for line in lines:
41
22
  if line.startswith("## "):
42
23
  break
43
24
  general.append(line)
25
+
44
26
  return general
45
27
 
46
28
 
47
- def extract_section(lines, header):
48
- """
49
- Extract lines under a given section header until the next header or EOF.
29
+ def extract_section(lines: list[str], header: str) -> list[str]:
30
+ """Extract lines under a given section header until the next header or EOF.
31
+
50
32
  Includes the header itself in the output.
51
33
  """
52
34
  content = []
@@ -58,287 +40,157 @@ def extract_section(lines, header):
58
40
  content.append(line)
59
41
  elif line.strip().lower() == header.lower():
60
42
  in_section = True
61
- content.append(line) # Include the header itself
62
- return content
63
-
64
-
65
- def write_rule(path, header_yaml, content_lines):
66
- """
67
- Write a rule file with front matter and content.
68
- """
69
- trimmed_content = trim_content(content_lines)
70
- with open(path, "w") as f:
71
- f.write(header_yaml.strip() + "\n")
72
- for line in trimmed_content:
73
- f.write(line)
74
-
75
-
76
- def trim_content(content_lines):
77
- """Remove leading and trailing empty lines from content."""
78
- # Find first non-empty line
79
- start = 0
80
- for i, line in enumerate(content_lines):
81
- if line.strip():
82
- start = i
83
- break
84
- else:
85
- # All lines are empty
86
- return []
87
-
88
- # Find last non-empty line
89
- end = len(content_lines)
90
- for i in range(len(content_lines) - 1, -1, -1):
91
- if content_lines[i].strip():
92
- end = i + 1
93
- break
43
+ content.append(line)
94
44
 
95
- return content_lines[start:end]
45
+ return content
96
46
 
97
47
 
98
- def replace_header_with_proper_casing(content_lines, proper_header):
99
- """Replace the first header in content with the properly cased version."""
100
- if not content_lines:
101
- return content_lines
48
+ def extract_all_sections(lines: list[str]) -> dict[str, list[str]]:
49
+ """Extract all sections from lines, returning dict of section_name -> content_lines."""
50
+ sections: dict[str, list[str]] = {}
51
+ current_section: str | None = None
52
+ current_content: list[str] = []
102
53
 
103
- # Find and replace the first header line
104
- for i, line in enumerate(content_lines):
54
+ for line in lines:
105
55
  if line.startswith("## "):
106
- content_lines[i] = f"## {proper_header}\n"
107
- break
108
-
109
- return content_lines
110
-
111
-
112
- def extract_description_and_filter_content(content_lines, default_description):
113
- """Extract description from first non-empty line that starts with 'Description:' and return filtered content."""
114
- trimmed_content = trim_content(content_lines)
115
- description = ""
116
- description_line = None
117
-
118
- # Find the first non-empty, non-header line that starts with "Description:"
119
- for i, line in enumerate(trimmed_content):
120
- stripped_line = line.strip()
121
- if (
122
- stripped_line
123
- and not stripped_line.startswith("#")
124
- and not stripped_line.startswith("##")
125
- ):
126
- if stripped_line.startswith("Description:"):
127
- # Extract the description text after "Description:"
128
- description = stripped_line[len("Description:") :].strip()
129
- description_line = i
130
- break
131
- else:
132
- # Found a non-header line that doesn't start with Description:, stop looking
133
- break
134
-
135
- # Only use explicit descriptions - no fallback extraction
136
- if description and description_line is not None:
137
- # Remove the description line from content
138
- filtered_content = (
139
- trimmed_content[:description_line] + trimmed_content[description_line + 1 :]
140
- )
141
- # Trim again after removing description line
142
- filtered_content = trim_content(filtered_content)
143
- else:
144
- # No description found, keep all content
145
- filtered_content = trimmed_content
146
-
147
- return description, filtered_content
148
-
149
-
150
- def write_cursor_prompt(content_lines, filename, prompts_dir, section_name=None):
151
- """Write a Cursor prompt file with frontmatter including description."""
152
- filepath = os.path.join(prompts_dir, filename + ".mdc")
153
-
154
- # Don't generate a default description, leave empty if none found
155
- default_description = ""
156
- description, filtered_content = extract_description_and_filter_content(
157
- content_lines, default_description
158
- )
56
+ if current_section:
57
+ sections[current_section] = current_content
159
58
 
160
- with open(filepath, "w") as f:
161
- # Only add frontmatter if description is not empty
162
- if description:
163
- frontmatter = f"""---
164
- description: {description}
165
- ---
166
- """
167
- f.write(frontmatter)
168
-
169
- for line in filtered_content:
170
- f.write(line)
171
-
172
-
173
- def write_github_prompt(content_lines, filename, prompts_dir, section_name=None):
174
- """Write a GitHub prompt file with proper frontmatter."""
175
- filepath = os.path.join(prompts_dir, filename + ".prompt.md")
176
-
177
- # Don't generate a default description, leave empty if none found
178
- default_description = ""
179
- description, filtered_content = extract_description_and_filter_content(
180
- content_lines, default_description
181
- )
182
-
183
- frontmatter = f"""---
184
- mode: 'agent'
185
- description: '{description}'
186
- ---
187
- """
59
+ current_section = line.strip()[3:]
60
+ current_content = [line]
61
+ elif current_section:
62
+ current_content.append(line)
188
63
 
189
- with open(filepath, "w") as f:
190
- f.write(frontmatter)
191
- for line in filtered_content:
192
- f.write(line)
64
+ if current_section:
65
+ sections[current_section] = current_content
193
66
 
67
+ return sections
194
68
 
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
69
 
199
- trimmed = trim_content(content_lines)
70
+ def process_command_section(
71
+ section_name: str,
72
+ section_content: list[str],
73
+ agents: list[BaseAgent],
74
+ dirs: dict[str, Path],
75
+ ) -> bool:
76
+ """Process a section as a command for all agents."""
77
+ if not any(line.strip() for line in section_content):
78
+ return False
200
79
 
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)
80
+ filename = header_to_filename(section_name)
81
+ section_content = replace_header_with_proper_casing(section_content, section_name)
209
82
 
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)
83
+ for agent in agents:
84
+ if agent.commands_dir:
85
+ agent.write_command(
86
+ section_content, filename, dirs[agent.name], section_name
87
+ )
232
88
 
233
- # Trim again after removing header
234
- filtered_content = trim_content(filtered_content)
89
+ return True
235
90
 
236
- with open(filepath, "w") as f:
237
- for line in filtered_content:
238
- f.write(line)
239
91
 
92
+ def process_unmapped_as_always_apply(
93
+ section_name: str,
94
+ section_content: list[str],
95
+ cursor_agent,
96
+ github_agent,
97
+ cursor_rules_dir: Path,
98
+ copilot_dir: Path,
99
+ ) -> bool:
100
+ """Process an unmapped section as an always-apply rule."""
101
+ if not any(line.strip() for line in section_content):
102
+ return False
240
103
 
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")
104
+ filename = header_to_filename(section_name)
105
+ section_content = replace_header_with_proper_casing(section_content, section_name)
244
106
 
245
- description, filtered_content = extract_description_and_filter_content(
246
- content_lines, ""
107
+ cursor_agent.write_rule(
108
+ section_content, filename, cursor_rules_dir, glob_pattern=None
247
109
  )
110
+ github_agent.write_rule(section_content, filename, copilot_dir, glob_pattern=None)
248
111
 
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)."""
276
- section_content = extract_section(lines, f"## {section_name}")
277
- if any(line.strip() for line in section_content):
278
- filename = header_to_filename(section_name)
279
-
280
- # Replace header with proper casing
281
- section_content = replace_header_with_proper_casing(
282
- section_content, section_name
283
- )
284
-
285
- # Create command files (same as None case in SECTION_GLOBS)
286
- write_cursor_command(section_content, filename, cursor_commands_dir, section_name)
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)
290
- return True
291
- return False
112
+ return True
292
113
 
293
114
 
294
115
  def explode_main(
295
116
  input_file: Annotated[
296
117
  str, typer.Argument(help="Input markdown file")
297
118
  ] = "instructions.md",
298
- verbose: Annotated[
299
- bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
300
- ] = False,
301
119
  config: Annotated[
302
- str, typer.Option("--config", "-c", help="Custom configuration file path")
120
+ str | None,
121
+ typer.Option("--config", "-c", help="Custom configuration file path"),
303
122
  ] = None,
304
- ):
123
+ agent: Annotated[
124
+ str,
125
+ typer.Option(
126
+ "--agent",
127
+ "-a",
128
+ help="Agent to explode for (cursor, github, claude, gemini, or all)",
129
+ ),
130
+ ] = "all",
131
+ ) -> None:
305
132
  """Convert instruction file to separate rule files."""
306
- if verbose:
307
- logging.basicConfig(level=logging.DEBUG)
308
- structlog.configure(
309
- wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
133
+
134
+ if agent not in VALID_AGENTS:
135
+ log.error("invalid agent", agent=agent, valid_agents=VALID_AGENTS)
136
+ error_msg = (
137
+ f"Invalid agent '{agent}'. Must be one of: {', '.join(VALID_AGENTS)}"
310
138
  )
139
+ typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
140
+ raise typer.Exit(1)
311
141
 
312
- # Load section globs (with optional custom config)
313
- SECTION_GLOBS = load_section_globs(config)
142
+ section_globs = load_section_globs(config)
314
143
 
315
- logger.info("Starting explode operation", input_file=input_file, config=config)
144
+ log.info(
145
+ "starting explode operation", input_file=input_file, agent=agent, config=config
146
+ )
316
147
 
317
- # Work in current directory ($PWD)
318
- rules_dir = os.path.join(os.getcwd(), ".cursor", "rules")
319
- cursor_commands_dir = os.path.join(os.getcwd(), ".cursor", "commands")
320
- copilot_dir = os.path.join(os.getcwd(), ".github", "instructions")
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")
148
+ cwd = Path.cwd()
324
149
 
325
- os.makedirs(rules_dir, exist_ok=True)
326
- os.makedirs(cursor_commands_dir, exist_ok=True)
327
- os.makedirs(copilot_dir, exist_ok=True)
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)
150
+ # Initialize only the agents we need
151
+ agents_to_process = []
152
+ if agent == "all":
153
+ agents_to_process = ["cursor", "github", "claude", "gemini", "opencode"]
154
+ else:
155
+ agents_to_process = [agent]
156
+
157
+ # Initialize agents and create directories
158
+ agent_instances = {}
159
+ agent_dirs = {}
160
+
161
+ for agent_name in agents_to_process:
162
+ agent_instances[agent_name] = get_agent(agent_name)
163
+
164
+ if agent_name in ["cursor", "github"]:
165
+ # These agents have both rules and commands
166
+ rules_dir = cwd / agent_instances[agent_name].rules_dir
167
+ commands_dir = cwd / agent_instances[agent_name].commands_dir
168
+ rules_dir.mkdir(parents=True, exist_ok=True)
169
+ commands_dir.mkdir(parents=True, exist_ok=True)
170
+ agent_dirs[agent_name] = {"rules": rules_dir, "commands": commands_dir}
171
+ else:
172
+ # claude, gemini, and opencode only have commands
173
+ commands_dir = cwd / agent_instances[agent_name].commands_dir
174
+ commands_dir.mkdir(parents=True, exist_ok=True)
175
+ agent_dirs[agent_name] = {"commands": commands_dir}
331
176
 
332
- input_path = os.path.join(os.getcwd(), input_file)
177
+ input_path = cwd / input_file
333
178
 
334
179
  try:
335
- with open(input_path, "r") as f:
336
- lines = f.readlines()
180
+ lines = input_path.read_text().splitlines(keepends=True)
337
181
  except FileNotFoundError:
338
- logger.error("Input file not found", input_file=input_path)
182
+ log.error("input file not found", input_file=str(input_path))
183
+ error_msg = f"Input file not found: {input_path}"
184
+ typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
339
185
  raise typer.Exit(1)
340
186
 
341
- # General instructions
187
+ commands_path = input_path.parent / "commands.md"
188
+ commands_lines = []
189
+ if commands_path.exists():
190
+ commands_lines = commands_path.read_text().splitlines(keepends=True)
191
+ log.info("found commands file", commands_file=str(commands_path))
192
+
193
+ # Process general instructions for agents that support rules
342
194
  general = extract_general(lines)
343
195
  if any(line.strip() for line in general):
344
196
  general_header = """
@@ -347,82 +199,122 @@ description:
347
199
  alwaysApply: true
348
200
  ---
349
201
  """
350
- write_rule(os.path.join(rules_dir, "general.mdc"), general_header, general)
351
- # Copilot general instructions (no frontmatter)
352
- write_rule(
353
- os.path.join(os.getcwd(), ".github", "copilot-instructions.md"), "", general
354
- )
202
+ if "cursor" in agent_instances:
203
+ write_rule_file(
204
+ agent_dirs["cursor"]["rules"] / "general.mdc", general_header, general
205
+ )
206
+ if "github" in agent_instances:
207
+ agent_instances["github"].write_general_instructions(general, cwd)
355
208
 
356
- # Process each section dynamically
209
+ # Process mapped sections for agents that support rules
357
210
  found_sections = set()
358
- for section_name, glob_or_description in SECTION_GLOBS.items():
211
+ for section_name, glob_pattern in section_globs.items():
359
212
  section_content = extract_section(lines, f"## {section_name}")
360
213
  if any(line.strip() for line in section_content):
361
214
  found_sections.add(section_name)
362
215
  filename = header_to_filename(section_name)
363
216
 
364
- # Replace header with proper casing from SECTION_GLOBS
365
217
  section_content = replace_header_with_proper_casing(
366
218
  section_content, section_name
367
219
  )
368
220
 
369
- if glob_or_description is not None:
370
- # It's a glob pattern - create instruction files
371
- cursor_header = generate_cursor_frontmatter(glob_or_description)
372
- write_rule(
373
- os.path.join(rules_dir, filename + ".mdc"),
374
- cursor_header,
221
+ if "cursor" in agent_instances:
222
+ agent_instances["cursor"].write_rule(
375
223
  section_content,
224
+ filename,
225
+ agent_dirs["cursor"]["rules"],
226
+ glob_pattern,
376
227
  )
377
-
378
- copilot_header = generate_copilot_frontmatter(glob_or_description)
379
- write_rule(
380
- os.path.join(copilot_dir, filename + ".instructions.md"),
381
- copilot_header,
228
+ if "github" in agent_instances:
229
+ agent_instances["github"].write_rule(
382
230
  section_content,
231
+ filename,
232
+ agent_dirs["github"]["rules"],
233
+ glob_pattern,
383
234
  )
384
- else:
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)
387
- write_github_prompt(
388
- section_content, filename, github_prompts_dir, section_name
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)
392
235
 
393
- # Check for sections in mapping that don't exist in the file
394
- for section_name in SECTION_GLOBS:
236
+ for section_name in section_globs:
395
237
  if section_name not in found_sections:
396
- logger.warning("Section not found in file", section=section_name)
238
+ log.warning("section not found in file", section=section_name)
397
239
 
398
- # Process unmapped sections as manually applied rules (commands)
399
- processed_unmapped = set()
400
- for line in lines:
401
- if line.startswith("## "):
402
- section_header = line.strip()
403
- section_name = section_header[3:] # Remove "## "
404
- # Case insensitive check and avoid duplicate processing
405
- if (
406
- not any(
240
+ # Process unmapped sections for agents that support rules
241
+ if "cursor" in agent_instances or "github" in agent_instances:
242
+ for line in lines:
243
+ if line.startswith("## "):
244
+ section_name = line.strip()[3:]
245
+ if not any(
407
246
  section_name.lower() == mapped_section.lower()
408
- for mapped_section in SECTION_GLOBS
409
- )
410
- and section_name not in processed_unmapped
411
- ):
412
- if process_unmapped_section(
413
- lines, section_name, cursor_commands_dir, github_prompts_dir, claude_commands_dir, gemini_commands_dir
247
+ for mapped_section in section_globs
414
248
  ):
415
- processed_unmapped.add(section_name)
416
-
417
- logger.info(
418
- "Explode operation completed",
419
- cursor_rules=rules_dir,
420
- cursor_commands=cursor_commands_dir,
421
- copilot_instructions=copilot_dir,
422
- github_prompts=github_prompts_dir,
423
- claude_commands=claude_commands_dir,
424
- gemini_commands=gemini_commands_dir,
425
- )
426
- typer.echo(
427
- "Created rules and commands in .cursor/, .claude/, .github/, and .gemini/ directories"
428
- )
249
+ log.warning(
250
+ "unmapped section in instructions.md, treating as always-apply rule",
251
+ section=section_name,
252
+ )
253
+ section_content = extract_section(lines, f"## {section_name}")
254
+
255
+ if "cursor" in agent_instances and "github" in agent_instances:
256
+ process_unmapped_as_always_apply(
257
+ section_name,
258
+ section_content,
259
+ agent_instances["cursor"],
260
+ agent_instances["github"],
261
+ agent_dirs["cursor"]["rules"],
262
+ agent_dirs["github"]["rules"],
263
+ )
264
+ elif "cursor" in agent_instances:
265
+ # Only cursor - write just cursor rules
266
+ if any(line.strip() for line in section_content):
267
+ filename = header_to_filename(section_name)
268
+ section_content = replace_header_with_proper_casing(
269
+ section_content, section_name
270
+ )
271
+ agent_instances["cursor"].write_rule(
272
+ section_content,
273
+ filename,
274
+ agent_dirs["cursor"]["rules"],
275
+ glob_pattern=None,
276
+ )
277
+ elif "github" in agent_instances:
278
+ # Only github - write just github rules
279
+ if any(line.strip() for line in section_content):
280
+ filename = header_to_filename(section_name)
281
+ section_content = replace_header_with_proper_casing(
282
+ section_content, section_name
283
+ )
284
+ agent_instances["github"].write_rule(
285
+ section_content,
286
+ filename,
287
+ agent_dirs["github"]["rules"],
288
+ glob_pattern=None,
289
+ )
290
+
291
+ # Process commands for all agents
292
+ if commands_lines:
293
+ command_sections = extract_all_sections(commands_lines)
294
+ agents = [agent_instances[name] for name in agents_to_process]
295
+ command_dirs = {
296
+ name: agent_dirs[name]["commands"] for name in agents_to_process
297
+ }
298
+
299
+ for section_name, section_content in command_sections.items():
300
+ process_command_section(section_name, section_content, agents, command_dirs)
301
+
302
+ # Build log message and user output based on processed agents
303
+ log_data = {"agent": agent}
304
+ created_dirs = []
305
+
306
+ for agent_name in agents_to_process:
307
+ if agent_name in ["cursor", "github"]:
308
+ log_data[f"{agent_name}_rules"] = str(agent_dirs[agent_name]["rules"])
309
+ log_data[f"{agent_name}_commands"] = str(agent_dirs[agent_name]["commands"])
310
+ created_dirs.append(f".{agent_name}/")
311
+ else:
312
+ log_data[f"{agent_name}_commands"] = str(agent_dirs[agent_name]["commands"])
313
+ created_dirs.append(f".{agent_name}/")
314
+
315
+ if len(created_dirs) == 1:
316
+ success_msg = f"Created files in {created_dirs[0]} directory"
317
+ typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
318
+ else:
319
+ success_msg = f"Created files in {', '.join(created_dirs)} directories"
320
+ typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))