oasr 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- __init__.py +3 -0
- __main__.py +6 -0
- adapter.py +396 -0
- adapters/__init__.py +17 -0
- adapters/base.py +254 -0
- adapters/claude.py +82 -0
- adapters/codex.py +84 -0
- adapters/copilot.py +210 -0
- adapters/cursor.py +78 -0
- adapters/windsurf.py +83 -0
- agents/__init__.py +25 -0
- agents/base.py +96 -0
- agents/claude.py +25 -0
- agents/codex.py +25 -0
- agents/copilot.py +25 -0
- agents/opencode.py +25 -0
- agents/registry.py +57 -0
- cli.py +97 -0
- commands/__init__.py +6 -0
- commands/adapter.py +102 -0
- commands/add.py +435 -0
- commands/clean.py +30 -0
- commands/clone.py +178 -0
- commands/config.py +163 -0
- commands/diff.py +180 -0
- commands/exec.py +245 -0
- commands/find.py +56 -0
- commands/help.py +51 -0
- commands/info.py +152 -0
- commands/list.py +110 -0
- commands/registry.py +447 -0
- commands/rm.py +128 -0
- commands/status.py +119 -0
- commands/sync.py +143 -0
- commands/update.py +417 -0
- commands/use.py +45 -0
- commands/validate.py +74 -0
- config/__init__.py +119 -0
- config/defaults.py +40 -0
- config/schema.py +73 -0
- discovery.py +145 -0
- manifest.py +437 -0
- oasr-0.5.0.dist-info/METADATA +358 -0
- oasr-0.5.0.dist-info/RECORD +59 -0
- oasr-0.5.0.dist-info/WHEEL +4 -0
- oasr-0.5.0.dist-info/entry_points.txt +3 -0
- oasr-0.5.0.dist-info/licenses/LICENSE +187 -0
- oasr-0.5.0.dist-info/licenses/NOTICE +8 -0
- policy/__init__.py +50 -0
- policy/defaults.py +27 -0
- policy/enforcement.py +98 -0
- policy/profile.py +185 -0
- registry.py +173 -0
- remote.py +482 -0
- skillcopy/__init__.py +71 -0
- skillcopy/local.py +40 -0
- skillcopy/remote.py +98 -0
- tracking.py +181 -0
- validate.py +362 -0
commands/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
|