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.
- llm_ide_rules/__init__.py +53 -9
- llm_ide_rules/__main__.py +1 -1
- llm_ide_rules/agents/__init__.py +28 -0
- llm_ide_rules/agents/base.py +329 -0
- llm_ide_rules/agents/claude.py +108 -0
- llm_ide_rules/agents/cursor.py +199 -0
- llm_ide_rules/agents/gemini.py +177 -0
- llm_ide_rules/agents/github.py +212 -0
- llm_ide_rules/agents/opencode.py +130 -0
- llm_ide_rules/commands/delete.py +24 -34
- llm_ide_rules/commands/download.py +146 -60
- llm_ide_rules/commands/explode.py +222 -382
- llm_ide_rules/commands/implode.py +174 -360
- llm_ide_rules/commands/mcp.py +119 -0
- llm_ide_rules/constants.py +6 -29
- llm_ide_rules/log.py +9 -0
- llm_ide_rules/markdown_parser.py +108 -0
- llm_ide_rules/mcp/__init__.py +7 -0
- llm_ide_rules/mcp/models.py +21 -0
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/METADATA +36 -59
- llm_ide_rules-0.7.0.dist-info/RECORD +23 -0
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/WHEEL +2 -2
- llm_ide_rules/sections.json +0 -27
- llm_ide_rules-0.5.0.dist-info/RECORD +0 -12
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/entry_points.txt +0 -0
|
@@ -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.
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
#
|
|
178
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
233
|
+
# Build log message and user output based on processed agents
|
|
234
|
+
log_data = {"agent": agent}
|
|
235
|
+
created_dirs = []
|
|
261
236
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
f
|
|
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
|
|
268
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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())
|