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.
Files changed (24) hide show
  1. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/PKG-INFO +2 -1
  2. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/pyproject.toml +10 -2
  3. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/__init__.py +1 -1
  4. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/base.py +52 -6
  5. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/claude.py +20 -4
  6. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/cursor.py +30 -9
  7. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/gemini.py +20 -4
  8. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/github.py +15 -10
  9. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/opencode.py +8 -4
  10. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/download.py +99 -9
  11. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/explode.py +103 -155
  12. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/implode.py +7 -38
  13. llm_ide_rules-0.7.0/src/llm_ide_rules/constants.py +13 -0
  14. llm_ide_rules-0.7.0/src/llm_ide_rules/markdown_parser.py +108 -0
  15. llm_ide_rules-0.6.0/src/llm_ide_rules/constants.py +0 -39
  16. llm_ide_rules-0.6.0/src/llm_ide_rules/sections.json +0 -17
  17. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/README.md +0 -0
  18. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/__main__.py +0 -0
  19. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/agents/__init__.py +0 -0
  20. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/delete.py +0 -0
  21. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/commands/mcp.py +0 -0
  22. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/log.py +0 -0
  23. {llm_ide_rules-0.6.0 → llm_ide_rules-0.7.0}/src/llm_ide_rules/mcp/__init__.py +0 -0
  24. {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.6.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.6.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 = ["typer>=0.9.0", "structlog-config>=0.6.0", "requests>=2.25.0", "pydantic>=2.0.0", "json5>=0.9.0", "tomli-w>=1.0.0"]
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.6.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(command_files, list(section_globs.keys()))
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(command_file.stem, section_globs)
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(others, list(section_globs.keys()))
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
- content = rule_file.read_text().strip()
54
- if not content:
55
+ file_content = rule_file.read_text().strip()
56
+ if not file_content:
55
57
  continue
56
58
 
57
- content = strip_yaml_frontmatter(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
- header = resolve_header_from_stem(rule_file.stem, section_globs)
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(command_files, list(section_globs.keys()))
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(command_file.stem, section_globs)
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(command_files, list(section_globs.keys()))
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(command_file.stem, section_globs)
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(base_stem, section_globs)
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
- for section_name in section_globs.keys():
105
- filename = header_to_filename(section_name)
106
- if filename in prompt_dict:
107
- ordered_prompts.append(prompt_dict[filename])
108
- del prompt_dict[filename]
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(base_stem, section_globs)
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(command_files, list(section_globs.keys()))
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(command_file.stem, section_globs)
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": {"directories": [".cursor"], "files": []},
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
- "exclude_patterns": ["workflows/*"],
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, target_subdir, config.get("exclude_patterns", [])
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, target_dir: Path, exclude_patterns: list[str]
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 {len(copied_items)} items to {target_path}:"
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 load_section_globs, header_to_filename, VALID_AGENTS
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 explode_main(
116
- input_file: Annotated[
117
- str, typer.Argument(help="Input markdown file")
118
- ] = "instructions.md",
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
- """Convert instruction file to separate rule files."""
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", input_file=input_file, agent=agent, config=config
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 = cwd / agent_instances[agent_name].rules_dir
167
- commands_dir = cwd / agent_instances[agent_name].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 = cwd / agent_instances[agent_name].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 = cwd / input_file
115
+ input_path = working_dir / input_file
178
116
 
179
117
  try:
180
- lines = input_path.read_text().splitlines(keepends=True)
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
- commands_lines = []
126
+ commands_text = ""
189
127
  if commands_path.exists():
190
- commands_lines = commands_path.read_text().splitlines(keepends=True)
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, cwd)
208
-
209
- # Process mapped sections for agents that support rules
210
- found_sections = set()
211
- for section_name, glob_pattern in section_globs.items():
212
- section_content = extract_section(lines, f"## {section_name}")
213
- if any(line.strip() for line in section_content):
214
- found_sections.add(section_name)
215
- filename = header_to_filename(section_name)
216
-
217
- section_content = replace_header_with_proper_casing(
218
- section_content, section_name
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
- if commands_lines:
293
- command_sections = extract_all_sections(commands_lines)
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, section_content in command_sections.items():
300
- process_command_section(section_name, section_content, agents, command_dirs)
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, section_globs)
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, section_globs)
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, section_globs)
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, section_globs)
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, section_globs)
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, section_globs)
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, section_globs)
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