llm-ide-rules 0.6.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/PKG-INFO +2 -1
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/pyproject.toml +10 -2
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/__init__.py +1 -1
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/base.py +52 -6
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/claude.py +20 -4
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/cursor.py +30 -9
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/gemini.py +20 -4
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/github.py +15 -10
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/opencode.py +8 -4
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/download.py +99 -9
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/explode.py +103 -155
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/implode.py +7 -38
- llm_ide_rules-0.7.0/src/llm_ide_rules/constants.py +13 -0
- llm_ide_rules-0.7.0/src/llm_ide_rules/markdown_parser.py +108 -0
- llm_ide_rules-0.6.0/src/llm_ide_rules/constants.py +0 -39
- llm_ide_rules-0.6.0/src/llm_ide_rules/sections.json +0 -17
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/README.md +0 -0
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/__main__.py +0 -0
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/__init__.py +0 -0
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/delete.py +0 -0
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/mcp.py +0 -0
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/log.py +0 -0
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/mcp/__init__.py +0 -0
- {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/mcp/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: llm-ide-rules
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: CLI tool for managing LLM IDE prompts and rules
|
|
5
5
|
Keywords: llm,ide,prompts,cursor,copilot
|
|
6
6
|
Author: Michael Bianco
|
|
@@ -11,6 +11,7 @@ Requires-Dist: requests>=2.25.0
|
|
|
11
11
|
Requires-Dist: pydantic>=2.0.0
|
|
12
12
|
Requires-Dist: json5>=0.9.0
|
|
13
13
|
Requires-Dist: tomli-w>=1.0.0
|
|
14
|
+
Requires-Dist: markdown-it-py>=4.0.0
|
|
14
15
|
Requires-Python: >=3.11
|
|
15
16
|
Project-URL: Repository, https://github.com/iloveitaly/llm-ide-rules
|
|
16
17
|
Description-Content-Type: text/markdown
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "llm-ide-rules"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "CLI tool for managing LLM IDE prompts and rules"
|
|
5
5
|
keywords = ["llm", "ide", "prompts", "cursor", "copilot"]
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
requires-python = ">=3.11"
|
|
8
|
-
dependencies = [
|
|
8
|
+
dependencies = [
|
|
9
|
+
"typer>=0.9.0",
|
|
10
|
+
"structlog-config>=0.6.0",
|
|
11
|
+
"requests>=2.25.0",
|
|
12
|
+
"pydantic>=2.0.0",
|
|
13
|
+
"json5>=0.9.0",
|
|
14
|
+
"tomli-w>=1.0.0",
|
|
15
|
+
"markdown-it-py>=4.0.0",
|
|
16
|
+
]
|
|
9
17
|
authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
|
|
10
18
|
urls = { "Repository" = "https://github.com/iloveitaly/llm-ide-rules" }
|
|
11
19
|
|
|
@@ -14,7 +14,7 @@ from llm_ide_rules.commands.download import download_main
|
|
|
14
14
|
from llm_ide_rules.commands.delete import delete_main
|
|
15
15
|
from llm_ide_rules.commands.mcp import mcp_app
|
|
16
16
|
|
|
17
|
-
__version__ = "0.
|
|
17
|
+
__version__ = "0.7.0"
|
|
18
18
|
|
|
19
19
|
app = typer.Typer(
|
|
20
20
|
name="llm_ide_rules",
|
|
@@ -23,14 +23,14 @@ class BaseAgent(ABC):
|
|
|
23
23
|
|
|
24
24
|
@abstractmethod
|
|
25
25
|
def bundle_rules(
|
|
26
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
26
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
27
27
|
) -> bool:
|
|
28
28
|
"""Bundle rule files into a single output file."""
|
|
29
29
|
...
|
|
30
30
|
|
|
31
31
|
@abstractmethod
|
|
32
32
|
def bundle_commands(
|
|
33
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
33
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
34
34
|
) -> bool:
|
|
35
35
|
"""Bundle command files into a single output file."""
|
|
36
36
|
...
|
|
@@ -57,6 +57,40 @@ class BaseAgent(ABC):
|
|
|
57
57
|
"""Write a single command file."""
|
|
58
58
|
...
|
|
59
59
|
|
|
60
|
+
def generate_root_doc(
|
|
61
|
+
self,
|
|
62
|
+
general_lines: list[str],
|
|
63
|
+
rules_sections: dict[str, list[str]],
|
|
64
|
+
command_sections: dict[str, list[str]],
|
|
65
|
+
output_dir: Path,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Generate a root documentation file (e.g. CLAUDE.md) if supported."""
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
def build_root_doc_content(
|
|
71
|
+
self,
|
|
72
|
+
general_lines: list[str],
|
|
73
|
+
rules_sections: dict[str, list[str]],
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Build the content string for a root documentation file by aggregating rules."""
|
|
76
|
+
content = []
|
|
77
|
+
|
|
78
|
+
# Add general instructions
|
|
79
|
+
if general_lines:
|
|
80
|
+
trimmed = trim_content(general_lines)
|
|
81
|
+
if trimmed:
|
|
82
|
+
content.extend(trimmed)
|
|
83
|
+
content.append("\n\n")
|
|
84
|
+
|
|
85
|
+
# Add sections in document order (dict maintains insertion order in Python 3.7+)
|
|
86
|
+
for section_name, lines in rules_sections.items():
|
|
87
|
+
trimmed = trim_content(lines)
|
|
88
|
+
if trimmed:
|
|
89
|
+
content.extend(trimmed)
|
|
90
|
+
content.append("\n\n")
|
|
91
|
+
|
|
92
|
+
return "".join(content).strip() + "\n" if content else ""
|
|
93
|
+
|
|
60
94
|
def get_rules_path(self, base_dir: Path) -> Path:
|
|
61
95
|
"""Get the full path to the rules directory."""
|
|
62
96
|
if not self.rules_dir:
|
|
@@ -155,9 +189,15 @@ def strip_toml_metadata(text: str) -> str:
|
|
|
155
189
|
|
|
156
190
|
|
|
157
191
|
def get_ordered_files(
|
|
158
|
-
file_list: list[Path], section_globs_keys: list[str]
|
|
192
|
+
file_list: list[Path], section_globs_keys: list[str] | None = None
|
|
159
193
|
) -> list[Path]:
|
|
160
|
-
"""Order files based on section_globs key order, with unmapped files at the end.
|
|
194
|
+
"""Order files based on section_globs key order, with unmapped files at the end.
|
|
195
|
+
|
|
196
|
+
If section_globs_keys is None, returns files sorted alphabetically.
|
|
197
|
+
"""
|
|
198
|
+
if not section_globs_keys:
|
|
199
|
+
return sorted(file_list, key=lambda p: p.name)
|
|
200
|
+
|
|
161
201
|
file_dict = {f.stem: f for f in file_list}
|
|
162
202
|
ordered_files = []
|
|
163
203
|
|
|
@@ -174,9 +214,15 @@ def get_ordered_files(
|
|
|
174
214
|
|
|
175
215
|
|
|
176
216
|
def get_ordered_files_github(
|
|
177
|
-
file_list: list[Path], section_globs_keys: list[str]
|
|
217
|
+
file_list: list[Path], section_globs_keys: list[str] | None = None
|
|
178
218
|
) -> list[Path]:
|
|
179
|
-
"""Order GitHub instruction files, handling .instructions suffix.
|
|
219
|
+
"""Order GitHub instruction files, handling .instructions suffix.
|
|
220
|
+
|
|
221
|
+
If section_globs_keys is None, returns files sorted alphabetically.
|
|
222
|
+
"""
|
|
223
|
+
if not section_globs_keys:
|
|
224
|
+
return sorted(file_list, key=lambda p: p.name)
|
|
225
|
+
|
|
180
226
|
file_dict = {}
|
|
181
227
|
for f in file_list:
|
|
182
228
|
base_stem = f.stem.replace(".instructions", "")
|
|
@@ -23,13 +23,13 @@ class ClaudeAgent(BaseAgent):
|
|
|
23
23
|
mcp_project_path = ".mcp.json"
|
|
24
24
|
|
|
25
25
|
def bundle_rules(
|
|
26
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
26
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
27
27
|
) -> bool:
|
|
28
28
|
"""Claude Code doesn't support rules, only commands."""
|
|
29
29
|
return False
|
|
30
30
|
|
|
31
31
|
def bundle_commands(
|
|
32
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
32
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
33
33
|
) -> bool:
|
|
34
34
|
"""Bundle Claude Code command files (.md) into a single output file."""
|
|
35
35
|
commands_dir = self.commands_dir
|
|
@@ -48,7 +48,9 @@ class ClaudeAgent(BaseAgent):
|
|
|
48
48
|
if not command_files:
|
|
49
49
|
return False
|
|
50
50
|
|
|
51
|
-
ordered_commands = get_ordered_files(
|
|
51
|
+
ordered_commands = get_ordered_files(
|
|
52
|
+
command_files, list(section_globs.keys()) if section_globs else None
|
|
53
|
+
)
|
|
52
54
|
|
|
53
55
|
content_parts: list[str] = []
|
|
54
56
|
for command_file in ordered_commands:
|
|
@@ -56,7 +58,9 @@ class ClaudeAgent(BaseAgent):
|
|
|
56
58
|
if not content:
|
|
57
59
|
continue
|
|
58
60
|
|
|
59
|
-
header = resolve_header_from_stem(
|
|
61
|
+
header = resolve_header_from_stem(
|
|
62
|
+
command_file.stem, section_globs if section_globs else {}
|
|
63
|
+
)
|
|
60
64
|
content_parts.append(f"## {header}\n\n")
|
|
61
65
|
content_parts.append(content)
|
|
62
66
|
content_parts.append("\n\n")
|
|
@@ -90,3 +94,15 @@ class ClaudeAgent(BaseAgent):
|
|
|
90
94
|
|
|
91
95
|
trimmed = trim_content(content_lines)
|
|
92
96
|
filepath.write_text("".join(trimmed))
|
|
97
|
+
|
|
98
|
+
def generate_root_doc(
|
|
99
|
+
self,
|
|
100
|
+
general_lines: list[str],
|
|
101
|
+
rules_sections: dict[str, list[str]],
|
|
102
|
+
command_sections: dict[str, list[str]],
|
|
103
|
+
output_dir: Path,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Generate CLAUDE.md from rules."""
|
|
106
|
+
content = self.build_root_doc_content(general_lines, rules_sections)
|
|
107
|
+
if content.strip():
|
|
108
|
+
(output_dir / "CLAUDE.md").write_text(content)
|
|
@@ -27,7 +27,7 @@ class CursorAgent(BaseAgent):
|
|
|
27
27
|
mcp_project_path = ".cursor/mcp.json"
|
|
28
28
|
|
|
29
29
|
def bundle_rules(
|
|
30
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
30
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
31
31
|
) -> bool:
|
|
32
32
|
"""Bundle Cursor rule files (.mdc) into a single output file."""
|
|
33
33
|
rules_dir = self.rules_dir
|
|
@@ -45,18 +45,35 @@ class CursorAgent(BaseAgent):
|
|
|
45
45
|
general = [f for f in rule_files if f.stem == "general"]
|
|
46
46
|
others = [f for f in rule_files if f.stem != "general"]
|
|
47
47
|
|
|
48
|
-
ordered_others = get_ordered_files(
|
|
48
|
+
ordered_others = get_ordered_files(
|
|
49
|
+
others, list(section_globs.keys()) if section_globs else None
|
|
50
|
+
)
|
|
49
51
|
ordered = general + ordered_others
|
|
50
52
|
|
|
51
53
|
content_parts: list[str] = []
|
|
52
54
|
for rule_file in ordered:
|
|
53
|
-
|
|
54
|
-
if not
|
|
55
|
+
file_content = rule_file.read_text().strip()
|
|
56
|
+
if not file_content:
|
|
55
57
|
continue
|
|
56
58
|
|
|
57
|
-
content
|
|
59
|
+
# Extract header from file content before stripping it
|
|
60
|
+
lines = file_content.splitlines()
|
|
61
|
+
extracted_header = None
|
|
62
|
+
for line in lines:
|
|
63
|
+
if line.startswith("## "):
|
|
64
|
+
extracted_header = line[3:].strip()
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
content = strip_yaml_frontmatter(file_content)
|
|
58
68
|
content = strip_header(content)
|
|
59
|
-
|
|
69
|
+
|
|
70
|
+
# Use extracted header if available, otherwise resolve from filename
|
|
71
|
+
if extracted_header:
|
|
72
|
+
header = extracted_header
|
|
73
|
+
else:
|
|
74
|
+
header = resolve_header_from_stem(
|
|
75
|
+
rule_file.stem, section_globs if section_globs else {}
|
|
76
|
+
)
|
|
60
77
|
|
|
61
78
|
if rule_file.stem != "general":
|
|
62
79
|
content_parts.append(f"## {header}\n\n")
|
|
@@ -71,7 +88,7 @@ class CursorAgent(BaseAgent):
|
|
|
71
88
|
return True
|
|
72
89
|
|
|
73
90
|
def bundle_commands(
|
|
74
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
91
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
75
92
|
) -> bool:
|
|
76
93
|
"""Bundle Cursor command files (.md) into a single output file."""
|
|
77
94
|
commands_dir = self.commands_dir
|
|
@@ -90,7 +107,9 @@ class CursorAgent(BaseAgent):
|
|
|
90
107
|
if not command_files:
|
|
91
108
|
return False
|
|
92
109
|
|
|
93
|
-
ordered_commands = get_ordered_files(
|
|
110
|
+
ordered_commands = get_ordered_files(
|
|
111
|
+
command_files, list(section_globs.keys()) if section_globs else None
|
|
112
|
+
)
|
|
94
113
|
|
|
95
114
|
content_parts: list[str] = []
|
|
96
115
|
for command_file in ordered_commands:
|
|
@@ -98,7 +117,9 @@ class CursorAgent(BaseAgent):
|
|
|
98
117
|
if not content:
|
|
99
118
|
continue
|
|
100
119
|
|
|
101
|
-
header = resolve_header_from_stem(
|
|
120
|
+
header = resolve_header_from_stem(
|
|
121
|
+
command_file.stem, section_globs if section_globs else {}
|
|
122
|
+
)
|
|
102
123
|
content_parts.append(f"## {header}\n\n")
|
|
103
124
|
content_parts.append(content)
|
|
104
125
|
content_parts.append("\n\n")
|
|
@@ -27,13 +27,13 @@ class GeminiAgent(BaseAgent):
|
|
|
27
27
|
mcp_project_path = ".gemini/settings.json"
|
|
28
28
|
|
|
29
29
|
def bundle_rules(
|
|
30
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
30
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
31
31
|
) -> bool:
|
|
32
32
|
"""Gemini CLI doesn't support rules, only commands."""
|
|
33
33
|
return False
|
|
34
34
|
|
|
35
35
|
def bundle_commands(
|
|
36
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
36
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
37
37
|
) -> bool:
|
|
38
38
|
"""Bundle Gemini CLI command files (.toml) into a single output file."""
|
|
39
39
|
commands_dir = self.commands_dir
|
|
@@ -52,7 +52,9 @@ class GeminiAgent(BaseAgent):
|
|
|
52
52
|
if not command_files:
|
|
53
53
|
return False
|
|
54
54
|
|
|
55
|
-
ordered_commands = get_ordered_files(
|
|
55
|
+
ordered_commands = get_ordered_files(
|
|
56
|
+
command_files, list(section_globs.keys()) if section_globs else None
|
|
57
|
+
)
|
|
56
58
|
|
|
57
59
|
content_parts: list[str] = []
|
|
58
60
|
for command_file in ordered_commands:
|
|
@@ -61,7 +63,9 @@ class GeminiAgent(BaseAgent):
|
|
|
61
63
|
continue
|
|
62
64
|
|
|
63
65
|
content = strip_toml_metadata(content)
|
|
64
|
-
header = resolve_header_from_stem(
|
|
66
|
+
header = resolve_header_from_stem(
|
|
67
|
+
command_file.stem, section_globs if section_globs else {}
|
|
68
|
+
)
|
|
65
69
|
content_parts.append(f"## {header}\n\n")
|
|
66
70
|
content_parts.append(content)
|
|
67
71
|
content_parts.append("\n\n")
|
|
@@ -159,3 +163,15 @@ class GeminiAgent(BaseAgent):
|
|
|
159
163
|
|
|
160
164
|
existing[self.mcp_root_key] = servers
|
|
161
165
|
path.write_text(json.dumps(existing, indent=2))
|
|
166
|
+
|
|
167
|
+
def generate_root_doc(
|
|
168
|
+
self,
|
|
169
|
+
general_lines: list[str],
|
|
170
|
+
rules_sections: dict[str, list[str]],
|
|
171
|
+
command_sections: dict[str, list[str]],
|
|
172
|
+
output_dir: Path,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Generate GEMINI.md from rules."""
|
|
175
|
+
content = self.build_root_doc_content(general_lines, rules_sections)
|
|
176
|
+
if content.strip():
|
|
177
|
+
(output_dir / "GEMINI.md").write_text(content)
|
|
@@ -28,7 +28,7 @@ class GitHubAgent(BaseAgent):
|
|
|
28
28
|
mcp_project_path = ".copilot/mcp-config.json"
|
|
29
29
|
|
|
30
30
|
def bundle_rules(
|
|
31
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
31
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
32
32
|
) -> bool:
|
|
33
33
|
"""Bundle GitHub instruction files into a single output file."""
|
|
34
34
|
rules_dir = self.rules_dir
|
|
@@ -46,7 +46,7 @@ class GitHubAgent(BaseAgent):
|
|
|
46
46
|
instr_files = list(instructions_path.glob(f"*{rule_ext}"))
|
|
47
47
|
|
|
48
48
|
ordered_instructions = get_ordered_files_github(
|
|
49
|
-
instr_files, list(section_globs.keys())
|
|
49
|
+
instr_files, list(section_globs.keys()) if section_globs else None
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
content_parts: list[str] = []
|
|
@@ -64,7 +64,9 @@ class GitHubAgent(BaseAgent):
|
|
|
64
64
|
content = strip_yaml_frontmatter(content)
|
|
65
65
|
content = strip_header(content)
|
|
66
66
|
base_stem = instr_file.stem.replace(".instructions", "")
|
|
67
|
-
header = resolve_header_from_stem(
|
|
67
|
+
header = resolve_header_from_stem(
|
|
68
|
+
base_stem, section_globs if section_globs else {}
|
|
69
|
+
)
|
|
68
70
|
content_parts.append(f"## {header}\n\n")
|
|
69
71
|
content_parts.append(content)
|
|
70
72
|
content_parts.append("\n\n")
|
|
@@ -76,7 +78,7 @@ class GitHubAgent(BaseAgent):
|
|
|
76
78
|
return True
|
|
77
79
|
|
|
78
80
|
def bundle_commands(
|
|
79
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
81
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
80
82
|
) -> bool:
|
|
81
83
|
"""Bundle GitHub prompt files into a single output file."""
|
|
82
84
|
commands_dir = self.commands_dir
|
|
@@ -101,11 +103,12 @@ class GitHubAgent(BaseAgent):
|
|
|
101
103
|
prompt_dict[base_stem] = f
|
|
102
104
|
|
|
103
105
|
ordered_prompts = []
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
if section_globs:
|
|
107
|
+
for section_name in section_globs.keys():
|
|
108
|
+
filename = header_to_filename(section_name)
|
|
109
|
+
if filename in prompt_dict:
|
|
110
|
+
ordered_prompts.append(prompt_dict[filename])
|
|
111
|
+
del prompt_dict[filename]
|
|
109
112
|
|
|
110
113
|
remaining_prompts = sorted(prompt_dict.values(), key=lambda p: p.name)
|
|
111
114
|
ordered_prompts.extend(remaining_prompts)
|
|
@@ -119,7 +122,9 @@ class GitHubAgent(BaseAgent):
|
|
|
119
122
|
content = strip_yaml_frontmatter(content)
|
|
120
123
|
content = strip_header(content)
|
|
121
124
|
base_stem = prompt_file.stem.replace(".prompt", "")
|
|
122
|
-
header = resolve_header_from_stem(
|
|
125
|
+
header = resolve_header_from_stem(
|
|
126
|
+
base_stem, section_globs if section_globs else {}
|
|
127
|
+
)
|
|
123
128
|
content_parts.append(f"## {header}\n\n")
|
|
124
129
|
content_parts.append(content)
|
|
125
130
|
content_parts.append("\n\n")
|
|
@@ -25,13 +25,13 @@ class OpenCodeAgent(BaseAgent):
|
|
|
25
25
|
mcp_root_key = "mcp"
|
|
26
26
|
|
|
27
27
|
def bundle_rules(
|
|
28
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
28
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
29
29
|
) -> bool:
|
|
30
30
|
"""OpenCode doesn't support rules."""
|
|
31
31
|
return False
|
|
32
32
|
|
|
33
33
|
def bundle_commands(
|
|
34
|
-
self, output_file: Path, section_globs: dict[str, str | None]
|
|
34
|
+
self, output_file: Path, section_globs: dict[str, str | None] | None = None
|
|
35
35
|
) -> bool:
|
|
36
36
|
"""Bundle OpenCode command files (.md) into a single output file."""
|
|
37
37
|
commands_dir = self.commands_dir
|
|
@@ -50,7 +50,9 @@ class OpenCodeAgent(BaseAgent):
|
|
|
50
50
|
if not command_files:
|
|
51
51
|
return False
|
|
52
52
|
|
|
53
|
-
ordered_commands = get_ordered_files(
|
|
53
|
+
ordered_commands = get_ordered_files(
|
|
54
|
+
command_files, list(section_globs.keys()) if section_globs else None
|
|
55
|
+
)
|
|
54
56
|
|
|
55
57
|
content_parts: list[str] = []
|
|
56
58
|
for command_file in ordered_commands:
|
|
@@ -58,7 +60,9 @@ class OpenCodeAgent(BaseAgent):
|
|
|
58
60
|
if not content:
|
|
59
61
|
continue
|
|
60
62
|
|
|
61
|
-
header = resolve_header_from_stem(
|
|
63
|
+
header = resolve_header_from_stem(
|
|
64
|
+
command_file.stem, section_globs if section_globs else {}
|
|
65
|
+
)
|
|
62
66
|
content_parts.append(f"## {header}\n\n")
|
|
63
67
|
content_parts.append(content)
|
|
64
68
|
content_parts.append("\n\n")
|
|
@@ -9,6 +9,8 @@ import requests
|
|
|
9
9
|
import typer
|
|
10
10
|
from typing_extensions import Annotated
|
|
11
11
|
|
|
12
|
+
from llm_ide_rules.commands.explode import explode_implementation
|
|
13
|
+
from llm_ide_rules.constants import VALID_AGENTS
|
|
12
14
|
from llm_ide_rules.log import log
|
|
13
15
|
|
|
14
16
|
DEFAULT_REPO = "iloveitaly/llm-ide-rules"
|
|
@@ -38,15 +40,36 @@ def normalize_repo(repo: str) -> str:
|
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
# Define what files/directories each instruction type includes
|
|
43
|
+
# For agents supported by 'explode' (cursor, github, gemini, claude, opencode),
|
|
44
|
+
# we don't download specific directories anymore. Instead, we download the source
|
|
45
|
+
# files (instructions.md, commands.md) and generate them locally using explode.
|
|
46
|
+
# The directories listed here are what gets created by explode and what delete removes.
|
|
41
47
|
INSTRUCTION_TYPES = {
|
|
42
|
-
"cursor": {
|
|
48
|
+
"cursor": {
|
|
49
|
+
"directories": [".cursor"],
|
|
50
|
+
"files": [],
|
|
51
|
+
"include_patterns": [],
|
|
52
|
+
},
|
|
43
53
|
"github": {
|
|
44
|
-
"directories": [".github"],
|
|
54
|
+
"directories": [".github/instructions", ".github/prompts"],
|
|
55
|
+
"files": [".github/copilot-instructions.md"],
|
|
56
|
+
"include_patterns": [],
|
|
57
|
+
},
|
|
58
|
+
"gemini": {
|
|
59
|
+
"directories": [".gemini"],
|
|
60
|
+
"files": ["GEMINI.md"],
|
|
61
|
+
"include_patterns": [],
|
|
62
|
+
},
|
|
63
|
+
"claude": {
|
|
64
|
+
"directories": [".claude"],
|
|
65
|
+
"files": ["CLAUDE.md"],
|
|
66
|
+
"include_patterns": [],
|
|
67
|
+
},
|
|
68
|
+
"opencode": {
|
|
69
|
+
"directories": [".opencode"],
|
|
45
70
|
"files": [],
|
|
46
|
-
"
|
|
71
|
+
"include_patterns": [],
|
|
47
72
|
},
|
|
48
|
-
"gemini": {"directories": [".gemini/commands"], "files": ["GEMINI.md"]},
|
|
49
|
-
"claude": {"directories": [".claude/commands"], "files": ["CLAUDE.md"]},
|
|
50
73
|
"agent": {"directories": [], "files": ["AGENT.md"]},
|
|
51
74
|
"agents": {"directories": [], "files": [], "recursive_files": ["AGENTS.md"]},
|
|
52
75
|
}
|
|
@@ -131,7 +154,10 @@ def copy_instruction_files(
|
|
|
131
154
|
|
|
132
155
|
# Copy all files from source to target
|
|
133
156
|
copy_directory_contents(
|
|
134
|
-
source_dir,
|
|
157
|
+
source_dir,
|
|
158
|
+
target_subdir,
|
|
159
|
+
config.get("exclude_patterns", []),
|
|
160
|
+
config.get("include_patterns", []),
|
|
135
161
|
)
|
|
136
162
|
copied_items.append(f"{dir_name}/")
|
|
137
163
|
|
|
@@ -208,7 +234,10 @@ def copy_recursive_files(
|
|
|
208
234
|
|
|
209
235
|
|
|
210
236
|
def copy_directory_contents(
|
|
211
|
-
source_dir: Path,
|
|
237
|
+
source_dir: Path,
|
|
238
|
+
target_dir: Path,
|
|
239
|
+
exclude_patterns: list[str],
|
|
240
|
+
include_patterns: list[str] = [],
|
|
212
241
|
):
|
|
213
242
|
"""Recursively copy directory contents, excluding specified patterns."""
|
|
214
243
|
for item in source_dir.rglob("*"):
|
|
@@ -234,6 +263,22 @@ def copy_directory_contents(
|
|
|
234
263
|
log.debug("excluding file", file=relative_str, pattern=pattern)
|
|
235
264
|
continue
|
|
236
265
|
|
|
266
|
+
# Check if file matches any include pattern (if any provided)
|
|
267
|
+
if include_patterns:
|
|
268
|
+
matched_include = False
|
|
269
|
+
for include_pattern in include_patterns:
|
|
270
|
+
# Match against filename only, or full relative path
|
|
271
|
+
if item.match(include_pattern):
|
|
272
|
+
matched_include = True
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
if not matched_include:
|
|
276
|
+
log.debug(
|
|
277
|
+
"skipping file (not matched in include_patterns)",
|
|
278
|
+
file=relative_str,
|
|
279
|
+
)
|
|
280
|
+
continue
|
|
281
|
+
|
|
237
282
|
target_file = target_dir / relative_path
|
|
238
283
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
239
284
|
target_file.write_bytes(item.read_bytes())
|
|
@@ -312,13 +357,58 @@ def download_main(
|
|
|
312
357
|
# Copy instruction files
|
|
313
358
|
copied_items = copy_instruction_files(repo_dir, instruction_types, target_path)
|
|
314
359
|
|
|
360
|
+
# Check for source files (instructions.md, commands.md) and copy them if available
|
|
361
|
+
# These are needed for 'explode' logic
|
|
362
|
+
source_files = ["instructions.md", "commands.md"]
|
|
363
|
+
sources_copied = False
|
|
364
|
+
|
|
365
|
+
# Only copy source files if we have at least one agent that uses explode
|
|
366
|
+
has_explode_agent = any(t in VALID_AGENTS for t in instruction_types)
|
|
367
|
+
|
|
368
|
+
if has_explode_agent:
|
|
369
|
+
for source_file in source_files:
|
|
370
|
+
src = repo_dir / source_file
|
|
371
|
+
dst = target_path / source_file
|
|
372
|
+
if src.exists():
|
|
373
|
+
log.info("copying source file", source=str(src), target=str(dst))
|
|
374
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
dst.write_bytes(src.read_bytes())
|
|
376
|
+
copied_items.append(source_file)
|
|
377
|
+
sources_copied = True
|
|
378
|
+
|
|
379
|
+
# Generate rule files locally for supported agents
|
|
380
|
+
explodable_agents = [t for t in instruction_types if t in VALID_AGENTS]
|
|
381
|
+
|
|
382
|
+
if explodable_agents:
|
|
383
|
+
if not sources_copied:
|
|
384
|
+
# Check if they existed in target already?
|
|
385
|
+
if not (target_path / "instructions.md").exists():
|
|
386
|
+
log.warning(
|
|
387
|
+
"source file instructions.md missing, generation might fail"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
for agent in explodable_agents:
|
|
391
|
+
log.info("generating rules locally", agent=agent)
|
|
392
|
+
try:
|
|
393
|
+
explode_implementation(
|
|
394
|
+
input_file="instructions.md",
|
|
395
|
+
agent=agent,
|
|
396
|
+
working_dir=target_path,
|
|
397
|
+
)
|
|
398
|
+
copied_items.append(f"(generated) {agent} rules")
|
|
399
|
+
except Exception as e:
|
|
400
|
+
log.error("failed to generate rules", agent=agent, error=str(e))
|
|
401
|
+
typer.echo(
|
|
402
|
+
f"Warning: Failed to generate rules for {agent}: {e}", err=True
|
|
403
|
+
)
|
|
404
|
+
|
|
315
405
|
if copied_items:
|
|
316
|
-
success_msg = f"Downloaded
|
|
406
|
+
success_msg = f"Downloaded/Generated items in {target_path}:"
|
|
317
407
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
318
408
|
for item in copied_items:
|
|
319
409
|
typer.echo(f" - {item}")
|
|
320
410
|
else:
|
|
321
|
-
log.warning("no files were copied")
|
|
411
|
+
log.warning("no files were copied or generated")
|
|
322
412
|
typer.echo("No matching instruction files found in the repository.")
|
|
323
413
|
|
|
324
414
|
finally:
|
|
@@ -12,59 +12,8 @@ from llm_ide_rules.agents.base import (
|
|
|
12
12
|
write_rule_file,
|
|
13
13
|
)
|
|
14
14
|
from llm_ide_rules.log import log
|
|
15
|
-
from llm_ide_rules.constants import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def extract_general(lines: list[str]) -> list[str]:
|
|
19
|
-
"""Extract lines before the first section header '## '."""
|
|
20
|
-
general = []
|
|
21
|
-
for line in lines:
|
|
22
|
-
if line.startswith("## "):
|
|
23
|
-
break
|
|
24
|
-
general.append(line)
|
|
25
|
-
|
|
26
|
-
return general
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
Includes the header itself in the output.
|
|
33
|
-
"""
|
|
34
|
-
content = []
|
|
35
|
-
in_section = False
|
|
36
|
-
for line in lines:
|
|
37
|
-
if in_section:
|
|
38
|
-
if line.startswith("## "):
|
|
39
|
-
break
|
|
40
|
-
content.append(line)
|
|
41
|
-
elif line.strip().lower() == header.lower():
|
|
42
|
-
in_section = True
|
|
43
|
-
content.append(line)
|
|
44
|
-
|
|
45
|
-
return content
|
|
46
|
-
|
|
47
|
-
|
|
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] = []
|
|
53
|
-
|
|
54
|
-
for line in lines:
|
|
55
|
-
if line.startswith("## "):
|
|
56
|
-
if current_section:
|
|
57
|
-
sections[current_section] = current_content
|
|
58
|
-
|
|
59
|
-
current_section = line.strip()[3:]
|
|
60
|
-
current_content = [line]
|
|
61
|
-
elif current_section:
|
|
62
|
-
current_content.append(line)
|
|
63
|
-
|
|
64
|
-
if current_section:
|
|
65
|
-
sections[current_section] = current_content
|
|
66
|
-
|
|
67
|
-
return sections
|
|
15
|
+
from llm_ide_rules.constants import header_to_filename, VALID_AGENTS
|
|
16
|
+
from llm_ide_rules.markdown_parser import parse_sections
|
|
68
17
|
|
|
69
18
|
|
|
70
19
|
def process_command_section(
|
|
@@ -112,24 +61,14 @@ def process_unmapped_as_always_apply(
|
|
|
112
61
|
return True
|
|
113
62
|
|
|
114
63
|
|
|
115
|
-
def
|
|
116
|
-
input_file:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
config: Annotated[
|
|
120
|
-
str | None,
|
|
121
|
-
typer.Option("--config", "-c", help="Custom configuration file path"),
|
|
122
|
-
] = None,
|
|
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",
|
|
64
|
+
def explode_implementation(
|
|
65
|
+
input_file: str = "instructions.md",
|
|
66
|
+
agent: str = "all",
|
|
67
|
+
working_dir: Path | None = None,
|
|
131
68
|
) -> None:
|
|
132
|
-
"""
|
|
69
|
+
"""Core implementation of explode command."""
|
|
70
|
+
if working_dir is None:
|
|
71
|
+
working_dir = Path.cwd()
|
|
133
72
|
|
|
134
73
|
if agent not in VALID_AGENTS:
|
|
135
74
|
log.error("invalid agent", agent=agent, valid_agents=VALID_AGENTS)
|
|
@@ -139,14 +78,13 @@ def explode_main(
|
|
|
139
78
|
typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
|
|
140
79
|
raise typer.Exit(1)
|
|
141
80
|
|
|
142
|
-
section_globs = load_section_globs(config)
|
|
143
|
-
|
|
144
81
|
log.info(
|
|
145
|
-
"starting explode operation",
|
|
82
|
+
"starting explode operation",
|
|
83
|
+
input_file=input_file,
|
|
84
|
+
agent=agent,
|
|
85
|
+
working_dir=str(working_dir),
|
|
146
86
|
)
|
|
147
87
|
|
|
148
|
-
cwd = Path.cwd()
|
|
149
|
-
|
|
150
88
|
# Initialize only the agents we need
|
|
151
89
|
agents_to_process = []
|
|
152
90
|
if agent == "all":
|
|
@@ -163,21 +101,21 @@ def explode_main(
|
|
|
163
101
|
|
|
164
102
|
if agent_name in ["cursor", "github"]:
|
|
165
103
|
# These agents have both rules and commands
|
|
166
|
-
rules_dir =
|
|
167
|
-
commands_dir =
|
|
104
|
+
rules_dir = working_dir / agent_instances[agent_name].rules_dir
|
|
105
|
+
commands_dir = working_dir / agent_instances[agent_name].commands_dir
|
|
168
106
|
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
169
107
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
170
108
|
agent_dirs[agent_name] = {"rules": rules_dir, "commands": commands_dir}
|
|
171
109
|
else:
|
|
172
110
|
# claude, gemini, and opencode only have commands
|
|
173
|
-
commands_dir =
|
|
111
|
+
commands_dir = working_dir / agent_instances[agent_name].commands_dir
|
|
174
112
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
175
113
|
agent_dirs[agent_name] = {"commands": commands_dir}
|
|
176
114
|
|
|
177
|
-
input_path =
|
|
115
|
+
input_path = working_dir / input_file
|
|
178
116
|
|
|
179
117
|
try:
|
|
180
|
-
|
|
118
|
+
input_text = input_path.read_text()
|
|
181
119
|
except FileNotFoundError:
|
|
182
120
|
log.error("input file not found", input_file=str(input_path))
|
|
183
121
|
error_msg = f"Input file not found: {input_path}"
|
|
@@ -185,13 +123,15 @@ def explode_main(
|
|
|
185
123
|
raise typer.Exit(1)
|
|
186
124
|
|
|
187
125
|
commands_path = input_path.parent / "commands.md"
|
|
188
|
-
|
|
126
|
+
commands_text = ""
|
|
189
127
|
if commands_path.exists():
|
|
190
|
-
|
|
128
|
+
commands_text = commands_path.read_text()
|
|
191
129
|
log.info("found commands file", commands_file=str(commands_path))
|
|
192
130
|
|
|
131
|
+
# Parse instructions
|
|
132
|
+
general, instruction_sections = parse_sections(input_text)
|
|
133
|
+
|
|
193
134
|
# Process general instructions for agents that support rules
|
|
194
|
-
general = extract_general(lines)
|
|
195
135
|
if any(line.strip() for line in general):
|
|
196
136
|
general_header = """
|
|
197
137
|
---
|
|
@@ -204,20 +144,52 @@ alwaysApply: true
|
|
|
204
144
|
agent_dirs["cursor"]["rules"] / "general.mdc", general_header, general
|
|
205
145
|
)
|
|
206
146
|
if "github" in agent_instances:
|
|
207
|
-
agent_instances["github"].write_general_instructions(general,
|
|
208
|
-
|
|
209
|
-
# Process
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
147
|
+
agent_instances["github"].write_general_instructions(general, working_dir)
|
|
148
|
+
|
|
149
|
+
# Process sections for agents that support rules
|
|
150
|
+
rules_sections: dict[str, list[str]] = {}
|
|
151
|
+
section_globs: dict[str, str | None] = {}
|
|
152
|
+
|
|
153
|
+
for section_name, section_data in instruction_sections.items():
|
|
154
|
+
content = section_data.content
|
|
155
|
+
glob_pattern = section_data.glob_pattern
|
|
156
|
+
|
|
157
|
+
if not any(line.strip() for line in content):
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
rules_sections[section_name] = content
|
|
161
|
+
section_globs[section_name] = glob_pattern
|
|
162
|
+
filename = header_to_filename(section_name)
|
|
163
|
+
|
|
164
|
+
section_content = replace_header_with_proper_casing(content, section_name)
|
|
220
165
|
|
|
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
|
|
221
193
|
if "cursor" in agent_instances:
|
|
222
194
|
agent_instances["cursor"].write_rule(
|
|
223
195
|
section_content,
|
|
@@ -233,71 +205,30 @@ alwaysApply: true
|
|
|
233
205
|
glob_pattern,
|
|
234
206
|
)
|
|
235
207
|
|
|
236
|
-
for section_name in section_globs:
|
|
237
|
-
if section_name not in found_sections:
|
|
238
|
-
log.warning("section not found in file", section=section_name)
|
|
239
|
-
|
|
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(
|
|
246
|
-
section_name.lower() == mapped_section.lower()
|
|
247
|
-
for mapped_section in section_globs
|
|
248
|
-
):
|
|
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
208
|
# Process commands for all agents
|
|
292
|
-
|
|
293
|
-
|
|
209
|
+
command_sections_data = {}
|
|
210
|
+
command_sections = {}
|
|
211
|
+
if commands_text:
|
|
212
|
+
_, command_sections_data = parse_sections(commands_text)
|
|
294
213
|
agents = [agent_instances[name] for name in agents_to_process]
|
|
295
214
|
command_dirs = {
|
|
296
215
|
name: agent_dirs[name]["commands"] for name in agents_to_process
|
|
297
216
|
}
|
|
298
217
|
|
|
299
|
-
for section_name,
|
|
300
|
-
|
|
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
|
+
)
|
|
223
|
+
|
|
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
|
+
)
|
|
301
232
|
|
|
302
233
|
# Build log message and user output based on processed agents
|
|
303
234
|
log_data = {"agent": agent}
|
|
@@ -318,3 +249,20 @@ alwaysApply: true
|
|
|
318
249
|
else:
|
|
319
250
|
success_msg = f"Created files in {', '.join(created_dirs)} directories"
|
|
320
251
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def explode_main(
|
|
255
|
+
input_file: Annotated[
|
|
256
|
+
str, typer.Argument(help="Input markdown file")
|
|
257
|
+
] = "instructions.md",
|
|
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:
|
|
267
|
+
"""Convert instruction file to separate rule files."""
|
|
268
|
+
explode_implementation(input_file, agent, Path.cwd())
|
|
@@ -6,7 +6,6 @@ from typing_extensions import Annotated
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
8
|
from llm_ide_rules.agents import get_agent
|
|
9
|
-
from llm_ide_rules.constants import load_section_globs
|
|
10
9
|
from llm_ide_rules.log import log
|
|
11
10
|
|
|
12
11
|
|
|
@@ -14,14 +13,9 @@ def cursor(
|
|
|
14
13
|
output: Annotated[
|
|
15
14
|
str, typer.Argument(help="Output file for rules")
|
|
16
15
|
] = "instructions.md",
|
|
17
|
-
config: Annotated[
|
|
18
|
-
str | None,
|
|
19
|
-
typer.Option("--config", "-c", help="Custom configuration file path"),
|
|
20
|
-
] = None,
|
|
21
16
|
) -> None:
|
|
22
17
|
"""Bundle Cursor rules into instructions.md and commands into commands.md."""
|
|
23
18
|
|
|
24
|
-
section_globs = load_section_globs(config)
|
|
25
19
|
agent = get_agent("cursor")
|
|
26
20
|
cwd = Path.cwd()
|
|
27
21
|
|
|
@@ -34,7 +28,6 @@ def cursor(
|
|
|
34
28
|
"bundling cursor rules and commands",
|
|
35
29
|
rules_dir=rules_dir,
|
|
36
30
|
commands_dir=agent.commands_dir,
|
|
37
|
-
config=config,
|
|
38
31
|
)
|
|
39
32
|
|
|
40
33
|
rules_path = cwd / rules_dir
|
|
@@ -45,7 +38,7 @@ def cursor(
|
|
|
45
38
|
raise typer.Exit(1)
|
|
46
39
|
|
|
47
40
|
output_path = cwd / output
|
|
48
|
-
rules_written = agent.bundle_rules(output_path
|
|
41
|
+
rules_written = agent.bundle_rules(output_path)
|
|
49
42
|
if rules_written:
|
|
50
43
|
success_msg = f"Bundled cursor rules into {output}"
|
|
51
44
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -54,7 +47,7 @@ def cursor(
|
|
|
54
47
|
log.info("no cursor rules to bundle")
|
|
55
48
|
|
|
56
49
|
commands_output_path = cwd / "commands.md"
|
|
57
|
-
commands_written = agent.bundle_commands(commands_output_path
|
|
50
|
+
commands_written = agent.bundle_commands(commands_output_path)
|
|
58
51
|
if commands_written:
|
|
59
52
|
success_msg = "Bundled cursor commands into commands.md"
|
|
60
53
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -66,14 +59,9 @@ def github(
|
|
|
66
59
|
output: Annotated[
|
|
67
60
|
str, typer.Argument(help="Output file for instructions")
|
|
68
61
|
] = "instructions.md",
|
|
69
|
-
config: Annotated[
|
|
70
|
-
str | None,
|
|
71
|
-
typer.Option("--config", "-c", help="Custom configuration file path"),
|
|
72
|
-
] = None,
|
|
73
62
|
) -> None:
|
|
74
63
|
"""Bundle GitHub instructions into instructions.md and prompts into commands.md."""
|
|
75
64
|
|
|
76
|
-
section_globs = load_section_globs(config)
|
|
77
65
|
agent = get_agent("github")
|
|
78
66
|
cwd = Path.cwd()
|
|
79
67
|
|
|
@@ -86,7 +74,6 @@ def github(
|
|
|
86
74
|
"bundling github instructions and prompts",
|
|
87
75
|
instructions_dir=rules_dir,
|
|
88
76
|
prompts_dir=agent.commands_dir,
|
|
89
|
-
config=config,
|
|
90
77
|
)
|
|
91
78
|
|
|
92
79
|
rules_path = cwd / rules_dir
|
|
@@ -99,7 +86,7 @@ def github(
|
|
|
99
86
|
raise typer.Exit(1)
|
|
100
87
|
|
|
101
88
|
output_path = cwd / output
|
|
102
|
-
instructions_written = agent.bundle_rules(output_path
|
|
89
|
+
instructions_written = agent.bundle_rules(output_path)
|
|
103
90
|
if instructions_written:
|
|
104
91
|
success_msg = f"Bundled github instructions into {output}"
|
|
105
92
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -108,7 +95,7 @@ def github(
|
|
|
108
95
|
log.info("no github instructions to bundle")
|
|
109
96
|
|
|
110
97
|
commands_output_path = cwd / "commands.md"
|
|
111
|
-
prompts_written = agent.bundle_commands(commands_output_path
|
|
98
|
+
prompts_written = agent.bundle_commands(commands_output_path)
|
|
112
99
|
if prompts_written:
|
|
113
100
|
success_msg = "Bundled github prompts into commands.md"
|
|
114
101
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -118,14 +105,9 @@ def github(
|
|
|
118
105
|
|
|
119
106
|
def claude(
|
|
120
107
|
output: Annotated[str, typer.Argument(help="Output file")] = "commands.md",
|
|
121
|
-
config: Annotated[
|
|
122
|
-
str | None,
|
|
123
|
-
typer.Option("--config", "-c", help="Custom configuration file path"),
|
|
124
|
-
] = None,
|
|
125
108
|
) -> None:
|
|
126
109
|
"""Bundle Claude Code commands into commands.md."""
|
|
127
110
|
|
|
128
|
-
section_globs = load_section_globs(config)
|
|
129
111
|
agent = get_agent("claude")
|
|
130
112
|
cwd = Path.cwd()
|
|
131
113
|
|
|
@@ -137,7 +119,6 @@ def claude(
|
|
|
137
119
|
log.info(
|
|
138
120
|
"bundling claude code commands",
|
|
139
121
|
commands_dir=commands_dir,
|
|
140
|
-
config=config,
|
|
141
122
|
)
|
|
142
123
|
|
|
143
124
|
commands_path = cwd / commands_dir
|
|
@@ -150,7 +131,7 @@ def claude(
|
|
|
150
131
|
raise typer.Exit(1)
|
|
151
132
|
|
|
152
133
|
output_path = cwd / output
|
|
153
|
-
commands_written = agent.bundle_commands(output_path
|
|
134
|
+
commands_written = agent.bundle_commands(output_path)
|
|
154
135
|
if commands_written:
|
|
155
136
|
success_msg = f"Bundled claude commands into {output}"
|
|
156
137
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -161,14 +142,9 @@ def claude(
|
|
|
161
142
|
|
|
162
143
|
def gemini(
|
|
163
144
|
output: Annotated[str, typer.Argument(help="Output file")] = "commands.md",
|
|
164
|
-
config: Annotated[
|
|
165
|
-
str | None,
|
|
166
|
-
typer.Option("--config", "-c", help="Custom configuration file path"),
|
|
167
|
-
] = None,
|
|
168
145
|
) -> None:
|
|
169
146
|
"""Bundle Gemini CLI commands into commands.md."""
|
|
170
147
|
|
|
171
|
-
section_globs = load_section_globs(config)
|
|
172
148
|
agent = get_agent("gemini")
|
|
173
149
|
cwd = Path.cwd()
|
|
174
150
|
|
|
@@ -180,7 +156,6 @@ def gemini(
|
|
|
180
156
|
log.info(
|
|
181
157
|
"bundling gemini cli commands",
|
|
182
158
|
commands_dir=commands_dir,
|
|
183
|
-
config=config,
|
|
184
159
|
)
|
|
185
160
|
|
|
186
161
|
commands_path = cwd / commands_dir
|
|
@@ -193,7 +168,7 @@ def gemini(
|
|
|
193
168
|
raise typer.Exit(1)
|
|
194
169
|
|
|
195
170
|
output_path = cwd / output
|
|
196
|
-
commands_written = agent.bundle_commands(output_path
|
|
171
|
+
commands_written = agent.bundle_commands(output_path)
|
|
197
172
|
if commands_written:
|
|
198
173
|
success_msg = f"Bundled gemini commands into {output}"
|
|
199
174
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -204,14 +179,9 @@ def gemini(
|
|
|
204
179
|
|
|
205
180
|
def opencode(
|
|
206
181
|
output: Annotated[str, typer.Argument(help="Output file")] = "commands.md",
|
|
207
|
-
config: Annotated[
|
|
208
|
-
str | None,
|
|
209
|
-
typer.Option("--config", "-c", help="Custom configuration file path"),
|
|
210
|
-
] = None,
|
|
211
182
|
) -> None:
|
|
212
183
|
"""Bundle OpenCode commands into commands.md."""
|
|
213
184
|
|
|
214
|
-
section_globs = load_section_globs(config)
|
|
215
185
|
agent = get_agent("opencode")
|
|
216
186
|
cwd = Path.cwd()
|
|
217
187
|
|
|
@@ -223,7 +193,6 @@ def opencode(
|
|
|
223
193
|
log.info(
|
|
224
194
|
"bundling opencode commands",
|
|
225
195
|
commands_dir=commands_dir,
|
|
226
|
-
config=config,
|
|
227
196
|
)
|
|
228
197
|
|
|
229
198
|
commands_path = cwd / commands_dir
|
|
@@ -236,7 +205,7 @@ def opencode(
|
|
|
236
205
|
raise typer.Exit(1)
|
|
237
206
|
|
|
238
207
|
output_path = cwd / output
|
|
239
|
-
commands_written = agent.bundle_commands(output_path
|
|
208
|
+
commands_written = agent.bundle_commands(output_path)
|
|
240
209
|
if commands_written:
|
|
241
210
|
success_msg = f"Bundled opencode commands into {output}"
|
|
242
211
|
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared constants for explode and implode functionality."""
|
|
2
|
+
|
|
3
|
+
VALID_AGENTS = ["cursor", "github", "claude", "gemini", "opencode", "all"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def header_to_filename(header: str) -> str:
|
|
7
|
+
"""Convert a section header to a filename."""
|
|
8
|
+
return header.lower().replace(" ", "-")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def filename_to_header(filename: str) -> str:
|
|
12
|
+
"""Convert a filename back to a section header."""
|
|
13
|
+
return filename.replace("-", " ").title()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Markdown parsing utilities using markdown-it-py."""
|
|
2
|
+
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
from markdown_it import MarkdownIt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SectionData(NamedTuple):
|
|
9
|
+
"""Data for a parsed section."""
|
|
10
|
+
|
|
11
|
+
content: list[str]
|
|
12
|
+
glob_pattern: str | None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def extract_glob_directive(
|
|
16
|
+
content_lines: list[str],
|
|
17
|
+
) -> tuple[list[str], str | None]:
|
|
18
|
+
"""Extract glob directive from section content if present.
|
|
19
|
+
|
|
20
|
+
Checks the first non-empty line after the header for 'globs: PATTERN'.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
content_lines: Lines including the header
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tuple of (content_without_directive, glob_pattern)
|
|
27
|
+
- glob_pattern is None if no directive found (means alwaysApply)
|
|
28
|
+
- glob_pattern is "manual" for manual-only sections
|
|
29
|
+
"""
|
|
30
|
+
if not content_lines:
|
|
31
|
+
return content_lines, None
|
|
32
|
+
|
|
33
|
+
header_idx = None
|
|
34
|
+
for i, line in enumerate(content_lines):
|
|
35
|
+
if line.startswith("## "):
|
|
36
|
+
header_idx = i
|
|
37
|
+
break
|
|
38
|
+
|
|
39
|
+
if header_idx is None:
|
|
40
|
+
return content_lines, None
|
|
41
|
+
|
|
42
|
+
for i in range(header_idx + 1, len(content_lines)):
|
|
43
|
+
line = content_lines[i].strip()
|
|
44
|
+
if not line:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
if line.lower().startswith("globs: "):
|
|
48
|
+
glob_value = line[7:].strip()
|
|
49
|
+
filtered_content = content_lines[:i] + content_lines[i + 1 :]
|
|
50
|
+
return filtered_content, glob_value
|
|
51
|
+
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
return content_lines, None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_sections(text: str) -> tuple[list[str], dict[str, SectionData]]:
|
|
58
|
+
"""Parse markdown text into general section and named sections.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Tuple of (general_lines, sections_dict) where:
|
|
62
|
+
- general_lines: Lines before the first H2 header
|
|
63
|
+
- sections_dict: Dict mapping section names to SectionData (content + glob_pattern)
|
|
64
|
+
"""
|
|
65
|
+
md = MarkdownIt()
|
|
66
|
+
tokens = md.parse(text)
|
|
67
|
+
lines = text.splitlines(keepends=True)
|
|
68
|
+
|
|
69
|
+
# Find all H2 headers
|
|
70
|
+
section_starts = []
|
|
71
|
+
for i, token in enumerate(tokens):
|
|
72
|
+
if token.type == "heading_open" and token.tag == "h2":
|
|
73
|
+
# Get the content of the header
|
|
74
|
+
# The next token is usually inline, which contains the text
|
|
75
|
+
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
|
|
76
|
+
header_content = tokens[i + 1].content.strip()
|
|
77
|
+
# token.map contains [start_line, end_line] (0-based)
|
|
78
|
+
if token.map:
|
|
79
|
+
start_line = token.map[0]
|
|
80
|
+
section_starts.append((start_line, header_content))
|
|
81
|
+
|
|
82
|
+
if not section_starts:
|
|
83
|
+
return lines, {}
|
|
84
|
+
|
|
85
|
+
# Extract general content (everything before first H2)
|
|
86
|
+
first_section_start = section_starts[0][0]
|
|
87
|
+
general_lines = lines[:first_section_start]
|
|
88
|
+
|
|
89
|
+
# Extract named sections
|
|
90
|
+
sections = {}
|
|
91
|
+
for i, (start_line, header_name) in enumerate(section_starts):
|
|
92
|
+
# End line is the start of the next section, or end of file
|
|
93
|
+
if i + 1 < len(section_starts):
|
|
94
|
+
end_line = section_starts[i + 1][0]
|
|
95
|
+
else:
|
|
96
|
+
end_line = len(lines)
|
|
97
|
+
|
|
98
|
+
# Extract all lines for this section
|
|
99
|
+
section_content = lines[start_line:end_line]
|
|
100
|
+
|
|
101
|
+
# Extract glob directive if present
|
|
102
|
+
filtered_content, glob_pattern = extract_glob_directive(section_content)
|
|
103
|
+
|
|
104
|
+
sections[header_name] = SectionData(
|
|
105
|
+
content=filtered_content, glob_pattern=glob_pattern
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return general_lines, sections
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"""Shared constants for explode and implode functionality."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
VALID_AGENTS = ["cursor", "github", "claude", "gemini", "opencode", "all"]
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def load_section_globs(custom_config_path: str | None = None) -> dict[str, str | None]:
|
|
10
|
-
"""Load section globs from JSON config file.
|
|
11
|
-
|
|
12
|
-
Args:
|
|
13
|
-
custom_config_path: Path to custom configuration file to override defaults
|
|
14
|
-
|
|
15
|
-
Returns:
|
|
16
|
-
Dictionary mapping section headers to their file globs or None for prompts
|
|
17
|
-
"""
|
|
18
|
-
if custom_config_path and Path(custom_config_path).exists():
|
|
19
|
-
config_path = Path(custom_config_path)
|
|
20
|
-
else:
|
|
21
|
-
config_path = Path(__file__).parent / "sections.json"
|
|
22
|
-
|
|
23
|
-
config = json.loads(config_path.read_text())
|
|
24
|
-
|
|
25
|
-
return config["section_globs"]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# Default section globs - loaded from bundled JSON
|
|
29
|
-
SECTION_GLOBS = load_section_globs()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def header_to_filename(header: str) -> str:
|
|
33
|
-
"""Convert a section header to a filename."""
|
|
34
|
-
return header.lower().replace(" ", "-")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def filename_to_header(filename: str) -> str:
|
|
38
|
-
"""Convert a filename back to a section header."""
|
|
39
|
-
return filename.replace("-", " ").title()
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"section_globs": {
|
|
3
|
-
"Python": "**/*.py,pyproject.toml",
|
|
4
|
-
"Python App": "**/*.py",
|
|
5
|
-
"Pytest Integration Tests": "tests/integration/**/*.py",
|
|
6
|
-
"Pytest Tests": "tests/**/*.py",
|
|
7
|
-
"Python Route Tests": "tests/routes/**/*.py",
|
|
8
|
-
"Alembic Migrations": "migrations/versions/*.py",
|
|
9
|
-
"FastAPI": "app/routes/**/*.py",
|
|
10
|
-
"React": "**/*.tsx",
|
|
11
|
-
"React Router": "web/app/routes/**/*.tsx",
|
|
12
|
-
"Shell": "**/*.sh",
|
|
13
|
-
"TypeScript": "**/*.ts,**/*.tsx",
|
|
14
|
-
"Secrets": "env/*.sh,.envrc",
|
|
15
|
-
"Stripe Backend": "manual"
|
|
16
|
-
}
|
|
17
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|