llm-ide-rules 0.4.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,200 +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
-
56
+ if current_section:
57
+ sections[current_section] = current_content
149
58
 
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")
59
+ current_section = line.strip()[3:]
60
+ current_content = [line]
61
+ elif current_section:
62
+ current_content.append(line)
153
63
 
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
- )
64
+ if current_section:
65
+ sections[current_section] = current_content
159
66
 
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)
67
+ return sections
168
68
 
169
- for line in filtered_content:
170
- f.write(line)
171
69
 
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
172
79
 
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")
80
+ filename = header_to_filename(section_name)
81
+ section_content = replace_header_with_proper_casing(section_content, section_name)
176
82
 
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
- )
83
+ for agent in agents:
84
+ if agent.commands_dir:
85
+ agent.write_command(
86
+ section_content, filename, dirs[agent.name], section_name
87
+ )
182
88
 
183
- frontmatter = f"""---
184
- mode: 'agent'
185
- description: '{description}'
186
- ---
187
- """
89
+ return True
188
90
 
189
- with open(filepath, "w") as f:
190
- f.write(frontmatter)
191
- for line in filtered_content:
192
- f.write(line)
193
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
194
103
 
195
- def process_unmapped_section(lines, section_name, rules_dir, github_prompts_dir):
196
- """Process an unmapped section as a manually applied rule (prompt)."""
197
- section_content = extract_section(lines, f"## {section_name}")
198
- if any(line.strip() for line in section_content):
199
- filename = header_to_filename(section_name)
104
+ filename = header_to_filename(section_name)
105
+ section_content = replace_header_with_proper_casing(section_content, section_name)
200
106
 
201
- # Replace header with proper casing
202
- section_content = replace_header_with_proper_casing(
203
- section_content, section_name
204
- )
107
+ cursor_agent.write_rule(
108
+ section_content, filename, cursor_rules_dir, glob_pattern=None
109
+ )
110
+ github_agent.write_rule(section_content, filename, copilot_dir, glob_pattern=None)
205
111
 
206
- # Create prompt files (same as None case in SECTION_GLOBS)
207
- write_cursor_prompt(section_content, filename, rules_dir, section_name)
208
- write_github_prompt(section_content, filename, github_prompts_dir, section_name)
209
- return True
210
- return False
112
+ return True
211
113
 
212
114
 
213
115
  def explode_main(
214
116
  input_file: Annotated[
215
117
  str, typer.Argument(help="Input markdown file")
216
118
  ] = "instructions.md",
217
- verbose: Annotated[
218
- bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
219
- ] = False,
220
119
  config: Annotated[
221
- str, typer.Option("--config", "-c", help="Custom configuration file path")
120
+ str | None,
121
+ typer.Option("--config", "-c", help="Custom configuration file path"),
222
122
  ] = None,
223
- ):
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:
224
132
  """Convert instruction file to separate rule files."""
225
- if verbose:
226
- logging.basicConfig(level=logging.DEBUG)
227
- structlog.configure(
228
- wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
229
- )
230
133
 
231
- # Load section globs (with optional custom config)
232
- SECTION_GLOBS = load_section_globs(config)
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)}"
138
+ )
139
+ typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
140
+ raise typer.Exit(1)
233
141
 
234
- logger.info("Starting explode operation", input_file=input_file, config=config)
142
+ section_globs = load_section_globs(config)
235
143
 
236
- # Work in current directory ($PWD)
237
- rules_dir = os.path.join(os.getcwd(), ".cursor", "rules")
238
- copilot_dir = os.path.join(os.getcwd(), ".github", "instructions")
239
- github_prompts_dir = os.path.join(os.getcwd(), ".github", "prompts")
144
+ log.info(
145
+ "starting explode operation", input_file=input_file, agent=agent, config=config
146
+ )
240
147
 
241
- os.makedirs(rules_dir, exist_ok=True)
242
- os.makedirs(copilot_dir, exist_ok=True)
243
- os.makedirs(github_prompts_dir, exist_ok=True)
148
+ cwd = Path.cwd()
244
149
 
