llm-ide-rules 0.4.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.
@@ -0,0 +1,161 @@
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]
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]
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(command_files, list(section_globs.keys()))
56
+
57
+ content_parts: list[str] = []
58
+ for command_file in ordered_commands:
59
+ content = command_file.read_text().strip()
60
+ if not content:
61
+ continue
62
+
63
+ content = strip_toml_metadata(content)
64
+ header = resolve_header_from_stem(command_file.stem, section_globs)
65
+ content_parts.append(f"## {header}\n\n")
66
+ content_parts.append(content)
67
+ content_parts.append("\n\n")
68
+
69
+ if not content_parts:
70
+ return False
71
+
72
+ output_file.write_text("".join(content_parts))
73
+ return True
74
+
75
+ def write_rule(
76
+ self,
77
+ content_lines: list[str],
78
+ filename: str,
79
+ rules_dir: Path,
80
+ glob_pattern: str | None = None,
81
+ ) -> None:
82
+ """Gemini CLI doesn't support rules."""
83
+ pass
84
+
85
+ def write_command(
86
+ self,
87
+ content_lines: list[str],
88
+ filename: str,
89
+ commands_dir: Path,
90
+ section_name: str | None = None,
91
+ ) -> None:
92
+ """Write a Gemini CLI command file (.toml) with TOML format."""
93
+ import tomli_w
94
+
95
+ extension = self.command_extension or ".toml"
96
+ filepath = commands_dir / f"{filename}{extension}"
97
+
98
+ description, filtered_content = extract_description_and_filter_content(
99
+ content_lines, ""
100
+ )
101
+
102
+ final_content = []
103
+ found_header = False
104
+ for line in filtered_content:
105
+ if not found_header and line.startswith("## "):
106
+ found_header = True
107
+ continue
108
+ final_content.append(line)
109
+
110
+ final_content = trim_content(final_content)
111
+ content_str = "".join(final_content).strip()
112
+
113
+ desc = description if description else (section_name or filename)
114
+
115
+ # Construct dict and dump to TOML
116
+ data = {
117
+ "description": desc,
118
+ "prompt": content_str + "\n", # Ensure trailing newline in multiline string
119
+ }
120
+
121
+ # tomli-w will handle escaping and multiline strings automatically
122
+ output = tomli_w.dumps(data)
123
+ filepath.write_text(output)
124
+
125
+ def transform_mcp_server(self, server: McpServer) -> dict:
126
+ """Transform unified server to Gemini format (uses httpUrl instead of url)."""
127
+ if server.url:
128
+ result: dict = {"httpUrl": server.url}
129
+ if server.env:
130
+ result["env"] = server.env
131
+ return result
132
+
133
+ result: dict = {"command": server.command, "args": server.args or []}
134
+ if server.env:
135
+ result["env"] = server.env
136
+ return result
137
+
138
+ def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
139
+ """Transform Gemini config back to unified format."""
140
+ if "httpUrl" in config:
141
+ return McpServer(
142
+ url=config["httpUrl"],
143
+ env=config.get("env"),
144
+ )
145
+
146
+ return McpServer(
147
+ command=config["command"],
148
+ args=config.get("args", []),
149
+ env=config.get("env"),
150
+ )
151
+
152
+ def write_mcp_config(self, servers: dict, path: Path) -> None:
153
+ """Write MCP config to path, merging with existing settings."""
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+
156
+ existing = {}
157
+ if path.exists():
158
+ existing = json.loads(path.read_text())
159
+
160
+ existing[self.mcp_root_key] = servers
161
+ path.write_text(json.dumps(existing, indent=2))
@@ -0,0 +1,207 @@
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]
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())
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(base_stem, section_globs)
68
+ content_parts.append(f"## {header}\n\n")
69
+ content_parts.append(content)
70
+ content_parts.append("\n\n")
71
+
72
+ if not content_parts:
73
+ return False
74
+
75
+ output_file.write_text("".join(content_parts))
76
+ return True
77
+
78
+ def bundle_commands(
79
+ self, output_file: Path, section_globs: dict[str, str | None]
80
+ ) -> bool:
81
+ """Bundle GitHub prompt files into a single output file."""
82
+ commands_dir = self.commands_dir
83
+ if not commands_dir:
84
+ return False
85
+
86
+ prompts_path = output_file.parent / commands_dir
87
+ if not prompts_path.exists():
88
+ return False
89
+
90
+ command_ext = self.command_extension
91
+ if not command_ext:
92
+ return False
93
+
94
+ prompt_files = list(prompts_path.glob(f"*{command_ext}"))
95
+ if not prompt_files:
96
+ return False
97
+
98
+ prompt_dict = {}
99
+ for f in prompt_files:
100
+ base_stem = f.stem.replace(".prompt", "")
101
+ prompt_dict[base_stem] = f
102
+
103
+ 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]
109
+
110
+ remaining_prompts = sorted(prompt_dict.values(), key=lambda p: p.name)
111
+ ordered_prompts.extend(remaining_prompts)
112
+
113
+ content_parts: list[str] = []
114
+ for prompt_file in ordered_prompts:
115
+ content = prompt_file.read_text().strip()
116
+ if not content:
117
+ continue
118
+
119
+ content = strip_yaml_frontmatter(content)
120
+ content = strip_header(content)
121
+ base_stem = prompt_file.stem.replace(".prompt", "")
122
+ header = resolve_header_from_stem(base_stem, section_globs)
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 GitHub instruction file (.instructions.md) with YAML frontmatter."""
141
+ extension = self.rule_extension or ".instructions.md"
142
+ filepath = rules_dir / f"{filename}{extension}"
143
+
144
+ if glob_pattern and glob_pattern != "manual":
145
+ header_yaml = f"""---
146
+ applyTo: "{glob_pattern}"
147
+ ---
148
+ """
149
+ else:
150
+ header_yaml = ""
151
+
152
+ write_rule_file(filepath, header_yaml, content_lines)
153
+
154
+ def write_command(
155
+ self,
156
+ content_lines: list[str],
157
+ filename: str,
158
+ commands_dir: Path,
159
+ section_name: str | None = None,
160
+ ) -> None:
161
+ """Write a GitHub prompt file (.prompt.md) with YAML frontmatter."""
162
+ extension = self.command_extension or ".prompt.md"
163
+ filepath = commands_dir / f"{filename}{extension}"
164
+
165
+ description, filtered_content = extract_description_and_filter_content(
166
+ content_lines, ""
167
+ )
168
+
169
+ frontmatter = f"---\nmode: 'agent'\ndescription: '{description}'\n---\n"
170
+ filepath.write_text(frontmatter + "".join(filtered_content))
171
+
172
+ def write_general_instructions(
173
+ self, content_lines: list[str], base_dir: Path
174
+ ) -> None:
175
+ """Write the general copilot-instructions.md file (no frontmatter)."""
176
+ filepath = base_dir / ".github" / "copilot-instructions.md"
177
+ write_rule_file(filepath, "", content_lines)
178
+
179
+ def transform_mcp_server(self, server: McpServer) -> dict:
180
+ """Transform unified server to GitHub Copilot format (adds type and tools)."""
181
+ base: dict = {"tools": ["*"]}
182
+ if server.env:
183
+ base["env"] = server.env
184
+
185
+ if server.url:
186
+ return {"type": "http", "url": server.url, **base}
187
+
188
+ return {
189
+ "type": "local",
190
+ "command": server.command,
191
+ "args": server.args or [],
192
+ **base,
193
+ }
194
+
195
+ def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
196
+ """Transform GitHub Copilot config back to unified format."""
197
+ if config.get("type") == "http":
198
+ return McpServer(
199
+ url=config["url"],
200
+ env=config.get("env"),
201
+ )
202
+
203
+ return McpServer(
204
+ command=config["command"],
205
+ args=config.get("args", []),
206
+ env=config.get("env"),
207
+ )
@@ -0,0 +1,126 @@
1
+ """OpenCode 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
+ from llm_ide_rules.mcp import McpServer
12
+
13
+
14
+ class OpenCodeAgent(BaseAgent):
15
+ """Agent for OpenCode."""
16
+
17
+ name = "opencode"
18
+ rules_dir = None
19
+ commands_dir = ".opencode/commands"
20
+ rule_extension = None
21
+ command_extension = ".md"
22
+
23
+ mcp_global_path = ".config/opencode/opencode.json"
24
+ mcp_project_path = "opencode.json"
25
+ mcp_root_key = "mcp"
26
+
27
+ def bundle_rules(
28
+ self, output_file: Path, section_globs: dict[str, str | None]
29
+ ) -> bool:
30
+ """OpenCode doesn't support rules."""
31
+ return False
32
+
33
+ def bundle_commands(
34
+ self, output_file: Path, section_globs: dict[str, str | None]
35
+ ) -> bool:
36
+ """Bundle OpenCode command files (.md) into a single output file."""
37
+ commands_dir = self.commands_dir
38
+ if not commands_dir:
39
+ return False
40
+
41
+ commands_path = output_file.parent / commands_dir
42
+ if not commands_path.exists():
43
+ return False
44
+
45
+ extension = self.command_extension
46
+ if not extension:
47
+ return False
48
+
49
+ command_files = list(commands_path.glob(f"*{extension}"))
50
+ if not command_files:
51
+ return False
52
+
53
+ ordered_commands = get_ordered_files(command_files, list(section_globs.keys()))
54
+
55
+ content_parts: list[str] = []
56
+ for command_file in ordered_commands:
57
+ content = command_file.read_text().strip()
58
+ if not content:
59
+ continue
60
+
61
+ header = resolve_header_from_stem(command_file.stem, section_globs)
62
+ content_parts.append(f"## {header}\n\n")
63
+ content_parts.append(content)
64
+ content_parts.append("\n\n")
65
+
66
+ if not content_parts:
67
+ return False
68
+
69
+ output_file.write_text("".join(content_parts))
70
+ return True
71
+
72
+ def write_rule(
73
+ self,
74
+ content_lines: list[str],
75
+ filename: str,
76
+ rules_dir: Path,
77
+ glob_pattern: str | None = None,
78
+ ) -> None:
79
+ """OpenCode doesn't support rules."""
80
+ pass
81
+
82
+ def write_command(
83
+ self,
84
+ content_lines: list[str],
85
+ filename: str,
86
+ commands_dir: Path,
87
+ section_name: str | None = None,
88
+ ) -> None:
89
+ """Write an OpenCode command file (.md) - plain markdown, no frontmatter."""
90
+ extension = self.command_extension or ".md"
91
+ filepath = commands_dir / f"{filename}{extension}"
92
+
93
+ trimmed = trim_content(content_lines)
94
+ filepath.write_text("".join(trimmed))
95
+
96
+ def transform_mcp_server(self, server: McpServer) -> dict:
97
+ """Transform unified server to OpenCode format (merged command array, environment key)."""
98
+ if server.url:
99
+ result = {"type": "sse", "url": server.url, "enabled": True}
100
+ if server.env:
101
+ result["environment"] = server.env
102
+ return result
103
+
104
+ result = {
105
+ "type": "local",
106
+ "command": [server.command] + (server.args or []),
107
+ "enabled": True,
108
+ }
109
+ if server.env:
110
+ result["environment"] = server.env
111
+ return result
112
+
113
+ def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
114
+ """Transform OpenCode config back to unified format."""
115
+ if config.get("type") == "sse":
116
+ return McpServer(
117
+ url=config["url"],
118
+ env=config.get("environment"),
119
+ )
120
+
121
+ command_array = config["command"]
122
+ return McpServer(
123
+ command=command_array[0] if command_array else None,
124
+ args=command_array[1:] if len(command_array) > 1 else [],
125
+ env=config.get("environment"),
126
+ )
@@ -1,24 +1,20 @@
1
1
  """Delete command: Remove downloaded LLM instruction files."""
