llm-ide-rules 0.6.0__py3-none-any.whl → 0.8.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.
@@ -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] = []
@@ -57,15 +57,21 @@ class GitHubAgent(BaseAgent):
57
57
  content_parts.append("\n\n")
58
58
 
59
59
  for instr_file in ordered_instructions:
60
- content = instr_file.read_text().strip()
61
- if not content:
60
+ file_content = instr_file.read_text().strip()
61
+ if not file_content:
62
62
  continue
63
63
 
64
- content = strip_yaml_frontmatter(content)
64
+ apply_to_pattern = self._extract_apply_to_from_frontmatter(file_content)
65
+
66
+ content = strip_yaml_frontmatter(file_content)
65
67
  content = strip_header(content)
66
68
  base_stem = instr_file.stem.replace(".instructions", "")
67
- header = resolve_header_from_stem(base_stem, section_globs)
69
+ header = resolve_header_from_stem(
70
+ base_stem, section_globs if section_globs else {}
71
+ )
68
72
  content_parts.append(f"## {header}\n\n")
73
+ if apply_to_pattern:
74
+ content_parts.append(f"globs: {apply_to_pattern}\n\n")
69
75
  content_parts.append(content)
70
76
  content_parts.append("\n\n")
71
77
 
@@ -75,8 +81,23 @@ class GitHubAgent(BaseAgent):
75
81
  output_file.write_text("".join(content_parts))
76
82
  return True
77
83
 
84
+ def _extract_apply_to_from_frontmatter(self, content: str) -> str | None:
85
+ """Extract applyTo pattern from YAML frontmatter."""
86
+ lines = content.splitlines()
87
+ if not lines or lines[0].strip() != "---":
88
+ return None
89
+
90
+ for i in range(1, len(lines)):
91
+ if lines[i].strip() == "---":
92
+ break
93
+ if lines[i].startswith("applyTo:"):
94
+ apply_to_value = lines[i][8:].strip().strip('"').strip("'")
95
+ return apply_to_value if apply_to_value else None
96
+
97
+ return None
98
+
78
99
  def bundle_commands(
79
- self, output_file: Path, section_globs: dict[str, str | None]
100
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
80
101
  ) -> bool:
81
102
  """Bundle GitHub prompt files into a single output file."""
82
103
  commands_dir = self.commands_dir
@@ -101,11 +122,12 @@ class GitHubAgent(BaseAgent):
101
122
  prompt_dict[base_stem] = f
102
123
 
103
124
  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]
125
+ if section_globs:
126
+ for section_name in section_globs.keys():
127
+ filename = header_to_filename(section_name)
128
+ if filename in prompt_dict:
129
+ ordered_prompts.append(prompt_dict[filename])
130
+ del prompt_dict[filename]
109
131
 
110
132
  remaining_prompts = sorted(prompt_dict.values(), key=lambda p: p.name)
111
133
  ordered_prompts.extend(remaining_prompts)
@@ -119,7 +141,9 @@ class GitHubAgent(BaseAgent):
119
141
  content = strip_yaml_frontmatter(content)
120
142
  content = strip_header(content)
121
143
  base_stem = prompt_file.stem.replace(".prompt", "")
122
- header = resolve_header_from_stem(base_stem, section_globs)
144
+ header = resolve_header_from_stem(
145
+ base_stem, section_globs if section_globs else {}
146
+ )
123
147
  content_parts.append(f"## {header}\n\n")
124
148
  content_parts.append(content)
125
149
  content_parts.append("\n\n")
@@ -136,6 +160,7 @@ class GitHubAgent(BaseAgent):
136
160
  filename: str,
137
161
  rules_dir: Path,
138
162
  glob_pattern: str | None = None,
163
+ description: str | None = None,
139
164
  ) -> None:
140
165
  """Write a GitHub instruction file (.instructions.md) with YAML frontmatter."""
141
166
  extension = self.rule_extension or ".instructions.md"
@@ -167,6 +192,7 @@ applyTo: "{glob_pattern}"
167
192
  )
168
193
 
169
194
  frontmatter = f"---\nmode: 'agent'\ndescription: '{description}'\n---\n"
195
+ filepath.parent.mkdir(parents=True, exist_ok=True)
170
196
  filepath.write_text(frontmatter + "".join(filtered_content))
171
197
 
172
198
  def write_general_instructions(
@@ -205,3 +231,13 @@ applyTo: "{glob_pattern}"
205
231
  args=config.get("args", []),
206
232
  env=config.get("env"),
207
233
  )
