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.
- llm_ide_rules/__init__.py +53 -9
- llm_ide_rules/__main__.py +1 -1
- llm_ide_rules/agents/__init__.py +28 -0
- llm_ide_rules/agents/base.py +329 -0
- llm_ide_rules/agents/claude.py +108 -0
- llm_ide_rules/agents/cursor.py +199 -0
- llm_ide_rules/agents/gemini.py +177 -0
- llm_ide_rules/agents/github.py +212 -0
- llm_ide_rules/agents/opencode.py +130 -0
- llm_ide_rules/commands/delete.py +24 -34
- llm_ide_rules/commands/download.py +146 -60
- llm_ide_rules/commands/explode.py +222 -382
- llm_ide_rules/commands/implode.py +174 -360
- llm_ide_rules/commands/mcp.py +119 -0
- llm_ide_rules/constants.py +6 -29
- llm_ide_rules/log.py +9 -0
- llm_ide_rules/markdown_parser.py +108 -0
- llm_ide_rules/mcp/__init__.py +7 -0
- llm_ide_rules/mcp/models.py +21 -0
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/METADATA +36 -59
- llm_ide_rules-0.7.0.dist-info/RECORD +23 -0
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/WHEEL +2 -2
- llm_ide_rules/sections.json +0 -27
- llm_ide_rules-0.5.0.dist-info/RECORD +0 -12
- {llm_ide_rules-0.5.0.dist-info → llm_ide_rules-0.7.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
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] | None = 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] | None = 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(
|
|
54
|
+
command_files, list(section_globs.keys()) if section_globs else None
|
|
55
|
+
)
|
|
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
|
+
header = resolve_header_from_stem(
|
|
64
|
+
command_file.stem, section_globs if section_globs else {}
|
|
65
|
+
)
|
|
66
|
+
content_parts.append(f"## {header}\n\n")
|
|
67
|
+
content_parts.append(content)
|
|
68
|
+
content_parts.append("\n\n")
|
|
69
|
+
|
|
70
|
+
if not content_parts:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
output_file.write_text("".join(content_parts))
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
def write_rule(
|
|
77
|
+
self,
|
|
78
|
+
content_lines: list[str],
|
|
79
|
+
filename: str,
|
|
80
|
+
rules_dir: Path,
|
|
81
|
+
glob_pattern: str | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""OpenCode doesn't support rules."""
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
def write_command(
|
|
87
|
+
self,
|
|
88
|
+
content_lines: list[str],
|
|
89
|
+
filename: str,
|
|
90
|
+
commands_dir: Path,
|
|
91
|
+
section_name: str | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Write an OpenCode command file (.md) - plain markdown, no frontmatter."""
|
|
94
|
+
extension = self.command_extension or ".md"
|
|
95
|
+
filepath = commands_dir / f"{filename}{extension}"
|
|
96
|
+
|
|
97
|
+
trimmed = trim_content(content_lines)
|
|
98
|
+
filepath.write_text("".join(trimmed))
|
|
99
|
+
|
|
100
|
+
def transform_mcp_server(self, server: McpServer) -> dict:
|
|
101
|
+
"""Transform unified server to OpenCode format (merged command array, environment key)."""
|
|
102
|
+
if server.url:
|
|
103
|
+
result = {"type": "sse", "url": server.url, "enabled": True}
|
|
104
|
+
if server.env:
|
|
105
|
+
result["environment"] = server.env
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
result = {
|
|
109
|
+
"type": "local",
|
|
110
|
+
"command": [server.command] + (server.args or []),
|
|
111
|
+
"enabled": True,
|
|
112
|
+
}
|
|
113
|
+
if server.env:
|
|
114
|
+
result["environment"] = server.env
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def reverse_transform_mcp_server(self, name: str, config: dict) -> McpServer:
|
|
118
|
+
"""Transform OpenCode config back to unified format."""
|
|
119
|
+
if config.get("type") == "sse":
|
|
120
|
+
return McpServer(
|
|
121
|
+
url=config["url"],
|
|
122
|
+
env=config.get("environment"),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
command_array = config["command"]
|
|
126
|
+
return McpServer(
|
|
127
|
+
command=command_array[0] if command_array else None,
|
|
128
|
+
args=command_array[1:] if len(command_array) > 1 else [],
|
|
129
|
+
env=config.get("environment"),
|
|
130
|
+
)
|
llm_ide_rules/commands/delete.py
CHANGED
|
@@ -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:
|
|
19
|
-
) -> tuple[
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
105
|
-
"
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
typer.echo(
|
|
168
|
+
success_msg = f"Successfully deleted {deleted_count} of {total_items} items."
|
|
169
|
+
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
"""Download command: Download LLM instruction files from GitHub repositories."""
|
|
2
2
|
|
|
3
|
-
import logging
|
|
4
3
|
import re
|
|
5
4
|
import tempfile
|
|
6
5
|
import zipfile
|
|
7
6
|
from pathlib import Path
|
|
8
|
-
from typing import List
|
|
9
7
|
|
|
10
8
|
import requests
|
|
11
|
-
import structlog
|
|
12
9
|
import typer
|
|
13
10
|
from typing_extensions import Annotated
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
from llm_ide_rules.commands.explode import explode_implementation
|
|
13
|
+
from llm_ide_rules.constants import VALID_AGENTS
|
|
14
|
+
from llm_ide_rules.log import log
|
|
16
15
|
|
|
17
16
|
DEFAULT_REPO = "iloveitaly/llm-ide-rules"
|
|
18
17
|
DEFAULT_BRANCH = "master"
|
|
@@ -20,7 +19,7 @@ DEFAULT_BRANCH = "master"
|
|
|
20
19
|
|
|
21
20
|
def normalize_repo(repo: str) -> str:
|
|
22
21
|
"""Normalize repository input to user/repo format.
|
|
23
|
-
|
|
22
|
+
|
|
24
23
|
Handles both formats:
|
|
25
24
|
- user/repo (unchanged)
|
|
26
25
|
- https://github.com/user/repo/ (extracts user/repo)
|
|
@@ -28,27 +27,49 @@ def normalize_repo(repo: str) -> str:
|
|
|
28
27
|
# If it's already in user/repo format, return as-is
|
|
29
28
|
if "/" in repo and not repo.startswith("http"):
|
|
30
29
|
return repo
|
|
31
|
-
|
|
30
|
+
|
|
32
31
|
# Extract user/repo from GitHub URL
|
|
33
32
|
github_pattern = r"https?://github\.com/([^/]+/[^/]+)/?.*"
|
|
34
33
|
match = re.match(github_pattern, repo)
|
|
35
|
-
|
|
34
|
+
|
|
36
35
|
if match:
|
|
37
36
|
return match.group(1)
|
|
38
|
-
|
|
37
|
+
|
|
39
38
|
# If no pattern matches, assume it's already in the correct format
|
|
40
39
|
return repo
|
|
41
40
|
|
|
41
|
+
|
|
42
42
|
# Define what files/directories each instruction type includes
|
|
43
|
+
# For agents supported by 'explode' (cursor, github, gemini, claude, opencode),
|
|
44
|
+
# we don't download specific directories anymore. Instead, we download the source
|
|
45
|
+
# files (instructions.md, commands.md) and generate them locally using explode.
|
|
46
|
+
# The directories listed here are what gets created by explode and what delete removes.
|
|
43
47
|
INSTRUCTION_TYPES = {
|
|
44
|
-
"cursor": {
|
|
48
|
+
"cursor": {
|
|
49
|
+
"directories": [".cursor"],
|
|
50
|
+
"files": [],
|
|
51
|
+
"include_patterns": [],
|
|
52
|
+
},
|
|
45
53
|
"github": {
|
|
46
|
-
"directories": [".github"],
|
|
54
|
+
"directories": [".github/instructions", ".github/prompts"],
|
|
55
|
+
"files": [".github/copilot-instructions.md"],
|
|
56
|
+
"include_patterns": [],
|
|
57
|
+
},
|
|
58
|
+
"gemini": {
|
|
59
|
+
"directories": [".gemini"],
|
|
60
|
+
"files": ["GEMINI.md"],
|
|
61
|
+
"include_patterns": [],
|
|
62
|
+
},
|
|
63
|
+
"claude": {
|
|
64
|
+
"directories": [".claude"],
|
|
65
|
+
"files": ["CLAUDE.md"],
|
|
66
|
+
"include_patterns": [],
|
|
67
|
+
},
|
|
68
|
+
"opencode": {
|
|
69
|
+
"directories": [".opencode"],
|
|
47
70
|
"files": [],
|
|
48
|
-
"
|
|
71
|
+
"include_patterns": [],
|
|
49
72
|
},
|
|
50
|
-
"gemini": {"directories": [], "files": ["GEMINI.md"]},
|
|
51
|
-
"claude": {"directories": [], "files": ["CLAUDE.md"]},
|
|
52
73
|
"agent": {"directories": [], "files": ["AGENT.md"]},
|
|
53
74
|
"agents": {"directories": [], "files": [], "recursive_files": ["AGENTS.md"]},
|
|
54
75
|
}
|
|
@@ -62,13 +83,19 @@ def download_and_extract_repo(repo: str, branch: str = DEFAULT_BRANCH) -> Path:
|
|
|
62
83
|
normalized_repo = normalize_repo(repo)
|
|
63
84
|
zip_url = f"https://github.com/{normalized_repo}/archive/{branch}.zip"
|
|
64
85
|
|
|
65
|
-
|
|
86
|
+
log.info(
|
|
87
|
+
"downloading repository",
|
|
88
|
+
repo=repo,
|
|
89
|
+
normalized_repo=normalized_repo,
|
|
90
|
+
branch=branch,
|
|
91
|
+
url=zip_url,
|
|
92
|
+
)
|
|
66
93
|
|
|
67
94
|
try:
|
|
68
95
|
response = requests.get(zip_url, timeout=30)
|
|
69
96
|
response.raise_for_status()
|
|
70
97
|
except requests.RequestException as e:
|
|
71
|
-
|
|
98
|
+
log.error("failed to download repository", error=str(e), url=zip_url)
|
|
72
99
|
raise typer.Exit(1)
|
|
73
100
|
|
|
74
101
|
# Create temporary directory and file
|
|
@@ -88,24 +115,24 @@ def download_and_extract_repo(repo: str, branch: str = DEFAULT_BRANCH) -> Path:
|
|
|
88
115
|
# Find the extracted repository directory (should be the only directory)
|
|
89
116
|
repo_dirs = [d for d in extract_dir.iterdir() if d.is_dir()]
|
|
90
117
|
if not repo_dirs:
|
|
91
|
-
|
|
118
|
+
log.error("no directories found in extracted zip")
|
|
92
119
|
raise typer.Exit(1)
|
|
93
120
|
|
|
94
121
|
repo_dir = repo_dirs[0]
|
|
95
|
-
|
|
122
|
+
log.info("repository extracted", path=str(repo_dir))
|
|
96
123
|
|
|
97
124
|
return repo_dir
|
|
98
125
|
|
|
99
126
|
|
|
100
127
|
def copy_instruction_files(
|
|
101
|
-
repo_dir: Path, instruction_types:
|
|
128
|
+
repo_dir: Path, instruction_types: list[str], target_dir: Path
|
|
102
129
|
):
|
|
103
130
|
"""Copy instruction files from the repository to the target directory."""
|
|
104
131
|
copied_items = []
|
|
105
132
|
|
|
106
133
|
for inst_type in instruction_types:
|
|
107
134
|
if inst_type not in INSTRUCTION_TYPES:
|
|
108
|
-
|
|
135
|
+
log.warning("unknown instruction type", type=inst_type)
|
|
109
136
|
continue
|
|
110
137
|
|
|
111
138
|
config = INSTRUCTION_TYPES[inst_type]
|
|
@@ -116,8 +143,8 @@ def copy_instruction_files(
|
|
|
116
143
|
target_subdir = target_dir / dir_name
|
|
117
144
|
|
|
118
145
|
if source_dir.exists():
|
|
119
|
-
|
|
120
|
-
"
|
|
146
|
+
log.info(
|
|
147
|
+
"copying directory",
|
|
121
148
|
source=str(source_dir),
|
|
122
149
|
target=str(target_subdir),
|
|
123
150
|
)
|
|
@@ -127,7 +154,10 @@ def copy_instruction_files(
|
|
|
127
154
|
|
|
128
155
|
# Copy all files from source to target
|
|
129
156
|
copy_directory_contents(
|
|
130
|
-
source_dir,
|
|
157
|
+
source_dir,
|
|
158
|
+
target_subdir,
|
|
159
|
+
config.get("exclude_patterns", []),
|
|
160
|
+
config.get("include_patterns", []),
|
|
131
161
|
)
|
|
132
162
|
copied_items.append(f"{dir_name}/")
|
|
133
163
|
|
|
@@ -137,8 +167,8 @@ def copy_instruction_files(
|
|
|
137
167
|
target_file = target_dir / file_name
|
|
138
168
|
|
|
139
169
|
if source_file.exists():
|
|
140
|
-
|
|
141
|
-
"
|
|
170
|
+
log.info(
|
|
171
|
+
"copying file", source=str(source_file), target=str(target_file)
|
|
142
172
|
)
|
|
143
173
|
|
|
144
174
|
# Create parent directories if needed
|
|
@@ -158,55 +188,56 @@ def copy_instruction_files(
|
|
|
158
188
|
|
|
159
189
|
def copy_recursive_files(
|
|
160
190
|
repo_dir: Path, target_dir: Path, file_pattern: str
|
|
161
|
-
) ->
|
|
191
|
+
) -> list[str]:
|
|
162
192
|
"""Recursively copy files matching pattern, preserving directory structure.
|
|
163
|
-
|
|
193
|
+
|
|
164
194
|
Only copies files to locations where the target directory already exists.
|
|
165
195
|
Warns and skips files where target directories don't exist.
|
|
166
|
-
|
|
196
|
+
|
|
167
197
|
Args:
|
|
168
198
|
repo_dir: Source repository directory
|
|
169
199
|
target_dir: Target directory to copy to
|
|
170
200
|
file_pattern: File pattern to search for (e.g., "AGENTS.md")
|
|
171
|
-
|
|
201
|
+
|
|
172
202
|
Returns:
|
|
173
203
|
List of copied file paths relative to target_dir
|
|
174
204
|
"""
|
|
175
205
|
copied_items = []
|
|
176
|
-
|
|
206
|
+
|
|
177
207
|
# Find all matching files recursively
|
|
178
208
|
matching_files = list(repo_dir.rglob(file_pattern))
|
|
179
|
-
|
|
209
|
+
|
|
180
210
|
for source_file in matching_files:
|
|
181
211
|
# Calculate relative path from repo root
|
|
182
212
|
relative_path = source_file.relative_to(repo_dir)
|
|
183
213
|
target_file = target_dir / relative_path
|
|
184
|
-
|
|
214
|
+
|
|
185
215
|
# Check if target directory already exists
|
|
186
216
|
target_parent = target_file.parent
|
|
187
217
|
if not target_parent.exists():
|
|
188
|
-
|
|
189
|
-
"
|
|
218
|
+
log.warning(
|
|
219
|
+
"target directory does not exist, skipping file copy",
|
|
190
220
|
target_directory=str(target_parent),
|
|
191
|
-
file=str(relative_path)
|
|
221
|
+
file=str(relative_path),
|
|
192
222
|
)
|
|
193
223
|
continue
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
"
|
|
197
|
-
source=str(source_file),
|
|
198
|
-
target=str(target_file)
|
|
224
|
+
|
|
225
|
+
log.info(
|
|
226
|
+
"copying recursive file", source=str(source_file), target=str(target_file)
|
|
199
227
|
)
|
|
200
|
-
|
|
228
|
+
|
|
201
229
|
# Copy file (parent directory already exists)
|
|
202
230
|
target_file.write_bytes(source_file.read_bytes())
|
|
203
231
|
copied_items.append(str(relative_path))
|
|
204
|
-
|
|
232
|
+
|
|
205
233
|
return copied_items
|
|
206
234
|
|
|
207
235
|
|
|
208
236
|
def copy_directory_contents(
|
|
209
|
-
source_dir: Path,
|
|
237
|
+
source_dir: Path,
|
|
238
|
+
target_dir: Path,
|
|
239
|
+
exclude_patterns: list[str],
|
|
240
|
+
include_patterns: list[str] = [],
|
|
210
241
|
):
|
|
211
242
|
"""Recursively copy directory contents, excluding specified patterns."""
|
|
212
243
|
for item in source_dir.rglob("*"):
|
|
@@ -216,6 +247,7 @@ def copy_directory_contents(
|
|
|
216
247
|
|
|
217
248
|
# Check if file matches any exclude pattern
|
|
218
249
|
should_exclude = False
|
|
250
|
+
pattern = ""
|
|
219
251
|
for pattern in exclude_patterns:
|
|
220
252
|
if pattern.endswith("/*"):
|
|
221
253
|
# Pattern like "workflows/*" - exclude if path starts with "workflows/"
|
|
@@ -228,9 +260,25 @@ def copy_directory_contents(
|
|
|
228
260
|
break
|
|
229
261
|
|
|
230
262
|
if should_exclude:
|
|
231
|
-
|
|
263
|
+
log.debug("excluding file", file=relative_str, pattern=pattern)
|
|
232
264
|
continue
|
|
233
265
|
|
|
266
|
+
# Check if file matches any include pattern (if any provided)
|
|
267
|
+
if include_patterns:
|
|
268
|
+
matched_include = False
|
|
269
|
+
for include_pattern in include_patterns:
|
|
270
|
+
# Match against filename only, or full relative path
|
|
271
|
+
if item.match(include_pattern):
|
|
272
|
+
matched_include = True
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
if not matched_include:
|
|
276
|
+
log.debug(
|
|
277
|
+
"skipping file (not matched in include_patterns)",
|
|
278
|
+
file=relative_str,
|
|
279
|
+
)
|
|
280
|
+
continue
|
|
281
|
+
|
|
234
282
|
target_file = target_dir / relative_path
|
|
235
283
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
236
284
|
target_file.write_bytes(item.read_bytes())
|
|
@@ -238,7 +286,7 @@ def copy_directory_contents(
|
|
|
238
286
|
|
|
239
287
|
def download_main(
|
|
240
288
|
instruction_types: Annotated[
|
|
241
|
-
|
|
289
|
+
list[str] | None,
|
|
242
290
|
typer.Argument(
|
|
243
291
|
help="Types of instructions to download (cursor, github, gemini, claude, agent, agents). Downloads everything by default."
|
|
244
292
|
),
|
|
@@ -252,9 +300,6 @@ def download_main(
|
|
|
252
300
|
target_dir: Annotated[
|
|
253
301
|
str, typer.Option("--target", "-t", help="Target directory to download to")
|
|
254
302
|
] = ".",
|
|
255
|
-
verbose: Annotated[
|
|
256
|
-
bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
|
|
257
|
-
] = False,
|
|
258
303
|
):
|
|
259
304
|
"""Download LLM instruction files from GitHub repositories.
|
|
260
305
|
|
|
@@ -279,12 +324,6 @@ def download_main(
|
|
|
279
324
|
# Download to a specific directory
|
|
280
325
|
llm_ide_rules download --target ./my-project
|
|
281
326
|
"""
|
|
282
|
-
if verbose:
|
|
283
|
-
logging.basicConfig(level=logging.DEBUG)
|
|
284
|
-
structlog.configure(
|
|
285
|
-
wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
|
|
286
|
-
)
|
|
287
|
-
|
|
288
327
|
# Use default types if none specified
|
|
289
328
|
if not instruction_types:
|
|
290
329
|
instruction_types = DEFAULT_TYPES
|
|
@@ -292,17 +331,19 @@ def download_main(
|
|
|
292
331
|
# Validate instruction types
|
|
293
332
|
invalid_types = [t for t in instruction_types if t not in INSTRUCTION_TYPES]
|
|
294
333
|
if invalid_types:
|
|
295
|
-
|
|
296
|
-
"
|
|
334
|
+
log.error(
|
|
335
|
+
"invalid instruction types",
|
|
297
336
|
invalid_types=invalid_types,
|
|
298
337
|
valid_types=list(INSTRUCTION_TYPES.keys()),
|
|
299
338
|
)
|
|
339
|
+
error_msg = f"Invalid instruction types: {', '.join(invalid_types)}"
|
|
340
|
+
typer.echo(typer.style(error_msg, fg=typer.colors.RED), err=True)
|
|
300
341
|
raise typer.Exit(1)
|
|
301
342
|
|
|
302
343
|
target_path = Path(target_dir).resolve()
|
|
303
344
|
|
|
304
|
-
|
|
305
|
-
"
|
|
345
|
+
log.info(
|
|
346
|
+
"starting download",
|
|
306
347
|
repo=repo,
|
|
307
348
|
branch=branch,
|
|
308
349
|
instruction_types=instruction_types,
|
|
@@ -316,13 +357,58 @@ def download_main(
|
|
|
316
357
|
# Copy instruction files
|
|
317
358
|
copied_items = copy_instruction_files(repo_dir, instruction_types, target_path)
|
|
318
359
|
|
|
360
|
+
# Check for source files (instructions.md, commands.md) and copy them if available
|
|
361
|
+
# These are needed for 'explode' logic
|
|
362
|
+
source_files = ["instructions.md", "commands.md"]
|
|
363
|
+
sources_copied = False
|
|
364
|
+
|
|
365
|
+
# Only copy source files if we have at least one agent that uses explode
|
|
366
|
+
has_explode_agent = any(t in VALID_AGENTS for t in instruction_types)
|
|
367
|
+
|
|
368
|
+
if has_explode_agent:
|
|
369
|
+
for source_file in source_files:
|
|
370
|
+
src = repo_dir / source_file
|
|
371
|
+
dst = target_path / source_file
|
|
372
|
+
if src.exists():
|
|
373
|
+
log.info("copying source file", source=str(src), target=str(dst))
|
|
374
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
dst.write_bytes(src.read_bytes())
|
|
376
|
+
copied_items.append(source_file)
|
|
377
|
+
sources_copied = True
|
|
378
|
+
|
|
379
|
+
# Generate rule files locally for supported agents
|
|
380
|
+
explodable_agents = [t for t in instruction_types if t in VALID_AGENTS]
|
|
381
|
+
|
|
382
|
+
if explodable_agents:
|
|
383
|
+
if not sources_copied:
|
|
384
|
+
# Check if they existed in target already?
|
|
385
|
+
if not (target_path / "instructions.md").exists():
|
|
386
|
+
log.warning(
|
|
387
|
+
"source file instructions.md missing, generation might fail"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
for agent in explodable_agents:
|
|
391
|
+
log.info("generating rules locally", agent=agent)
|
|
392
|
+
try:
|
|
393
|
+
explode_implementation(
|
|
394
|
+
input_file="instructions.md",
|
|
395
|
+
agent=agent,
|
|
396
|
+
working_dir=target_path,
|
|
397
|
+
)
|
|
398
|
+
copied_items.append(f"(generated) {agent} rules")
|
|
399
|
+
except Exception as e:
|
|
400
|
+
log.error("failed to generate rules", agent=agent, error=str(e))
|
|
401
|
+
typer.echo(
|
|
402
|
+
f"Warning: Failed to generate rules for {agent}: {e}", err=True
|
|
403
|
+
)
|
|
404
|
+
|
|
319
405
|
if copied_items:
|
|
320
|
-
|
|
321
|
-
typer.echo(
|
|
406
|
+
success_msg = f"Downloaded/Generated items in {target_path}:"
|
|
407
|
+
typer.echo(typer.style(success_msg, fg=typer.colors.GREEN))
|
|
322
408
|
for item in copied_items:
|
|
323
409
|
typer.echo(f" - {item}")
|
|
324
410
|
else:
|
|
325
|
-
|
|
411
|
+
log.warning("no files were copied or generated")
|
|
326
412
|
typer.echo("No matching instruction files found in the repository.")
|
|
327
413
|
|
|
328
414
|
finally:
|