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/registry.py ADDED
@@ -0,0 +1,447 @@
1
+ """`oasr registry` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from manifest import (
11
+ check_manifest,
12
+ create_manifest,
13
+ load_manifest,
14
+ save_manifest,
15
+ sync_manifest,
16
+ )
17
+ from registry import load_registry, remove_skill
18
+ from skillcopy.remote import is_remote_source
19
+
20
+
21
+ def register(subparsers) -> None:
22
+ """Register the registry command and subcommands."""
23
+ p = subparsers.add_parser("registry", help="Manage skill registry (validate, add, remove, sync)")
24
+
25
+ # Subcommands
26
+ registry_subparsers = p.add_subparsers(dest="registry_command", help="Registry operation")
27
+
28
+ # registry (default - validate)
29
+ p.add_argument("-v", "--verbose", action="store_true", help="Show detailed per-skill status")
30
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
31
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
32
+ p.add_argument("--config", type=Path, help="Override config file path")
33
+ p.set_defaults(func=run_validate)
34
+
35
+ # registry list
36
+ list_p = registry_subparsers.add_parser("list", help="List all registered skills")
37
+ list_p.add_argument("--json", action="store_true", help="Output in JSON format")
38
+ list_p.set_defaults(func=run_list)
39
+
40
+ # registry add
41
+ add_p = registry_subparsers.add_parser("add", help="Add skill(s) to registry")
42
+ add_p.add_argument("paths", nargs="+", help="Path(s) or URL(s) to skill directories")
43
+ add_p.add_argument("-r", "--recursive", action="store_true", help="Recursively discover skills")
44
+ add_p.add_argument("--strict", action="store_true", help="Fail on validation warnings")
45
+ add_p.add_argument("--json", action="store_true", help="Output in JSON format")
46
+ add_p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
47
+ add_p.add_argument("--config", type=Path, help="Override config file path")
48
+ add_p.set_defaults(func=run_add)
49
+
50
+ # registry rm
51
+ rm_p = registry_subparsers.add_parser("rm", help="Remove skill(s) from registry")
52
+ rm_p.add_argument("targets", nargs="+", help="Skill name(s), path(s), or glob pattern(s) to remove")
53
+ rm_p.add_argument("-r", "--recursive", action="store_true", help="Recursively remove skills")
54
+ rm_p.add_argument("--json", action="store_true", help="Output in JSON format")
55
+ rm_p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
56
+ rm_p.set_defaults(func=run_rm)
57
+
58
+ # registry sync
59
+ sync_p = registry_subparsers.add_parser("sync", help="Sync registry with remote sources")
60
+ sync_p.add_argument("names", nargs="*", help="Skill name(s) to sync (default: all)")
61
+ sync_p.add_argument("--prune", action="store_true", help="Remove skills with missing sources")
62
+ sync_p.add_argument("--json", action="store_true", help="Output in JSON format")
63
+ sync_p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
64
+ sync_p.add_argument("--config", type=Path, help="Override config file path")
65
+ sync_p.set_defaults(func=run_sync)
66
+
67
+ # registry prune
68
+ prune_p = registry_subparsers.add_parser("prune", help="Clean up corrupted/missing skills and orphaned artifacts")
69
+ prune_p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
70
+ prune_p.add_argument("--json", action="store_true", help="Output in JSON format")
71
+ prune_p.add_argument("--dry-run", action="store_true", help="Show what would be cleaned without doing it")
72
+ prune_p.set_defaults(func=run_prune)
73
+
74
+
75
+ def run_validate(args: argparse.Namespace) -> int:
76
+ """Validate registry manifests (default oasr registry behavior)."""
77
+ entries = load_registry()
78
+
79
+ if not entries:
80
+ if args.json:
81
+ print(json.dumps({"valid": 0, "modified": 0, "missing": 0}))
82
+ else:
83
+ print("No skills registered.")
84
+ return 0
85
+
86
+ # Check for remote skills
87
+ remote_count = sum(
88
+ 1 for entry in entries if (manifest := load_manifest(entry.name)) and is_remote_source(manifest.source_path)
89
+ )
90
+
91
+ if remote_count > 0 and not args.quiet and not args.json:
92
+ print(f"Checking {remote_count} remote skill(s)...", file=sys.stderr)
93
+
94
+ valid_count = 0
95
+ modified_count = 0
96
+ missing_count = 0
97
+ results = []
98
+
99
+ for entry in entries:
100
+ manifest = load_manifest(entry.name)
101
+
102
+ if manifest is None:
103
+ # Create missing manifest
104
+ manifest = create_manifest(
105
+ name=entry.name,
106
+ source_path=Path(entry.path),
107
+ description=entry.description,
108
+ )
109
+ save_manifest(manifest)
110
+ valid_count += 1
111
+ status_info = {"name": entry.name, "status": "valid", "message": "Manifest created"}
112
+ else:
113
+ # Show progress for remote skills
114
+ is_remote = is_remote_source(manifest.source_path)
115
+ if is_remote and not args.quiet and not args.json:
116
+ platform = (
117
+ "GitHub"
118
+ if "github.com" in manifest.source_path
119
+ else "GitLab"
120
+ if "gitlab.com" in manifest.source_path
121
+ else "remote"
122
+ )
123
+ print(f" ↓ {entry.name} (checking {platform}...)", file=sys.stderr, flush=True)
124
+
125
+ status = check_manifest(manifest)
126
+
127
+ if is_remote and not args.quiet and not args.json:
128
+ print(f" ✓ {entry.name} (checked)", file=sys.stderr)
129
+
130
+ if status.status == "valid":
131
+ valid_count += 1
132
+ elif status.status == "modified":
133
+ modified_count += 1
134
+ elif status.status == "missing":
135
+ missing_count += 1
136
+
137
+ status_info = status.to_dict()
138
+
139
+ results.append(status_info)
140
+
141
+ if args.json:
142
+ print(
143
+ json.dumps(
144
+ {
145
+ "valid": valid_count,
146
+ "modified": modified_count,
147
+ "missing": missing_count,
148
+ "results": results if args.verbose else None,
149
+ },
150
+ indent=2,
151
+ )
152
+ )
153
+ elif args.verbose:
154
+ # Detailed output (like old oasr status)
155
+ for result in results:
156
+ status_symbol = "✓" if result["status"] == "valid" else "⚠" if result["status"] == "modified" else "✗"
157
+ print(f"{status_symbol} {result['name']}: {result['status']}")
158
+ if result.get("message"):
159
+ print(f" {result['message']}")
160
+ else:
161
+ # Summary output (like old oasr sync)
162
+ for result in results:
163
+ if result["status"] == "valid":
164
+ print(f"✓ {result['name']}: up to date")
165
+ elif result["status"] == "modified":
166
+ print(f"⚠ {result['name']}: modified")
167
+ elif result["status"] == "missing":
168
+ print(f"✗ {result['name']}: missing")
169
+
170
+ print(f"\n{valid_count} valid, {modified_count} modified, {missing_count} missing")
171
+
172
+ return 1 if missing_count > 0 else 0
173
+
174
+
175
+ def run_list(args: argparse.Namespace) -> int:
176
+ """List all registered skills (oasr registry list)."""
177
+ from commands.list import run as list_run
178
+
179
+ return list_run(args)
180
+
181
+
182
+ def run_add(args: argparse.Namespace) -> int:
183
+ """Add skills to registry (oasr registry add)."""
184
+ from commands.add import run as add_run
185
+
186
+ return add_run(args)
187
+
188
+
189
+ def run_rm(args: argparse.Namespace) -> int:
190
+ """Remove skills from registry (oasr registry rm)."""
191
+ from commands.rm import run as rm_run
192
+
193
+ return rm_run(args)
194
+
195
+
196
+ def run_sync(args: argparse.Namespace) -> int:
197
+ """Sync registry with remotes (oasr registry sync)."""
198
+ entries = load_registry()
199
+
200
+ if not entries:
201
+ if args.json:
202
+ print(json.dumps({"synced": 0, "error": "no skills registered"}))
203
+ else:
204
+ print("No skills registered.")
205
+ return 0
206
+
207
+ # Filter by names if provided
208
+ if args.names:
209
+ entry_map = {e.name: e for e in entries}
210
+ entries = [entry_map[n] for n in args.names if n in entry_map]
211
+ missing = [n for n in args.names if n not in entry_map]
212
+ if missing and not args.quiet:
213
+ for n in missing:
214
+ print(f"⚠ Skill not found: {n}", file=sys.stderr)
215
+
216
+ # Check for remote skills
217
+ remote_count = sum(
218
+ 1 for entry in entries if (manifest := load_manifest(entry.name)) and is_remote_source(manifest.source_path)
219
+ )
220
+
221
+ if remote_count > 0 and not args.quiet and not args.json:
222
+ print(f"Checking {remote_count} remote skill(s)...", file=sys.stderr)
223
+
224
+ synced = 0
225
+ missing_count = 0
226
+ modified_count = 0
227
+ pruned = []
228
+ results = []
229
+
230
+ for entry in entries:
231
+ manifest = load_manifest(entry.name)
232
+
233
+ if manifest is None:
234
+ manifest = create_manifest(
235
+ name=entry.name,
236
+ source_path=Path(entry.path),
237
+ description=entry.description,
238
+ )
239
+ save_manifest(manifest)
240
+ status_info = {"name": entry.name, "status": "created", "message": "Manifest created"}
241
+ else:
242
+ # Show progress for remote skills
243
+ is_remote = is_remote_source(manifest.source_path)
244
+ if is_remote and not args.quiet and not args.json:
245
+ platform = (
246
+ "GitHub"
247
+ if "github.com" in manifest.source_path
248
+ else "GitLab"
249
+ if "gitlab.com" in manifest.source_path
250
+ else "remote"
251
+ )
252
+ print(f" ↓ {entry.name} (checking {platform}...)", file=sys.stderr, flush=True)
253
+
254
+ status = check_manifest(manifest)
255
+
256
+ if is_remote and not args.quiet and not args.json:
257
+ print(f" ✓ {entry.name} (checked)", file=sys.stderr)
258
+
259
+ if status.status == "missing":
260
+ missing_count += 1
261
+ status_info = status.to_dict()
262
+
263
+ if args.prune:
264
+ remove_skill(entry.name)
265
+ pruned.append(entry.name)
266
+ status_info["pruned"] = True
267
+ elif status.status == "modified":
268
+ modified_count += 1
269
+ # Update manifest
270
+ new_manifest = sync_manifest(manifest)
271
+ save_manifest(new_manifest)
272
+ synced += 1
273
+ status_info = {"name": entry.name, "status": "synced", "message": "Manifest updated"}
274
+ else:
275
+ status_info = status.to_dict()
276
+
277
+ results.append(status_info)
278
+
279
+ if args.json:
280
+ print(
281
+ json.dumps(
282
+ {
283
+ "synced": synced,
284
+ "missing": missing_count,
285
+ "modified": modified_count,
286
+ "pruned": len(pruned),
287
+ "results": results,
288
+ },
289
+ indent=2,
290
+ )
291
+ )
292
+ else:
293
+ for result in results:
294
+ if result["status"] == "synced":
295
+ print(f"✓ {result['name']}: synced")
296
+ elif result["status"] == "modified":
297
+ print(f"⚠ {result['name']}: modified (use --update)")
298
+ elif result["status"] == "missing":
299
+ msg = f"✗ {result['name']}: missing"
300
+ if result.get("pruned"):
301
+ msg += " (removed)"
302
+ print(msg)
303
+ else:
304
+ print(f"✓ {result['name']}: up to date")
305
+
306
+ print(f"\n{synced} synced, {modified_count} modified, {missing_count} missing")
307
+ if pruned:
308
+ print(f"{len(pruned)} skill(s) pruned")
309
+
310
+ return 1 if missing_count > 0 else 0
311
+
312
+
313
+ def run_prune(args: argparse.Namespace) -> int:
314
+ """Clean up corrupted/missing skills and orphaned artifacts (oasr registry prune)."""
315
+ from manifest import delete_manifest, list_manifests
316
+
317
+ entries = load_registry()
318
+ registered_names = {e.name for e in entries}
319
+ manifest_names = set(list_manifests())
320
+
321
+ to_remove_skills = []
322
+ to_remove_manifests = []
323
+
324
+ # Check for remote skills and show progress header
325
+ remote_count = 0
326
+ for entry in entries:
327
+ manifest = load_manifest(entry.name)
328
+ if manifest and is_remote_source(manifest.source_path):
329
+ remote_count += 1
330
+
331
+ if remote_count > 0 and not args.json:
332
+ print(f"Checking {remote_count} remote skill(s)...", file=sys.stderr)
333
+
334
+ for entry in entries:
335
+ manifest = load_manifest(entry.name)
336
+ if manifest:
337
+ # Show progress for remote skills
338
+ is_remote = is_remote_source(manifest.source_path)
339
+ if is_remote and not args.json:
340
+ platform = (
341
+ "GitHub"
342
+ if "github.com" in manifest.source_path
343
+ else "GitLab"
344
+ if "gitlab.com" in manifest.source_path
345
+ else "remote"
346
+ )
347
+ print(f" ↓ {entry.name} (checking {platform}...)", file=sys.stderr, flush=True)
348
+
349
+ status = check_manifest(manifest)
350
+
351
+ if is_remote and not args.json:
352
+ print(f" ✓ {entry.name} (checked)", file=sys.stderr)
353
+
354
+ if status.status == "missing":
355
+ to_remove_skills.append(
356
+ {
357
+ "name": entry.name,
358
+ "reason": "source missing",
359
+ "path": entry.path,
360
+ }
361
+ )
362
+
363
+ orphaned = manifest_names - registered_names
364
+ for name in orphaned:
365
+ to_remove_manifests.append(
366
+ {
367
+ "name": name,
368
+ "reason": "orphaned manifest (not in registry)",
369
+ }
370
+ )
371
+
372
+ if not to_remove_skills and not to_remove_manifests:
373
+ if args.json:
374
+ print(json.dumps({"cleaned": 0, "message": "nothing to clean"}))
375
+ else:
376
+ print("Nothing to clean.")
377
+ return 0
378
+
379
+ if args.json:
380
+ result = {
381
+ "skills_to_remove": to_remove_skills,
382
+ "manifests_to_remove": to_remove_manifests,
383
+ "dry_run": args.dry_run,
384
+ }
385
+ if not args.dry_run and not args.yes:
386
+ result["requires_confirmation"] = True
387
+ print(json.dumps(result, indent=2))
388
+ if args.dry_run:
389
+ return 0
390
+ else:
391
+ print("The following will be cleaned:\n")
392
+
393
+ if to_remove_skills:
394
+ print("Skills with missing sources:")
395
+ for s in to_remove_skills:
396
+ print(f" ✗ {s['name']} ({s['path']})")
397
+
398
+ if to_remove_manifests:
399
+ print("\nOrphaned manifests:")
400
+ for m in to_remove_manifests:
401
+ print(f" ✗ {m['name']}")
402
+
403
+ print()
404
+
405
+ if args.dry_run:
406
+ print("(dry run - no changes made)")
407
+ return 0
408
+
409
+ if not args.yes and not args.json:
410
+ try:
411
+ response = input("Proceed with cleanup? [y/N] ").strip().lower()
412
+ if response not in ("y", "yes"):
413
+ print("Aborted.")
414
+ return 1
415
+ except (EOFError, KeyboardInterrupt):
416
+ print("\nAborted.")
417
+ return 1
418
+
419
+ removed_skills = []
420
+ removed_manifests = []
421
+
422
+ for s in to_remove_skills:
423
+ remove_skill(s["name"])
424
+ removed_skills.append(s["name"])
425
+
426
+ for m in to_remove_manifests:
427
+ delete_manifest(m["name"])
428
+ removed_manifests.append(m["name"])
429
+
430
+ if args.json:
431
+ print(
432
+ json.dumps(
433
+ {
434
+ "removed_skills": removed_skills,
435
+ "removed_manifests": removed_manifests,
436
+ },
437
+ indent=2,
438
+ )
439
+ )
440
+ else:
441
+ for name in removed_skills:
442
+ print(f"Removed skill: {name}")
443
+ for name in removed_manifests:
444
+ print(f"Removed manifest: {name}")
445
+ print(f"\nCleaned {len(removed_skills)} skill(s), {len(removed_manifests)} manifest(s)")
446
+
447
+ return 0
commands/rm.py ADDED
@@ -0,0 +1,128 @@
1
+ """`asr rm` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import glob as globlib
7
+ import json
8
+ import sys
9
+ from fnmatch import fnmatchcase
10
+ from pathlib import Path
11
+
12
+ from registry import load_registry, remove_skill
13
+
14
+ _GLOB_CHARS = set("*?[")
15
+
16
+
17
+ def _looks_like_glob(value: str) -> bool:
18
+ return any(ch in value for ch in _GLOB_CHARS)
19
+
20
+
21
+ def _expand_path_patterns(patterns: list[str]) -> list[Path]:
22
+ expanded: list[Path] = []
23
+ for raw in patterns:
24
+ pat = str(Path(raw).expanduser())
25
+ if _looks_like_glob(pat):
26
+ matches = globlib.glob(pat, recursive=True)
27
+ expanded.extend(Path(m) for m in matches)
28
+ else:
29
+ expanded.append(Path(pat))
30
+ return expanded
31
+
32
+
33
+ def _match_registry_targets(target: str, entries) -> list:
34
+ """Match a rm target against registry entries.
35
+
36
+ Rules:
37
+ - If target looks like a glob, match by name first (fnmatch), then by path.
38
+ - Otherwise match by exact name or exact path.
39
+ """
40
+ target_expanded = str(Path(target).expanduser())
41
+ if _looks_like_glob(target_expanded):
42
+ by_name = [e for e in entries if fnmatchcase(e.name, target_expanded)]
43
+ if by_name:
44
+ return by_name
45
+ return [e for e in entries if fnmatchcase(e.path, target_expanded)]
46
+
47
+ return [e for e in entries if e.name == target or e.path == target_expanded]
48
+
49
+
50
+ def register(subparsers) -> None:
51
+ p = subparsers.add_parser("rm", help="Unregister a skill")
52
+ p.add_argument("targets", nargs="+", help="Skill name(s), path(s), or glob pattern(s) to remove")
53
+ p.add_argument("-r", "--recursive", action="store_true", help="Recursively remove all skills under path")
54
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
55
+ p.set_defaults(func=run)
56
+
57
+
58
+ def run(args: argparse.Namespace) -> int:
59
+ if args.recursive:
60
+ return _run_recursive(args)
61
+
62
+ entries = load_registry()
63
+ removed_names: list[str] = []
64
+ missing: list[str] = []
65
+
66
+ for target in args.targets:
67
+ matches = _match_registry_targets(target, entries)
68
+ if not matches:
69
+ missing.append(target)
70
+ continue
71
+
72
+ for entry in matches:
73
+ if remove_skill(entry.name):
74
+ removed_names.append(entry.name)
75
+
76
+ # Deduplicate while preserving order.
77
+ seen = set()
78
+ removed_names = [n for n in removed_names if not (n in seen or seen.add(n))]
79
+
80
+ if args.json:
81
+ print(json.dumps({"removed": len(removed_names), "skills": removed_names, "missing": missing}, indent=2))
82
+ else:
83
+ for name in removed_names:
84
+ print(f"Removed: {name}")
85
+ for m in missing:
86
+ print(f"Not found: {m}", file=sys.stderr)
87
+
88
+ if removed_names and not missing:
89
+ print(f"\n{len(removed_names)} skill(s) removed")
90
+ elif removed_names and missing:
91
+ print(f"\n{len(removed_names)} skill(s) removed, {len(missing)} not found")
92
+
93
+ return 0 if removed_names and not missing else 1 if missing else 0
94
+
95
+
96
+ def _run_recursive(args: argparse.Namespace) -> int:
97
+ roots = [p.resolve() for p in _expand_path_patterns(args.targets)]
98
+ for root in roots:
99
+ if not root.is_dir():
100
+ print(f"Error: Not a directory: {root}", file=sys.stderr)
101
+ return 2
102
+
103
+ entries = load_registry()
104
+ removed_names: list[str] = []
105
+
106
+ for root in roots:
107
+ root_str = str(root)
108
+ to_remove = [e for e in entries if e.path.startswith(root_str)]
109
+ for entry in to_remove:
110
+ if remove_skill(entry.name):
111
+ removed_names.append(entry.name)
112
+
113
+ # Deduplicate while preserving order.
114
+ seen = set()
115
+ removed_names = [n for n in removed_names if not (n in seen or seen.add(n))]
116
+
117
+ if args.json:
118
+ print(json.dumps({"removed": len(removed_names), "skills": removed_names}, indent=2))
119
+ else:
120
+ if not removed_names:
121
+ for root in roots:
122
+ print(f"No registered skills found under {root}")
123
+ return 0
124
+ for name in removed_names:
125
+ print(f"Removed: {name}")
126
+ print(f"\n{len(removed_names)} skill(s) removed")
127
+
128
+ return 0
commands/status.py ADDED
@@ -0,0 +1,119 @@
1
+ """`asr status` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+
8
+ from manifest import check_manifest, load_manifest
9
+ from registry import load_registry
10
+
11
+
12
+ def register(subparsers) -> None:
13
+ p = subparsers.add_parser("status", help="Show skill manifest status")
14
+ p.add_argument("names", nargs="*", help="Skill name(s) to check (default: all)")
15
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
16
+ p.set_defaults(func=run)
17
+
18
+
19
+ def run(args: argparse.Namespace) -> int:
20
+ entries = load_registry()
21
+
22
+ if not entries:
23
+ if args.json:
24
+ print("[]")
25
+ else:
26
+ print("No skills registered.")
27
+ return 0
28
+
29
+ if args.names:
30
+ entry_map = {e.name: e for e in entries}
31
+ entries = [entry_map[n] for n in args.names if n in entry_map]
32
+
33
+ # Check for remote skills and show progress header
34
+ from skillcopy.remote import is_remote_source
35
+
36
+ remote_count = 0
37
+ for entry in entries:
38
+ manifest = load_manifest(entry.name)
39
+ if manifest and is_remote_source(manifest.source_path):
40
+ remote_count += 1
41
+
42
+ if remote_count > 0 and not args.json:
43
+ import sys
44
+
45
+ print(f"Checking {remote_count} remote skill(s)...", file=sys.stderr)
46
+
47
+ results = []
48
+
49
+ for entry in entries:
50
+ manifest = load_manifest(entry.name)
51
+
52
+ if manifest is None:
53
+ status_info = {
54
+ "name": entry.name,
55
+ "status": "untracked",
56
+ "source_path": entry.path,
57
+ "message": "No manifest (run 'asr sync' to create)",
58
+ }
59
+ else:
60
+ # Show progress for remote skills
61
+ is_remote = is_remote_source(manifest.source_path)
62
+ if is_remote and not args.json:
63
+ import sys
64
+
65
+ platform = (
66
+ "GitHub"
67
+ if "github.com" in manifest.source_path
68
+ else "GitLab"
69
+ if "gitlab.com" in manifest.source_path
70
+ else "remote"
71
+ )
72
+ print(f" ↓ {entry.name} (checking {platform}...)", file=sys.stderr, flush=True)
73
+
74
+ status = check_manifest(manifest)
75
+ status_info = status.to_dict()
76
+
77
+ if is_remote and not args.json:
78
+ import sys
79
+
80
+ print(f" ✓ {entry.name} (checked)", file=sys.stderr)
81
+
82
+ results.append(status_info)
83
+
84
+ if args.json:
85
+ print(json.dumps(results, indent=2))
86
+ else:
87
+ for r in results:
88
+ status = r.get("status", "unknown")
89
+ name = r.get("name", "?")
90
+
91
+ if status == "valid":
92
+ print(f"✓ {name}")
93
+ elif status == "untracked":
94
+ print(f"? {name} (untracked)")
95
+ elif status == "missing":
96
+ print(f"✗ {name} (source missing)")
97
+ elif status == "modified":
98
+ print(f"⚠ {name} (modified)")
99
+ if r.get("changed_files"):
100
+ for f in r["changed_files"][:5]:
101
+ print(f" ~ {f}")
102
+ if len(r["changed_files"]) > 5:
103
+ print(f" ... and {len(r['changed_files']) - 5} more")
104
+ if r.get("added_files"):
105
+ for f in r["added_files"][:3]:
106
+ print(f" + {f}")
107
+ if r.get("removed_files"):
108
+ for f in r["removed_files"][:3]:
109
+ print(f" - {f}")
110
+
111
+ valid = sum(1 for r in results if r.get("status") == "valid")
112
+ modified = sum(1 for r in results if r.get("status") == "modified")
113
+ missing = sum(1 for r in results if r.get("status") == "missing")
114
+ untracked = sum(1 for r in results if r.get("status") == "untracked")
115
+
116
+ if not args.json:
117
+ print(f"\n{valid} valid, {modified} modified, {missing} missing, {untracked} untracked")
118
+
119
+ return 0