234
+
235
+ def configure_agents_md(self, base_dir: Path) -> bool:
236
+ """Configure VS Code to use AGENTS.md."""
237
+ from llm_ide_rules.utils import modify_json_file
238
+
239
+ settings_path = base_dir / ".vscode" / "settings.json"
240
+
241
+ updates = {"chat.useAgentsMdFile": True, "chat.useNestedAgentsMdFiles": True}
242
+
243
+ return modify_json_file(settings_path, updates)
@@ -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")
@@ -75,6 +79,7 @@ class OpenCodeAgent(BaseAgent):
75
79
  filename: str,
76
80
  rules_dir: Path,
77
81
  glob_pattern: str | None = None,
82
+ description: str | None = None,
78
83
  ) -> None:
79
84
  """OpenCode doesn't support rules."""
80
85
  pass
@@ -91,6 +96,7 @@ class OpenCodeAgent(BaseAgent):
91
96
  filepath = commands_dir / f"{filename}{extension}"
92
97
 
93
98
  trimmed = trim_content(content_lines)
99
+ filepath.parent.mkdir(parents=True, exist_ok=True)
94
100
  filepath.write_text("".join(trimmed))
95
101
 
96
102
  def transform_mcp_server(self, server: McpServer) -> dict:
@@ -124,3 +130,7 @@ class OpenCodeAgent(BaseAgent):
124
130
  args=command_array[1:] if len(command_array) > 1 else [],
125
131
  env=config.get("environment"),
126
132
  )
133
+
134
+ def configure_agents_md(self, base_dir: Path) -> bool:
135
+ """OpenCode has native support, no configuration needed."""
136
+ return False
@@ -0,0 +1,88 @@
1
+ """VS Code agent implementation."""
2
+
3
+ from pathlib import Path
4
+
5
+ from llm_ide_rules.agents.base import BaseAgent
6
+ from llm_ide_rules.mcp import McpServer
7
+
8
+
9
+ class VSCodeAgent(BaseAgent):
10
+ """Agent for VS Code (native MCP support)."""
11
+
12
+ name = "vscode"
13
+ rules_dir = None # VS Code typically uses .github (handled by GitHubAgent)
14
+ commands_dir = None
15
+ rule_extension = None
16
+ command_extension = None
17
+
18
+ mcp_global_path = None # VS Code user settings are complex, focusing on workspace
19
+ mcp_project_path = ".vscode/mcp.json"
20
+ mcp_root_key = "servers"
21
+
22
+ def bundle_rules(
23
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
24
+ ) -> bool:
25
+ """VS Code doesn't support rules directly (uses GitHub Copilot)."""
26
+ return False
27
+
28
+ def bundle_commands(
29
+ self, output_file: Path, section_globs: dict[str, str | None] | None = None
30
+ ) -> bool:
31
+ """VS Code doesn't support commands directly."""
32
+ return False
33
+
34
+ def write_rule(
35
+ self,
36
+ content_lines: list[str],
37
+ filename: str,
38
+ rules_dir: Path,
39
+ glob_pattern: str | None = None,
40
+ description: str | None = None,
41
+ ) -> None:
42
+ """VS Code doesn't support rules."""
43
+ pass
44
+
45
+ def write_command(
46
+ self,
47
+ content_lines: list[str],
48
+ filename: str,
49
+ commands_dir: Path,
50
+ section_name: str | None = None,
51
+ ) -> None:
52
+ """VS Code doesn't support commands."""
53
+ pass
54
+
55
+ def transform_mcp_server(self, server: McpServer) -> dict:
56
+ """Transform unified server to VS Code format."""
57
+ # VS Code uses "env" key, similar to standard MCP
58
+ base = {}
59
+ if server.env:
60
+ base["env"] = server.env
61
+
62
+ if server.url:
63
+ # VS Code supports SSE via "url" (or "type": "sse"?)
64
+ # Research indicates basic MCP config in VS Code is similar to Claude
65
+ # but usually requires "command" for stdio.
66
+ # However, for remote/SSE, it might just need 'url'.
67
+ # Let's assume standard 'url' for now based on 'mcp.json' schema compatibility.
68
+ return {"url": server.url, **base}
69
+
70
+ return {
71
+ "command": server.command,
72
+ "args": server.args or [],
73
+ **base,
74
+ }
75
+
76
+ def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
77
+ """Transform VS Code config back to unified format."""
78
+ if "url" in config:
79
+ return McpServer(
80
+ url=config["url"],
81
+ env=config.get("env"),
82
+ )
83
+
84
+ return McpServer(
85
+ command=config["command"],
86
+ args=config.get("args", []),
87
+ env=config.get("env"),
88
+ )
@@ -0,0 +1,46 @@
1
+ """Command to configure agents to use AGENTS.md."""
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from typing_extensions import Annotated
7
+
8
+ from llm_ide_rules.agents import get_agent, get_all_agents
9
+
10
+
11
+ def config_main(
12
+ agent: Annotated[
13
+ str | None,
14
+ typer.Option(help="Specific agent to configure (cursor, github, etc.)"),
15
+ ] = None,
16
+ ):
17
+ """
18
+ Configure agents to use AGENTS.md as their context source.
19
+ """
20
+ base_dir = Path.cwd()
21
+
22
+ agents_to_configure = []
23
+ if agent:
24
+ try:
25
+ agents_to_configure.append(get_agent(agent))
26
+ except ValueError as e:
27
+ typer.echo(f"Error: {e}", err=True)
28
+ raise typer.Exit(code=1)
29
+ else:
30
+ agents_to_configure = get_all_agents()
31
+
32
+ for agent_inst in agents_to_configure:
33
+ if agent_inst.name == "agents":
34
+ continue
35
+
36
+ try:
37
+ configured = agent_inst.configure_agents_md(base_dir)
38
+ if configured:
39
+ typer.echo(
40
+ typer.style(f"Configured {agent_inst.name}", fg=typer.colors.GREEN)
41
+ )
42
+ else:
43
+ msg = f"Skipped {agent_inst.name} (no changes needed or not applicable)"
44
+ typer.echo(typer.style(msg, fg=typer.colors.YELLOW))
45
+ except Exception as e:
46
+ typer.echo(f"Failed to configure {agent_inst.name}: {e}", err=True)
@@ -7,7 +7,58 @@ import typer
7
7
  from typing_extensions import Annotated