245
- input_path = os.path.join(os.getcwd(), input_file)
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}
176
+
177
+ input_path = cwd / input_file
246
178
 
247
179
  try:
248
- with open(input_path, "r") as f:
249
- lines = f.readlines()
180
+ lines = input_path.read_text().splitlines(keepends=True)
250
181
  except FileNotFoundError:
251
- 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)
252
185
  raise typer.Exit(1)
253
186
 
254
- # 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
255
194
  general = extract_general(lines)
256
195
  if any(line.strip() for line in general):
257
196
  general_header = """
@@ -260,77 +199,122 @@ description:
260
199
  alwaysApply: true
261
200
  ---
262
201
  """
263
- write_rule(os.path.join(rules_dir, "general.mdc"), general_header, general)
264
- # Copilot general instructions (no frontmatter)
265
- write_rule(
266
- os.path.join(os.getcwd(), ".github", "copilot-instructions.md"), "", general
267
- )
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)
268
208
 
269
- # Process each section dynamically
209
+ # Process mapped sections for agents that support rules
270
210
  found_sections = set()
271
- for section_name, glob_or_description in SECTION_GLOBS.items():
211
+ for section_name, glob_pattern in section_globs.items():
272
212
  section_content = extract_section(lines, f"## {section_name}")
273
213
  if any(line.strip() for line in section_content):
274
214
  found_sections.add(section_name)
275
215
  filename = header_to_filename(section_name)
276
216
 
277
- # Replace header with proper casing from SECTION_GLOBS
278
217
  section_content = replace_header_with_proper_casing(
279
218
  section_content, section_name
280
219
  )
281
220
 
282
- if glob_or_description is not None:
283
- # It's a glob pattern - create instruction files
284
- cursor_header = generate_cursor_frontmatter(glob_or_description)
285
- write_rule(
286
- os.path.join(rules_dir, filename + ".mdc"),
287
- cursor_header,
221
+ if "cursor" in agent_instances:
222
+ agent_instances["cursor"].write_rule(
288
223
  section_content,
224
+ filename,
225
+ agent_dirs["cursor"]["rules"],
226
+ glob_pattern,
289
227
  )
290
-
291
- copilot_header = generate_copilot_frontmatter(glob_or_description)
292
- write_rule(
293
- os.path.join(copilot_dir, filename + ".instructions.md"),
294
- copilot_header,
228
+ if "github" in agent_instances:
229
+ agent_instances["github"].write_rule(
295
230
  section_content,
296
- )
297
- 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)
300
- write_github_prompt(
301
- section_content, filename, github_prompts_dir, section_name
231
+ filename,
232
+ agent_dirs["github"]["rules"],
233
+ glob_pattern,
302
234
  )
303
235
 
304
- # Check for sections in mapping that don't exist in the file
305
- for section_name in SECTION_GLOBS:
236
+ for section_name in section_globs:
306
237
  if section_name not in found_sections:
307
- logger.warning("Section not found in file", section=section_name)
238
+ log.warning("section not found in file", section=section_name)
308
239
 
309
- # Process unmapped sections as manually applied rules (prompts)
310
- processed_unmapped = set()
311
- for line in lines:
312
- if line.startswith("## "):
313
- section_header = line.strip()
314
- section_name = section_header[3:] # Remove "## "
315
- # Case insensitive check and avoid duplicate processing
316
- if (
317
- 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(
318
246
  section_name.lower() == mapped_section.lower()
319
- for mapped_section in SECTION_GLOBS
320
- )
321
- and section_name not in processed_unmapped
322
- ):
323
- if process_unmapped_section(
324
- lines, section_name, rules_dir, github_prompts_dir
247
+ for mapped_section in section_globs
325
248
  ):
326
- processed_unmapped.add(section_name)
327
-
328
- logger.info(
329
- "Explode operation completed",
330
- cursor_rules=rules_dir,
331
- copilot_instructions=copilot_dir,
332
- github_prompts=github_prompts_dir,
333
- )
334
- typer.echo(
335
- "Created Cursor rules in .cursor/rules/, Copilot instructions in .github/instructions/, and prompts in respective directories"
336
- )
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))