oasr 0.3.4__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.
commands/registry.py ADDED
@@ -0,0 +1,303 @@
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
+
68
+ def run_validate(args: argparse.Namespace) -> int:
69
+ """Validate registry manifests (default oasr registry behavior)."""
70
+ entries = load_registry()
71
+
72
+ if not entries:
73
+ if args.json:
74
+ print(json.dumps({"valid": 0, "modified": 0, "missing": 0}))
75
+ else:
76
+ print("No skills registered.")
77
+ return 0
78
+
79
+ # Check for remote skills
80
+ remote_count = sum(
81
+ 1 for entry in entries if (manifest := load_manifest(entry.name)) and is_remote_source(manifest.source_path)
82
+ )
83
+
84
+ if remote_count > 0 and not args.quiet and not args.json:
85
+ print(f"Checking {remote_count} remote skill(s)...", file=sys.stderr)
86
+
87
+ valid_count = 0
88
+ modified_count = 0
89
+ missing_count = 0
90
+ results = []
91
+
92
+ for entry in entries:
93
+ manifest = load_manifest(entry.name)
94
+
95
+ if manifest is None:
96
+ # Create missing manifest
97
+ manifest = create_manifest(
98
+ name=entry.name,
99
+ source_path=Path(entry.path),
100
+ description=entry.description,
101
+ )
102
+ save_manifest(manifest)
103
+ valid_count += 1
104
+ status_info = {"name": entry.name, "status": "valid", "message": "Manifest created"}
105
+ else:
106
+ # Show progress for remote skills
107
+ is_remote = is_remote_source(manifest.source_path)
108
+ if is_remote and not args.quiet and not args.json:
109
+ platform = (
110
+ "GitHub"
111
+ if "github.com" in manifest.source_path
112
+ else "GitLab"
113
+ if "gitlab.com" in manifest.source_path
114
+ else "remote"
115
+ )
116
+ print(f" ↓ {entry.name} (checking {platform}...)", file=sys.stderr, flush=True)
117
+
118
+ status = check_manifest(manifest)
119
+
120
+ if is_remote and not args.quiet and not args.json:
121
+ print(f" ✓ {entry.name} (checked)", file=sys.stderr)
122
+
123
+ if status.status == "valid":
124
+ valid_count += 1
125
+ elif status.status == "modified":
126
+ modified_count += 1
127
+ elif status.status == "missing":
128
+ missing_count += 1
129
+
130
+ status_info = status.to_dict()
131
+
132
+ results.append(status_info)
133
+
134
+ if args.json:
135
+ print(
136
+ json.dumps(
137
+ {
138
+ "valid": valid_count,
139
+ "modified": modified_count,
140
+ "missing": missing_count,
141
+ "results": results if args.verbose else None,
142
+ },
143
+ indent=2,
144
+ )
145
+ )
146
+ elif args.verbose:
147
+ # Detailed output (like old oasr status)
148
+ for result in results:
149
+ status_symbol = "✓" if result["status"] == "valid" else "⚠" if result["status"] == "modified" else "✗"
150
+ print(f"{status_symbol} {result['name']}: {result['status']}")
151
+ if result.get("message"):
152
+ print(f" {result['message']}")
153
+ else:
154
+ # Summary output (like old oasr sync)
155
+ for result in results:
156
+ if result["status"] == "valid":
157
+ print(f"✓ {result['name']}: up to date")
158
+ elif result["status"] == "modified":
159
+ print(f"⚠ {result['name']}: modified")
160
+ elif result["status"] == "missing":
161
+ print(f"✗ {result['name']}: missing")
162
+
163
+ print(f"\n{valid_count} valid, {modified_count} modified, {missing_count} missing")
164
+
165
+ return 1 if missing_count > 0 else 0
166
+
167
+
168
+ def run_list(args: argparse.Namespace) -> int:
169
+ """List all registered skills (oasr registry list)."""
170
+ from commands.list import run as list_run
171
+
172
+ return list_run(args)
173
+
174
+
175
+ def run_add(args: argparse.Namespace) -> int:
176
+ """Add skills to registry (oasr registry add)."""
177
+ from commands.add import run as add_run
178
+
179
+ return add_run(args)
180
+
181
+
182
+ def run_rm(args: argparse.Namespace) -> int:
183
+ """Remove skills from registry (oasr registry rm)."""
184
+ from commands.rm import run as rm_run
185
+
186
+ return rm_run(args)
187
+
188
+
189
+ def run_sync(args: argparse.Namespace) -> int:
190
+ """Sync registry with remotes (oasr registry sync)."""
191
+ entries = load_registry()
192
+
193
+ if not entries:
194
+ if args.json:
195
+ print(json.dumps({"synced": 0, "error": "no skills registered"}))
196
+ else:
197
+ print("No skills registered.")
198
+ return 0
199
+
200
+ # Filter by names if provided
201
+ if args.names:
202
+ entry_map = {e.name: e for e in entries}
203
+ entries = [entry_map[n] for n in args.names if n in entry_map]
204
+ missing = [n for n in args.names if n not in entry_map]
205
+ if missing and not args.quiet:
206
+ for n in missing:
207
+ print(f"⚠ Skill not found: {n}", file=sys.stderr)
208
+
209
+ # Check for remote skills
210
+ remote_count = sum(
211
+ 1 for entry in entries if (manifest := load_manifest(entry.name)) and is_remote_source(manifest.source_path)
212
+ )
213
+
214
+ if remote_count > 0 and not args.quiet and not args.json:
215
+ print(f"Checking {remote_count} remote skill(s)...", file=sys.stderr)
216
+
217
+ synced = 0
218
+ missing_count = 0
219
+ modified_count = 0
220
+ pruned = []
221
+ results = []
222
+
223
+ for entry in entries:
224
+ manifest = load_manifest(entry.name)
225
+
226
+ if manifest is None:
227
+ manifest = create_manifest(
228
+ name=entry.name,
229
+ source_path=Path(entry.path),
230
+ description=entry.description,
231
+ )
232
+ save_manifest(manifest)
233
+ status_info = {"name": entry.name, "status": "created", "message": "Manifest created"}
234
+ else:
235
+ # Show progress for remote skills
236
+ is_remote = is_remote_source(manifest.source_path)
237
+ if is_remote and not args.quiet and not args.json:
238
+ platform = (
239
+ "GitHub"
240
+ if "github.com" in manifest.source_path
241
+ else "GitLab"
242
+ if "gitlab.com" in manifest.source_path
243
+ else "remote"
244
+ )
245
+ print(f" ↓ {entry.name} (checking {platform}...)", file=sys.stderr, flush=True)
246
+
247
+ status = check_manifest(manifest)
248
+
249
+ if is_remote and not args.quiet and not args.json:
250
+ print(f" ✓ {entry.name} (checked)", file=sys.stderr)
251
+
252
+ if status.status == "missing":
253
+ missing_count += 1
254
+ status_info = status.to_dict()
255
+
256
+ if args.prune:
257
+ remove_skill(entry.name)
258
+ pruned.append(entry.name)
259
+ status_info["pruned"] = True
260
+ elif status.status == "modified":
261
+ modified_count += 1
262
+ # Update manifest
263
+ new_manifest = sync_manifest(manifest)
264
+ save_manifest(new_manifest)
265
+ synced += 1
266
+ status_info = {"name": entry.name, "status": "synced", "message": "Manifest updated"}
267
+ else:
268
+ status_info = status.to_dict()
269
+
270
+ results.append(status_info)
271
+
272
+ if args.json:
273
+ print(
274
+ json.dumps(
275
+ {
276
+ "synced": synced,
277
+ "missing": missing_count,
278
+ "modified": modified_count,
279
+ "pruned": len(pruned),
280
+ "results": results,
281
+ },
282
+ indent=2,
283
+ )
284
+ )
285
+ else:
286
+ for result in results:
287
+ if result["status"] == "synced":
288
+ print(f"✓ {result['name']}: synced")
289
+ elif result["status"] == "modified":
290
+ print(f"⚠ {result['name']}: modified (use --update)")
291
+ elif result["status"] == "missing":
292
+ msg = f"✗ {result['name']}: missing"
293
+ if result.get("pruned"):
294
+ msg += " (removed)"
295
+ print(msg)
296
+ else:
297
+ print(f"✓ {result['name']}: up to date")
298
+
299
+ print(f"\n{synced} synced, {modified_count} modified, {missing_count} missing")
300
+ if pruned:
301
+ print(f"{len(pruned)} skill(s) pruned")
302
+
303
+ return 1 if missing_count > 0 else 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
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