oasr 0.5.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.
Files changed (59) hide show
  1. __init__.py +3 -0
  2. __main__.py +6 -0
  3. adapter.py +396 -0
  4. adapters/__init__.py +17 -0
  5. adapters/base.py +254 -0
  6. adapters/claude.py +82 -0
  7. adapters/codex.py +84 -0
  8. adapters/copilot.py +210 -0
  9. adapters/cursor.py +78 -0
  10. adapters/windsurf.py +83 -0
  11. agents/__init__.py +25 -0
  12. agents/base.py +96 -0
  13. agents/claude.py +25 -0
  14. agents/codex.py +25 -0
  15. agents/copilot.py +25 -0
  16. agents/opencode.py +25 -0
  17. agents/registry.py +57 -0
  18. cli.py +97 -0
  19. commands/__init__.py +6 -0
  20. commands/adapter.py +102 -0
  21. commands/add.py +435 -0
  22. commands/clean.py +30 -0
  23. commands/clone.py +178 -0
  24. commands/config.py +163 -0
  25. commands/diff.py +180 -0
  26. commands/exec.py +245 -0
  27. commands/find.py +56 -0
  28. commands/help.py +51 -0
  29. commands/info.py +152 -0
  30. commands/list.py +110 -0
  31. commands/registry.py +447 -0
  32. commands/rm.py +128 -0
  33. commands/status.py +119 -0
  34. commands/sync.py +143 -0
  35. commands/update.py +417 -0
  36. commands/use.py +45 -0
  37. commands/validate.py +74 -0
  38. config/__init__.py +119 -0
  39. config/defaults.py +40 -0
  40. config/schema.py +73 -0
  41. discovery.py +145 -0
  42. manifest.py +437 -0
  43. oasr-0.5.0.dist-info/METADATA +358 -0
  44. oasr-0.5.0.dist-info/RECORD +59 -0
  45. oasr-0.5.0.dist-info/WHEEL +4 -0
  46. oasr-0.5.0.dist-info/entry_points.txt +3 -0
  47. oasr-0.5.0.dist-info/licenses/LICENSE +187 -0
  48. oasr-0.5.0.dist-info/licenses/NOTICE +8 -0
  49. policy/__init__.py +50 -0
  50. policy/defaults.py +27 -0
  51. policy/enforcement.py +98 -0
  52. policy/profile.py +185 -0
  53. registry.py +173 -0
  54. remote.py +482 -0
  55. skillcopy/__init__.py +71 -0
  56. skillcopy/local.py +40 -0
  57. skillcopy/remote.py +98 -0
  58. tracking.py +181 -0
  59. validate.py +362 -0
