llm-ide-rules 0.5.0__py3-none-any.whl → 0.7.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,428 +1,268 @@
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
13
-
14
- logger = structlog.get_logger()
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 header_to_filename, VALID_AGENTS
16
+ from llm_ide_rules.markdown_parser import parse_sections
17
+
18
+
19
+ def process_command_section(
20
+ section_name: str,
21
+ section_content: list[str],
22
+ agents: list[BaseAgent],
23
+ dirs: dict[str, Path],
24
+ ) -> bool:
25
+ """Process a section as a command for all agents."""
26
+ if not any(line.strip() for line in section_content):
27
+ return False
28
+
29
+ filename = header_to_filename(section_name)
30
+ section_content = replace_header_with_proper_casing(section_content, section_name)
31
+
32
+ for agent in agents:
33
+ if agent.commands_dir:
34
+ agent.write_command(
35
+ section_content, filename, dirs[agent.name], section_name
36
+ )
15
37
 
38
+ return True
16
39
 
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
40
 
41
+ def process_unmapped_as_always_apply(
42
+ section_name: str,
43
+ section_content: list[str],
44
+ cursor_agent,
45
+ github_agent,
46
+ cursor_rules_dir: Path,
47
+ copilot_dir: Path,
48
+ ) -> bool:
49
+ """Process an unmapped section as an always-apply rule."""
50
+ if not any(line.strip() for line in section_content):
51
+ return False
26
52
 
27
- def generate_copilot_frontmatter(glob):
28
- """Generate Copilot instruction frontmatter for a given glob pattern."""
29
- return f"""---
30
- applyTo: "{glob}"
31
- ---
32
- """
53
+ filename = header_to_filename(section_name)
54
+ section_content = replace_header_with_proper_casing(section_content, section_name)
33
55
 
56
+ cursor_agent.write_rule(
57
+ section_content, filename, cursor_rules_dir, glob_pattern=None
58
+ )
59
+ github_agent.write_rule(section_content, filename, copilot_dir, glob_pattern=None)
34
60
 
35
- def extract_general(lines):
36
- """
37
- Extract lines before the first section header '## '.
38
- """
39
- general = []
40
- for line in lines:
41
- if line.startswith("## "):
42
- break
43
- general.append(line)
44
- return general
45
-
46
-
47
- def extract_section(lines, header):
48
- """
49
- Extract lines under a given section header until the next header or EOF.
50
- Includes the header itself in the output.
51
- """
52
- content = []
53
- in_section = False
54
- for line in lines:
55
- if in_section:
56
- if line.startswith("## "):
57
- break
58
- content.append(line)
59
- elif line.strip().lower() == header.lower():
60
- 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
94
-
95
- return content_lines[start:end]
96
-
97
-
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
102
-
103
- # Find and replace the first header line
104
- for i, line in enumerate(content_lines):
105
- 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
61
+ return True
146
62
 
147
- return description, filtered_content
148
63
 
64
+ def explode_implementation(
65
+ input_file: str = "instructions.md",
66
+ agent: str = "all",
67
+ working_dir: Path | None = None,
68
+ ) -> None:
69
+ """Core implementation of explode command."""
70
+ if working_dir is None:
71
+ working_dir = Path.cwd()
149
72
 
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")
73
+ if agent not in VALID_AGENTS:
74
+ log.error("invalid agent", agent=agent, valid_agents=VALID_AGENTS)
75
+ error_msg = (
76
+ f"Invalid agent '{agent}'. Must be one of: {', '.join(VALID_AGENTS)}"
77
+ )
78
+ typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
79
+ raise typer.Exit(1)
153
80
 
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
81
+ log.info(
82
+ "starting explode operation",
83
+ input_file=input_file,
84
+ agent=agent,
85
+ working_dir=str(working_dir),
158
86
  )
159
87
 
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)
88
+ # Initialize only the agents we need
89
+ agents_to_process = []
90
+ if agent == "all":
91
+ agents_to_process = ["cursor", "github", "claude", "gemini", "opencode"]
92
+ else:
93
+ agents_to_process = [agent]
94
+
95
+ # Initialize agents and create directories
96
+ agent_instances = {}
97
+ agent_dirs = {}
98
+
99
+ for agent_name in agents_to_process:
100
+ agent_instances[agent_name] = get_agent(agent_name)
101
+
102
+ if agent_name in ["cursor", "github"]:
103
+ # These agents have both rules and commands
104
+ rules_dir = working_dir / agent_instances[agent_name].rules_dir
105
+ commands_dir = working_dir / agent_instances[agent_name].commands_dir
106
+ rules_dir.mkdir(parents=True, exist_ok=True)
107
+ commands_dir.mkdir(parents=True, exist_ok=True)
108
+ agent_dirs[agent_name] = {"rules": rules_dir, "commands": commands_dir}
109
+ else:
110
+ # claude, gemini, and opencode only have commands
111
+ commands_dir = working_dir / agent_instances[agent_name].commands_dir
112
+ commands_dir.mkdir(parents=True, exist_ok=True)
113
+ agent_dirs[agent_name] = {"commands": commands_dir}
168
114
 
