oasr 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- __init__.py +3 -0
- __main__.py +6 -0
- adapter.py +396 -0
- adapters/__init__.py +17 -0
- adapters/base.py +254 -0
- adapters/claude.py +82 -0
- adapters/codex.py +84 -0
- adapters/copilot.py +210 -0
- adapters/cursor.py +78 -0
- adapters/windsurf.py +83 -0
- cli.py +94 -0
- commands/__init__.py +6 -0
- commands/adapter.py +102 -0
- commands/add.py +302 -0
- commands/clean.py +155 -0
- commands/diff.py +180 -0
- commands/find.py +56 -0
- commands/help.py +51 -0
- commands/info.py +152 -0
- commands/list.py +110 -0
- commands/registry.py +303 -0
- commands/rm.py +128 -0
- commands/status.py +119 -0
- commands/sync.py +143 -0
- commands/update.py +417 -0
- commands/use.py +172 -0
- commands/validate.py +74 -0
- config.py +86 -0
- discovery.py +145 -0
- manifest.py +437 -0
- oasr-0.3.4.dist-info/METADATA +358 -0
- oasr-0.3.4.dist-info/RECORD +43 -0
- oasr-0.3.4.dist-info/WHEEL +4 -0
- oasr-0.3.4.dist-info/entry_points.txt +3 -0
- oasr-0.3.4.dist-info/licenses/LICENSE +187 -0
- oasr-0.3.4.dist-info/licenses/NOTICE +8 -0
- registry.py +173 -0
- remote.py +482 -0
- skillcopy/__init__.py +71 -0
- skillcopy/local.py +40 -0
- skillcopy/remote.py +98 -0
- tracking.py +181 -0
- validate.py +362 -0
registry.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Registry management for ~/.oasr/registry.toml."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else:
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
import tomli_w
|
|
13
|
+
|
|
14
|
+
from config import OASR_DIR, ensure_skills_dir
|
|
15
|
+
from manifest import create_manifest, delete_manifest, save_manifest
|
|
16
|
+
|
|
17
|
+
REGISTRY_FILE = OASR_DIR / "registry.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SkillEntry:
|
|
22
|
+
"""A registered skill entry."""
|
|
23
|
+
|
|
24
|
+
path: str
|
|
25
|
+
name: str
|
|
26
|
+
description: str
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, str]:
|
|
29
|
+
"""Convert to dictionary for TOML serialization."""
|
|
30
|
+
return {
|
|
31
|
+
"path": self.path,
|
|
32
|
+
"name": self.name,
|
|
33
|
+
"description": self.description,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict[str, str]) -> "SkillEntry":
|
|
38
|
+
"""Create from dictionary."""
|
|
39
|
+
return cls(
|
|
40
|
+
path=data.get("path", ""),
|
|
41
|
+
name=data.get("name", ""),
|
|
42
|
+
description=data.get("description", ""),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_registry(registry_path: Path | None = None) -> list[SkillEntry]:
|
|
47
|
+
"""Load registry from TOML file.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
registry_path: Override registry file path. Defaults to ~/.oasr/registry.toml.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of registered skill entries.
|
|
54
|
+
"""
|
|
55
|
+
path = registry_path or REGISTRY_FILE
|
|
56
|
+
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
with open(path, "rb") as f:
|
|
61
|
+
data = tomllib.load(f)
|
|
62
|
+
|
|
63
|
+
skills = data.get("skill", [])
|
|
64
|
+
return [SkillEntry.from_dict(s) for s in skills]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_registry(entries: list[SkillEntry], registry_path: Path | None = None) -> None:
|
|
68
|
+
"""Save registry to TOML file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
entries: List of skill entries to save.
|
|
72
|
+
registry_path: Override registry file path. Defaults to ~/.skills/registry.toml.
|
|
73
|
+
"""
|
|
74
|
+
path = registry_path or REGISTRY_FILE
|
|
75
|
+
ensure_skills_dir()
|
|
76
|
+
|
|
77
|
+
data = {"skill": [e.to_dict() for e in entries]}
|
|
78
|
+
|
|
79
|
+
with open(path, "wb") as f:
|
|
80
|
+
tomli_w.dump(data, f)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def add_skill(
|
|
84
|
+
entry: SkillEntry,
|
|
85
|
+
registry_path: Path | None = None,
|
|
86
|
+
create_manifest_artifact: bool = True,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Add or update a skill in the registry.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
entry: Skill entry to add.
|
|
92
|
+
registry_path: Override registry file path.
|
|
93
|
+
create_manifest_artifact: Whether to create/update the manifest artifact.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if skill was added, False if it was updated (already existed).
|
|
97
|
+
"""
|
|
98
|
+
entries = load_registry(registry_path)
|
|
99
|
+
is_new = True
|
|
100
|
+
|
|
101
|
+
for i, existing in enumerate(entries):
|
|
102
|
+
if existing.name == entry.name or existing.path == entry.path:
|
|
103
|
+
entries[i] = entry
|
|
104
|
+
is_new = False
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if is_new:
|
|
108
|
+
entries.append(entry)
|
|
109
|
+
|
|
110
|
+
save_registry(entries, registry_path)
|
|
111
|
+
|
|
112
|
+
if create_manifest_artifact:
|
|
113
|
+
manifest = create_manifest(
|
|
114
|
+
name=entry.name,
|
|
115
|
+
source_path=entry.path, # Keep as string (can be URL or path)
|
|
116
|
+
description=entry.description,
|
|
117
|
+
)
|
|
118
|
+
save_manifest(manifest)
|
|
119
|
+
|
|
120
|
+
return is_new
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remove_skill(
|
|
124
|
+
name_or_path: str,
|
|
125
|
+
registry_path: Path | None = None,
|
|
126
|
+
delete_manifest_artifact: bool = True,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""Remove a skill from the registry by name or path.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
name_or_path: Skill name or path to remove.
|
|
132
|
+
registry_path: Override registry file path.
|
|
133
|
+
delete_manifest_artifact: Whether to delete the manifest artifact.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if skill was removed, False if not found.
|
|
137
|
+
"""
|
|
138
|
+
entries = load_registry(registry_path)
|
|
139
|
+
removed_name = None
|
|
140
|
+
|
|
141
|
+
new_entries = []
|
|
142
|
+
for e in entries:
|
|
143
|
+
if e.name == name_or_path or e.path == name_or_path:
|
|
144
|
+
removed_name = e.name
|
|
145
|
+
else:
|
|
146
|
+
new_entries.append(e)
|
|
147
|
+
|
|
148
|
+
if removed_name:
|
|
149
|
+
save_registry(new_entries, registry_path)
|
|
150
|
+
if delete_manifest_artifact:
|
|
151
|
+
delete_manifest(removed_name)
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_skill(name_or_path: str, registry_path: Path | None = None) -> SkillEntry | None:
|
|
158
|
+
"""Find a skill by name or path.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
name_or_path: Skill name or path to find.
|
|
162
|
+
registry_path: Override registry file path.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Skill entry if found, None otherwise.
|
|
166
|
+
"""
|
|
167
|
+
entries = load_registry(registry_path)
|
|
168
|
+
|
|
169
|
+
for entry in entries:
|
|
170
|
+
if entry.name == name_or_path or entry.path == name_or_path:
|
|
171
|
+
return entry
|
|
172
|
+
|
|
173
|
+
return None
|
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}"
|