commands/add.py ADDED
@@ -0,0 +1,435 @@
1
+ """`asr add` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import glob as globlib
7
+ import json
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from config import load_config
13
+ from discovery import discover_single, find_skills
14
+ from registry import SkillEntry, add_skill
15
+ from remote import InvalidRemoteUrlError, derive_skill_name, fetch_remote_to_temp, validate_remote_url
16
+ from skillcopy.remote import is_remote_source
17
+ from validate import validate_skill
18
+
19
+ _GLOB_CHARS = set("*?[")
20
+
21
+
22
+ def _looks_like_glob(value: str) -> bool:
23
+ return any(ch in value for ch in _GLOB_CHARS)
24
+
25
+
26
+ def _expand_path_patterns(patterns: list[str]) -> list[Path]:
27
+ expanded: list[Path] = []
28
+ for raw in patterns:
29
+ pat = str(Path(raw).expanduser())
30
+ if _looks_like_glob(pat):
31
+ matches = globlib.glob(pat, recursive=True)
32
+ expanded.extend(Path(m) for m in matches)
33
+ else:
34
+ expanded.append(Path(pat))
35
+ return expanded
36
+
37
+
38
+ def _print_validation_result(result) -> None:
39
+ print(f"{result.name}")
40
+ if result.valid and not result.warnings:
41
+ print(" ✓ Valid")
42
+ else:
43
+ for msg in result.all_messages:
44
+ print(f" {msg}")
45
+
46
+
47
+ def register(subparsers) -> None:
48
+ p = subparsers.add_parser("add", help="Register a skill")
49
+ p.add_argument(
50
+ "paths",
51
+ nargs="+",
52
+ help="Path(s), URL(s), or glob pattern(s) to skill dir(s) (or root(s) for recursive)",
53
+ )
54
+ p.add_argument("-r", "--recursive", action="store_true", help="Recursively add all valid skills from path")
55
+ p.add_argument("--strict", action="store_true", help="Fail if validation has warnings")
56
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
57
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
58
+ p.add_argument("--config", type=Path, help="Override config file path")
59
+ p.set_defaults(func=run)
60
+
61
+
62
+ def run(args: argparse.Namespace) -> int:
63
+ config = load_config(args.config)
64
+ max_lines = config["validation"]["reference_max_lines"]
65
+
66
+ # Separate remote URLs from local paths
67
+ remote_urls = []
68
+ local_patterns = []
69
+
70
+ for pattern in args.paths:
71
+ if is_remote_source(pattern):
72
+ remote_urls.append(pattern)
73
+ else:
74
+ local_patterns.append(pattern)
75
+
76
+ # Expand local paths
77
+ expanded = []
78
+ if local_patterns:
79
+ expanded = [p.resolve() for p in _expand_path_patterns(local_patterns)]
80
+
81
+ # Check if we have anything to process
82
+ if not expanded and not remote_urls:
83
+ if args.json:
84
+ print(json.dumps({"added": 0, "skipped": 0, "skills": [], "error": "no paths matched"}))
85
+ else:
86
+ print("No paths matched.", file=sys.stderr)
87
+ return 2
88
+
89
+ # Handle recursive mode (local paths only)
90
+ if args.recursive:
91
+ if remote_urls:
92
+ print("Warning: --recursive flag ignored for remote URLs", file=sys.stderr)
93
+ exit_code = 0
94
+ for root in expanded:
95
+ code = _run_recursive(args, root, max_lines)
96
+ if code != 0:
97
+ exit_code = code
98
+ return exit_code
99
+
100
+ results: list[dict] = []
101
+ added_count = 0
102
+ skipped_count = 0
103
+ exit_code = 0
104
+
105
+ # Process remote URLs
106
+ for url in remote_urls:
107
+ # Validate URL format
108
+ valid, error_msg = validate_remote_url(url)
109
+ if not valid:
110
+ skipped_count += 1
111
+ results.append({"url": url, "added": False, "reason": f"Invalid URL: {error_msg}"})
112
+ exit_code = 1
113
+ if not args.quiet and not args.json:
114
+ print(f"Invalid URL: {url} - {error_msg}", file=sys.stderr)
115
+ continue
116
+
117
+ # Derive skill name
118
+ try:
119
+ derive_skill_name(url)
120
+ except InvalidRemoteUrlError as e:
121
+ skipped_count += 1
122
+ results.append({"url": url, "added": False, "reason": str(e)})
123
+ exit_code = 1
124
+ if not args.quiet and not args.json:
125
+ print(f"Cannot derive name from URL: {url}", file=sys.stderr)
126
+ continue
127
+
128
+ # Fetch to temp dir for validation
129
+ try:
130
+ if not args.quiet and not args.json:
131
+ # Determine platform for user feedback
132
+ if "github.com" in url:
133
+ platform = "GitHub"
134
+ elif "gitlab.com" in url:
135
+ platform = "GitLab"
136
+ else:
137
+ platform = "remote source"
138
+ print(f"Registering from {platform}...", file=sys.stderr)
139
+
140
+ temp_dir = fetch_remote_to_temp(url)
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
+
189
+ if not args.quiet and not args.json:
190
+ # Count files validated
191
+ file_count = sum(1 for _ in skill_dir.rglob("*") if _.is_file())
192
+ print(f"✓ Validated {file_count} file(s)", file=sys.stderr)
193
+ except Exception as e:
194
+ skipped_count += 1
195
+ results.append({"url": url, "added": False, "reason": f"Fetch failed: {e}"})
196
+ exit_code = 1
197
+ if not args.quiet and not args.json:
198
+ print(f"Failed to fetch {url}: {e}", file=sys.stderr)
199
+ continue
200
+
201
+ try:
202
+ # Validate fetched content
203
+ result = validate_skill(skill_dir, reference_max_lines=max_lines, skip_name_match=True)
204
+ if not args.quiet and not args.json:
205
+ _print_validation_result(result)
206
+ print()
207
+
208
+ if not result.valid:
209
+ skipped_count += 1
210
+ results.append({"url": url, "added": False, "reason": "validation errors"})
211
+ exit_code = 1
212
+ continue
213
+
214
+ if args.strict and result.warnings:
215
+ skipped_count += 1
216
+ results.append({"url": url, "added": False, "reason": "validation warnings (strict mode)"})
217
+ exit_code = 1
218
+ continue
219
+
220
+ # Discover skill info from fetched content
221
+ discovered = discover_single(skill_dir)
222
+ if not discovered:
223
+ skipped_count += 1
224
+ results.append({"url": url, "added": False, "reason": "could not discover skill info"})
225
+ exit_code = 3
226
+ continue
227
+
228
+ # Create entry with URL as source_path
229
+ entry = SkillEntry(
230
+ path=url, # Store URL, not temp path
231
+ name=discovered.name,
232
+ description=discovered.description,
233
+ )
234
+
235
+ is_new = add_skill(entry)
236
+ added_count += 1
237
+ results.append({"name": entry.name, "url": url, "added": True, "new": is_new})
238
+
239
+ if not args.quiet and not args.json:
240
+ action = "Added" if is_new else "Updated"
241
+ print(f"{action} remote skill: {entry.name} from {url}")
242
+
243
+ finally:
244
+ shutil.rmtree(temp_dir, ignore_errors=True)
245
+
246
+ # Process local paths
247
+ for path in expanded:
248
+ if not path.exists():
249
+ skipped_count += 1
250
+ results.append({"path": str(path), "added": False, "reason": "path missing"})
251
+ exit_code = 1
252
+ if not args.quiet and not args.json:
253
+ print(f"Not found: {path}", file=sys.stderr)
254
+ continue
255
+
256
+ result = validate_skill(path, reference_max_lines=max_lines)
257
+ if not args.quiet and not args.json:
258
+ _print_validation_result(result)
259
+ print()
260
+
261
+ if not result.valid:
262
+ skipped_count += 1
263
+ results.append({"path": str(path), "added": False, "reason": "validation errors"})
264
+ exit_code = 1
265
+ continue
266
+
267
+ if args.strict and result.warnings:
268
+ skipped_count += 1
269
+ results.append({"path": str(path), "added": False, "reason": "validation warnings (strict mode)"})
270
+ exit_code = 1
271
+ continue
272
+
273
+ discovered = discover_single(path)
274
+ if not discovered:
275
+ skipped_count += 1
276
+ results.append({"path": str(path), "added": False, "reason": "could not discover skill info"})
277
+ exit_code = 3
278
+ continue
279
+
280
+ entry = SkillEntry(
281
+ path=str(discovered.path),
282
+ name=discovered.name,
283
+ description=discovered.description,
284
+ )
285
+
286
+ is_new = add_skill(entry)
287
+ added_count += 1
288
+ results.append({"name": entry.name, "path": entry.path, "added": True, "new": is_new})
289
+
290
+ if not args.quiet and not args.json:
291
+ action = "Added" if is_new else "Updated"
292
+ print(f"{action} skill: {entry.name}")
293
+
294
+ if args.json:
295
+ print(json.dumps({"added": added_count, "skipped": skipped_count, "skills": results}, indent=2))
296
+
297
+ return exit_code
298
+
299
+
300
+ def _run_recursive(args: argparse.Namespace, root: Path, max_lines: int) -> int:
301
+ if not root.is_dir():
302
+ print(f"Error: Not a directory: {root}", file=sys.stderr)
303
+ return 2
304
+
305
+ skills = find_skills(root)
306
+ if not skills:
307
+ if args.json:
308
+ print(json.dumps({"added": 0, "skipped": 0, "skills": []}))
309
+ else:
310
+ print(f"No skills found under {root}")
311
+ return 0
312
+
313
+ added_count = 0
314
+ skipped_count = 0
315
+ results = []
316
+
317
+ for s in skills:
318
+ result = validate_skill(s.path, reference_max_lines=max_lines)
319
+
320
+ if not result.valid:
321
+ skipped_count += 1
322
+ if not args.quiet:
323
+ print(f"⚠ Skipping {s.name}: validation errors", file=sys.stderr)
324
+ results.append({"name": s.name, "added": False, "reason": "validation errors"})
325
+ continue
326
+
327
+ if args.strict and result.warnings:
328
+ skipped_count += 1
329
+ if not args.quiet:
330
+ print(f"⚠ Skipping {s.name}: validation warnings (strict)", file=sys.stderr)
331
+ results.append({"name": s.name, "added": False, "reason": "validation warnings"})
332
+ continue
333
+
334
+ entry = SkillEntry(path=str(s.path), name=s.name, description=s.description)
335
+ is_new = add_skill(entry)
336
+ added_count += 1
337
+
338
+ if not args.quiet and not args.json:
339
+ action = "Added" if is_new else "Updated"
340
+ print(f"{action}: {s.name}")
341
+
342
+ results.append({"name": s.name, "added": True, "new": is_new})
343
+
344
+ if args.json:
345
+ print(json.dumps({"added": added_count, "skipped": skipped_count, "skills": results}, indent=2))
346
+ elif not args.quiet:
347
+ print(f"\n{added_count} skill(s) added, {skipped_count} skipped")
348
+
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/clean.py ADDED
@@ -0,0 +1,30 @@
1
+ """`asr clean` command - DEPRECATED, use `oasr registry prune` instead."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+
9
+ def register(subparsers) -> None:
10
+ p = subparsers.add_parser(
11
+ "clean",
12
+ help="(Deprecated) Clean up corrupted/missing skills - use 'oasr registry prune'",
13
+ )
14
+ p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
15
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
16
+ p.add_argument("--dry-run", action="store_true", help="Show what would be cleaned without doing it")
17
+ p.set_defaults(func=run)
18
+
19
+
20
+ def run(args: argparse.Namespace) -> int:
21
+ """Delegate to registry prune with deprecation warning."""
22
+ # Show deprecation warning unless --json or --quiet
23
+ if not args.json:
24
+ print("⚠ Warning: 'oasr clean' is deprecated. Use 'oasr registry prune' instead.", file=sys.stderr)
25
+ print(" This command will be removed in v0.6.0.\n", file=sys.stderr)
26
+
27
+ # Delegate to registry prune
28
+ from commands.registry import run_prune
29
+
30
+ return run_prune(args)
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