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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
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] | None = 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(
49
+ others, list(section_globs.keys()) if section_globs else None
50
+ )
51
+ ordered = general + ordered_others
52
+
53
+ content_parts: list[str] = []
54
+ for rule_file in ordered:
55
+ file_content = rule_file.read_text().strip()
56
+ if not file_content:
57
+ continue
58
+
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)
68
+ content = strip_header(content)
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
+ )
77
+
78
+ if rule_file.stem != "general":
79
+ content_parts.append(f"## {header}\n\n")
80
+
81
+ content_parts.append(content)
82
+ content_parts.append("\n\n")
83
+
84
+ if not content_parts:
85
+ return False
86
+
87
+ output_file.write_text("".join(content_parts))
88
+ return True
89
+
90
+ def bundle_commands(
91
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
92
+ ) -> bool:
93
+ """Bundle Cursor command files (.md) into a single output file."""
94
+ commands_dir = self.commands_dir
95
+ if not commands_dir:
96
+ return False
97
+
98
+ commands_path = output_file.parent / commands_dir
99
+ if not commands_path.exists():
100
+ return False
101
+
102
+ command_ext = self.command_extension
103
+ if not command_ext:
104
+ return False
105
+
106
+ command_files = list(commands_path.glob(f"*{command_ext}"))
107
+ if not command_files:
108
+ return False
109
+
110
+ ordered_commands = get_ordered_files(
111
+ command_files, list(section_globs.keys()) if section_globs else None
112
+ )
113
+
114
+ content_parts: list[str] = []
115
+ for command_file in ordered_commands:
116
+ content = command_file.read_text().strip()
117
+ if not content:
118
+ continue
119
+
120
+ header = resolve_header_from_stem(
121
+ command_file.stem, section_globs if section_globs else {}
122
+ )
123
+ content_parts.append(f"## {header}\n\n")
124
+ content_parts.append(content)
125
+ content_parts.append("\n\n")
126
+
127
+ if not content_parts:
128
+ return False
129
+
130
+ output_file.write_text("".join(content_parts))
131
+ return True
132
+
133
+ def write_rule(
134
+ self,
135
+ content_lines: list[str],
136
+ filename: str,
137
+ rules_dir: Path,
138
+ glob_pattern: str | None = None,
139
+ ) -> None:
140
+ """Write a Cursor rule file (.mdc) with YAML frontmatter."""
141
+ extension = self.rule_extension or ".mdc"
142
+ filepath = rules_dir / f"{filename}{extension}"
143
+
144
+ if glob_pattern and glob_pattern != "manual":
145
+ header_yaml = f"""---
146
+ description:
147
+ globs: {glob_pattern}
148
+ alwaysApply: false
149
+ ---
150
+ """
151
+ elif glob_pattern == "manual":
152
+ header_yaml = """---
153
+ description:
154
+ alwaysApply: false
155
+ ---
156
+ """
157
+ else:
158
+ header_yaml = """---
159
+ description:
160
+ alwaysApply: true
161
+ ---
162
+ """
163
+ write_rule_file(filepath, header_yaml, content_lines)
164
+
165
+ def write_command(
166
+ self,
167
+ content_lines: list[str],
168
+ filename: str,
169
+ commands_dir: Path,
170
+ section_name: str | None = None,
171
+ ) -> None:
172
+ """Write a Cursor command file (.md) - plain markdown, no frontmatter."""
173
+ extension = self.command_extension or ".md"
174
+ filepath = commands_dir / f"{filename}{extension}"
175
+
176
+ trimmed = trim_content(content_lines)
177
+ filepath.write_text("".join(trimmed))
178
+
179
+ def write_prompt(
180
+ self,
181
+ content_lines: list[str],
182
+ filename: str,
183
+ prompts_dir: Path,
184
+ section_name: str | None = None,
185
+ ) -> None:
186
+ """Write a Cursor prompt file (.mdc) with optional frontmatter."""
187
+ extension = self.rule_extension or ".mdc"
188
+ filepath = prompts_dir / f"{filename}{extension}"
189
+
190
+ description, filtered_content = extract_description_and_filter_content(
191
+ content_lines, ""
192
+ )
193
+
194
+ output_parts: list[str] = []
195
+ if description:
196
+ output_parts.append(f"---\ndescription: {description}\n---\n")
197
+
198
+ output_parts.extend(filtered_content)
199
+ filepath.write_text("".join(output_parts))
@@ -0,0 +1,177 @@
1
+ """Gemini CLI agent implementation."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from llm_ide_rules.agents.base import (
7
+ BaseAgent,
8
+ get_ordered_files,
9
+ resolve_header_from_stem,
10
+ strip_toml_metadata,
11
+ trim_content,
12
+ extract_description_and_filter_content,
13
+ )
14
+ from llm_ide_rules.mcp import McpServer
15
+
16
+
17
+ class GeminiAgent(BaseAgent):
18
+ """Agent for Gemini CLI."""
19
+
20
+ name = "gemini"
21
+ rules_dir = None
22
+ commands_dir = ".gemini/commands"
23
+ rule_extension = None
24
+ command_extension = ".toml"
25
+
26
+ mcp_global_path = ".gemini/settings.json"
27
+ mcp_project_path = ".gemini/settings.json"
28
+
29
+ def bundle_rules(
30
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
31
+ ) -> bool:
32
+ """Gemini CLI doesn't support rules, only commands."""
33
+ return False
34
+
35
+ def bundle_commands(
36
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
37
+ ) -> bool:
38
+ """Bundle Gemini CLI command files (.toml) into a single output file."""
39
+ commands_dir = self.commands_dir
40
+ if not commands_dir:
41
+ return False
42
+
43
+ commands_path = output_file.parent / commands_dir
44
+ if not commands_path.exists():
45
+ return False
46
+
47
+ extension = self.command_extension
48
+ if not extension:
49
+ return False
50
+
51
+ command_files = list(commands_path.glob(f"*{extension}"))
52
+ if not command_files:
53
+ return False
54
+
55
+ ordered_commands = get_ordered_files(
56
+ command_files, list(section_globs.keys()) if section_globs else None
57
+ )
58
+
59
+ content_parts: list[str] = []
60
+ for command_file in ordered_commands:
61
+ content = command_file.read_text().strip()
62
+ if not content:
63
+ continue
64
+
65
+ content = strip_toml_metadata(content)
66
+ header = resolve_header_from_stem(
67
+ command_file.stem, section_globs if section_globs else {}
68
+ )
69
+ content_parts.append(f"## {header}\n\n")
70
+ content_parts.append(content)
71
+ content_parts.append("\n\n")
72
+
73
+ if not content_parts:
74
+ return False
75
+
76
+ output_file.write_text("".join(content_parts))
77
+ return True
78
+
79
+ def write_rule(
80
+ self,
81
+ content_lines: list[str],
82
+ filename: str,
83
+ rules_dir: Path,
84
+ glob_pattern: str | None = None,
85
+ ) -> None:
86
+ """Gemini CLI doesn't support rules."""
87
+ pass
88
+
89
+ def write_command(
90
+ self,
91
+ content_lines: list[str],
92
+ filename: str,
93
+ commands_dir: Path,
94
+ section_name: str | None = None,
95
+ ) -> None:
96
+ """Write a Gemini CLI command file (.toml) with TOML format."""
97
+ import tomli_w
98
+
99
+ extension = self.command_extension or ".toml"
100
+ filepath = commands_dir / f"{filename}{extension}"
101
+
102
+ description, filtered_content = extract_description_and_filter_content(
103
+ content_lines, ""
104
+ )
105
+
106
+ final_content = []
107
+ found_header = False
108
+ for line in filtered_content:
109
+ if not found_header and line.startswith("## "):
110
+ found_header = True
111
+ continue
112
+ final_content.append(line)
113
+
114
+ final_content = trim_content(final_content)
115
+ content_str = "".join(final_content).strip()
116
+
117
+ desc = description if description else (section_name or filename)
118
+
119
+ # Construct dict and dump to TOML
120
+ data = {
121
+ "description": desc,
122
+ "prompt": content_str + "\n", # Ensure trailing newline in multiline string
123
+ }
124
+
125
+ # tomli-w will handle escaping and multiline strings automatically
126
+ output = tomli_w.dumps(data)
127
+ filepath.write_text(output)
128
+
129
+ def transform_mcp_server(self, server: McpServer) -> dict:
130
+ """Transform unified server to Gemini format (uses httpUrl instead of url)."""
131
+ if server.url:
132
+ result: dict = {"httpUrl": server.url}
133
+ if server.env:
134
+ result["env"] = server.env
135
+ return result
136
+
137
+ result: dict = {"command": server.command, "args": server.args or []}
138
+ if server.env:
139
+ result["env"] = server.env
140
+ return result
141
+
142
+ def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
143
+ """Transform Gemini config back to unified format."""
144
+ if "httpUrl" in config:
145
+ return McpServer(
146
+ url=config["httpUrl"],
147
+ env=config.get("env"),
148
+ )
149
+
150
+ return McpServer(
151
+ command=config["command"],
152
+ args=config.get("args", []),
153
+ env=config.get("env"),
154
+ )
155
+
156
+ def write_mcp_config(self, servers: dict, path: Path) -> None:
157
+ """Write MCP config to path, merging with existing settings."""
158
+ path.parent.mkdir(parents=True, exist_ok=True)
159
+
160
+ existing = {}
161
+ if path.exists():
162
+ existing = json.loads(path.read_text())
163
+
164
+ existing[self.mcp_root_key] = servers
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)
@@ -0,0 +1,212 @@
1
+ """GitHub/Copilot agent implementation."""
2
+
3
+ from pathlib import Path
4
+
5
+ from llm_ide_rules.agents.base import (
6
+ BaseAgent,
7
+ get_ordered_files_github,
8
+ resolve_header_from_stem,
9
+ strip_yaml_frontmatter,
10
+ strip_header,
11
+ write_rule_file,
12
+ extract_description_and_filter_content,
13
+ )
14
+ from llm_ide_rules.constants import header_to_filename
15
+ from llm_ide_rules.mcp import McpServer
16
+
17
+
18
+ class GitHubAgent(BaseAgent):
19
+ """Agent for GitHub Copilot."""
20
+
21
+ name = "github"
22
+ rules_dir = ".github/instructions"
23
+ commands_dir = ".github/prompts"
24
+ rule_extension = ".instructions.md"
25
+ command_extension = ".prompt.md"
26
+
27
+ mcp_global_path = ".copilot/mcp-config.json"
28
+ mcp_project_path = ".copilot/mcp-config.json"
29
+
30
+ def bundle_rules(
31
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
32
+ ) -> bool:
33
+ """Bundle GitHub instruction files into a single output file."""
34
+ rules_dir = self.rules_dir
35
+ if not rules_dir:
36
+ return False
37
+
38
+ base_dir = output_file.parent
39
+ instructions_path = base_dir / rules_dir
40
+ copilot_general = base_dir / ".github" / "copilot-instructions.md"
41
+
42
+ rule_ext = self.rule_extension
43
+ if not rule_ext:
44
+ return False
45
+
46
+ instr_files = list(instructions_path.glob(f"*{rule_ext}"))
47
+
48
+ ordered_instructions = get_ordered_files_github(
49
+ instr_files, list(section_globs.keys()) if section_globs else None
50
+ )
51
+
52
+ content_parts: list[str] = []
53
+ if copilot_general.exists():
54
+ content = copilot_general.read_text().strip()
55
+ if content:
56
+ content_parts.append(content)
57
+ content_parts.append("\n\n")
58
+
59
+ for instr_file in ordered_instructions:
60
+ content = instr_file.read_text().strip()
61
+ if not content:
62
+ continue
63
+
64
+ content = strip_yaml_frontmatter(content)
65
+ content = strip_header(content)
66
+ base_stem = instr_file.stem.replace(".instructions", "")
67
+ header = resolve_header_from_stem(
68
+ base_stem, section_globs if section_globs else {}
69
+ )
70
+ content_parts.append(f"## {header}\n\n")
71
+ content_parts.append(content)
72
+ content_parts.append("\n\n")
73
+
74
+ if not content_parts:
75
+ return False
76
+
77
+ output_file.write_text("".join(content_parts))
78
+ return True
79
+
80
+ def bundle_commands(
81
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
82
+ ) -> bool:
83
+ """Bundle GitHub prompt files into a single output file."""
84
+ commands_dir = self.commands_dir
85
+ if not commands_dir:
86
+ return False
87
+
88
+ prompts_path = output_file.parent / commands_dir
89
+ if not prompts_path.exists():
90
+ return False
91
+
92
+ command_ext = self.command_extension
93
+ if not command_ext:
94
+ return False
95
+
96
+ prompt_files = list(prompts_path.glob(f"*{command_ext}"))
97
+ if not prompt_files:
98
+ return False
99
+
100
+ prompt_dict = {}
101
+ for f in prompt_files:
102
+ base_stem = f.stem.replace(".prompt", "")
103
+ prompt_dict[base_stem] = f
104
+
105
+ ordered_prompts = []
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]
112
+
113
+ remaining_prompts = sorted(prompt_dict.values(), key=lambda p: p.name)
114
+ ordered_prompts.extend(remaining_prompts)
115
+
116
+ content_parts: list[str] = []
117
+ for prompt_file in ordered_prompts:
118
+ content = prompt_file.read_text().strip()
119
+ if not content:
120
+ continue
121
+
122
+ content = strip_yaml_frontmatter(content)
123
+ content = strip_header(content)
124
+ base_stem = prompt_file.stem.replace(".prompt", "")
125
+ header = resolve_header_from_stem(
126
+ base_stem, section_globs if section_globs else {}
127
+ )
128
+ content_parts.append(f"## {header}\n\n")
129
+ content_parts.append(content)
130
+ content_parts.append("\n\n")
131
+
132
+ if not content_parts:
133
+ return False
134
+
135
+ output_file.write_text("".join(content_parts))
136
+ return True
137
+
138
+ def write_rule(
139
+ self,
140
+ content_lines: list[str],
141
+ filename: str,
142
+ rules_dir: Path,
143
+ glob_pattern: str | None = None,
144
+ ) -> None:
145
+ """Write a GitHub instruction file (.instructions.md) with YAML frontmatter."""
146
+ extension = self.rule_extension or ".instructions.md"
147
+ filepath = rules_dir / f"{filename}{extension}"
148
+
149
+ if glob_pattern and glob_pattern != "manual":
150
+ header_yaml = f"""---
151
+ applyTo: "{glob_pattern}"
152
+ ---
153
+ """
154
+ else:
155
+ header_yaml = ""
156
+
157
+ write_rule_file(filepath, header_yaml, content_lines)
158
+
159
+ def write_command(
160
+ self,
161
+ content_lines: list[str],
162
+ filename: str,
163
+ commands_dir: Path,
164
+ section_name: str | None = None,
165
+ ) -> None:
166
+ """Write a GitHub prompt file (.prompt.md) with YAML frontmatter."""
167
+ extension = self.command_extension or ".prompt.md"
168
+ filepath = commands_dir / f"{filename}{extension}"
169
+
170
+ description, filtered_content = extract_description_and_filter_content(
171
+ content_lines, ""
172
+ )
173
+
174
+ frontmatter = f"---\nmode: 'agent'\ndescription: '{description}'\n---\n"
175
+ filepath.write_text(frontmatter + "".join(filtered_content))
176
+
177
+ def write_general_instructions(
178
+ self, content_lines: list[str], base_dir: Path
179
+ ) -> None:
180
+ """Write the general copilot-instructions.md file (no frontmatter)."""
181
+ filepath = base_dir / ".github" / "copilot-instructions.md"
182
+ write_rule_file(filepath, "", content_lines)
183
+
184
+ def transform_mcp_server(self, server: McpServer) -> dict:
185
+ """Transform unified server to GitHub Copilot format (adds type and tools)."""
186
+ base: dict = {"tools": ["*"]}
187
+ if server.env:
188
+ base["env"] = server.env
189
+
190
+ if server.url:
191
+ return {"type": "http", "url": server.url, **base}
192
+
193
+ return {
194
+ "type": "local",
195
+ "command": server.command,
196
+ "args": server.args or [],
197
+ **base,
198
+ }
199
+
200
+ def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
201
+ """Transform GitHub Copilot config back to unified format."""
202
+ if config.get("type") == "http":
203
+ return McpServer(
204
+ url=config["url"],
205
+ env=config.get("env"),
206
+ )
207
+
208
+ return McpServer(
209
+ command=config["command"],
210
+ args=config.get("args", []),
211
+ env=config.get("env"),
212
+ )