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
remote.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Remote skill fetching from GitHub and GitLab.
|
|
2
|
+
|
|
3
|
+
Provides low-level operations for parsing URLs, fetching from APIs,
|
|
4
|
+
and managing remote skill content.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib import error as urlerror
|
|
14
|
+
from urllib import parse as urlparse
|
|
15
|
+
from urllib import request
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Custom exceptions
|
|
19
|
+
class RemoteSkillError(Exception):
|
|
20
|
+
"""Base exception for remote skill operations."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RemoteSkillNotFoundError(RemoteSkillError):
|
|
26
|
+
"""Remote skill or repository not found."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RemoteRateLimitError(RemoteSkillError):
|
|
32
|
+
"""API rate limit exceeded."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RemoteNetworkError(RemoteSkillError):
|
|
38
|
+
"""Network error during remote operation."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InvalidRemoteUrlError(RemoteSkillError):
|
|
44
|
+
"""Invalid remote URL format."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# URL patterns
|
|
50
|
+
GITHUB_PATTERN = re.compile(r"^https?://github\.com/([^/]+)/([^/]+)(?:/(?:tree|blob)/([^/]+)/(.+))?/?$")
|
|
51
|
+
GITLAB_PATTERN = re.compile(r"^https?://gitlab\.com/([^/]+)/([^/]+)(?:/(?:-/)?tree/([^/]+)/(.+))?/?$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_github_url(url: str) -> dict | None:
|
|
55
|
+
"""Parse a GitHub URL into components.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
url: GitHub URL (e.g., https://github.com/user/repo/tree/main/path)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dictionary with 'owner', 'repo', 'ref', 'path' keys, or None if invalid
|
|
62
|
+
"""
|
|
63
|
+
match = GITHUB_PATTERN.match(url)
|
|
64
|
+
if not match:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
owner, repo, ref, path = match.groups()
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"owner": owner,
|
|
71
|
+
"repo": repo,
|
|
72
|
+
"ref": ref or "main", # Default to main branch
|
|
73
|
+
"path": path or "",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_gitlab_url(url: str) -> dict | None:
|
|
78
|
+
"""Parse a GitLab URL into components.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
url: GitLab URL (e.g., https://gitlab.com/user/project/tree/main/path)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Dictionary with 'owner', 'repo', 'ref', 'path' keys, or None if invalid
|
|
85
|
+
"""
|
|
86
|
+
match = GITLAB_PATTERN.match(url)
|
|
87
|
+
if not match:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
owner, repo, ref, path = match.groups()
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"owner": owner,
|
|
94
|
+
"repo": repo,
|
|
95
|
+
"ref": ref or "main",
|
|
96
|
+
"path": path or "",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def derive_skill_name(url: str) -> str:
|
|
101
|
+
"""Derive a skill name from a remote URL.
|
|
102
|
+
|
|
103
|
+
Extracts repo name and skill path to create a kebab-case name.
|
|
104
|
+
Format: {repo}-{skill-identifier}
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
url: Remote URL
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Derived skill name in kebab-case
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
InvalidRemoteUrlError: If URL cannot be parsed
|
|
114
|
+
"""
|
|
115
|
+
parsed = parse_github_url(url) or parse_gitlab_url(url)
|
|
116
|
+
|
|
117
|
+
if not parsed:
|
|
118
|
+
raise InvalidRemoteUrlError(f"Cannot parse URL: {url}")
|
|
119
|
+
|
|
120
|
+
repo = parsed["repo"]
|
|
121
|
+
path = parsed["path"]
|
|
122
|
+
|
|
123
|
+
# Get last component of path as skill identifier
|
|
124
|
+
if path:
|
|
125
|
+
skill_id = Path(path).name
|
|
126
|
+
else:
|
|
127
|
+
skill_id = ""
|
|
128
|
+
|
|
129
|
+
# Combine repo and skill identifier
|
|
130
|
+
if skill_id:
|
|
131
|
+
name = f"{repo}-{skill_id}"
|
|
132
|
+
else:
|
|
133
|
+
name = repo
|
|
134
|
+
|
|
135
|
+
# Ensure kebab-case (lowercase with hyphens)
|
|
136
|
+
name = name.lower().replace("_", "-")
|
|
137
|
+
|
|
138
|
+
return name
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_remote_url(url: str) -> tuple[bool, str]:
|
|
142
|
+
"""Validate a remote URL format.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
url: URL to validate
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Tuple of (is_valid, error_message)
|
|
149
|
+
"""
|
|
150
|
+
if not url:
|
|
151
|
+
return False, "URL is empty"
|
|
152
|
+
|
|
153
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
154
|
+
return False, "URL must start with http:// or https://"
|
|
155
|
+
|
|
156
|
+
if parse_github_url(url):
|
|
157
|
+
return True, ""
|
|
158
|
+
|
|
159
|
+
if parse_gitlab_url(url):
|
|
160
|
+
return True, ""
|
|
161
|
+
|
|
162
|
+
return False, "URL must be a valid GitHub or GitLab repository URL"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _get_auth_token(platform: str) -> str | None:
|
|
166
|
+
"""Get authentication token from environment.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
platform: 'github' or 'gitlab'
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Token string or None
|
|
173
|
+
"""
|
|
174
|
+
if platform == "github":
|
|
175
|
+
return os.environ.get("GITHUB_TOKEN")
|
|
176
|
+
elif platform == "gitlab":
|
|
177
|
+
return os.environ.get("GITLAB_TOKEN")
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def fetch_github_directory(
|
|
182
|
+
owner: str,
|
|
183
|
+
repo: str,
|
|
184
|
+
path: str,
|
|
185
|
+
ref: str = "main",
|
|
186
|
+
token: str | None = None,
|
|
187
|
+
) -> list[dict]:
|
|
188
|
+
"""Fetch directory contents from GitHub API.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
owner: Repository owner
|
|
192
|
+
repo: Repository name
|
|
193
|
+
path: Path within repository
|
|
194
|
+
ref: Branch/tag/commit reference
|
|
195
|
+
token: Optional authentication token
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
List of file/directory entries
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
RemoteSkillNotFoundError: If repository or path not found
|
|
202
|
+
RemoteRateLimitError: If rate limit exceeded
|
|
203
|
+
RemoteNetworkError: On network errors
|
|
204
|
+
"""
|
|
205
|
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}"
|
|
206
|
+
if ref:
|
|
207
|
+
api_url += f"?ref={ref}"
|
|
208
|
+
|
|
209
|
+
headers = {"Accept": "application/vnd.github.v3+json"}
|
|
210
|
+
if token:
|
|
211
|
+
headers["Authorization"] = f"token {token}"
|
|
212
|
+
|
|
213
|
+
req = request.Request(api_url, headers=headers)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
with request.urlopen(req) as response:
|
|
217
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
218
|
+
return data if isinstance(data, list) else [data]
|
|
219
|
+
except urlerror.HTTPError as e:
|
|
220
|
+
if e.code == 404:
|
|
221
|
+
raise RemoteSkillNotFoundError(f"Not found: {owner}/{repo}/{path}")
|
|
222
|
+
elif e.code == 403:
|
|
223
|
+
raise RemoteRateLimitError("GitHub API rate limit exceeded. Set GITHUB_TOKEN environment variable.")
|
|
224
|
+
else:
|
|
225
|
+
raise RemoteNetworkError(f"HTTP error {e.code}: {e.reason}")
|
|
226
|
+
except urlerror.URLError as e:
|
|
227
|
+
raise RemoteNetworkError(f"Network error: {e.reason}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def fetch_gitlab_directory(
|
|
231
|
+
owner: str,
|
|
232
|
+
repo: str,
|
|
233
|
+
path: str,
|
|
234
|
+
ref: str = "main",
|
|
235
|
+
token: str | None = None,
|
|
236
|
+
) -> list[dict]:
|
|
237
|
+
"""Fetch directory contents from GitLab API.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
owner: Project owner/group
|
|
241
|
+
repo: Repository name
|
|
242
|
+
path: Path within repository
|
|
243
|
+
ref: Branch/tag/commit reference
|
|
244
|
+
token: Optional authentication token
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of file/directory entries
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
RemoteSkillNotFoundError: If repository or path not found
|
|
251
|
+
RemoteRateLimitError: If rate limit exceeded
|
|
252
|
+
RemoteNetworkError: On network errors
|
|
253
|
+
"""
|
|
254
|
+
project_id = f"{owner}%2F{repo}"
|
|
255
|
+
api_url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/tree"
|
|
256
|
+
params = {"ref": ref, "path": path, "recursive": "false"}
|
|
257
|
+
full_url = f"{api_url}?{urlparse.urlencode(params)}"
|
|
258
|
+
|
|
259
|
+
headers = {}
|
|
260
|
+
if token:
|
|
261
|
+
headers["PRIVATE-TOKEN"] = token
|
|
262
|
+
|
|
263
|
+
req = request.Request(full_url, headers=headers)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
with request.urlopen(req) as response:
|
|
267
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
268
|
+
return data if isinstance(data, list) else []
|
|
269
|
+
except urlerror.HTTPError as e:
|
|
270
|
+
if e.code == 404:
|
|
271
|
+
raise RemoteSkillNotFoundError(f"Not found: {owner}/{repo}/{path}")
|
|
272
|
+
elif e.code == 429:
|
|
273
|
+
raise RemoteRateLimitError("GitLab API rate limit exceeded. Set GITLAB_TOKEN environment variable.")
|
|
274
|
+
else:
|
|
275
|
+
raise RemoteNetworkError(f"HTTP error {e.code}: {e.reason}")
|
|
276
|
+
except urlerror.URLError as e:
|
|
277
|
+
raise RemoteNetworkError(f"Network error: {e.reason}")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _download_file(url: str, dest: Path, token: str | None = None, platform: str = "github"):
|
|
281
|
+
"""Download a file from URL to destination.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
url: File download URL
|
|
285
|
+
dest: Destination file path
|
|
286
|
+
token: Optional authentication token
|
|
287
|
+
platform: 'github' or 'gitlab'
|
|
288
|
+
"""
|
|
289
|
+
headers = {}
|
|
290
|
+
if token:
|
|
291
|
+
if platform == "github":
|
|
292
|
+
headers["Authorization"] = f"token {token}"
|
|
293
|
+
elif platform == "gitlab":
|
|
294
|
+
headers["PRIVATE-TOKEN"] = token
|
|
295
|
+
|
|
296
|
+
req = request.Request(url, headers=headers)
|
|
297
|
+
|
|
298
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
|
|
300
|
+
with request.urlopen(req) as response:
|
|
301
|
+
dest.write_bytes(response.read())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _fetch_github_recursive(
|
|
305
|
+
owner: str,
|
|
306
|
+
repo: str,
|
|
307
|
+
path: str,
|
|
308
|
+
ref: str,
|
|
309
|
+
dest_dir: Path,
|
|
310
|
+
token: str | None = None,
|
|
311
|
+
):
|
|
312
|
+
"""Recursively fetch GitHub directory structure.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
owner: Repository owner
|
|
316
|
+
repo: Repository name
|
|
317
|
+
path: Path within repository
|
|
318
|
+
ref: Branch/tag/commit reference
|
|
319
|
+
dest_dir: Destination directory
|
|
320
|
+
token: Optional authentication token
|
|
321
|
+
"""
|
|
322
|
+
entries = fetch_github_directory(owner, repo, path, ref, token)
|
|
323
|
+
|
|
324
|
+
for entry in entries:
|
|
325
|
+
entry_path = entry["path"]
|
|
326
|
+
entry_name = entry["name"]
|
|
327
|
+
entry_type = entry["type"]
|
|
328
|
+
|
|
329
|
+
if entry_type == "file":
|
|
330
|
+
download_url = entry["download_url"]
|
|
331
|
+
dest_file = dest_dir / entry_name
|
|
332
|
+
_download_file(download_url, dest_file, token, "github")
|
|
333
|
+
elif entry_type == "dir":
|
|
334
|
+
subdir = dest_dir / entry_name
|
|
335
|
+
subdir.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
_fetch_github_recursive(owner, repo, entry_path, ref, subdir, token)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _fetch_gitlab_recursive(
|
|
340
|
+
owner: str,
|
|
341
|
+
repo: str,
|
|
342
|
+
path: str,
|
|
343
|
+
ref: str,
|
|
344
|
+
dest_dir: Path,
|
|
345
|
+
token: str | None = None,
|
|
346
|
+
):
|
|
347
|
+
"""Recursively fetch GitLab directory structure.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
owner: Project owner/group
|
|
351
|
+
repo: Repository name
|
|
352
|
+
path: Path within repository
|
|
353
|
+
ref: Branch/tag/commit reference
|
|
354
|
+
dest_dir: Destination directory
|
|
355
|
+
token: Optional authentication token
|
|
356
|
+
"""
|
|
357
|
+
entries = fetch_gitlab_directory(owner, repo, path, ref, token)
|
|
358
|
+
|
|
359
|
+
for entry in entries:
|
|
360
|
+
entry_path = entry["path"]
|
|
361
|
+
entry["name"]
|
|
362
|
+
entry_type = entry["type"]
|
|
363
|
+
|
|
364
|
+
if entry_type == "blob": # File
|
|
365
|
+
# Construct raw file URL
|
|
366
|
+
project_id = f"{owner}%2F{repo}"
|
|
367
|
+
file_path_encoded = urlparse.quote(entry_path, safe="")
|
|
368
|
+
download_url = (
|
|
369
|
+
f"https://gitlab.com/api/v4/projects/{project_id}/repository/files/{file_path_encoded}/raw?ref={ref}"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Calculate relative path from base path
|
|
373
|
+
rel_path = Path(entry_path).relative_to(path) if path else Path(entry_path)
|
|
374
|
+
dest_file = dest_dir / rel_path
|
|
375
|
+
_download_file(download_url, dest_file, token, "gitlab")
|
|
376
|
+
elif entry_type == "tree": # Directory
|
|
377
|
+
rel_path = Path(entry_path).relative_to(path) if path else Path(entry_path)
|
|
378
|
+
subdir = dest_dir / rel_path
|
|
379
|
+
subdir.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
_fetch_gitlab_recursive(owner, repo, entry_path, ref, subdir, token)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def fetch_remote_to_temp(url: str) -> Path:
|
|
384
|
+
"""Fetch a remote skill to a temporary directory.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
url: Remote skill URL
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Path to temporary directory containing fetched skill
|
|
391
|
+
|
|
392
|
+
Raises:
|
|
393
|
+
InvalidRemoteUrlError: If URL is invalid
|
|
394
|
+
RemoteSkillNotFoundError: If skill not found
|
|
395
|
+
RemoteNetworkError: On network errors
|
|
396
|
+
"""
|
|
397
|
+
# Parse URL
|
|
398
|
+
github_parsed = parse_github_url(url)
|
|
399
|
+
gitlab_parsed = parse_gitlab_url(url)
|
|
400
|
+
|
|
401
|
+
if github_parsed:
|
|
402
|
+
parsed = github_parsed
|
|
403
|
+
platform = "github"
|
|
404
|
+
elif gitlab_parsed:
|
|
405
|
+
parsed = gitlab_parsed
|
|
406
|
+
platform = "gitlab"
|
|
407
|
+
else:
|
|
408
|
+
raise InvalidRemoteUrlError(f"Invalid URL: {url}")
|
|
409
|
+
|
|
410
|
+
owner = parsed["owner"]
|
|
411
|
+
repo = parsed["repo"]
|
|
412
|
+
ref = parsed["ref"]
|
|
413
|
+
path = parsed["path"]
|
|
414
|
+
token = _get_auth_token(platform)
|
|
415
|
+
|
|
416
|
+
# Create temporary directory
|
|
417
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="asr-remote-"))
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
if platform == "github":
|
|
421
|
+
_fetch_github_recursive(owner, repo, path, ref, temp_dir, token)
|
|
422
|
+
elif platform == "gitlab":
|
|
423
|
+
_fetch_gitlab_recursive(owner, repo, path, ref, temp_dir, token)
|
|
424
|
+
|
|
425
|
+
return temp_dir
|
|
426
|
+
except Exception:
|
|
427
|
+
# Clean up on error
|
|
428
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
429
|
+
raise
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def check_remote_reachability(url: str) -> tuple[bool, int, str]:
|
|
433
|
+
"""Check if a remote URL is reachable.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
url: Remote URL to check
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Tuple of (is_reachable, status_code, message)
|
|
440
|
+
"""
|
|
441
|
+
# Parse URL to get API endpoint for the specific path
|
|
442
|
+
github_parsed = parse_github_url(url)
|
|
443
|
+
gitlab_parsed = parse_gitlab_url(url)
|
|
444
|
+
|
|
445
|
+
if github_parsed:
|
|
446
|
+
parsed = github_parsed
|
|
447
|
+
# Check the specific path, not just the repo
|
|
448
|
+
path = parsed["path"] or ""
|
|
449
|
+
api_url = f"https://api.github.com/repos/{parsed['owner']}/{parsed['repo']}/contents/{path}"
|
|
450
|
+
if parsed["ref"]:
|
|
451
|
+
api_url += f"?ref={parsed['ref']}"
|
|
452
|
+
token = _get_auth_token("github")
|
|
453
|
+
headers = {}
|
|
454
|
+
if token:
|
|
455
|
+
headers["Authorization"] = f"token {token}"
|
|
456
|
+
elif gitlab_parsed:
|
|
457
|
+
parsed = gitlab_parsed
|
|
458
|
+
project_id = f"{parsed['owner']}%2F{parsed['repo']}"
|
|
459
|
+
path = parsed["path"] or ""
|
|
460
|
+
# Check the specific path, not just the project
|
|
461
|
+
api_url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/tree?path={path}"
|
|
462
|
+
if parsed["ref"]:
|
|
463
|
+
api_url += f"&ref={parsed['ref']}"
|
|
464
|
+
token = _get_auth_token("gitlab")
|
|
465
|
+
headers = {}
|
|
466
|
+
if token:
|
|
467
|
+
headers["PRIVATE-TOKEN"] = token
|
|
468
|
+
else:
|
|
469
|
+
return False, 0, "Invalid URL format"
|
|
470
|
+
|
|
471
|
+
req = request.Request(api_url, headers=headers, method="HEAD")
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
with request.urlopen(req) as response:
|
|
475
|
+
return True, response.status, "Reachable"
|
|
476
|
+
except urlerror.HTTPError as e:
|
|
477
|
+
if e.code in (404, 410):
|
|
478
|
+
return False, e.code, f"Not found (HTTP {e.code})"
|
|
479
|
+
else:
|
|
480
|
+
return False, e.code, f"HTTP error: {e.reason}"
|
|
481
|
+
except urlerror.URLError as e:
|
|
482
|
+
return False, 0, f"Network error: {e.reason}"
|
skillcopy/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Unified interface for copying skills (local or remote).
|
|
2
|
+
|
|
3
|
+
This module provides a single entry point for copying skills from any source
|
|
4
|
+
(local filesystem or remote URL) to a destination directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .local import copy_local_skill
|
|
10
|
+
from .remote import copy_remote_skill, is_remote_source
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def copy_skill(
|
|
14
|
+
source: str,
|
|
15
|
+
dest: Path,
|
|
16
|
+
*,
|
|
17
|
+
validate: bool = True,
|
|
18
|
+
show_progress: bool = False,
|
|
19
|
+
skill_name: str = None,
|
|
20
|
+
inject_tracking: bool = False,
|
|
21
|
+
source_hash: str | None = None,
|
|
22
|
+
) -> Path:
|
|
23
|
+
"""Copy a skill from source (path or URL) to destination.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
source: Local path or remote URL
|
|
27
|
+
dest: Destination directory
|
|
28
|
+
validate: Whether to validate skill structure after copy
|
|
29
|
+
show_progress: If True, show progress messages for remote skills
|
|
30
|
+
skill_name: Optional skill name for progress messages
|
|
31
|
+
inject_tracking: If True, inject metadata.oasr tracking info
|
|
32
|
+
source_hash: Optional content hash for tracking (required if inject_tracking=True)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Path to copied skill directory
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: If source is invalid or inject_tracking=True without source_hash
|
|
39
|
+
OSError: If copy operation fails
|
|
40
|
+
"""
|
|
41
|
+
if inject_tracking and source_hash is None:
|
|
42
|
+
raise ValueError("source_hash required when inject_tracking=True")
|
|
43
|
+
|
|
44
|
+
if is_remote_source(source):
|
|
45
|
+
dest_path = copy_remote_skill(
|
|
46
|
+
source, dest, validate=validate, show_progress=show_progress, skill_name=skill_name
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
dest_path = copy_local_skill(source, dest, validate=validate)
|
|
50
|
+
|
|
51
|
+
# Inject tracking metadata if requested
|
|
52
|
+
if inject_tracking:
|
|
53
|
+
try:
|
|
54
|
+
from tracking import inject_metadata
|
|
55
|
+
|
|
56
|
+
success = inject_metadata(dest_path, source_hash, source)
|
|
57
|
+
if not success:
|
|
58
|
+
# Log warning but don't fail the copy
|
|
59
|
+
import sys
|
|
60
|
+
|
|
61
|
+
print(f"Warning: Failed to inject tracking metadata for {dest_path.name}", file=sys.stderr)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
# Log warning but don't fail the copy
|
|
64
|
+
import sys
|
|
65
|
+
|
|
66
|
+
print(f"Warning: Error injecting tracking metadata: {e}", file=sys.stderr)
|
|
67
|
+
|
|
68
|
+
return dest_path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ["copy_skill", "is_remote_source"]
|
skillcopy/local.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Local filesystem skill copy operations."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def copy_local_skill(source: str, dest: Path, *, validate: bool = True) -> Path:
|
|
8
|
+
"""Copy a skill from local filesystem to destination.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
source: Local filesystem path
|
|
12
|
+
dest: Destination directory
|
|
13
|
+
validate: Whether to validate skill structure (reserved for future)
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Path to copied skill directory
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
FileNotFoundError: If source doesn't exist
|
|
20
|
+
OSError: If copy operation fails
|
|
21
|
+
"""
|
|
22
|
+
src_path = Path(source).resolve()
|
|
23
|
+
|
|
24
|
+
if not src_path.exists():
|
|
25
|
+
raise FileNotFoundError(f"Source path does not exist: {source}")
|
|
26
|
+
|
|
27
|
+
if not src_path.is_dir():
|
|
28
|
+
raise ValueError(f"Source is not a directory: {source}")
|
|
29
|
+
|
|
30
|
+
dest = dest.resolve()
|
|
31
|
+
|
|
32
|
+
# Remove existing destination if it exists
|
|
33
|
+
if dest.exists():
|
|
34
|
+
shutil.rmtree(dest)
|
|
35
|
+
|
|
36
|
+
# Copy skill directory
|
|
37
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
shutil.copytree(src_path, dest)
|
|
39
|
+
|
|
40
|
+
return dest
|
skillcopy/remote.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Remote skill fetch and copy operations."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from remote import fetch_remote_to_temp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_remote_source(source: str) -> bool:
|
|
11
|
+
"""Check if source is a remote URL.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
source: Path or URL string
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
True if source is a URL, False otherwise
|
|
18
|
+
"""
|
|
19
|
+
return isinstance(source, str) and (source.startswith("http://") or source.startswith("https://"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def copy_remote_skill(
|
|
23
|
+
url: str,
|
|
24
|
+
dest: Path,
|
|
25
|
+
*,
|
|
26
|
+
validate: bool = True,
|
|
27
|
+
force_refresh: bool = False,
|
|
28
|
+
show_progress: bool = False,
|
|
29
|
+
skill_name: str = None,
|
|
30
|
+
) -> Path:
|
|
31
|
+
"""Copy a skill from remote URL to destination.
|
|
32
|
+
|
|
33
|
+
Smart caching: If destination exists and content matches manifest hash,
|
|
34
|
+
skip the fetch to avoid unnecessary API calls.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
url: Remote skill URL
|
|
38
|
+
dest: Destination directory
|
|
39
|
+
validate: Whether to validate skill structure (reserved for future)
|
|
40
|
+
force_refresh: If True, always fetch (ignore cache)
|
|
41
|
+
show_progress: If True, print progress messages
|
|
42
|
+
skill_name: Optional skill name for progress messages
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Path to copied skill directory
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: If URL is invalid
|
|
49
|
+
OSError: If fetch or copy operation fails
|
|
50
|
+
"""
|
|
51
|
+
dest = dest.resolve()
|
|
52
|
+
|
|
53
|
+
# Smart check: if destination exists and is up-to-date, skip fetch
|
|
54
|
+
if not force_refresh and dest.exists():
|
|
55
|
+
try:
|
|
56
|
+
from manifest import hash_directory, load_manifest
|
|
57
|
+
|
|
58
|
+
# Try to load manifest to get expected hash
|
|
59
|
+
# Derive skill name from destination directory name
|
|
60
|
+
check_name = skill_name or dest.name
|
|
61
|
+
manifest = load_manifest(check_name)
|
|
62
|
+
|
|
63
|
+
# If manifest source matches this URL, compare hashes
|
|
64
|
+
if manifest and manifest.source_path == url:
|
|
65
|
+
current_hash, _ = hash_directory(dest)
|
|
66
|
+
if current_hash == manifest.content_hash:
|
|
67
|
+
# Destination is up-to-date, no need to fetch
|
|
68
|
+
if show_progress:
|
|
69
|
+
print(f" ✓ {skill_name or dest.name} (cached)", file=sys.stderr)
|
|
70
|
+
return dest
|
|
71
|
+
except Exception:
|
|
72
|
+
# If any error checking cache, proceed with fetch
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Show progress before fetching
|
|
76
|
+
if show_progress:
|
|
77
|
+
platform = "GitHub" if "github.com" in url else "GitLab" if "gitlab.com" in url else "remote"
|
|
78
|
+
print(f" ↓ {skill_name or dest.name} (fetching from {platform}...)", file=sys.stderr, flush=True)
|
|
79
|
+
|
|
80
|
+
# Fetch to temporary directory
|
|
81
|
+
temp_dir = fetch_remote_to_temp(url)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# Remove existing destination if it exists
|
|
85
|
+
if dest.exists():
|
|
86
|
+
shutil.rmtree(dest)
|
|
87
|
+
|
|
88
|
+
# Copy from temp to destination
|
|
89
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
shutil.copytree(temp_dir, dest)
|
|
91
|
+
|
|
92
|
+
if show_progress:
|
|
93
|
+
print(f" ✓ {skill_name or dest.name} (downloaded)", file=sys.stderr)
|
|
94
|
+
|
|
95
|
+
return dest
|
|
96
|
+
finally:
|
|
97
|
+
# Clean up temporary directory
|
|
98
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|