2
2
 
3
- import logging
4
3
  import shutil
5
4
  from pathlib import Path
6
- from typing import List
7
5
 
8
- import structlog
9
6
  import typer
10
7
  from typing_extensions import Annotated
11
8
 
12
9
  from llm_ide_rules.commands.download import INSTRUCTION_TYPES, DEFAULT_TYPES
13
-
14
- logger = structlog.get_logger()
10
+ from llm_ide_rules.log import log
15
11
 
16
12
 
17
13
  def find_files_to_delete(
18
- instruction_types: List[str], target_dir: Path
19
- ) -> tuple[List[Path], List[Path]]:
14
+ instruction_types: list[str], target_dir: Path
15
+ ) -> tuple[list[Path], list[Path]]:
20
16
  """Find all files and directories that would be deleted.
21
-
17
+
22
18
  Returns:
23
19
  Tuple of (directories, files) to delete
24
20
  """
@@ -27,7 +23,7 @@ def find_files_to_delete(
27
23
 
28
24
  for inst_type in instruction_types:
29
25
  if inst_type not in INSTRUCTION_TYPES:
30
- logger.warning("Unknown instruction type", type=inst_type)
26
+ log.warning("unknown instruction type", type=inst_type)
31
27
  continue
32
28
 
33
29
  config = INSTRUCTION_TYPES[inst_type]
@@ -51,7 +47,7 @@ def find_files_to_delete(
51
47
 
52
48
  def delete_main(
53
49
  instruction_types: Annotated[
54
- List[str],
50
+ list[str] | None,
55
51
  typer.Argument(
56
52
  help="Types of instructions to delete (cursor, github, gemini, claude, agent, agents). Deletes everything by default."
57
53
  ),
@@ -61,10 +57,9 @@ def delete_main(
61
57
  ] = ".",
62
58
  yes: Annotated[
63
59
  bool,
64
- typer.Option("--yes", "-y", help="Skip confirmation prompt and delete immediately"),
65
- ] = False,
66
- verbose: Annotated[
67
- bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
60
+ typer.Option(
61
+ "--yes", "-y", help="Skip confirmation prompt and delete immediately"
62
+ ),
68
63
  ] = False,
69
64
  ):
70
65
  """Remove downloaded LLM instruction files.
@@ -90,19 +85,13 @@ def delete_main(
90
85
  # Delete from a specific directory
91
86
  llm_ide_rules delete --target ./my-project
92
87
  """
93
- if verbose:
94
- logging.basicConfig(level=logging.DEBUG)
95
- structlog.configure(
96
- wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
97
- )
98
-
99
88
  if not instruction_types:
100
89
  instruction_types = DEFAULT_TYPES
101
90
 
102
91
  invalid_types = [t for t in instruction_types if t not in INSTRUCTION_TYPES]
103
92
  if invalid_types:
104
- logger.error(
105
- "Invalid instruction types",
93
+ log.error(
94
+ "invalid instruction types",
106
95
  invalid_types=invalid_types,
107
96
  valid_types=list(INSTRUCTION_TYPES.keys()),
108
97
  )
@@ -111,12 +100,13 @@ def delete_main(
111
100
  target_path = Path(target_dir).resolve()
112
101
 
113
102
  if not target_path.exists():
114
- logger.error("Target directory does not exist", target_dir=str(target_path))
115
- typer.echo(f"Error: Target directory does not exist: {target_path}")
103
+ log.error("target directory does not exist", target_dir=str(target_path))
104
+ error_msg = f"Target directory does not exist: {target_path}"
105
+ typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
116
106
  raise typer.Exit(1)
117
107
 
118
- logger.info(
119
- "Finding files to delete",
108
+ log.info(
109
+ "finding files to delete",
120
110
  instruction_types=instruction_types,
121
111
  target_dir=str(target_path),
122
112
  )
@@ -126,7 +116,7 @@ def delete_main(
126
116
  )
127
117
 
128
118
  if not dirs_to_delete and not files_to_delete:
129
- logger.info("No files found to delete")
119
+ log.info("no files found to delete")
130
120
  typer.echo("No matching instruction files found to delete.")
131
121
  return
132
122
 
@@ -151,7 +141,7 @@ def delete_main(
151
141
  typer.echo()
152
142
  confirm = typer.confirm("Are you sure you want to delete these files?")
153
143
  if not confirm:
154
- logger.info("Deletion cancelled by user")
144
+ log.info("deletion cancelled by user")
155
145
  typer.echo("Deletion cancelled.")
156
146
  raise typer.Exit(0)
157
147
 
@@ -159,21 +149,21 @@ def delete_main(
159
149
 
160
150
  for dir_path in dirs_to_delete:
161
151
  try:
162
- logger.info("Deleting directory", path=str(dir_path))
152
+ log.info("deleting directory", path=str(dir_path))
163
153
  shutil.rmtree(dir_path)
164
154
  deleted_count += 1
165
155
  except Exception as e:
166
- logger.error("Failed to delete directory", path=str(dir_path), error=str(e))
156
+ log.error("failed to delete directory", path=str(dir_path), error=str(e))
167
157
  typer.echo(f"Error deleting {dir_path}: {e}", err=True)
168
158
 
169
159
  for file_path in files_to_delete:
170
160
  try:
171
- logger.info("Deleting file", path=str(file_path))
161
+ log.info("deleting file", path=str(file_path))
172
162
  file_path.unlink()
173
163
  deleted_count += 1
174
164
  except Exception as e:
175
- logger.error("Failed to delete file", path=str(file_path), error=str(e))
165
+ log.error("failed to delete file", path=str(file_path), error=str(e))
176
166
  typer.echo(f"Error deleting {file_path}: {e}", err=True)
177
167
 
178
- logger.info("Deletion completed", deleted_count=deleted_count, total_items=total_items)
179
- typer.echo(f"\nSuccessfully deleted {deleted_count} of {total_items} items.")
168
+ success_msg = f"Successfully deleted {deleted_count} of {total_items} items."
169
+ typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))