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.
- __init__.py +3 -0
- __main__.py +6 -0
- adapter.py +396 -0
- adapters/__init__.py +17 -0
- adapters/base.py +254 -0
- adapters/claude.py +82 -0
- adapters/codex.py +84 -0
- adapters/copilot.py +210 -0
- adapters/cursor.py +78 -0
- adapters/windsurf.py +83 -0
- agents/__init__.py +25 -0
- agents/base.py +96 -0
- agents/claude.py +25 -0
- agents/codex.py +25 -0
- agents/copilot.py +25 -0
- agents/opencode.py +25 -0
- agents/registry.py +57 -0
- cli.py +97 -0
- commands/__init__.py +6 -0
- commands/adapter.py +102 -0
- commands/add.py +435 -0
- commands/clean.py +30 -0
- commands/clone.py +178 -0
- commands/config.py +163 -0
- commands/diff.py +180 -0
- commands/exec.py +245 -0
- commands/find.py +56 -0
- commands/help.py +51 -0
- commands/info.py +152 -0
- commands/list.py +110 -0
- commands/registry.py +447 -0
- commands/rm.py +128 -0
- commands/status.py +119 -0
- commands/sync.py +143 -0
- commands/update.py +417 -0
- commands/use.py +45 -0
- commands/validate.py +74 -0
- config/__init__.py +119 -0
- config/defaults.py +40 -0
- config/schema.py +73 -0
- discovery.py +145 -0
- manifest.py +437 -0
- oasr-0.5.0.dist-info/METADATA +358 -0
- oasr-0.5.0.dist-info/RECORD +59 -0
- oasr-0.5.0.dist-info/WHEEL +4 -0
- oasr-0.5.0.dist-info/entry_points.txt +3 -0
- oasr-0.5.0.dist-info/licenses/LICENSE +187 -0
- oasr-0.5.0.dist-info/licenses/NOTICE +8 -0
- policy/__init__.py +50 -0
- policy/defaults.py +27 -0
- policy/enforcement.py +98 -0
- policy/profile.py +185 -0
- registry.py +173 -0
- remote.py +482 -0
- skillcopy/__init__.py +71 -0
- skillcopy/local.py +40 -0
- skillcopy/remote.py +98 -0
- tracking.py +181 -0
- 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
|