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/adapter.py ADDED
@@ -0,0 +1,102 @@
1
+ """`asr adapter` 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 adapters import ClaudeAdapter, CodexAdapter, CopilotAdapter, CursorAdapter, WindsurfAdapter
11
+ from config import load_config
12
+ from registry import load_registry
13
+
14
+ ADAPTERS = {
15
+ "cursor": CursorAdapter(),
16
+ "windsurf": WindsurfAdapter(),
17
+ "codex": CodexAdapter(),
18
+ "copilot": CopilotAdapter(),
19
+ "claude": ClaudeAdapter(),
20
+ }
21
+
22
+
23
+ def register(subparsers) -> None:
24
+ p = subparsers.add_parser("adapter", help="Generate IDE-specific files")
25
+ p.add_argument("--exclude", help="Comma-separated skill names to exclude")
26
+ p.add_argument("--output-dir", type=Path, default=Path("."), help="Output directory")
27
+ p.add_argument("--copy", action="store_true", help="(Deprecated) Skills are always copied now")
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.add_argument("--config", type=Path, help="Override config file path")
31
+
32
+ adapter_subs = p.add_subparsers(dest="target", help="Target IDE")
33
+
34
+ for name in ["cursor", "windsurf", "codex", "copilot", "claude"]:
35
+ adapter_subs.add_parser(name, help=f"Generate {name} files")
36
+
37
+ p.set_defaults(func=run)
38
+
39
+
40
+ def run(args: argparse.Namespace) -> int:
41
+ config = load_config(args.config)
42
+ entries = load_registry()
43
+
44
+ if not entries:
45
+ if args.json:
46
+ print(json.dumps({"generated": 0, "error": "no skills registered"}))
47
+ else:
48
+ print("No skills registered. Use 'asr add <path>' first.")
49
+ return 1
50
+
51
+ exclude = set()
52
+ if args.exclude:
53
+ exclude = set(args.exclude.split(","))
54
+
55
+ output_dir = args.output_dir
56
+
57
+ if args.target:
58
+ targets = [args.target]
59
+ else:
60
+ targets = config["adapter"]["default_targets"]
61
+
62
+ total_generated = 0
63
+ total_removed = 0
64
+ results = {}
65
+
66
+ for target in targets:
67
+ if target not in ADAPTERS:
68
+ if not args.quiet:
69
+ print(f"Warning: Unknown adapter target: {target}", file=sys.stderr)
70
+ continue
71
+
72
+ adapter = ADAPTERS[target]
73
+ # Always copy skills now (--copy flag is deprecated but kept for backward compat)
74
+ generated, removed = adapter.generate_all(entries, output_dir, exclude, copy=True)
75
+
76
+ total_generated += len(generated)
77
+ total_removed += len(removed)
78
+
79
+ results[target] = {
80
+ "generated": len(generated),
81
+ "removed": len(removed),
82
+ "output_dir": str(adapter.resolve_output_dir(output_dir)),
83
+ }
84
+
85
+ if args.json:
86
+ print(
87
+ json.dumps(
88
+ {
89
+ "total_generated": total_generated,
90
+ "total_removed": total_removed,
91
+ "targets": results,
92
+ },
93
+ indent=2,
94
+ )
95
+ )
96
+ else:
97
+ for target, info in results.items():
98
+ print(f"{target}: Generated {info['generated']} file(s) in {info['output_dir']}")
99
+ if info["removed"]:
100
+ print(f" Removed {info['removed']} stale file(s)")
101
+
102
+ return 0
commands/add.py ADDED
@@ -0,0 +1,302 @@
1
+ """`asr add` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import glob as globlib
7
+ import json
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from config import load_config
13
+ from discovery import discover_single, find_skills
14
+ from registry import SkillEntry, add_skill
15
+ from remote import InvalidRemoteUrlError, derive_skill_name, fetch_remote_to_temp, validate_remote_url
16
+ from skillcopy.remote import is_remote_source
17
+ from validate import validate_skill
18
+
19
+ _GLOB_CHARS = set("*?[")
20
+
21
+
22
+ def _looks_like_glob(value: str) -> bool:
23
+ return any(ch in value for ch in _GLOB_CHARS)
24
+
25
+
26
+ def _expand_path_patterns(patterns: list[str]) -> list[Path]:
27
+ expanded: list[Path] = []
28
+ for raw in patterns:
29
+ pat = str(Path(raw).expanduser())
30
+ if _looks_like_glob(pat):
31
+ matches = globlib.glob(pat, recursive=True)
32
+ expanded.extend(Path(m) for m in matches)
33
+ else:
34
+ expanded.append(Path(pat))
35
+ return expanded
36
+
37
+
38
+ def _print_validation_result(result) -> None:
39
+ print(f"{result.name}")
40
+ if result.valid and not result.warnings:
41
+ print(" ✓ Valid")
42
+ else:
43
+ for msg in result.all_messages:
44
+ print(f" {msg}")
45
+
46
+
47
+ def register(subparsers) -> None:
48
+ p = subparsers.add_parser("add", help="Register a skill")
49
+ p.add_argument(
50
+ "paths",
51
+ nargs="+",
52
+ help="Path(s), URL(s), or glob pattern(s) to skill dir(s) (or root(s) for recursive)",
53
+ )
54
+ p.add_argument("-r", "--recursive", action="store_true", help="Recursively add all valid skills from path")
55
+ p.add_argument("--strict", action="store_true", help="Fail if validation has warnings")
56
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
57
+ p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
58
+ p.add_argument("--config", type=Path, help="Override config file path")
59
+ p.set_defaults(func=run)
60
+
61
+
62
+ def run(args: argparse.Namespace) -> int:
63
+ config = load_config(args.config)
64
+ max_lines = config["validation"]["reference_max_lines"]
65
+
66
+ # Separate remote URLs from local paths
67
+ remote_urls = []
68
+ local_patterns = []
69
+
70
+ for pattern in args.paths:
71
+ if is_remote_source(pattern):
72
+ remote_urls.append(pattern)
73
+ else:
74
+ local_patterns.append(pattern)
75
+
76
+ # Expand local paths
77
+ expanded = []
78
+ if local_patterns:
79
+ expanded = [p.resolve() for p in _expand_path_patterns(local_patterns)]
80
+
81
+ # Check if we have anything to process
82
+ if not expanded and not remote_urls:
83
+ if args.json:
84
+ print(json.dumps({"added": 0, "skipped": 0, "skills": [], "error": "no paths matched"}))
85
+ else:
86
+ print("No paths matched.", file=sys.stderr)
87
+ return 2
88
+
89
+ # Handle recursive mode (local paths only)
90
+ if args.recursive:
91
+ if remote_urls:
92
+ print("Warning: --recursive flag ignored for remote URLs", file=sys.stderr)
93
+ exit_code = 0
94
+ for root in expanded:
95
+ code = _run_recursive(args, root, max_lines)
96
+ if code != 0:
97
+ exit_code = code
98
+ return exit_code
99
+
100
+ results: list[dict] = []
101
+ added_count = 0
102
+ skipped_count = 0
103
+ exit_code = 0
104
+
105
+ # Process remote URLs
106
+ for url in remote_urls:
107
+ # Validate URL format
108
+ valid, error_msg = validate_remote_url(url)
109
+ if not valid:
110
+ skipped_count += 1
111
+ results.append({"url": url, "added": False, "reason": f"Invalid URL: {error_msg}"})
112
+ exit_code = 1
113
+ if not args.quiet and not args.json:
114
+ print(f"Invalid URL: {url} - {error_msg}", file=sys.stderr)
115
+ continue
116
+
117
+ # Derive skill name
118
+ try:
119
+ derive_skill_name(url)
120
+ except InvalidRemoteUrlError as e:
121
+ skipped_count += 1
122
+ results.append({"url": url, "added": False, "reason": str(e)})
123
+ exit_code = 1
124
+ if not args.quiet and not args.json:
125
+ print(f"Cannot derive name from URL: {url}", file=sys.stderr)
126
+ continue
127
+
128
+ # Fetch to temp dir for validation
129
+ try:
130
+ if not args.quiet and not args.json:
131
+ # Determine platform for user feedback
132
+ if "github.com" in url:
133
+ platform = "GitHub"
134
+ elif "gitlab.com" in url:
135
+ platform = "GitLab"
136
+ else:
137
+ platform = "remote source"
138
+ print(f"Registering from {platform}...", file=sys.stderr)
139
+
140
+ temp_dir = fetch_remote_to_temp(url)
141
+
142
+ if not args.quiet and not args.json:
143
+ # Count files validated
144
+ file_count = sum(1 for _ in temp_dir.rglob("*") if _.is_file())
145
+ print(f"✓ Validated {file_count} file(s)", file=sys.stderr)
146
+ except Exception as e:
147
+ skipped_count += 1
148
+ results.append({"url": url, "added": False, "reason": f"Fetch failed: {e}"})
149
+ exit_code = 1
150
+ if not args.quiet and not args.json:
151
+ print(f"Failed to fetch {url}: {e}", file=sys.stderr)
152
+ continue
153
+
154
+ try:
155
+ # Validate fetched content (skip name match for temp directory)
156
+ result = validate_skill(temp_dir, reference_max_lines=max_lines, skip_name_match=True)
157
+ if not args.quiet and not args.json:
158
+ _print_validation_result(result)
159
+ print()
160
+
161
+ if not result.valid:
162
+ skipped_count += 1
163
+ results.append({"url": url, "added": False, "reason": "validation errors"})
164
+ exit_code = 1
165
+ continue
166
+
167
+ if args.strict and result.warnings:
168
+ skipped_count += 1
169
+ results.append({"url": url, "added": False, "reason": "validation warnings (strict mode)"})
170
+ exit_code = 1
171
+ continue
172
+
173
+ # Discover skill info from fetched content
174
+ discovered = discover_single(temp_dir)
175
+ if not discovered:
176
+ skipped_count += 1
177
+ results.append({"url": url, "added": False, "reason": "could not discover skill info"})
178
+ exit_code = 3
179
+ continue
180
+
181
+ # Create entry with URL as source_path
182
+ entry = SkillEntry(
183
+ path=url, # Store URL, not temp path
184
+ name=discovered.name,
185
+ description=discovered.description,
186
+ )
187
+
188
+ is_new = add_skill(entry)
189
+ added_count += 1
190
+ results.append({"name": entry.name, "url": url, "added": True, "new": is_new})
191
+
192
+ if not args.quiet and not args.json:
193
+ action = "Added" if is_new else "Updated"
194
+ print(f"{action} remote skill: {entry.name} from {url}")
195
+
196
+ finally:
197
+ shutil.rmtree(temp_dir, ignore_errors=True)
198
+
199
+ # Process local paths
200
+ for path in expanded:
201
+ if not path.exists():
202
+ skipped_count += 1
203
+ results.append({"path": str(path), "added": False, "reason": "path missing"})
204
+ exit_code = 1
205
+ if not args.quiet and not args.json:
206
+ print(f"Not found: {path}", file=sys.stderr)
207
+ continue
208
+
209
+ result = validate_skill(path, reference_max_lines=max_lines)
210
+ if not args.quiet and not args.json:
211
+ _print_validation_result(result)
212
+ print()
213
+
214
+ if not result.valid:
215
+ skipped_count += 1
216
+ results.append({"path": str(path), "added": False, "reason": "validation errors"})
217
+ exit_code = 1
218
+ continue
219
+
220
+ if args.strict and result.warnings:
221
+ skipped_count += 1
222
+ results.append({"path": str(path), "added": False, "reason": "validation warnings (strict mode)"})
223
+ exit_code = 1
224
+ continue
225
+
226
+ discovered = discover_single(path)
227
+ if not discovered:
228
+ skipped_count += 1
229
+ results.append({"path": str(path), "added": False, "reason": "could not discover skill info"})
230
+ exit_code = 3
231
+ continue
232
+
233
+ entry = SkillEntry(
234
+ path=str(discovered.path),
235
+ name=discovered.name,
236
+ description=discovered.description,
237
+ )
238
+
239
+ is_new = add_skill(entry)
240
+ added_count += 1
241
+ results.append({"name": entry.name, "path": entry.path, "added": True, "new": is_new})
242
+
243
+ if not args.quiet and not args.json:
244
+ action = "Added" if is_new else "Updated"
245
+ print(f"{action} skill: {entry.name}")
246
+
247
+ if args.json:
248
+ print(json.dumps({"added": added_count, "skipped": skipped_count, "skills": results}, indent=2))
249
+
250
+ return exit_code
251
+
252
+
253
+ def _run_recursive(args: argparse.Namespace, root: Path, max_lines: int) -> int:
254
+ if not root.is_dir():
255
+ print(f"Error: Not a directory: {root}", file=sys.stderr)
256
+ return 2
257
+
258
+ skills = find_skills(root)
259
+ if not skills:
260
+ if args.json:
261
+ print(json.dumps({"added": 0, "skipped": 0, "skills": []}))
262
+ else:
263
+ print(f"No skills found under {root}")
264
+ return 0
265
+
266
+ added_count = 0
267
+ skipped_count = 0
268
+ results = []
269
+
270
+ for s in skills:
271
+ result = validate_skill(s.path, reference_max_lines=max_lines)
272
+
273
+ if not result.valid:
274
+ skipped_count += 1
275
+ if not args.quiet:
276
+ print(f"⚠ Skipping {s.name}: validation errors", file=sys.stderr)
277
+ results.append({"name": s.name, "added": False, "reason": "validation errors"})
278
+ continue
279
+
280
+ if args.strict and result.warnings:
281
+ skipped_count += 1
282
+ if not args.quiet:
283
+ print(f"⚠ Skipping {s.name}: validation warnings (strict)", file=sys.stderr)
284
+ results.append({"name": s.name, "added": False, "reason": "validation warnings"})
285
+ continue
286
+
287
+ entry = SkillEntry(path=str(s.path), name=s.name, description=s.description)
288
+ is_new = add_skill(entry)
289
+ added_count += 1
290
+
291
+ if not args.quiet and not args.json:
292
+ action = "Added" if is_new else "Updated"
293
+ print(f"{action}: {s.name}")
294
+
295
+ results.append({"name": s.name, "added": True, "new": is_new})
296
+
297
+ if args.json:
298
+ print(json.dumps({"added": added_count, "skipped": skipped_count, "skills": results}, indent=2))
299
+ elif not args.quiet:
300
+ print(f"\n{added_count} skill(s) added, {skipped_count} skipped")
301
+
302
+ return 0
commands/clean.py ADDED
@@ -0,0 +1,155 @@
1
+ """`asr clean` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+
8
+ from manifest import check_manifest, delete_manifest, list_manifests, load_manifest
9
+ from registry import load_registry, remove_skill
10
+
11
+
12
+ def register(subparsers) -> None:
13
+ p = subparsers.add_parser("clean", help="Clean up corrupted/missing skills and orphaned artifacts")
14
+ p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
15
+ p.add_argument("--json", action="store_true", help="Output in JSON format")
16
+ p.add_argument("--dry-run", action="store_true", help="Show what would be cleaned without doing it")
17
+ p.set_defaults(func=run)
18
+
19
+
20
+ def run(args: argparse.Namespace) -> int:
21
+ entries = load_registry()
22
+ registered_names = {e.name for e in entries}
23
+ manifest_names = set(list_manifests())
24
+
25
+ to_remove_skills = []
26
+ to_remove_manifests = []
27
+
28
+ # Check for remote skills and show progress header
29
+ import sys
30
+
31
+ from skillcopy.remote import is_remote_source
32
+
33
+ remote_count = 0
34
+ for entry in entries:
35
+ manifest = load_manifest(entry.name)
36
+ if manifest and is_remote_source(manifest.source_path):
37
+ remote_count += 1
38
+
39
+ if remote_count > 0 and not args.json:
40
+ print(f"Checking {remote_count} remote skill(s)...", file=sys.stderr)
41
+
42
+ for entry in entries:
43
+ manifest = load_manifest(entry.name)
44
+ if manifest:
45
+ # Show progress for remote skills
46
+ is_remote = is_remote_source(manifest.source_path)
47
+ if is_remote and not args.json:
48
+ platform = (
49
+ "GitHub"
50
+ if "github.com" in manifest.source_path
51
+ else "GitLab"
52
+ if "gitlab.com" in manifest.source_path
53
+ else "remote"
54
+ )
55
+ print(f" ↓ {entry.name} (checking {platform}...)", file=sys.stderr, flush=True)
56
+
57
+ status = check_manifest(manifest)
58
+
59
+ if is_remote and not args.json:
60
+ print(f" ✓ {entry.name} (checked)", file=sys.stderr)
61
+
62
+ if status.status == "missing":
63
+ to_remove_skills.append(
64
+ {
65
+ "name": entry.name,
66
+ "reason": "source missing",
67
+ "path": entry.path,
68
+ }
69
+ )
70
+
71
+ orphaned = manifest_names - registered_names
72
+ for name in orphaned:
73
+ to_remove_manifests.append(
74
+ {
75
+ "name": name,
76
+ "reason": "orphaned manifest (not in registry)",
77
+ }
78
+ )
79
+
80
+ if not to_remove_skills and not to_remove_manifests:
81
+ if args.json:
82
+ print(json.dumps({"cleaned": 0, "message": "nothing to clean"}))
83
+ else:
84
+ print("Nothing to clean.")
85
+ return 0
86
+
87
+ if args.json:
88
+ result = {
89
+ "skills_to_remove": to_remove_skills,
90
+ "manifests_to_remove": to_remove_manifests,
91
+ "dry_run": args.dry_run,
92
+ }
93
+ if not args.dry_run and not args.yes:
94
+ result["requires_confirmation"] = True
95
+ print(json.dumps(result, indent=2))
96
+ if args.dry_run:
97
+ return 0
98
+ else:
99
+ print("The following will be cleaned:\n")
100
+
101
+ if to_remove_skills:
102
+ print("Skills with missing sources:")
103
+ for s in to_remove_skills:
104
+ print(f" ✗ {s['name']} ({s['path']})")
105
+
106
+ if to_remove_manifests:
107
+ print("\nOrphaned manifests:")
108
+ for m in to_remove_manifests:
109
+ print(f" ✗ {m['name']}")
110
+
111
+ print()
112
+
113
+ if args.dry_run:
114
+ print("(dry run - no changes made)")
115
+ return 0
116
+
117
+ if not args.yes and not args.json:
118
+ try:
119
+ response = input("Proceed with cleanup? [y/N] ").strip().lower()
120
+ if response not in ("y", "yes"):
121
+ print("Aborted.")
122
+ return 1
123
+ except (EOFError, KeyboardInterrupt):
124
+ print("\nAborted.")
125
+ return 1
126
+
127
+ removed_skills = []
128
+ removed_manifests = []
129
+
130
+ for s in to_remove_skills:
131
+ remove_skill(s["name"])
132
+ removed_skills.append(s["name"])
133
+
134
+ for m in to_remove_manifests:
135
+ delete_manifest(m["name"])
136
+ removed_manifests.append(m["name"])
137
+
138
+ if args.json:
139
+ print(
140
+ json.dumps(
141
+ {
142
+ "removed_skills": removed_skills,
143
+ "removed_manifests": removed_manifests,
144
+ },
145
+ indent=2,
146
+ )
147
+ )
148
+ else:
149
+ for name in removed_skills:
150
+ print(f"Removed skill: {name}")
151
+ for name in removed_manifests:
152
+ print(f"Removed manifest: {name}")
153
+ print(f"\nCleaned {len(removed_skills)} skill(s), {len(removed_manifests)} manifest(s)")
154
+
155
+ return 0