169
- for line in filtered_content:
170
- f.write(line)
115
+ input_path = working_dir / input_file
171
116
 
117
+ try:
118
+ input_text = input_path.read_text()
119
+ except FileNotFoundError:
120
+ log.error("input file not found", input_file=str(input_path))
121
+ error_msg = f"Input file not found: {input_path}"
122
+ typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
123
+ raise typer.Exit(1)
172
124
 
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")
125
+ commands_path = input_path.parent / "commands.md"
126
+ commands_text = ""
127
+ if commands_path.exists():
128
+ commands_text = commands_path.read_text()
129
+ log.info("found commands file", commands_file=str(commands_path))
176
130
 
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
- )
131
+ # Parse instructions
132
+ general, instruction_sections = parse_sections(input_text)
182
133
 
183
- frontmatter = f"""---
184
- mode: 'agent'
185
- description: '{description}'
134
+ # Process general instructions for agents that support rules
135
+ if any(line.strip() for line in general):
136
+ general_header = """
137
+ ---
138
+ description:
139
+ alwaysApply: true
186
140
  ---
187
141
  """
142
+ if "cursor" in agent_instances:
143
+ write_rule_file(
144
+ agent_dirs["cursor"]["rules"] / "general.mdc", general_header, general
145
+ )
146
+ if "github" in agent_instances:
147
+ agent_instances["github"].write_general_instructions(general, working_dir)
188
148
 
189
- with open(filepath, "w") as f:
190
- f.write(frontmatter)
191
- for line in filtered_content:
192
- f.write(line)
193
-
194
-
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")
149
+ # Process sections for agents that support rules
150
+ rules_sections: dict[str, list[str]] = {}
151
+ section_globs: dict[str, str | None] = {}
221
152
 
222
- trimmed = trim_content(content_lines)
153
+ for section_name, section_data in instruction_sections.items():
154
+ content = section_data.content
155
+ glob_pattern = section_data.glob_pattern
223
156
 
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
157
+ if not any(line.strip() for line in content):
230
158
  continue
231
- filtered_content.append(line)
232
-
233
- # Trim again after removing header
234
- filtered_content = trim_content(filtered_content)
235
159
 
236
- with open(filepath, "w") as f:
237
- for line in filtered_content:
238
- f.write(line)
160
+ rules_sections[section_name] = content
161
+ section_globs[section_name] = glob_pattern
162
+ filename = header_to_filename(section_name)
239
163
 
164
+ section_content = replace_header_with_proper_casing(content, section_name)
240
165
 
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")
166
+ if glob_pattern is None:
167
+ # No directive = alwaysApply
168
+ if "cursor" in agent_instances and "github" in agent_instances:
169
+ process_unmapped_as_always_apply(
170
+ section_name,
171
+ section_content,
172
+ agent_instances["cursor"],
173
+ agent_instances["github"],
174
+ agent_dirs["cursor"]["rules"],
175
+ agent_dirs["github"]["rules"],
176
+ )
177
+ elif "cursor" in agent_instances:
178
+ agent_instances["cursor"].write_rule(
179
+ section_content,
180
+ filename,
181
+ agent_dirs["cursor"]["rules"],
182
+ glob_pattern=None,
183
+ )
184
+ elif "github" in agent_instances:
185
+ agent_instances["github"].write_rule(
186
+ section_content,
187
+ filename,
188
+ agent_dirs["github"]["rules"],
189
+ glob_pattern=None,
190
+ )
191
+ elif glob_pattern != "manual":
192
+ # Has glob pattern = file-specific rule
193
+ if "cursor" in agent_instances:
194
+ agent_instances["cursor"].write_rule(
195
+ section_content,
196
+ filename,
197
+ agent_dirs["cursor"]["rules"],
198
+ glob_pattern,
199
+ )
200
+ if "github" in agent_instances:
201
+ agent_instances["github"].write_rule(
202
+ section_content,
203
+ filename,
204
+ agent_dirs["github"]["rules"],
205
+ glob_pattern,
206
+ )
244
207
 
245
- description, filtered_content = extract_description_and_filter_content(
246
- content_lines, ""
247
- )
208
+ # Process commands for all agents
209
+ command_sections_data = {}
210
+ command_sections = {}
211
+ if commands_text:
212
+ _, command_sections_data = parse_sections(commands_text)
213
+ agents = [agent_instances[name] for name in agents_to_process]
214
+ command_dirs = {
215
+ name: agent_dirs[name]["commands"] for name in agents_to_process
216
+ }
217
+
218
+ for section_name, section_data in command_sections_data.items():
219
+ command_sections[section_name] = section_data.content
220
+ process_command_section(
221
+ section_name, section_data.content, agents, command_dirs
222
+ )
248
223
 
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)
224
+ # Generate root documentation (CLAUDE.md, GEMINI.md, etc.)
225
+ for agent_name, agent_inst in agent_instances.items():
226
+ agent_inst.generate_root_doc(
227
+ general,
228
+ rules_sections,
229
+ command_sections,
230
+ working_dir,
231
+ )
257
232
 
258
- # Trim and convert to string
259
- final_content = trim_content(final_content)
260
- content_str = "".join(final_content).strip()
233
+ # Build log message and user output based on processed agents
234
+ log_data = {"agent": agent}
235
+ created_dirs = []
261
236
 
262
- with open(filepath, "w") as f:
263
- f.write(f'name = "{filename}"\n')
264
- if description:
265
- f.write(f'description = "{description}"\n')
237
+ for agent_name in agents_to_process:
238
+ if agent_name in ["cursor", "github"]:
239
+ log_data[f"{agent_name}_rules"] = str(agent_dirs[agent_name]["rules"])
240
+ log_data[f"{agent_name}_commands"] = str(agent_dirs[agent_name]["commands"])
241
+ created_dirs.append(f".{agent_name}/")
266
242
  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
- )
243
+ log_data[f"{agent_name}_commands"] = str(agent_dirs[agent_name]["commands"])
244
+ created_dirs.append(f".{agent_name}/")
284
245
 
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
246
+ if len(created_dirs) == 1:
247
+ success_msg = f"Created files in {created_dirs[0]} directory"
248
+ typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
249
+ else:
250
+ success_msg = f"Created files in {', '.join(created_dirs)} directories"
251
+ typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
292
252
 
293
253
 
294
254
  def explode_main(
295
255
  input_file: Annotated[
296
256
  str, typer.Argument(help="Input markdown file")
297
257
  ] = "instructions.md",
298
- verbose: Annotated[
299
- bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
300
- ] = False,
301
- config: Annotated[
302
- str, typer.Option("--config", "-c", help="Custom configuration file path")
303
- ] = None,
304
- ):
258
+ agent: Annotated[
259
+ str,
260
+ typer.Option(
261
+ "--agent",
262
+ "-a",
263
+ help="Agent to explode for (cursor, github, claude, gemini, or all)",
264
+ ),
265
+ ] = "all",
266
+ ) -> None:
305
267
  """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),
