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.
- __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
- cli.py +94 -0
- commands/__init__.py +6 -0
- commands/adapter.py +102 -0
- commands/add.py +302 -0
- commands/clean.py +155 -0
- commands/diff.py +180 -0
- commands/find.py +56 -0
- commands/help.py +51 -0
- commands/info.py +152 -0
- commands/list.py +110 -0
- commands/registry.py +303 -0
- commands/rm.py +128 -0
- commands/status.py +119 -0
- commands/sync.py +143 -0
- commands/update.py +417 -0
- commands/use.py +172 -0
- commands/validate.py +74 -0
- config.py +86 -0
- discovery.py +145 -0
- manifest.py +437 -0
- oasr-0.3.4.dist-info/METADATA +358 -0
- oasr-0.3.4.dist-info/RECORD +43 -0
- oasr-0.3.4.dist-info/WHEEL +4 -0
- oasr-0.3.4.dist-info/entry_points.txt +3 -0
- oasr-0.3.4.dist-info/licenses/LICENSE +187 -0
- oasr-0.3.4.dist-info/licenses/NOTICE +8 -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/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
|