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