310
- )
311
-
312
- # Load section globs (with optional custom config)
313
- SECTION_GLOBS = load_section_globs(config)
314
-
315
- logger.info("Starting explode operation", input_file=input_file, config=config)
316
-
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")
324
-
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)
331
-
332
- input_path = os.path.join(os.getcwd(), input_file)
333
-
334
- try:
335
- with open(input_path, "r") as f:
336
- lines = f.readlines()
337
- except FileNotFoundError:
338
- logger.error("Input file not found", input_file=input_path)
339
- raise typer.Exit(1)
340
-
341
- # General instructions
342
- general = extract_general(lines)
343
- if any(line.strip() for line in general):
344
- general_header = """
345
- ---
346
- description:
347
- alwaysApply: true
348
- ---
349
- """
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
- )
355
-
356
- # Process each section dynamically
357
- found_sections = set()
358
- for section_name, glob_or_description in SECTION_GLOBS.items():
359
- section_content = extract_section(lines, f"## {section_name}")
360
- if any(line.strip() for line in section_content):
361
- found_sections.add(section_name)
362
- filename = header_to_filename(section_name)
363
-
364
- # Replace header with proper casing from SECTION_GLOBS
365
- section_content = replace_header_with_proper_casing(
366
- section_content, section_name
367
- )
368
-
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,
375
- section_content,
376
- )
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,
382
- section_content,
383
- )
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
-
393
- # Check for sections in mapping that don't exist in the file
394
- for section_name in SECTION_GLOBS:
395
- if section_name not in found_sections:
396
- logger.warning("Section not found in file", section=section_name)
397
-
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(
407
- 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
414
- ):
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
- )
268
+ explode_implementation(input_file, agent, Path.cwd())