8
8
 
9
9
  from llm_ide_rules.commands.download import INSTRUCTION_TYPES, DEFAULT_TYPES
10
+ from llm_ide_rules.constants import header_to_filename
10
11
  from llm_ide_rules.log import log
12
+ from llm_ide_rules.markdown_parser import parse_sections
13
+
14
+
15
+ def get_generated_files(target_dir: Path) -> set[Path]:
16
+ """Identify files that would be generated from local instruction files."""
17
+ generated = set()
18
+
19
+ # Check instructions.md
20
+ instructions_path = target_dir / "instructions.md"
21
+ if instructions_path.exists():
22
+ try:
23
+ general, sections = parse_sections(instructions_path.read_text())
24
+
25
+ # If general instructions exist, these files are generated
26
+ if any(line.strip() for line in general):
27
+ generated.add(target_dir / ".cursor/rules/general.mdc")
28
+ generated.add(target_dir / ".github/copilot-instructions.md")
29
+ generated.add(target_dir / "CLAUDE.md")
30
+
31
+ # If any sections exist, root docs are definitely generated
32
+ if sections:
33
+ generated.add(target_dir / "CLAUDE.md")
34
+
35
+ # Section specific files
36
+ for header in sections:
37
+ filename = header_to_filename(header)
38
+ generated.add(target_dir / f".cursor/rules/{filename}.mdc")
39
+ generated.add(
40
+ target_dir / f".github/instructions/{filename}.instructions.md"
41
+ )
42
+
43
+ except Exception as e:
44
+ log.warning("failed to parse instructions.md", error=str(e))
45
+
46
+ # Check commands.md
47
+ commands_path = target_dir / "commands.md"
48
+ if commands_path.exists():
49
+ try:
50
+ _, sections = parse_sections(commands_path.read_text())
51
+ for header in sections:
52
+ filename = header_to_filename(header)
53
+ generated.add(target_dir / f".cursor/commands/{filename}.md")
54
+ generated.add(target_dir / f".github/prompts/{filename}.prompt.md")
55
+ generated.add(target_dir / f".gemini/commands/{filename}.toml")
56
+ generated.add(target_dir / f".claude/commands/{filename}.md")
57
+ generated.add(target_dir / f".opencode/commands/{filename}.md")
58
+ except Exception as e:
59
+ log.warning("failed to parse commands.md", error=str(e))
60
+
61
+ return {p.resolve() for p in generated}
11
62
 
12
63
 
13
64
  def find_files_to_delete(
@@ -38,6 +89,11 @@ def find_files_to_delete(
38
89
  if file_path.exists() and file_path.is_file():
39
90
  files_to_delete.append(file_path)
40
91
 
92
+ for file_name in config.get("generated_files", []):
93
+ file_path = target_dir / file_name
94
+ if file_path.exists() and file_path.is_file():
95
+ files_to_delete.append(file_path)
96
+
41
97
  for file_pattern in config.get("recursive_files", []):
42
98
  matching_files = list(target_dir.rglob(file_pattern))
43
99
  files_to_delete.extend([f for f in matching_files if f.is_file()])
@@ -49,12 +105,19 @@ def delete_main(
49
105
  instruction_types: Annotated[
50
106
  list[str] | None,
51
107
  typer.Argument(
52
- help="Types of instructions to delete (cursor, github, gemini, claude, agent, agents). Deletes everything by default."
108
+ help="Types of instructions to delete (cursor, github, gemini, claude, opencode, agents). Deletes everything by default."
53
109
  ),
54
110
  ] = None,
55
111
  target_dir: Annotated[
56
112
  str, typer.Option("--target", "-t", help="Target directory to delete from")
57
113
  ] = ".",
114
+ everything: Annotated[
115
+ bool,
116
+ typer.Option(
117
+ "--everything",
118
+ help="Delete all instruction files, not just those generated from local sources.",
119
+ ),
120
+ ] = False,
58
121
  yes: Annotated[
59
122
  bool,
60
123
  typer.Option(
@@ -64,17 +127,25 @@ def delete_main(
64
127
  ):
65
128
  """Remove downloaded LLM instruction files.
66
129
 
67
- This command removes files and directories that were downloaded by the 'download' command.
68
- It will show you what will be deleted and ask for confirmation before proceeding.
130
+ This command removes files and directories that were downloaded by the 'download' command
131
+ or generated by the 'explode' command.
132
+
133
+ By default, it ONLY deletes files that correspond to your local 'instructions.md' and
134
+ 'commands.md' files. This prevents accidental deletion of manually created files.
135
+ Use --everything to delete all standard instruction files and directories.
69
136
 
70
137
  Examples:
71
138
 
72
139
  \b
73
- # Delete everything (with confirmation)
140
+ # Delete only generated files (safest, default)
74
141
  llm_ide_rules delete
75
142
 
76
143
  \b
77
- # Delete only Cursor and Gemini files
144
+ # Delete ALL instruction files (including manual ones)
145
+ llm_ide_rules delete --everything
146
+
147
+ \b
148
+ # Delete only Cursor and Gemini files (but only if generated)
78
149
  llm_ide_rules delete cursor gemini
79
150
 
80
151
  \b
@@ -115,9 +186,39 @@ def delete_main(
115
186
  instruction_types, target_path
116
187
  )
117
188
 
189
+ skipped_files = []
190
+
191
+ if not everything:
192
+ log.info("filtering files to delete based on local sources")
193
+ generated_files = get_generated_files(target_path)
194
+
195
+ # Expand directories to files for granular filtering
196
+ expanded_files = []
197
+ for d in dirs_to_delete:
198
+ expanded_files.extend([f for f in d.rglob("*") if f.is_file()])
199
+
200
+ all_candidates = files_to_delete + expanded_files
201
+
202
+ # Filter: keep only files that are in the generated set
203
+ # We compare resolved paths to be safe
204
+ files_to_delete = [f for f in all_candidates if f.resolve() in generated_files]
205
+
206
+ # Identify skipped files (candidates that were NOT in generated set)
207
+ skipped_files = [
208
+ f for f in all_candidates if f.resolve() not in generated_files
209
+ ]
210
+
211
+ # We are no longer deleting whole directories in safe mode
212
+ dirs_to_delete = []
213
+
118
214
  if not dirs_to_delete and not files_to_delete:
119
215
  log.info("no files found to delete")
120
216
  typer.echo("No matching instruction files found to delete.")
217
+ if skipped_files:
218
+ typer.echo(
219
+ f"\n{len(skipped_files)} files were skipped because they don't match local instructions/commands."
220
+ )
221
+ typer.echo("Use --everything to delete them.")
121
222
  return
122
223
 
123
224
  typer.echo("\nThe following files and directories will be deleted:\n")
@@ -137,6 +238,11 @@ def delete_main(
137
238
  total_items = len(dirs_to_delete) + len(files_to_delete)
138
239
  typer.echo(f"\nTotal: {total_items} items")
139
240
 
241
+ if skipped_files:
242
+ typer.echo(
243
+ f"\n(Note: {len(skipped_files)} other files will be preserved. Use --everything to delete them)"
244
+ )
245
+
140
246
  if not yes:
141
247
  typer.echo()
142
248
  confirm = typer.confirm("Are you sure you want to delete these files?")