llm-ide-rules 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
llm_ide_rules/__init__.py CHANGED
@@ -1,14 +1,20 @@
1
1
  """LLM Rules CLI package for managing IDE prompts and rules."""
2
2
 
3
+ import os
4
+
5
+ if "LOG_LEVEL" not in os.environ:
6
+ os.environ["LOG_LEVEL"] = "WARNING"
7
+
3
8
  import typer
4
9
  from typing_extensions import Annotated
5
10
 
6
11
  from llm_ide_rules.commands.explode import explode_main
7
- from llm_ide_rules.commands.implode import cursor, github, claude, gemini
12
+ from llm_ide_rules.commands.implode import cursor, github, claude, gemini, opencode
8
13
  from llm_ide_rules.commands.download import download_main
9
14
  from llm_ide_rules.commands.delete import delete_main
15
+ from llm_ide_rules.commands.mcp import mcp_app
10
16
 
11
- __version__ = "0.5.0"
17
+ __version__ = "0.6.0"
12
18
 
13
19
  app = typer.Typer(
14
20
  name="llm_ide_rules",
@@ -16,22 +22,60 @@ app = typer.Typer(
16
22
  no_args_is_help=True,
17
23
  )
18
24
 
25
+
26
+ @app.callback()
27
+ def main_callback(
28
+ verbose: Annotated[
29
+ bool,
30
+ typer.Option(
31
+ "--verbose", "-v", help="Enable verbose logging (sets LOG_LEVEL=DEBUG)"
32
+ ),
33
+ ] = False,
34
+ ):
35
+ """Global CLI options."""
36
+ if verbose:
37
+ os.environ["LOG_LEVEL"] = "DEBUG"
38
+ import structlog_config
39
+
40
+ structlog_config.configure_logger()
41
+
42
+
19
43
  # Add commands directly
20
- app.command("explode", help="Convert instruction file to separate rule files")(explode_main)
21
- app.command("download", help="Download LLM instruction files from GitHub repositories")(download_main)
44
+ app.command("explode", help="Convert instruction file to separate rule files")(
45
+ explode_main
46
+ )
47
+ app.command("download", help="Download LLM instruction files from GitHub repositories")(
48
+ download_main
49
+ )
22
50
  app.command("delete", help="Remove downloaded LLM instruction files")(delete_main)
23
51
 
24
52
  # Create implode sub-typer
25
53
  implode_app = typer.Typer(help="Bundle rule files into a single instruction file")
26
- implode_app.command("cursor", help="Bundle Cursor rules and commands into a single file")(cursor)
27
- implode_app.command("github", help="Bundle GitHub/Copilot instructions and prompts into a single file")(github)
28
- implode_app.command("claude", help="Bundle Claude Code commands into a single file")(claude)
29
- implode_app.command("gemini", help="Bundle Gemini CLI commands into a single file")(gemini)
54
+ implode_app.command(
55
+ "cursor", help="Bundle Cursor rules and commands into a single file"
56
+ )(cursor)
57
+ implode_app.command(
58
+ "github", help="Bundle GitHub/Copilot instructions and prompts into a single file"
59
+ )(github)
60
+ implode_app.command("claude", help="Bundle Claude Code commands into a single file")(
61
+ claude
62
+ )
63
+ implode_app.command("gemini", help="Bundle Gemini CLI commands into a single file")(
64
+ gemini
65
+ )
66
+ implode_app.command("opencode", help="Bundle OpenCode commands into a single file")(
67
+ opencode
68
+ )
30
69
  app.add_typer(implode_app, name="implode")
31
70
 
71
+ # Add MCP configuration management
72
+ app.add_typer(mcp_app, name="mcp")
73
+
74
+
32
75
  def main():
33
76
  """Main entry point for the CLI."""
34
77
  app()
35
78
 
79
+
36
80
  if __name__ == "__main__":
37
- main()
81
+ main()
llm_ide_rules/__main__.py CHANGED
@@ -3,4 +3,4 @@
3
3
  from llm_ide_rules import main
4
4
 
5
5
  if __name__ == "__main__":
6
- main()
6
+ main()
@@ -0,0 +1,28 @@
1
+ """Agent registry for LLM IDE rules."""
2
+
3
+ from llm_ide_rules.agents.base import BaseAgent
4
+ from llm_ide_rules.agents.cursor import CursorAgent
5
+ from llm_ide_rules.agents.github import GitHubAgent
6
+ from llm_ide_rules.agents.claude import ClaudeAgent
7
+ from llm_ide_rules.agents.gemini import GeminiAgent
8
+ from llm_ide_rules.agents.opencode import OpenCodeAgent
9
+
10
+ AGENTS: dict[str, type[BaseAgent]] = {
11
+ "cursor": CursorAgent,
12
+ "github": GitHubAgent,
13
+ "claude": ClaudeAgent,
14
+ "gemini": GeminiAgent,
15
+ "opencode": OpenCodeAgent,
16
+ }
17
+
18
+
19
+ def get_agent(name: str) -> BaseAgent:
20
+ """Get an agent instance by name."""
21
+ if name not in AGENTS:
22
+ raise ValueError(f"Unknown agent: {name}. Available: {list(AGENTS.keys())}")
23
+ return AGENTS[name]()
24
+
25
+
26
+ def get_all_agents() -> list[BaseAgent]:
27
+ """Get instances of all registered agents."""
28
+ return [agent_cls() for agent_cls in AGENTS.values()]
@@ -0,0 +1,283 @@
1
+ """Base agent class and shared utilities for LLM IDE rules."""
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+
7
+ from llm_ide_rules.constants import header_to_filename
8
+ from llm_ide_rules.mcp import McpServer
9
+
10
+
11
+ class BaseAgent(ABC):
12
+ """Base class for all IDE agents."""
13
+
14
+ name: str
15
+ rules_dir: str | None = None
16
+ commands_dir: str | None = None
17
+ rule_extension: str | None = None
18
+ command_extension: str | None = None
19
+
20
+ mcp_global_path: str | None = None
21
+ mcp_project_path: str | None = None
22
+ mcp_root_key: str = "mcpServers"
23
+
24
+ @abstractmethod
25
+ def bundle_rules(
26
+ self, output_file: Path, section_globs: dict[str, str | None]
27
+ ) -> bool:
28
+ """Bundle rule files into a single output file."""
29
+ ...
30
+
31
+ @abstractmethod
32
+ def bundle_commands(
33
+ self, output_file: Path, section_globs: dict[str, str | None]
34
+ ) -> bool:
35
+ """Bundle command files into a single output file."""
36
+ ...
37
+
38
+ @abstractmethod
39
+ def write_rule(
40
+ self,
41
+ content_lines: list[str],
42
+ filename: str,
43
+ rules_dir: Path,
44
+ glob_pattern: str | None = None,
45
+ ) -> None:
46
+ """Write a single rule file."""
47
+ ...
48
+
49
+ @abstractmethod
50
+ def write_command(
51
+ self,
52
+ content_lines: list[str],
53
+ filename: str,
54
+ commands_dir: Path,
55
+ section_name: str | None = None,
56
+ ) -> None:
57
+ """Write a single command file."""
58
+ ...
59
+
60
+ def get_rules_path(self, base_dir: Path) -> Path:
61
+ """Get the full path to the rules directory."""
62
+ if not self.rules_dir:
63
+ raise NotImplementedError(f"{self.name} does not support rules")
64
+ return base_dir / self.rules_dir
65
+
66
+ def get_commands_path(self, base_dir: Path) -> Path:
67
+ """Get the full path to the commands directory."""
68
+ if not self.commands_dir:
69
+ raise NotImplementedError(f"{self.name} does not support commands")
70
+ return base_dir / self.commands_dir
71
+
72
+ def transform_mcp_server(self, server: McpServer) -> dict:
73
+ """Transform unified server to platform-specific format."""
74
+ if server.url:
75
+ result: dict = {"url": server.url}
76
+ if server.env:
77
+ result["env"] = server.env
78
+ return result
79
+
80
+ result: dict = {"command": server.command, "args": server.args or []}
81
+ if server.env:
82
+ result["env"] = server.env
83
+ return result
84
+
85
+ def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
86
+ """Transform platform config back to unified format."""
87
+ if "url" in config:
88
+ return McpServer(
89
+ url=config["url"],
90
+ env=config.get("env"),
91
+ )
92
+
93
+ return McpServer(
94
+ command=config["command"],
95
+ args=config.get("args", []),
96
+ env=config.get("env"),
97
+ )
98
+
99
+ def write_mcp_config(self, servers: dict, path: Path) -> None:
100
+ """Write MCP config to path."""
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ config = {self.mcp_root_key: servers}
103
+ path.write_text(json.dumps(config, indent=2))
104
+
105
+ def read_mcp_config(self, path: Path) -> dict | None:
106
+ """Read MCP config from path."""
107
+ if not path.exists():
108
+ return None
109
+
110
+ config = json.loads(path.read_text())
111
+ return config.get(self.mcp_root_key)
112
+
113
+
114
+ def strip_yaml_frontmatter(text: str) -> str:
115
+ """Strip YAML frontmatter from text."""
116
+ lines = text.splitlines()
117
+ if lines and lines[0].strip() == "---":
118
+ for i in range(1, len(lines)):
119
+ if lines[i].strip() == "---":
120
+ return "\n".join(lines[i + 1 :]).lstrip("\n")
121
+ return text
122
+
123
+
124
+ def strip_header(text: str) -> str:
125
+ """Remove the first markdown header (## Header) from text if present."""
126
+ lines = text.splitlines()
127
+ if lines and lines[0].startswith("## "):
128
+ remaining_lines = lines[1:]
129
+ while remaining_lines and not remaining_lines[0].strip():
130
+ remaining_lines = remaining_lines[1:]
131
+ return "\n".join(remaining_lines)
132
+ return text
133
+
134
+
135
+ def strip_toml_metadata(text: str) -> str:
136
+ """Extract content from TOML prompt block (supports old [command] shell=... and new prompt=...)."""
137
+ import tomllib
138
+
139
+ try:
140
+ data = tomllib.loads(text)
141
+ # Check new format
142
+ if "prompt" in data:
143
+ return str(data["prompt"]).strip()
144
+ # Check legacy format
145
+ if (
146
+ "command" in data
147
+ and isinstance(data["command"], dict)
148
+ and "shell" in data["command"]
149
+ ):
150
+ return str(data["command"]["shell"]).strip()
151
+ except Exception:
152
+ pass
153
+
154
+ return text.strip()
155
+
156
+
157
+ def get_ordered_files(
158
+ file_list: list[Path], section_globs_keys: list[str]
159
+ ) -> list[Path]:
160
+ """Order files based on section_globs key order, with unmapped files at the end."""
161
+ file_dict = {f.stem: f for f in file_list}
162
+ ordered_files = []
163
+
164
+ for section_name in section_globs_keys:
165
+ filename = header_to_filename(section_name)
166
+ if filename in file_dict:
167
+ ordered_files.append(file_dict[filename])
168
+ del file_dict[filename]
169
+
170
+ remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
171
+ ordered_files.extend(remaining_files)
172
+
173
+ return ordered_files
174
+
175
+
176
+ def get_ordered_files_github(
177
+ file_list: list[Path], section_globs_keys: list[str]
178
+ ) -> list[Path]:
179
+ """Order GitHub instruction files, handling .instructions suffix."""
180
+ file_dict = {}
181
+ for f in file_list:
182
+ base_stem = f.stem.replace(".instructions", "")
183
+ file_dict[base_stem] = f
184
+
185
+ ordered_files = []
186
+
187
+ for section_name in section_globs_keys:
188
+ filename = header_to_filename(section_name)
189
+ if filename in file_dict:
190
+ ordered_files.append(file_dict[filename])
191
+ del file_dict[filename]
192
+
193
+ remaining_files = sorted(file_dict.values(), key=lambda p: p.name)
194
+ ordered_files.extend(remaining_files)
195
+
196
+ return ordered_files
197
+
198
+
199
+ def resolve_header_from_stem(stem: str, section_globs: dict[str, str | None]) -> str:
200
+ """Return the canonical header for a given filename stem.
201
+
202
+ Prefer exact header names from section_globs (preserves acronyms like FastAPI, TypeScript).
203
+ Fallback to title-casing the filename when not found in section_globs.
204
+ """
205
+ for section_name in section_globs.keys():
206
+ if header_to_filename(section_name) == stem:
207
+ return section_name
208
+
209
+ return stem.replace("-", " ").title()
210
+
211
+
212
+ def trim_content(content_lines: list[str]) -> list[str]:
213
+ """Remove leading and trailing empty lines from content."""
214
+ start = 0
215
+ for i, line in enumerate(content_lines):
216
+ if line.strip():
217
+ start = i
218
+ break
219
+ else:
220
+ return []
221
+
222
+ end = len(content_lines)
223
+ for i in range(len(content_lines) - 1, -1, -1):
224
+ if content_lines[i].strip():
225
+ end = i + 1
226
+ break
227
+
228
+ return content_lines[start:end]
229
+
230
+
231
+ def write_rule_file(path: Path, header_yaml: str, content_lines: list[str]) -> None:
232
+ """Write a rule file with front matter and content."""
233
+ trimmed_content = trim_content(content_lines)
234
+ output = header_yaml.strip() + "\n" + "".join(trimmed_content)
235
+ path.write_text(output)
236
+
237
+
238
+ def replace_header_with_proper_casing(
239
+ content_lines: list[str], proper_header: str
240
+ ) -> list[str]:
241
+ """Replace the first header in content with the properly cased version."""
242
+ if not content_lines:
243
+ return content_lines
244
+
245
+ for i, line in enumerate(content_lines):
246
+ if line.startswith("## "):
247
+ content_lines[i] = f"## {proper_header}\n"
248
+ break
249
+
250
+ return content_lines
251
+
252
+
253
+ def extract_description_and_filter_content(
254
+ content_lines: list[str], default_description: str
255
+ ) -> tuple[str, list[str]]:
256
+ """Extract description from first non-empty line that starts with 'Description:' and return filtered content."""
257
+ trimmed_content = trim_content(content_lines)
258
+ description = ""
259
+ description_line = None
260
+
261
+ for i, line in enumerate(trimmed_content):
262
+ stripped_line = line.strip()
263
+ if (
264
+ stripped_line
265
+ and not stripped_line.startswith("#")
266
+ and not stripped_line.startswith("##")
267
+ ):
268
+ if stripped_line.startswith("Description:"):
269
+ description = stripped_line[len("Description:") :].strip()
270
+ description_line = i
271
+ break
272
+ else:
273
+ break
274
+
275
+ if description and description_line is not None:
276
+ filtered_content = (
277
+ trimmed_content[:description_line] + trimmed_content[description_line + 1 :]
278
+ )
279
+ filtered_content = trim_content(filtered_content)
280
+ else:
281
+ filtered_content = trimmed_content
282
+
283
+ return description, filtered_content
@@ -0,0 +1,92 @@
1
+ """Claude Code agent implementation."""
2
+
3
+ from pathlib import Path
4
+
5
+ from llm_ide_rules.agents.base import (
6
+ BaseAgent,
7
+ get_ordered_files,
8
+ resolve_header_from_stem,
9
+ trim_content,
10
+ )
11
+
12
+
13
+ class ClaudeAgent(BaseAgent):
14
+ """Agent for Claude Code."""
15
+
16
+ name = "claude"
17
+ rules_dir = None
18
+ commands_dir = ".claude/commands"
19
+ rule_extension = None
20
+ command_extension = ".md"
21
+
22
+ mcp_global_path = ".claude.json"
23
+ mcp_project_path = ".mcp.json"
24
+
25
+ def bundle_rules(
26
+ self, output_file: Path, section_globs: dict[str, str | None]
27
+ ) -> bool:
28
+ """Claude Code doesn't support rules, only commands."""
29
+ return False
30
+
31
+ def bundle_commands(
32
+ self, output_file: Path, section_globs: dict[str, str | None]
33
+ ) -> bool:
34
+ """Bundle Claude Code command files (.md) into a single output file."""
35
+ commands_dir = self.commands_dir
36
+ if not commands_dir:
37
+ return False
38
+
39
+ commands_path = output_file.parent / commands_dir
40
+ if not commands_path.exists():
41
+ return False
42
+
43
+ extension = self.command_extension
44
+ if not extension:
45
+ return False
46
+
47
+ command_files = list(commands_path.glob(f"*{extension}"))
48
+ if not command_files:
49
+ return False
50
+
51
+ ordered_commands = get_ordered_files(command_files, list(section_globs.keys()))
52
+
53
+ content_parts: list[str] = []
54
+ for command_file in ordered_commands:
55
+ content = command_file.read_text().strip()
56
+ if not content:
57
+ continue
58
+
59
+ header = resolve_header_from_stem(command_file.stem, section_globs)
60
+ content_parts.append(f"## {header}\n\n")
61
+ content_parts.append(content)
62
+ content_parts.append("\n\n")
63
+
64
+ if not content_parts:
65
+ return False
66
+
67
+ output_file.write_text("".join(content_parts))
68
+ return True
69
+
70
+ def write_rule(
71
+ self,
72
+ content_lines: list[str],
73
+ filename: str,
74
+ rules_dir: Path,
75
+ glob_pattern: str | None = None,
76
+ ) -> None:
77
+ """Claude Code doesn't support rules."""
78
+ pass
79
+
80
+ def write_command(
81
+ self,
82
+ content_lines: list[str],
83
+ filename: str,
84
+ commands_dir: Path,
85
+ section_name: str | None = None,
86
+ ) -> None:
87
+ """Write a Claude Code command file (.md) - plain markdown, no frontmatter."""
88
+ extension = self.command_extension or ".md"
89
+ filepath = commands_dir / f"{filename}{extension}"
90
+
91
+ trimmed = trim_content(content_lines)
92
+ filepath.write_text("".join(trimmed))
@@ -0,0 +1,178 @@
1
+ """Cursor IDE agent implementation."""
2
+
3
+ from pathlib import Path
4
+
5
+ from llm_ide_rules.agents.base import (
6
+ BaseAgent,
7
+ get_ordered_files,
8
+ resolve_header_from_stem,
9
+ strip_yaml_frontmatter,
10
+ strip_header,
11
+ trim_content,
12
+ write_rule_file,
13
+ extract_description_and_filter_content,
14
+ )
15
+
16
+
17
+ class CursorAgent(BaseAgent):
18
+ """Agent for Cursor IDE."""
19
+
20
+ name = "cursor"
21
+ rules_dir = ".cursor/rules"
22
+ commands_dir = ".cursor/commands"
23
+ rule_extension = ".mdc"
24
+ command_extension = ".md"
25
+
26
+ mcp_global_path = ".cursor/mcp.json"
27
+ mcp_project_path = ".cursor/mcp.json"
28
+
29
+ def bundle_rules(
30
+ self, output_file: Path, section_globs: dict[str, str | None]
31
+ ) -> bool:
32
+ """Bundle Cursor rule files (.mdc) into a single output file."""
33
+ rules_dir = self.rules_dir
34
+ if not rules_dir:
35
+ return False
36
+
37
+ rules_path = output_file.parent / rules_dir
38
+
39
+ rule_ext = self.rule_extension
40
+ if not rule_ext:
41
+ return False
42
+
43
+ rule_files = list(rules_path.glob(f"*{rule_ext}"))
44
+
45
+ general = [f for f in rule_files if f.stem == "general"]
46
+ others = [f for f in rule_files if f.stem != "general"]
47
+
48
+ ordered_others = get_ordered_files(others, list(section_globs.keys()))
49
+ ordered = general + ordered_others
50
+
51
+ content_parts: list[str] = []
52
+ for rule_file in ordered:
53
+ content = rule_file.read_text().strip()
54
+ if not content:
55
+ continue
56
+
57
+ content = strip_yaml_frontmatter(content)
58
+ content = strip_header(content)
59
+ header = resolve_header_from_stem(rule_file.stem, section_globs)
60
+
61
+ if rule_file.stem != "general":
62
+ content_parts.append(f"## {header}\n\n")
63
+
64
+ content_parts.append(content)
65
+ content_parts.append("\n\n")
66
+
67
+ if not content_parts:
68
+ return False
69
+
70
+ output_file.write_text("".join(content_parts))
71
+ return True
72
+
73
+ def bundle_commands(
74
+ self, output_file: Path, section_globs: dict[str, str | None]
75
+ ) -> bool:
76
+ """Bundle Cursor command files (.md) into a single output file."""
77
+ commands_dir = self.commands_dir
78
+ if not commands_dir:
79
+ return False
80
+
81
+ commands_path = output_file.parent / commands_dir
82
+ if not commands_path.exists():
83
+ return False
84
+
85
+ command_ext = self.command_extension
86
+ if not command_ext:
87
+ return False
88
+
89
+ command_files = list(commands_path.glob(f"*{command_ext}"))
90
+ if not command_files:
91
+ return False
92
+
93
+ ordered_commands = get_ordered_files(command_files, list(section_globs.keys()))
94
+
95
+ content_parts: list[str] = []
96
+ for command_file in ordered_commands:
97
+ content = command_file.read_text().strip()
98
+ if not content:
99
+ continue
100
+
101
+ header = resolve_header_from_stem(command_file.stem, section_globs)
102
+ content_parts.append(f"## {header}\n\n")
103
+ content_parts.append(content)
104
+ content_parts.append("\n\n")
105
+
106
+ if not content_parts:
107
+ return False
108
+
109
+ output_file.write_text("".join(content_parts))
110
+ return True
111
+
112
+ def write_rule(
113
+ self,
114
+ content_lines: list[str],
115
+ filename: str,
116
+ rules_dir: Path,
117
+ glob_pattern: str | None = None,
118
+ ) -> None:
119
+ """Write a Cursor rule file (.mdc) with YAML frontmatter."""
120
+ extension = self.rule_extension or ".mdc"
121
+ filepath = rules_dir / f"{filename}{extension}"
122
+
123
+ if glob_pattern and glob_pattern != "manual":
124
+ header_yaml = f"""---
125
+ description:
126
+ globs: {glob_pattern}
127
+ alwaysApply: false
128
+ ---
129
+ """
130
+ elif glob_pattern == "manual":
131
+ header_yaml = """---
132
+ description:
133
+ alwaysApply: false
134
+ ---
135
+ """
136
+ else:
137
+ header_yaml = """---
138
+ description:
139
+ alwaysApply: true
140
+ ---
141
+ """
142
+ write_rule_file(filepath, header_yaml, content_lines)
143
+
144
+ def write_command(
145
+ self,
146
+ content_lines: list[str],
147
+ filename: str,
148
+ commands_dir: Path,
149
+ section_name: str | None = None,
150
+ ) -> None:
151
+ """Write a Cursor command file (.md) - plain markdown, no frontmatter."""
152
+ extension = self.command_extension or ".md"
153
+ filepath = commands_dir / f"{filename}{extension}"
154
+
155
+ trimmed = trim_content(content_lines)
156
+ filepath.write_text("".join(trimmed))
157
+
158
+ def write_prompt(
159
+ self,
160
+ content_lines: list[str],
161
+ filename: str,
162
+ prompts_dir: Path,
163
+ section_name: str | None = None,
164
+ ) -> None:
165
+ """Write a Cursor prompt file (.mdc) with optional frontmatter."""
166
+ extension = self.rule_extension or ".mdc"
167
+ filepath = prompts_dir / f"{filename}{extension}"
168
+
169
+ description, filtered_content = extract_description_and_filter_content(
170
+ content_lines, ""
171
+ )
172
+
173
+ output_parts: list[str] = []
174
+ if description:
175
+ output_parts.append(f"---\ndescription: {description}\n---\n")
176
+
177
+ output_parts.extend(filtered_content)
178
+ filepath.write_text("".join(output_parts))