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/sync.py ADDED
@@ -0,0 +1,143 @@
1
+ """`oasr sync` command - refresh tracked local skills."""
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 load_manifest
11
+ from skillcopy import copy_skill
12
+ from tracking import extract_metadata
13
+
14
+
15
+ def register(subparsers) -> None:
16
+ """Register the sync command."""
17
+ p = subparsers.add_parser("sync", help="Refresh outdated tracked skills from registry")
18
+ p.add_argument(
19
+ "path",
20
+ nargs="?",
21
+ type=Path,
22
+ default=Path.cwd(),
23
+ help="Path to scan for tracked skills (default: current directory)",
24
+ )
25
+ p.add_argument("--force", action="store_true", help="Overwrite modified skills (default: skip)")
26
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
27
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
28
+ p.set_defaults(func=run)
29
+
30
+
31
+ def run(args: argparse.Namespace) -> int:
32
+ """Refresh outdated tracked skills."""
33
+ scan_path = args.path.resolve()
34
+
35
+ if not scan_path.exists():
36
+ print(f"Error: Path does not exist: {scan_path}", file=sys.stderr)
37
+ return 1
38
+
39
+ # Find all SKILL.md files recursively
40
+ if not args.quiet and not args.json:
41
+ print(f"Scanning {scan_path} for tracked skills...", file=sys.stderr)
42
+
43
+ tracked_skills = []
44
+ skill_md_files = list(scan_path.rglob("SKILL.md"))
45
+
46
+ for skill_md in skill_md_files:
47
+ skill_dir = skill_md.parent
48
+ metadata = extract_metadata(skill_dir)
49
+
50
+ if metadata:
51
+ tracked_skills.append((skill_dir, metadata))
52
+
53
+ if not tracked_skills:
54
+ if args.json:
55
+ print(json.dumps({"updated": 0, "skipped": 0, "failed": 0}))
56
+ else:
57
+ print("No tracked skills found.")
58
+ return 0
59
+
60
+ # Check status and update outdated skills
61
+ from registry import load_registry
62
+
63
+ entries = load_registry()
64
+ entry_map = {e.name: e for e in entries}
65
+
66
+ updated = 0
67
+ skipped = 0
68
+ failed = 0
69
+ results = []
70
+
71
+ if not args.quiet and not args.json:
72
+ print(f"Found {len(tracked_skills)} tracked skill(s)...", file=sys.stderr)
73
+
74
+ for skill_dir, metadata in tracked_skills:
75
+ skill_name = skill_dir.name
76
+ tracked_hash = metadata.get("hash")
77
+
78
+ # Check if in registry
79
+ if skill_name not in entry_map:
80
+ skipped += 1
81
+ results.append(
82
+ {"name": skill_name, "path": str(skill_dir), "status": "skipped", "message": "Not in registry"}
83
+ )
84
+ if not args.quiet and not args.json:
85
+ print(f" ? {skill_name}: skipped (not in registry)", file=sys.stderr)
86
+ continue
87
+
88
+ entry = entry_map[skill_name]
89
+ manifest = load_manifest(skill_name)
90
+
91
+ if not manifest:
92
+ skipped += 1
93
+ results.append({"name": skill_name, "path": str(skill_dir), "status": "skipped", "message": "No manifest"})
94
+ if not args.quiet and not args.json:
95
+ print(f" ? {skill_name}: skipped (no manifest)", file=sys.stderr)
96
+ continue
97
+
98
+ # Check if outdated (compare tracked hash with registry hash)
99
+ if manifest.content_hash == tracked_hash:
100
+ # Already up-to-date
101
+ results.append({"name": skill_name, "path": str(skill_dir), "status": "up-to-date", "message": "Current"})
102
+ if not args.quiet and not args.json:
103
+ print(f" ✓ {skill_name}: up to date", file=sys.stderr)
104
+ continue
105
+
106
+ # Update skill
107
+ try:
108
+ if not args.quiet and not args.json:
109
+ print(f" ↻ {skill_name}: updating...", file=sys.stderr, flush=True)
110
+
111
+ copy_skill(entry.path, skill_dir, validate=False, inject_tracking=True, source_hash=manifest.content_hash)
112
+
113
+ updated += 1
114
+ results.append(
115
+ {"name": skill_name, "path": str(skill_dir), "status": "updated", "message": "Refreshed from registry"}
116
+ )
117
+ if not args.quiet and not args.json:
118
+ print(f" ✓ {skill_name}: updated", file=sys.stderr)
119
+ except Exception as e:
120
+ failed += 1
121
+ results.append({"name": skill_name, "path": str(skill_dir), "status": "failed", "message": str(e)})
122
+ if not args.quiet and not args.json:
123
+ print(f" ✗ {skill_name}: failed ({e})", file=sys.stderr)
124
+
125
+ if args.json:
126
+ print(
127
+ json.dumps(
128
+ {
129
+ "updated": updated,
130
+ "skipped": skipped,
131
+ "failed": failed,
132
+ "skills": results,
133
+ },
134
+ indent=2,
135
+ )
136
+ )
137
+ else:
138
+ if updated > 0 or skipped > 0 or failed > 0:
139
+ print(f"\n{updated} updated, {skipped} skipped, {failed} failed")
140
+ else:
141
+ print("All tracked skills up to date.")
142
+
143
+ return 1 if failed > 0 else 0
commands/update.py ADDED
@@ -0,0 +1,417 @@
1
+ """`oasr update` command - Update ASR tool from GitHub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def find_asr_repo() -> Path | None:
13
+ """Find the ASR git repository path.
14
+
15
+ Returns:
16
+ Path to ASR repo, or None if not found.
17
+ """
18
+ # Try to find via current module location
19
+ try:
20
+ import cli
21
+
22
+ cli_file = Path(cli.__file__).resolve()
23
+
24
+ # Walk up to find .git directory
25
+ current = cli_file.parent
26
+ for _ in range(5): # Max 5 levels up
27
+ if (current / ".git").exists():
28
+ return current
29
+ if current.parent == current: # Reached root
30
+ break
31
+ current = current.parent
32
+ except Exception:
33
+ pass
34
+
35
+ return None
36
+
37
+
38
+ def get_git_remote_url(repo_path: Path) -> str | None:
39
+ """Get the git remote URL.
40
+
41
+ Args:
42
+ repo_path: Path to git repository.
43
+
44
+ Returns:
45
+ Remote URL or None.
46
+ """
47
+ try:
48
+ result = subprocess.run(
49
+ ["git", "remote", "get-url", "origin"],
50
+ cwd=repo_path,
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=5,
54
+ )
55
+ if result.returncode == 0:
56
+ return result.stdout.strip()
57
+ except Exception:
58
+ pass
59
+ return None
60
+
61
+
62
+ def get_current_commit(repo_path: Path) -> str | None:
63
+ """Get current git commit hash.
64
+
65
+ Args:
66
+ repo_path: Path to git repository.
67
+
68
+ Returns:
69
+ Commit hash or None.
70
+ """
71
+ try:
72
+ result = subprocess.run(
73
+ ["git", "rev-parse", "HEAD"],
74
+ cwd=repo_path,
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=5,
78
+ )
79
+ if result.returncode == 0:
80
+ return result.stdout.strip()
81
+ except Exception:
82
+ pass
83
+ return None
84
+
85
+
86
+ def check_working_tree_clean(repo_path: Path) -> bool:
87
+ """Check if git working tree is clean.
88
+
89
+ Args:
90
+ repo_path: Path to git repository.
91
+
92
+ Returns:
93
+ True if clean, False if dirty.
94
+ """
95
+ try:
96
+ result = subprocess.run(
97
+ ["git", "status", "--porcelain"],
98
+ cwd=repo_path,
99
+ capture_output=True,
100
+ text=True,
101
+ timeout=5,
102
+ )
103
+ return result.returncode == 0 and not result.stdout.strip()
104
+ except Exception:
105
+ return False
106
+
107
+
108
+ def pull_updates(repo_path: Path) -> tuple[bool, str]:
109
+ """Pull updates from git remote.
110
+
111
+ Args:
112
+ repo_path: Path to git repository.
113
+
114
+ Returns:
115
+ Tuple of (success, message).
116
+ """
117
+ try:
118
+ result = subprocess.run(
119
+ ["git", "pull", "--ff-only"],
120
+ cwd=repo_path,
121
+ capture_output=True,
122
+ text=True,
123
+ timeout=30,
124
+ )
125
+
126
+ if result.returncode == 0:
127
+ # Check if already up to date
128
+ if "Already up to date" in result.stdout or "Already up-to-date" in result.stdout:
129
+ return True, "already_up_to_date"
130
+ return True, "updated"
131
+ else:
132
+ return False, result.stderr.strip()
133
+ except subprocess.TimeoutExpired:
134
+ return False, "Update timed out"
135
+ except Exception as e:
136
+ return False, str(e)
137
+
138
+
139
+ def get_changelog(repo_path: Path, old_commit: str, new_commit: str, max_lines: int = 10) -> list[str]:
140
+ """Get changelog between two commits.
141
+
142
+ Args:
143
+ repo_path: Path to git repository.
144
+ old_commit: Old commit hash.
145
+ new_commit: New commit hash.
146
+ max_lines: Maximum number of commits to show.
147
+
148
+ Returns:
149
+ List of commit messages.
150
+ """
151
+ try:
152
+ result = subprocess.run(
153
+ ["git", "log", "--oneline", f"{old_commit}..{new_commit}", f"-{max_lines}"],
154
+ cwd=repo_path,
155
+ capture_output=True,
156
+ text=True,
157
+ timeout=5,
158
+ )
159
+
160
+ if result.returncode == 0 and result.stdout.strip():
161
+ return result.stdout.strip().split("\n")
162
+ except Exception:
163
+ pass
164
+ return []
165
+
166
+
167
+ def get_stats(repo_path: Path, old_commit: str, new_commit: str) -> dict:
168
+ """Get statistics about changes.
169
+
170
+ Args:
171
+ repo_path: Path to git repository.
172
+ old_commit: Old commit hash.
173
+ new_commit: New commit hash.
174
+
175
+ Returns:
176
+ Dictionary with stats (commits, files, insertions, deletions).
177
+ """
178
+ stats = {"commits": 0, "files": 0, "insertions": 0, "deletions": 0}
179
+
180
+ try:
181
+ # Count commits
182
+ result = subprocess.run(
183
+ ["git", "rev-list", "--count", f"{old_commit}..{new_commit}"],
184
+ cwd=repo_path,
185
+ capture_output=True,
186
+ text=True,
187
+ timeout=5,
188
+ )
189
+ if result.returncode == 0:
190
+ stats["commits"] = int(result.stdout.strip())
191
+
192
+ # Get file stats
193
+ result = subprocess.run(
194
+ ["git", "diff", "--shortstat", old_commit, new_commit],
195
+ cwd=repo_path,
196
+ capture_output=True,
197
+ text=True,
198
+ timeout=5,
199
+ )
200
+ if result.returncode == 0 and result.stdout.strip():
201
+ # Parse: "5 files changed, 123 insertions(+), 45 deletions(-)"
202
+ output = result.stdout.strip()
203
+ if "file" in output:
204
+ parts = output.split(",")
205
+ for part in parts:
206
+ if "file" in part:
207
+ stats["files"] = int(part.split()[0])
208
+ elif "insertion" in part:
209
+ stats["insertions"] = int(part.split()[0])
210
+ elif "deletion" in part:
211
+ stats["deletions"] = int(part.split()[0])
212
+ except Exception:
213
+ pass
214
+
215
+ return stats
216
+
217
+
218
+ def reinstall_asr(repo_path: Path) -> tuple[bool, str]:
219
+ """Reinstall ASR using uv or pip.
220
+
221
+ Args:
222
+ repo_path: Path to ASR repository.
223
+
224
+ Returns:
225
+ Tuple of (success, message).
226
+ """
227
+ # Try uv first
228
+ try:
229
+ result = subprocess.run(
230
+ ["uv", "pip", "install", "-e", "."],
231
+ cwd=repo_path,
232
+ capture_output=True,
233
+ text=True,
234
+ timeout=60,
235
+ )
236
+ if result.returncode == 0:
237
+ return True, "Reinstalled with uv"
238
+ except (subprocess.TimeoutExpired, FileNotFoundError):
239
+ pass
240
+
241
+ # Fall back to pip
242
+ try:
243
+ result = subprocess.run(
244
+ [sys.executable, "-m", "pip", "install", "-e", "."],
245
+ cwd=repo_path,
246
+ capture_output=True,
247
+ text=True,
248
+ timeout=60,
249
+ )
250
+ if result.returncode == 0:
251
+ return True, "Reinstalled with pip"
252
+ else:
253
+ return False, result.stderr.strip()
254
+ except Exception as e:
255
+ return False, str(e)
256
+
257
+
258
+ def register(subparsers) -> None:
259
+ """Register the update command."""
260
+ p = subparsers.add_parser(
261
+ "update",
262
+ help="Update ASR tool from GitHub",
263
+ )
264
+ p.add_argument(
265
+ "--no-reinstall",
266
+ action="store_true",
267
+ help="Skip reinstallation step",
268
+ )
269
+ p.add_argument(
270
+ "--changelog",
271
+ type=int,
272
+ default=10,
273
+ metavar="N",
274
+ help="Number of changelog entries to show (default: 10)",
275
+ )
276
+ p.add_argument(
277
+ "--json",
278
+ action="store_true",
279
+ help="Output in JSON format",
280
+ )
281
+ p.add_argument(
282
+ "--quiet",
283
+ action="store_true",
284
+ help="Suppress info messages",
285
+ )
286
+ p.set_defaults(func=run)
287
+
288
+
289
+ def run(args: argparse.Namespace) -> int:
290
+ """Run the update command."""
291
+ # Find ASR repository
292
+ repo_path = find_asr_repo()
293
+
294
+ if not repo_path:
295
+ if args.json:
296
+ print(json.dumps({"success": False, "error": "Could not find ASR git repository"}))
297
+ else:
298
+ print("✗ Could not find ASR git repository", file=sys.stderr)
299
+ print(" Make sure ASR is installed from git (git clone + pip install -e .)", file=sys.stderr)
300
+ return 1
301
+
302
+ if not args.quiet and not args.json:
303
+ print(f"Found ASR repository: {repo_path}")
304
+
305
+ # Check if it's a git repository
306
+ if not (repo_path / ".git").exists():
307
+ if args.json:
308
+ print(json.dumps({"success": False, "error": "Not a git repository"}))
309
+ else:
310
+ print(f"✗ {repo_path} is not a git repository", file=sys.stderr)
311
+ return 1
312
+
313
+ # Get remote URL
314
+ remote_url = get_git_remote_url(repo_path)
315
+ if remote_url and not args.quiet and not args.json:
316
+ print(f"Remote: {remote_url}")
317
+
318
+ # Check working tree
319
+ if not check_working_tree_clean(repo_path):
320
+ if args.json:
321
+ print(json.dumps({"success": False, "error": "Working tree has uncommitted changes"}))
322
+ else:
323
+ print("✗ Working tree has uncommitted changes", file=sys.stderr)
324
+ print(" Commit or stash your changes before updating", file=sys.stderr)
325
+ return 1
326
+
327
+ # Get current commit before update
328
+ old_commit = get_current_commit(repo_path)
329
+ if not old_commit:
330
+ if args.json:
331
+ print(json.dumps({"success": False, "error": "Could not get current commit"}))
332
+ else:
333
+ print("✗ Could not get current commit", file=sys.stderr)
334
+ return 1
335
+
336
+ # Pull updates
337
+ if not args.quiet and not args.json:
338
+ print("Pulling updates from GitHub...")
339
+
340
+ success, message = pull_updates(repo_path)
341
+
342
+ if not success:
343
+ if args.json:
344
+ print(json.dumps({"success": False, "error": f"Git pull failed: {message}"}))
345
+ else:
346
+ print(f"✗ Git pull failed: {message}", file=sys.stderr)
347
+ return 1
348
+
349
+ # Check if already up to date
350
+ if message == "already_up_to_date":
351
+ if args.json:
352
+ print(json.dumps({"success": True, "updated": False, "message": "Already up to date"}))
353
+ else:
354
+ print("✓ Already up to date")
355
+ return 0
356
+
357
+ # Get new commit
358
+ new_commit = get_current_commit(repo_path)
359
+ if not new_commit or new_commit == old_commit:
360
+ if args.json:
361
+ print(json.dumps({"success": True, "updated": False, "message": "No changes"}))
362
+ else:
363
+ print("✓ No changes")
364
+ return 0
365
+
366
+ # Get statistics
367
+ stats = get_stats(repo_path, old_commit, new_commit)
368
+
369
+ # Get changelog
370
+ changelog = get_changelog(repo_path, old_commit, new_commit, max_lines=args.changelog)
371
+
372
+ if args.json:
373
+ print(
374
+ json.dumps(
375
+ {
376
+ "success": True,
377
+ "updated": True,
378
+ "old_commit": old_commit[:7],
379
+ "new_commit": new_commit[:7],
380
+ "stats": stats,
381
+ "changelog": changelog,
382
+ },
383
+ indent=2,
384
+ )
385
+ )
386
+ else:
387
+ print(f"✓ Updated ASR from {old_commit[:7]} to {new_commit[:7]}")
388
+ print(f" {stats['commits']} commit(s), {stats['files']} file(s) changed", end="")
389
+ if stats["insertions"] > 0:
390
+ print(f", +{stats['insertions']}", end="")
391
+ if stats["deletions"] > 0:
392
+ print(f", -{stats['deletions']}", end="")
393
+ print()
394
+
395
+ if changelog:
396
+ print("\nRecent changes:")
397
+ for line in changelog:
398
+ print(f" {line}")
399
+
400
+ # Reinstall if requested
401
+ if not args.no_reinstall:
402
+ if not args.quiet and not args.json:
403
+ print("\nReinstalling ASR...")
404
+
405
+ success, message = reinstall_asr(repo_path)
406
+
407
+ if success:
408
+ if not args.quiet and not args.json:
409
+ print(f"✓ {message}")
410
+ else:
411
+ if args.json:
412
+ print(json.dumps({"warning": f"Reinstall failed: {message}"}), file=sys.stderr)
413
+ else:
414
+ print(f"⚠ Reinstall failed: {message}", file=sys.stderr)
415
+ print(" You may need to reinstall manually", file=sys.stderr)
416
+
417
+ return 0
commands/use.py ADDED
@@ -0,0 +1,45 @@
1
+ """`asr use` command - DEPRECATED, use `oasr clone` instead."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from commands import clone
10
+
11
+
12
+ def register(subparsers) -> None:
13
+ """Register the deprecated use command."""
14
+ p = subparsers.add_parser(
15
+ "use",
16
+ help="[DEPRECATED] Copy skill(s) - use 'clone' instead",
17
+ description="DEPRECATED: Use 'oasr clone' instead. This command will be removed in v0.5.0.",
18
+ )
19
+ p.add_argument("names", nargs="+", help="Skill name(s) or glob pattern(s) to copy")
20
+ p.add_argument(
21
+ "-d",
22
+ "--dir",
23
+ type=Path,
24
+ default=Path("."),
25
+ dest="output_dir",
26
+ help="Target directory (default: current)",
27
+ )
28
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
29
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
30
+ p.set_defaults(func=run)
31
+
32
+
33
+ def run(args: argparse.Namespace) -> int:
34
+ """Execute the deprecated use command (delegates to clone)."""
35
+ # Show deprecation warning unless --quiet or --json
36
+ if not args.quiet and not args.json:
37
+ print(
38
+ "⚠ Warning: 'oasr use' is deprecated. Use 'oasr clone' instead.",
39
+ file=sys.stderr,
40
+ )
41
+ print(" This command will be removed in v0.5.0.", file=sys.stderr)
42
+ print(file=sys.stderr)
43
+
44
+ # Delegate to clone command
45
+ return clone.run(args)
commands/validate.py ADDED
@@ -0,0 +1,74 @@
1
+ """`asr validate` 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 config import load_config
11
+ from registry import load_registry
12
+ from validate import validate_all, validate_skill
13
+
14
+
15
+ def _print_validation_result(result) -> None:
16
+ print(f"{result.name}")
17
+ if result.valid and not result.warnings:
18
+ print(" ✓ Valid")
19
+ else:
20
+ for msg in result.all_messages:
21
+ print(f" {msg}")
22
+
23
+
24
+ def register(subparsers) -> None:
25
+ p = subparsers.add_parser("validate", help="Validate skills")
26
+ p.add_argument("path", type=Path, nargs="?", help="Path to skill directory")
27
+ p.add_argument("--all", action="store_true", dest="validate_all", help="Validate all registered skills")
28
+ p.add_argument("--strict", action="store_true", help="Treat warnings as errors")
29
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
30
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
31
+ p.add_argument("--config", type=Path, help="Override config file path")
32
+ p.set_defaults(func=run)
33
+
34
+
35
+ def run(args: argparse.Namespace) -> int:
36
+ config = load_config(args.config)
37
+ max_lines = config["validation"]["reference_max_lines"]
38
+
39
+ if args.validate_all:
40
+ entries = load_registry()
41
+ if not entries:
42
+ if args.json:
43
+ print("[]")
44
+ else:
45
+ print("No skills registered.")
46
+ return 0
47
+
48
+ results = validate_all(entries, reference_max_lines=max_lines)
49
+ elif args.path:
50
+ result = validate_skill(args.path.resolve(), reference_max_lines=max_lines)
51
+ results = [result]
52
+ else:
53
+ print("Error: Specify a path or use --all", file=sys.stderr)
54
+ return 2
55
+
56
+ if args.json:
57
+ print(json.dumps([r.to_dict() for r in results], indent=2))
58
+ else:
59
+ for result in results:
60
+ _print_validation_result(result)
61
+ print()
62
+
63
+ total_errors = sum(len(r.errors) for r in results)
64
+ total_warnings = sum(len(r.warnings) for r in results)
65
+
66
+ if not args.json and not args.quiet:
67
+ print(f"{len(results)} skill(s) validated: {total_errors} error(s), {total_warnings} warning(s)")
68
+
69
+ if total_errors > 0:
70
+ return 1
71
+ if args.strict and total_warnings > 0:
72
+ return 1
73
+
74
+ return 0