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,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
+ )
@@ -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))
@@ -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
- logger = structlog.get_logger()
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": {"directories": [".cursor"], "files": []},
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
- "exclude_patterns": ["workflows/*"],
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
- logger.info("Downloading repository", repo=repo, normalized_repo=normalized_repo, branch=branch, url=zip_url)
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
- logger.error("Failed to download repository", error=str(e), url=zip_url)
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
- logger.error("No directories found in extracted ZIP")
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
- logger.info("Repository extracted", path=str(repo_dir))
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: List[str], target_dir: Path
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
- logger.warning("Unknown instruction type", type=inst_type)
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
- logger.info(
120
- "Copying directory",
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, target_subdir, config.get("exclude_patterns", [])
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
- logger.info(
141
- "Copying file", source=str(source_file), target=str(target_file)
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
- ) -> List[str]:
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
- logger.warning(
189
- "Target directory does not exist, skipping file copy",
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
- logger.info(
196
- "Copying recursive file",
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, target_dir: Path, exclude_patterns: List[str]
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
- logger.debug("Excluding file", file=relative_str, pattern=pattern)
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
- List[str],
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
- logger.error(
296
- "Invalid instruction types",
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
- logger.info(
305
- "Starting download",
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
- logger.info("Download completed successfully", copied_items=copied_items)
321
- typer.echo(f"Downloaded {len(copied_items)} items to {target_path}:")
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
- logger.warning("No files were copied")
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: