oasr 0.3.4__py3-none-any.whl → 0.4.1__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.
adapters/base.py CHANGED
@@ -105,15 +105,15 @@ class BaseAdapter(ABC):
105
105
  Path to the copied skill directory.
106
106
  """
107
107
  dest = skills_dir / skill.name
108
-
108
+
109
109
  # Get the skill's content hash from manifest for tracking
110
110
  # If manifest doesn't exist, skip tracking (graceful degradation)
111
111
  inject_tracking = False
112
112
  source_hash = None
113
-
113
+
114
114
  try:
115
115
  from manifest import load_manifest
116
-
116
+
117
117
  manifest = load_manifest(skill.name)
118
118
  if manifest:
119
119
  source_hash = manifest.content_hash
@@ -121,7 +121,7 @@ class BaseAdapter(ABC):
121
121
  except Exception:
122
122
  # Gracefully skip tracking if manifest cannot be loaded
123
123
  pass
124
-
124
+
125
125
  return copy_skill_unified(
126
126
  skill.path,
127
127
  dest,
agents/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Agent driver system."""
2
+
3
+ from agents.base import AgentDriver
4
+ from agents.claude import ClaudeDriver
5
+ from agents.codex import CodexDriver
6
+ from agents.copilot import CopilotDriver
7
+ from agents.opencode import OpenCodeDriver
8
+ from agents.registry import (
9
+ DRIVERS,
10
+ detect_available_agents,
11
+ get_all_agent_names,
12
+ get_driver,
13
+ )
14
+
15
+ __all__ = [
16
+ "AgentDriver",
17
+ "CodexDriver",
18
+ "CopilotDriver",
19
+ "ClaudeDriver",
20
+ "OpenCodeDriver",
21
+ "DRIVERS",
22
+ "get_driver",
23
+ "detect_available_agents",
24
+ "get_all_agent_names",
25
+ ]
agents/base.py ADDED
@@ -0,0 +1,96 @@
1
+ """Abstract base class for agent drivers."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+
9
+ class AgentDriver(ABC):
10
+ """Abstract base class for AI agent CLI drivers."""
11
+
12
+ @abstractmethod
13
+ def get_name(self) -> str:
14
+ """Get the agent name.
15
+
16
+ Returns:
17
+ Agent name (e.g., 'codex', 'copilot', 'claude').
18
+ """
19
+
20
+ @abstractmethod
21
+ def get_binary_name(self) -> str:
22
+ """Get the CLI binary name to check for.
23
+
24
+ Returns:
25
+ Binary name (e.g., 'codex', 'copilot', 'claude').
26
+ """
27
+
28
+ def detect(self) -> bool:
29
+ """Check if the agent binary is available in PATH.
30
+
31
+ Returns:
32
+ True if binary is found, False otherwise.
33
+ """
34
+ return shutil.which(self.get_binary_name()) is not None
35
+
36
+ @abstractmethod
37
+ def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
38
+ """Build the command to execute.
39
+
40
+ Args:
41
+ skill_content: Full SKILL.md content.
42
+ user_prompt: User's prompt/request.
43
+ cwd: Current working directory.
44
+
45
+ Returns:
46
+ Command as list of strings (for subprocess).
47
+ """
48
+
49
+ def execute(self, skill_content: str, user_prompt: str, cwd: Path | None = None) -> subprocess.CompletedProcess:
50
+ """Execute skill with agent.
51
+
52
+ Args:
53
+ skill_content: Full SKILL.md content.
54
+ user_prompt: User's prompt/request.
55
+ cwd: Working directory for execution (defaults to current dir).
56
+
57
+ Returns:
58
+ CompletedProcess with stdout/stderr/returncode.
59
+
60
+ Raises:
61
+ FileNotFoundError: If agent binary not found.
62
+ """
63
+ if not self.detect():
64
+ raise FileNotFoundError(f"{self.get_name()} binary '{self.get_binary_name()}' not found in PATH")
65
+
66
+ working_dir = cwd or Path.cwd()
67
+ cmd = self.build_command(skill_content, user_prompt, working_dir)
68
+
69
+ return subprocess.run(
70
+ cmd,
71
+ cwd=working_dir,
72
+ capture_output=False, # Stream to stdout/stderr
73
+ text=True,
74
+ )
75
+
76
+ def format_injected_prompt(self, skill_content: str, user_prompt: str, cwd: Path) -> str:
77
+ """Format the injected prompt with skill content.
78
+
79
+ Args:
80
+ skill_content: Full SKILL.md content.
81
+ user_prompt: User's prompt/request.
82
+ cwd: Current working directory.
83
+
84
+ Returns:
85
+ Formatted prompt string.
86
+ """
87
+ return f"""You are executing a skill. Follow these instructions carefully:
88
+
89
+ ━━━━━━━━ SKILL INSTRUCTIONS ━━━━━━━━
90
+ {skill_content}
91
+ ━━━━━━━━ END SKILL ━━━━━━━━
92
+
93
+ USER REQUEST: {user_prompt}
94
+
95
+ Working directory: {cwd}
96
+ Execute the skill above for this request."""
agents/claude.py ADDED
@@ -0,0 +1,25 @@
1
+ """Claude CLI agent driver."""
2
+
3
+ from pathlib import Path
4
+
5
+ from agents.base import AgentDriver
6
+
7
+
8
+ class ClaudeDriver(AgentDriver):
9
+ """Driver for Claude CLI agent."""
10
+
11
+ def get_name(self) -> str:
12
+ """Get the agent name."""
13
+ return "claude"
14
+
15
+ def get_binary_name(self) -> str:
16
+ """Get the CLI binary name."""
17
+ return "claude"
18
+
19
+ def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
20
+ """Build claude command.
21
+
22
+ Claude syntax: claude <prompt> -p
23
+ """
24
+ injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
25
+ return ["claude", injected_prompt, "-p"]
agents/codex.py ADDED
@@ -0,0 +1,25 @@
1
+ """Codex agent driver."""
2
+
3
+ from pathlib import Path
4
+
5
+ from agents.base import AgentDriver
6
+
7
+
8
+ class CodexDriver(AgentDriver):
9
+ """Driver for Codex CLI agent."""
10
+
11
+ def get_name(self) -> str:
12
+ """Get the agent name."""
13
+ return "codex"
14
+
15
+ def get_binary_name(self) -> str:
16
+ """Get the CLI binary name."""
17
+ return "codex"
18
+
19
+ def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
20
+ """Build codex exec command.
21
+
22
+ Codex syntax: codex exec "<prompt>"
23
+ """
24
+ injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
25
+ return ["codex", "exec", injected_prompt]
agents/copilot.py ADDED
@@ -0,0 +1,25 @@
1
+ """GitHub Copilot agent driver."""
2
+
3
+ from pathlib import Path
4
+
5
+ from agents.base import AgentDriver
6
+
7
+
8
+ class CopilotDriver(AgentDriver):
9
+ """Driver for GitHub Copilot CLI agent."""
10
+
11
+ def get_name(self) -> str:
12
+ """Get the agent name."""
13
+ return "copilot"
14
+
15
+ def get_binary_name(self) -> str:
16
+ """Get the CLI binary name."""
17
+ return "copilot"
18
+
19
+ def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
20
+ """Build copilot command.
21
+
22
+ Copilot syntax: copilot -p "<prompt>"
23
+ """
24
+ injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
25
+ return ["copilot", "-p", injected_prompt]
agents/opencode.py ADDED
@@ -0,0 +1,25 @@
1
+ """OpenCode CLI agent driver."""
2
+
3
+ from pathlib import Path
4
+
5
+ from agents.base import AgentDriver
6
+
7
+
8
+ class OpenCodeDriver(AgentDriver):
9
+ """Driver for OpenCode CLI agent."""
10
+
11
+ def get_name(self) -> str:
12
+ """Get the agent name."""
13
+ return "opencode"
14
+
15
+ def get_binary_name(self) -> str:
16
+ """Get the CLI binary name."""
17
+ return "opencode"
18
+
19
+ def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
20
+ """Build opencode run command.
21
+
22
+ OpenCode syntax: opencode run "<prompt>"
23
+ """
24
+ injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
25
+ return ["opencode", "run", injected_prompt]
agents/registry.py ADDED
@@ -0,0 +1,57 @@
1
+ """Agent driver registry and factory."""
2
+
3
+ from agents.base import AgentDriver
4
+ from agents.claude import ClaudeDriver
5
+ from agents.codex import CodexDriver
6
+ from agents.copilot import CopilotDriver
7
+ from agents.opencode import OpenCodeDriver
8
+
9
+ # Registry of all available drivers
10
+ DRIVERS: dict[str, type[AgentDriver]] = {
11
+ "codex": CodexDriver,
12
+ "copilot": CopilotDriver,
13
+ "claude": ClaudeDriver,
14
+ "opencode": OpenCodeDriver,
15
+ }
16
+
17
+
18
+ def get_driver(agent_name: str) -> AgentDriver:
19
+ """Get driver instance by agent name.
20
+
21
+ Args:
22
+ agent_name: Name of agent (codex, copilot, claude).
23
+
24
+ Returns:
25
+ AgentDriver instance.
26
+
27
+ Raises:
28
+ ValueError: If agent name is invalid.
29
+ """
30
+ if agent_name not in DRIVERS:
31
+ valid = ", ".join(sorted(DRIVERS.keys()))
32
+ raise ValueError(f"Invalid agent '{agent_name}'. Must be one of: {valid}")
33
+
34
+ return DRIVERS[agent_name]()
35
+
36
+
37
+ def detect_available_agents() -> list[str]:
38
+ """Detect which agent binaries are available in PATH.
39
+
40
+ Returns:
41
+ List of available agent names.
42
+ """
43
+ available = []
44
+ for name, driver_class in DRIVERS.items():
45
+ driver = driver_class()
46
+ if driver.detect():
47
+ available.append(name)
48
+ return sorted(available)
49
+
50
+
51
+ def get_all_agent_names() -> list[str]:
52
+ """Get all supported agent names.
53
+
54
+ Returns:
55
+ List of all agent names.
56
+ """
57
+ return sorted(DRIVERS.keys())
cli.py CHANGED
@@ -10,10 +10,10 @@ import json
10
10
  import sys
11
11
  from pathlib import Path
12
12
 
13
- from commands import adapter, clean, diff, find, registry, sync, update, use, validate
13
+ from commands import adapter, clean, clone, config, diff, exec, find, registry, sync, update, use, validate
14
14
  from commands import help as help_cmd
15
15
 
16
- __version__ = "0.3.4"
16
+ __version__ = "0.4.1"
17
17
 
18
18
 
19
19
  def main(argv: list[str] | None = None) -> int:
@@ -67,13 +67,16 @@ def create_parser() -> argparse.ArgumentParser:
67
67
 
68
68
  subparsers = parser.add_subparsers(dest="command", help="Available commands")
69
69
 
70
- # New taxonomy (v0.3.0)
70
+ # New taxonomy (v0.3.0+)
71
71
  registry.register(subparsers) # Registry operations (add, rm, sync, list)
72
72
  diff.register(subparsers) # Show tracked skill status
73
73
  sync.register(subparsers) # Refresh tracked skills
74
+ config.register(subparsers) # Configuration management
75
+ clone.register(subparsers) # Clone skills to directory
76
+ exec.register(subparsers) # Execute skills with agent CLI
74
77
 
75
- # Unchanged commands
76
- use.register(subparsers)
78
+ # Deprecated commands
79
+ use.register(subparsers) # DEPRECATED - use clone instead
77
80
  find.register(subparsers)
78
81
  validate.register(subparsers)
79
82
  clean.register(subparsers)
commands/add.py CHANGED
@@ -139,9 +139,56 @@ def run(args: argparse.Namespace) -> int:
139
139
 
140
140
  temp_dir = fetch_remote_to_temp(url)
141
141
 
142
+ # Check for multiple skills in the repo
143
+ skill_dirs = _find_skill_dirs(temp_dir)
144
+
145
+ if len(skill_dirs) > 1:
146
+ # Multiple skills found
147
+ if not args.quiet and not args.json:
148
+ print(f"✓ Found {len(skill_dirs)} skills in repository:", file=sys.stderr)
149
+ for skill_dir in skill_dirs:
150
+ rel_path = skill_dir.relative_to(temp_dir)
151
+ print(f" - {rel_path}", file=sys.stderr)
152
+ print(file=sys.stderr)
153
+
154
+ # Prompt user
155
+ response = input("Add all skills? [Y/n]: ").strip().lower()
156
+ if response and response not in ("y", "yes"):
157
+ skipped_count += len(skill_dirs)
158
+ for skill_dir in skill_dirs:
159
+ rel_path = skill_dir.relative_to(temp_dir)
160
+ results.append(
161
+ {"url": url, "skill": str(rel_path), "added": False, "reason": "user declined"}
162
+ )
163
+ continue
164
+
165
+ # Add all skills
166
+ for skill_dir in skill_dirs:
167
+ rel_path = skill_dir.relative_to(temp_dir)
168
+ skill_url = f"{url}/{rel_path}" if str(rel_path) != "." else url
169
+
170
+ result = _add_single_remote_skill(
171
+ skill_dir,
172
+ skill_url,
173
+ url, # repo_root
174
+ args,
175
+ max_lines,
176
+ results,
177
+ )
178
+
179
+ if result["added"]:
180
+ added_count += 1
181
+ else:
182
+ skipped_count += 1
183
+
184
+ continue # Skip single-skill handling
185
+
186
+ # Single skill handling (original logic)
187
+ skill_dir = skill_dirs[0] if skill_dirs else temp_dir
188
+
142
189
  if not args.quiet and not args.json:
143
190
  # Count files validated
144
- file_count = sum(1 for _ in temp_dir.rglob("*") if _.is_file())
191
+ file_count = sum(1 for _ in skill_dir.rglob("*") if _.is_file())
145
192
  print(f"✓ Validated {file_count} file(s)", file=sys.stderr)
146
193
  except Exception as e:
147
194
  skipped_count += 1
@@ -152,8 +199,8 @@ def run(args: argparse.Namespace) -> int:
152
199
  continue
153
200
 
154
201
  try:
155
- # Validate fetched content (skip name match for temp directory)
156
- result = validate_skill(temp_dir, reference_max_lines=max_lines, skip_name_match=True)
202
+ # Validate fetched content
203
+ result = validate_skill(skill_dir, reference_max_lines=max_lines, skip_name_match=True)
157
204
  if not args.quiet and not args.json:
158
205
  _print_validation_result(result)
159
206
  print()
@@ -171,7 +218,7 @@ def run(args: argparse.Namespace) -> int:
171
218
  continue
172
219
 
173
220
  # Discover skill info from fetched content
174
- discovered = discover_single(temp_dir)
221
+ discovered = discover_single(skill_dir)
175
222
  if not discovered:
176
223
  skipped_count += 1
177
224
  results.append({"url": url, "added": False, "reason": "could not discover skill info"})
@@ -300,3 +347,89 @@ def _run_recursive(args: argparse.Namespace, root: Path, max_lines: int) -> int:
300
347
  print(f"\n{added_count} skill(s) added, {skipped_count} skipped")
301
348
 
302
349
  return 0
350
+
351
+
352
+ def _find_skill_dirs(root: Path) -> list[Path]:
353
+ """Find all directories containing SKILL.md files.
354
+
355
+ Args:
356
+ root: Root directory to search.
357
+
358
+ Returns:
359
+ List of directories containing SKILL.md (sorted by depth, then name).
360
+ """
361
+ skill_dirs = []
362
+
363
+ for skill_md in root.rglob("SKILL.md"):
364
+ skill_dir = skill_md.parent
365
+ skill_dirs.append(skill_dir)
366
+
367
+ # Sort by depth (shallowest first), then alphabetically
368
+ skill_dirs.sort(key=lambda p: (len(p.relative_to(root).parts), str(p)))
369
+
370
+ return skill_dirs
371
+
372
+
373
+ def _add_single_remote_skill(
374
+ skill_dir: Path,
375
+ skill_url: str,
376
+ repo_root_url: str,
377
+ args: argparse.Namespace,
378
+ max_lines: int,
379
+ results: list[dict],
380
+ ) -> dict:
381
+ """Add a single remote skill.
382
+
383
+ Args:
384
+ skill_dir: Local path to skill directory (in temp).
385
+ skill_url: Full URL to this specific skill.
386
+ repo_root_url: URL to repository root.
387
+ args: Command arguments.
388
+ max_lines: Max reference lines for validation.
389
+ results: Results list to append to.
390
+
391
+ Returns:
392
+ Result dictionary with 'added' status.
393
+ """
394
+ # Validate skill
395
+ result = validate_skill(skill_dir, reference_max_lines=max_lines, skip_name_match=True)
396
+
397
+ if not result.valid:
398
+ res = {"url": skill_url, "added": False, "reason": "validation errors"}
399
+ results.append(res)
400
+ if not args.quiet and not args.json:
401
+ print(f"⚠ Skipping {skill_dir.name}: validation errors", file=sys.stderr)
402
+ return res
403
+
404
+ if args.strict and result.warnings:
405
+ res = {"url": skill_url, "added": False, "reason": "validation warnings (strict mode)"}
406
+ results.append(res)
407
+ if not args.quiet and not args.json:
408
+ print(f"⚠ Skipping {skill_dir.name}: validation warnings (strict)", file=sys.stderr)
409
+ return res
410
+
411
+ # Discover skill info
412
+ discovered = discover_single(skill_dir)
413
+ if not discovered:
414
+ res = {"url": skill_url, "added": False, "reason": "could not discover skill info"}
415
+ results.append(res)
416
+ if not args.quiet and not args.json:
417
+ print(f"⚠ Skipping {skill_dir.name}: could not discover skill info", file=sys.stderr)
418
+ return res
419
+
420
+ # Create entry with skill-specific URL
421
+ entry = SkillEntry(
422
+ path=skill_url, # Full path to this skill
423
+ name=discovered.name,
424
+ description=discovered.description,
425
+ )
426
+
427
+ is_new = add_skill(entry)
428
+ res = {"name": entry.name, "url": skill_url, "added": True, "new": is_new}
429
+ results.append(res)
430
+
431
+ if not args.quiet and not args.json:
432
+ action = "Added" if is_new else "Updated"
433
+ print(f"{action} skill: {entry.name}", file=sys.stderr)
434
+
435
+ return res
commands/clone.py ADDED
@@ -0,0 +1,178 @@
1
+ """`oasr clone` command - copy skill(s) to target directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import fnmatch
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from registry import load_registry
12
+ from skillcopy import copy_skill
13
+
14
+
15
+ def register(subparsers) -> None:
16
+ """Register the clone command."""
17
+ p = subparsers.add_parser(
18
+ "clone",
19
+ help="Clone skill(s) to target directory",
20
+ description="Clone skills from the registry to a target directory with tracking metadata",
21
+ )
22
+ p.add_argument("names", nargs="+", help="Skill name(s) or glob pattern(s) to clone")
23
+ p.add_argument(
24
+ "-d",
25
+ "--dir",
26
+ type=Path,
27
+ default=Path("."),
28
+ dest="output_dir",
29
+ help="Target directory (default: current)",
30
+ )
31
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
32
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
33
+ p.set_defaults(func=run)
34
+
35
+
36
+ def _match_skills(patterns: list[str], entry_map: dict) -> tuple[list[str], list[str]]:
37
+ """Match skill names against patterns (exact or glob).
38
+
39
+ Returns:
40
+ Tuple of (matched_names, unmatched_patterns).
41
+ """
42
+ matched = set()
43
+ unmatched = []
44
+ all_names = list(entry_map.keys())
45
+
46
+ for pattern in patterns:
47
+ if pattern in entry_map:
48
+ matched.add(pattern)
49
+ elif any(c in pattern for c in "*?["):
50
+ # Glob pattern
51
+ matches = fnmatch.filter(all_names, pattern)
52
+ if matches:
53
+ matched.update(matches)
54
+ else:
55
+ unmatched.append(pattern)
56
+ else:
57
+ unmatched.append(pattern)
58
+
59
+ return list(matched), unmatched
60
+
61
+
62
+ def run(args: argparse.Namespace) -> int:
63
+ """Execute the clone command."""
64
+ entries = load_registry()
65
+ entry_map = {e.name: e for e in entries}
66
+
67
+ output_dir = args.output_dir.resolve()
68
+ output_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+ copied = []
71
+ warnings = []
72
+
73
+ matched_names, unmatched = _match_skills(args.names, entry_map)
74
+
75
+ for pattern in unmatched:
76
+ warnings.append(f"No skills matched: {pattern}")
77
+
78
+ # Get manifests for tracking metadata
79
+ from manifest import load_manifest
80
+
81
+ # Separate remote and local skills for parallel processing
82
+ from skillcopy.remote import is_remote_source
83
+
84
+ remote_names = [name for name in matched_names if is_remote_source(entry_map[name].path)]
85
+ local_names = [name for name in matched_names if not is_remote_source(entry_map[name].path)]
86
+
87
+ # Handle remote skills with parallel fetching
88
+ if remote_names:
89
+ print(f"Fetching {len(remote_names)} remote skill(s)...", file=sys.stderr)
90
+ import threading
91
+ from concurrent.futures import ThreadPoolExecutor, as_completed
92
+
93
+ print_lock = threading.Lock()
94
+
95
+ def copy_remote_entry(name):
96
+ """Copy a remote skill with thread-safe progress."""
97
+ entry = entry_map[name]
98
+ dest = output_dir / name
99
+
100
+ try:
101
+ with print_lock:
102
+ platform = (
103
+ "GitHub" if "github.com" in entry.path else "GitLab" if "gitlab.com" in entry.path else "remote"
104
+ )
105
+ print(f" ↓ {name} (fetching from {platform}...)", file=sys.stderr, flush=True)
106
+
107
+ # Get manifest hash for tracking
108
+ manifest = load_manifest(name)
109
+ source_hash = manifest.content_hash if manifest else None
110
+
111
+ copy_skill(
112
+ entry.path,
113
+ dest,
114
+ validate=False,
115
+ show_progress=False,
116
+ skill_name=name,
117
+ inject_tracking=True,
118
+ source_hash=source_hash,
119
+ )
120
+
121
+ with print_lock:
122
+ print(f" ✓ {name} (downloaded)", file=sys.stderr)
123
+
124
+ return {"name": name, "src": entry.path, "dest": str(dest)}, None
125
+ except Exception as e:
126
+ with print_lock:
127
+ print(f" ✗ {name} ({str(e)[:50]}...)", file=sys.stderr)
128
+ return None, f"Failed to clone {name}: {e}"
129
+
130
+ # Copy remote skills in parallel
131
+ with ThreadPoolExecutor(max_workers=4) as executor:
132
+ futures = {executor.submit(copy_remote_entry, name): name for name in remote_names}
133
+
134
+ for future in as_completed(futures):
135
+ result, error = future.result()
136
+ if result:
137
+ copied.append(result)
138
+ if error:
139
+ warnings.append(error)
140
+
141
+ # Handle local skills sequentially (fast anyway)
142
+ for name in sorted(local_names):
143
+ entry = entry_map[name]
144
+ dest = output_dir / name
145
+
146
+ try:
147
+ # Get manifest hash for tracking
148
+ manifest = load_manifest(name)
149
+ source_hash = manifest.content_hash if manifest else None
150
+
151
+ # Unified copy with tracking
152
+ copy_skill(entry.path, dest, validate=False, inject_tracking=True, source_hash=source_hash)
153
+ copied.append({"name": name, "src": entry.path, "dest": str(dest)})
154
+ except Exception as e:
155
+ warnings.append(f"Failed to clone {name}: {e}")
156
+
157
+ if not args.quiet:
158
+ for w in warnings:
159
+ print(f"⚠ {w}", file=sys.stderr)
160
+
161
+ if args.json:
162
+ print(
163
+ json.dumps(
164
+ {
165
+ "copied": len(copied),
166
+ "warnings": len(warnings),
167
+ "skills": copied,
168
+ },
169
+ indent=2,
170
+ )
171
+ )
172
+ else:
173
+ for c in copied:
174
+ print(f"Cloned: {c['name']} → {c['dest']}")
175
+ if copied:
176
+ print(f"\n{len(copied)} skill(s) cloned to {output_dir}")
177
+
178
+ return 1 if warnings and not copied else 0