llm-ide-rules 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ """LLM Rules CLI package for managing IDE prompts and rules."""
2
+
3
+ import os
4
+
5
+ if "LOG_LEVEL" not in os.environ:
6
+ os.environ["LOG_LEVEL"] = "WARNING"
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from llm_ide_rules.commands.config import config_main
13
+ from llm_ide_rules.commands.delete import delete_main
14
+ from llm_ide_rules.commands.download import download_main
15
+ from llm_ide_rules.commands.explode import explode_main
16
+ from llm_ide_rules.commands.exploded import exploded_main
17
+ from llm_ide_rules.commands.ignores import ignores_main
18
+ from llm_ide_rules.commands.implode import (
19
+ agents,
20
+ antigravity,
21
+ claude,
22
+ cursor,
23
+ github,
24
+ grok,
25
+ opencode,
26
+ )
27
+ from llm_ide_rules.version import __version__
28
+
29
+
30
+ def version_callback(value: bool):
31
+ """Callback to display the version and exit."""
32
+ if value:
33
+ print(f"llm-ide-rules version {__version__}")
34
+ raise typer.Exit()
35
+
36
+
37
+ app = typer.Typer(
38
+ name="llm_ide_rules",
39
+ help="CLI tool for managing LLM IDE prompts and rules",
40
+ no_args_is_help=True,
41
+ )
42
+
43
+
44
+ @app.callback()
45
+ def main_callback(
46
+ verbose: Annotated[
47
+ bool,
48
+ typer.Option(
49
+ "--verbose", "-v", help="Enable verbose logging (sets LOG_LEVEL=DEBUG)"
50
+ ),
51
+ ] = False,
52
+ version: Annotated[
53
+ bool | None,
54
+ typer.Option(
55
+ "--version",
56
+ help="Show the version and exit",
57
+ callback=version_callback,
58
+ is_eager=True,
59
+ ),
60
+ ] = None,
61
+ ):
62
+ """Global CLI options."""
63
+ if verbose:
64
+ os.environ["LOG_LEVEL"] = "DEBUG"
65
+ import structlog_config
66
+
67
+ structlog_config.configure_logger()
68
+
69
+
70
+ # Add commands directly
71
+ app.command("explode", help="Convert instruction file to separate rule files")(
72
+ explode_main
73
+ )
74
+ app.command("exploded", help="List clients that were exploded")(exploded_main)
75
+ app.command("ignores", help="Generate list of files to ignore based on instructions")(
76
+ ignores_main
77
+ )
78
+ app.command("download", help="Download LLM instruction files from GitHub repositories")(
79
+ download_main
80
+ )
81
+ app.command("delete", help="Remove downloaded LLM instruction files")(delete_main)
82
+ app.command("config", help="Configure agents to use AGENTS.md")(config_main)
83
+
84
+ # Create implode sub-typer
85
+ implode_app = typer.Typer(help="Bundle rule files into a single instruction file")
86
+ implode_app.command(
87
+ "cursor", help="Bundle Cursor rules and commands into a single file"
88
+ )(cursor)
89
+ implode_app.command(
90
+ "github", help="Bundle GitHub/Copilot instructions and prompts into a single file"
91
+ )(github)
92
+ implode_app.command(
93
+ "claude", help="Bundle Claude Code rules and commands into single files"
94
+ )(claude)
95
+ implode_app.command(
96
+ "antigravity",
97
+ help="Bundle Antigravity (.agents) rules and skills into single files",
98
+ )(antigravity)
99
+ implode_app.command(
100
+ "grok", help="Bundle Grok (.agents) rules and skills into single files"
101
+ )(grok)
102
+ implode_app.command("opencode", help="Bundle OpenCode commands into a single file")(
103
+ opencode
104
+ )
105
+ implode_app.command("agents", help="Bundle AGENTS.md files into a single file")(agents)
106
+ app.add_typer(implode_app, name="implode")
107
+
108
+
109
+ def main():
110
+ """Main entry point for the CLI."""
111
+ app()
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,6 @@
1
+ """Entry point for running llm_ide_rules with python -m llm_ide_rules."""
2
+
3
+ from llm_ide_rules import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,45 @@
1
+ """Agent registry for LLM IDE rules."""
2
+
3
+ from llm_ide_rules.agents.agents import AgentsAgent
4
+ from llm_ide_rules.agents.antigravity import AntigravityAgent
5
+ from llm_ide_rules.agents.base import BaseAgent
6
+ from llm_ide_rules.agents.claude import ClaudeAgent
7
+ from llm_ide_rules.agents.cursor import CursorAgent
8
+ from llm_ide_rules.agents.github import GitHubAgent
9
+ from llm_ide_rules.agents.grok import GrokAgent
10
+ from llm_ide_rules.agents.opencode import OpenCodeAgent
11
+ from llm_ide_rules.agents.vscode import VSCodeAgent
12
+
13
+ AGENTS: dict[str, type[BaseAgent]] = {
14
+ "cursor": CursorAgent,
15
+ "github": GitHubAgent,
16
+ "claude": ClaudeAgent,
17
+ "opencode": OpenCodeAgent,
18
+ "agents": AgentsAgent,
19
+ "vscode": VSCodeAgent,
20
+ "antigravity": AntigravityAgent,
21
+ "grok": GrokAgent,
22
+ }
23
+
24
+ # Aliases for user-friendly names
25
+ AGENT_ALIASES: dict[str, str] = {}
26
+
27
+
28
+ def get_agent(name: str) -> BaseAgent:
29
+ """Get an agent instance by name."""
30
+ if name in AGENT_ALIASES:
31
+ name = AGENT_ALIASES[name]
32
+ if name not in AGENTS:
33
+ raise ValueError(f"Unknown agent: {name}. Available: {list(AGENTS.keys())}")
34
+ return AGENTS[name]()
35
+
36
+
37
+ def get_all_agents() -> list[BaseAgent]:
38
+ """Get instances of all registered agents."""
39
+ seen: set[type[BaseAgent]] = set()
40
+ result: list[BaseAgent] = []
41
+ for agent_cls in AGENTS.values():
42
+ if agent_cls not in seen:
43
+ seen.add(agent_cls)
44
+ result.append(agent_cls())
45
+ return result
@@ -0,0 +1,179 @@
1
+ """Agents documentation agent implementation."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from llm_ide_rules.agents.base import BaseAgent
8
+
9
+
10
+ class AgentsAgent(BaseAgent):
11
+ """Agent for generating AGENTS.md documentation."""
12
+
13
+ name = "agents"
14
+ rules_dir = None
15
+ commands_dir = None
16
+ rule_extension = None
17
+ command_extension = None
18
+
19
+ def bundle_rules(
20
+ self,
21
+ output_file: Path,
22
+ section_globs: dict[str, str | None] | None = None,
23
+ filename: str = "AGENTS.md",
24
+ ) -> bool:
25
+ """Bundle all AGENTS.md files into a single output file."""
26
+ base_dir = output_file.parent
27
+ # Find all AGENTS.md files recursively
28
+ agents_files = list(base_dir.rglob(filename))
29
+ if not agents_files:
30
+ return False
31
+
32
+ # Sort files: root first, then by depth and name
33
+ root_agents = [f for f in agents_files if f.parent == base_dir]
34
+ subdir_agents = sorted(
35
+ [f for f in agents_files if f.parent != base_dir],
36
+ key=lambda f: f.as_posix(),
37
+ )
38
+
39
+ all_agents = root_agents + subdir_agents
40
+ content_parts: list[str] = []
41
+ processed_sections: set[str] = set()
42
+
43
+ for agents_file in all_agents:
44
+ content = agents_file.read_text().strip()
45
+ if not content:
46
+ continue
47
+
48
+ from llm_ide_rules.markdown_parser import parse_sections
49
+
50
+ general, sections = parse_sections(content)
51
+
52
+ # Only include general instructions once (from root)
53
+ if general and agents_file.parent == base_dir:
54
+ from llm_ide_rules.agents.base import trim_content
55
+
56
+ trimmed = trim_content(general)
57
+ if trimmed:
58
+ content_parts.extend(trimmed)
59
+ content_parts.append("\n\n")
60
+
61
+ for section_name, section_data in sections.items():
62
+ if section_name in processed_sections:
63
+ continue
64
+
65
+ processed_sections.add(section_name)
66
+ section_content = section_data.content
67
+
68
+ # Reconstruct header and globs if it's from a subdirectory
69
+ content_parts.append(f"## {section_name}\n\n")
70
+
71
+ if agents_file.parent != base_dir:
72
+ rel_dir = agents_file.parent.relative_to(base_dir)
73
+ content_parts.append(f"globs: {rel_dir.as_posix()}/**/*\n\n")
74
+
75
+ from llm_ide_rules.agents.base import trim_content
76
+
77
+ trimmed = trim_content(section_content)
78
+ # Remove the section header from content as we already added it
79
+ if trimmed and trimmed[0].startswith("## "):
80
+ trimmed = trimmed[1:]
81
+ trimmed = trim_content(trimmed)
82
+
83
+ if trimmed:
84
+ content_parts.extend(trimmed)
85
+ content_parts.append("\n\n")
86
+
87
+ if not content_parts:
88
+ return False
89
+
90
+ self._write_bundled_content(output_file, "".join(content_parts))
91
+ return True
92
+
93
+ def bundle_commands(
94
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
95
+ ) -> bool:
96
+ """Agents doesn't support bundling commands."""
97
+ return False
98
+
99
+ def write_rule(
100
+ self,
101
+ content_lines: list[str],
102
+ filename: str,
103
+ rules_dir: Path,
104
+ glob_pattern: str | None = None,
105
+ description: str | None = None,
106
+ ) -> None:
107
+ """Agents doesn't support writing rules."""
108
+
109
+ def write_command(
110
+ self,
111
+ content_lines: list[str],
112
+ filename: str,
113
+ commands_dir: Path,
114
+ section_name: str | None = None,
115
+ ) -> None:
116
+ """Agents doesn't support writing commands."""
117
+
118
+ def generate_root_doc(
119
+ self,
120
+ general_lines: list[str],
121
+ rules_sections: dict[str, list[str]],
122
+ command_sections: dict[str, list[str]],
123
+ output_dir: Path,
124
+ section_globs: dict[str, str | None] | None = None,
125
+ filename: str = "AGENTS.md",
126
+ ) -> None:
127
+ """Generate AGENTS.md files, potentially distributed based on globs."""
128
+ if not section_globs:
129
+ # Fallback to single root AGENTS.md
130
+ content = self.build_root_doc_content(general_lines, rules_sections)
131
+ if content.strip():
132
+ (output_dir / filename).write_text(content)
133
+ return
134
+
135
+ # Group rules by target directory
136
+ rules_by_dir: dict[Path, dict[str, list[str]]] = {}
137
+
138
+ # Always include root directory for rules without specific directory targets
139
+ rules_by_dir[output_dir] = {}
140
+
141
+ from llm_ide_rules.utils import resolve_target_dir
142
+
143
+ for section_name, lines in rules_sections.items():
144
+ glob_pattern = section_globs.get(section_name)
145
+ target_dir = resolve_target_dir(output_dir, glob_pattern)
146
+
147
+ if target_dir != output_dir and glob_pattern and "**" in glob_pattern:
148
+ prefix = glob_pattern.split("**")[0].strip("/")
149
+ potential_dir = output_dir / prefix
150
+ if target_dir != potential_dir:
151
+ rel_potential = potential_dir.relative_to(output_dir)
152
+ rel_actual = target_dir.relative_to(output_dir)
153
+ typer.secho(
154
+ f"Warning: Directory '{rel_potential}' for section '{section_name}' does not exist. "
155
+ f"Placing in '{rel_actual}' instead.",
156
+ fg=typer.colors.YELLOW,
157
+ err=True,
158
+ )
159
+
160
+ if target_dir not in rules_by_dir:
161
+ rules_by_dir[target_dir] = {}
162
+
163
+ rules_by_dir[target_dir][section_name] = lines
164
+
165
+ # Generate AGENTS.md for each directory
166
+ for target_dir, sections in rules_by_dir.items():
167
+ if not sections:
168
+ continue
169
+
170
+ # Only include general instructions in the root AGENTS.md
171
+ current_general_lines = general_lines if target_dir == output_dir else []
172
+
173
+ content = self.build_root_doc_content(current_general_lines, sections)
174
+ if content.strip():
175
+ (target_dir / filename).write_text(content)
176
+
177
+ def detect(self, base_dir: Path) -> bool:
178
+ "detect if AGENTS.md is in use in the given directory"
179
+ return (base_dir / "AGENTS.md").exists()
@@ -0,0 +1,19 @@
1
+ """Antigravity CLI agent implementation."""
2
+
3
+ from pathlib import Path
4
+
5
+ from llm_ide_rules.agents.dotagents import DotAgentsBaseAgent
6
+
7
+
8
+ class AntigravityAgent(DotAgentsBaseAgent):
9
+ """Agent for Antigravity CLI."""
10
+
11
+ name = "antigravity"
12
+
13
+ def detect(self, base_dir: Path) -> bool:
14
+ "detect if Antigravity is in use in the given directory"
15
+ return (
16
+ super().detect(base_dir)
17
+ or (base_dir / ".gemini").exists()
18
+ or (base_dir / ".antigravity").exists()
19
+ )
@@ -0,0 +1,299 @@
1
+ """Base agent class and shared utilities for LLM IDE rules."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+
6
+ from llm_ide_rules.constants import header_to_filename
7
+
8
+
9
+ class BaseAgent(ABC):
10
+ """Base class for all IDE agents."""
11
+
12
+ name: str
13
+ rules_dir: str | None = None
14
+ commands_dir: str | None = None
15
+ rule_extension: str | None = None
16
+ command_extension: str | None = None
17
+
18
+ @abstractmethod
19
+ def bundle_rules(
20
+ self,
21
+ output_file: Path,
22
+ section_globs: dict[str, str | None] | None = None,
23
+ filename: str = "AGENTS.md",
24
+ ) -> bool:
25
+ """Bundle rule files into a single output file."""
26
+ ...
27
+
28
+ @abstractmethod
29
+ def bundle_commands(
30
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
31
+ ) -> bool:
32
+ """Bundle command files into a single output file."""
33
+ ...
34
+
35
+ @abstractmethod
36
+ def write_rule(
37
+ self,
38
+ content_lines: list[str],
39
+ filename: str,
40
+ rules_dir: Path,
41
+ glob_pattern: str | None = None,
42
+ description: str | None = None,
43
+ ) -> None:
44
+ """Write a single rule file."""
45
+ ...
46
+
47
+ @abstractmethod
48
+ def write_command(
49
+ self,
50
+ content_lines: list[str],
51
+ filename: str,
52
+ commands_dir: Path,
53
+ section_name: str | None = None,
54
+ ) -> None:
55
+ """Write a single command file."""
56
+ ...
57
+
58
+ def detect(self, base_dir: Path) -> bool:
59
+ "detect if this agent is in use in the given directory"
60
+ return False
61
+
62
+ def configure_agents_md(self, base_dir: Path) -> bool:
63
+ """Configure the agent to use AGENTS.md as context (default: no-op).
64
+
65
+ Returns:
66
+ bool: True if configuration was applied, False otherwise.
67
+ """
68
+ return False
69
+
70
+ def generate_root_doc(
71
+ self,
72
+ general_lines: list[str],
73
+ rules_sections: dict[str, list[str]],
74
+ command_sections: dict[str, list[str]],
75
+ output_dir: Path,
76
+ section_globs: dict[str, str | None] | None = None,
77
+ filename: str = "AGENTS.md",
78
+ ) -> None:
79
+ """Generate a root documentation file (e.g. CLAUDE.md) if supported."""
80
+ return
81
+
82
+ def build_root_doc_content(
83
+ self,
84
+ general_lines: list[str],
85
+ rules_sections: dict[str, list[str]],
86
+ ) -> str:
87
+ """Build the content string for a root documentation file by aggregating rules."""
88
+ content = []
89
+
90
+ # Add general instructions
91
+ if general_lines:
92
+ trimmed = trim_content(general_lines)
93
+ if trimmed:
94
+ content.extend(trimmed)
95
+ content.append("\n\n")
96
+
97
+ # Add sections in document order (dict maintains insertion order in Python 3.7+)
98
+ for lines in rules_sections.values():
99
+ trimmed = trim_content(lines)
100
+ if trimmed:
101
+ content.extend(trimmed)
102
+ content.append("\n\n")
103
+
104
+ return "".join(content).strip() + "\n" if content else ""
105
+
106
+ def get_rules_path(self, base_dir: Path) -> Path:
107
+ """Get the full path to the rules directory."""
108
+ if not self.rules_dir:
109
+ raise NotImplementedError(f"{self.name} does not support rules")
110
+ return base_dir / self.rules_dir
111
+
112
+ def get_commands_path(self, base_dir: Path) -> Path:
113
+ """Get the full path to the commands directory."""
114
+ if not self.commands_dir:
115
+ raise NotImplementedError(f"{self.name} does not support commands")
116
+ return base_dir / self.commands_dir
117
+
118
+ def _write_bundled_content(self, output_file: Path, content: str) -> None:
119
+ """Write bundled content to output file, preserving custom instructions after marker."""
120
+ marker = "<!-- END CLONED INSTRUCTIONS -->"
121
+ local_custom_content = ""
122
+
123
+ if output_file.exists():
124
+ try:
125
+ current_content = output_file.read_text(encoding="utf-8")
126
+ if marker in current_content:
127
+ local_custom_content = current_content.split(marker, 1)[1]
128
+ except OSError:
129
+ # Fallback if file cannot be read
130
+ pass
131
+
132
+ if marker not in content:
133
+ # Ensure marker is present at the end of the bundled content
134
+ content = content.rstrip() + f"\n\n{marker}\n"
135
+
136
+ output_file.write_text(content + local_custom_content, encoding="utf-8")
137
+
138
+
139
+ def strip_yaml_frontmatter(text: str) -> str:
140
+ """Strip YAML frontmatter from text."""
141
+ lines = text.splitlines()
142
+ if lines and lines[0].strip() == "---":
143
+ for i in range(1, len(lines)):
144
+ if lines[i].strip() == "---":
145
+ return "\n".join(lines[i + 1 :]).lstrip("\n")
146
+ return text
147
+
148
+
149
+ def strip_header(text: str) -> str:
150
+ """Remove the first markdown header (## Header) from text if present."""
151
+ lines = text.splitlines()
152
+ if lines and lines[0].startswith("## "):
153
+ remaining_lines = lines[1:]
154
+ while remaining_lines and not remaining_lines[0].strip():
155
+ remaining_lines = remaining_lines[1:]
156
+ return "\n".join(remaining_lines)
157
+ return text
158
+
159
+
160
+ def get_ordered_files(
161
+ file_list: list[Path], section_globs_keys: list[str] | None = None
162
+ ) -> list[Path]:
163
+ """Order files based on section_globs key order, with unmapped files at the end.
164
+
165
+ If section_globs_keys is None, returns files sorted alphabetically.
166
+ """
167
+ if not section_globs_keys:
168
+ return sorted(file_list, key=lambda p: p.name)
169
+
170
+ file_dict = {f.stem: f for f in file_list}
171
+ ordered_files = []
172
+
173
+ for section_name in section_globs_keys:
174
+ filename = header_to_filename(section_name)
175
+ if filename in file_dict:
176
+ ordered_files.append(file_dict[filename])
177
+ del file_dict[filename]
178
+
179
+ remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
180
+ ordered_files.extend(remaining_files)
181
+
182
+ return ordered_files
183
+
184
+
185
+ def get_ordered_files_github(
186
+ file_list: list[Path], section_globs_keys: list[str] | None = None
187
+ ) -> list[Path]:
188
+ """Order GitHub instruction files, handling .instructions suffix.
189
+
190
+ If section_globs_keys is None, returns files sorted alphabetically.
191
+ """
192
+ if not section_globs_keys:
193
+ return sorted(file_list, key=lambda p: p.name)
194
+
195
+ file_dict = {}
196
+ for f in file_list:
197
+ base_stem = f.stem.replace(".instructions", "")
198
+ file_dict[base_stem] = f
199
+
200
+ ordered_files = []
201
+
202
+ for section_name in section_globs_keys:
203
+ filename = header_to_filename(section_name)
204
+ if filename in file_dict:
205
+ ordered_files.append(file_dict[filename])
206
+ del file_dict[filename]
207
+
208
+ remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
209
+ ordered_files.extend(remaining_files)
210
+
211
+ return ordered_files
212
+
213
+
214
+ def resolve_header_from_stem(stem: str, section_globs: dict[str, str | None]) -> str:
215
+ """Return the canonical header for a given filename stem.
216
+
217
+ Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
218
+ Fallback to title-casing the filename when not found in section_globs.
219
+ """
220
+ for section_name in section_globs:
221
+ if header_to_filename(section_name) == stem:
222
+ return section_name
223
+
224
+ return stem.replace("-", " ").title()
225
+
226
+
227
+ def trim_content(content_lines: list[str]) -> list[str]:
228
+ """Remove leading and trailing empty lines from content."""
229
+ start = 0
230
+ for i, line in enumerate(content_lines):
231
+ if line.strip():
232
+ start = i
233
+ break
234
+ else:
235
+ return []
236
+
237
+ end = len(content_lines)
238
+ for i in range(len(content_lines) - 1, -1, -1):
239
+ if content_lines[i].strip():
240
+ end = i + 1
241
+ break
242
+
243
+ return content_lines[start:end]
244
+
245
+
246
+ def write_rule_file(path: Path, header_yaml: str, content_lines: list[str]) -> None:
247
+ """Write a rule file with front matter and content."""
248
+ trimmed_content = trim_content(content_lines)
249
+ output = header_yaml.strip() + "\n" + "".join(trimmed_content)
250
+ path.parent.mkdir(parents=True, exist_ok=True)
251
+ path.write_text(output)
252
+
253
+
254
+ def replace_header_with_proper_casing(
255
+ content_lines: list[str], proper_header: str
256
+ ) -> list[str]:
257
+ """Replace the first header in content with the properly cased version."""
258
+ if not content_lines:
259
+ return content_lines
260
+
261
+ for i, line in enumerate(content_lines):
262
+ if line.startswith("## "):
263
+ content_lines[i] = f"## {proper_header}\n"
264
+ break
265
+
266
+ return content_lines
267
+
268
+
269
+ def extract_description_and_filter_content(
270
+ content_lines: list[str], default_description: str
271
+ ) -> tuple[str, list[str]]:
272
+ """Extract description from first non-empty line that starts with 'Description:' and return filtered content."""
273
+ trimmed_content = trim_content(content_lines)
274
+ description = ""
275
+ description_line = None
276
+
277
+ for i, line in enumerate(trimmed_content):
278
+ stripped_line = line.strip()
279
+ if (
280
+ stripped_line
281
+ and not stripped_line.startswith("#")
282
+ and not stripped_line.startswith("##")
283
+ ):
284
+ if stripped_line.startswith("Description:"):
285
+ description = stripped_line[len("Description:") :].strip()
286
+ description_line = i
287
+ break
288
+ else:
289
+ break
290
+
291
+ if description and description_line is not None:
292
+ filtered_content = (
293
+ trimmed_content[:description_line] + trimmed_content[description_line + 1 :]
294
+ )
295
+ filtered_content = trim_content(filtered_content)
296
+ else:
297
+ filtered_content = trimmed_content
298
+
299
+ return description, filtered_content