agr 0.4.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.
- agr/__init__.py +3 -0
- agr/cli/__init__.py +5 -0
- agr/cli/add.py +132 -0
- agr/cli/common.py +1085 -0
- agr/cli/init.py +292 -0
- agr/cli/main.py +34 -0
- agr/cli/remove.py +125 -0
- agr/cli/run.py +385 -0
- agr/cli/sync.py +263 -0
- agr/cli/update.py +140 -0
- agr/config.py +187 -0
- agr/exceptions.py +33 -0
- agr/fetcher.py +781 -0
- agr/github.py +95 -0
- agr/scaffold.py +194 -0
- agr-0.4.0.dist-info/METADATA +17 -0
- agr-0.4.0.dist-info/RECORD +19 -0
- agr-0.4.0.dist-info/WHEEL +4 -0
- agr-0.4.0.dist-info/entry_points.txt +3 -0
agr/fetcher.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""Generic resource fetcher for skills, commands, and agents."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Generator
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from agr.exceptions import (
|
|
15
|
+
AgrError,
|
|
16
|
+
BundleNotFoundError,
|
|
17
|
+
RepoNotFoundError,
|
|
18
|
+
ResourceExistsError,
|
|
19
|
+
ResourceNotFoundError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResourceType(Enum):
|
|
24
|
+
"""Type of resource to fetch."""
|
|
25
|
+
|
|
26
|
+
SKILL = "skill"
|
|
27
|
+
COMMAND = "command"
|
|
28
|
+
AGENT = "agent"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ResourceConfig:
|
|
33
|
+
"""Configuration for a resource type."""
|
|
34
|
+
|
|
35
|
+
resource_type: ResourceType
|
|
36
|
+
source_subdir: str # e.g., ".claude/skills", ".claude/commands"
|
|
37
|
+
dest_subdir: str # e.g., "skills", "commands"
|
|
38
|
+
is_directory: bool # True for skills, False for commands/agents
|
|
39
|
+
file_extension: str | None # None for skills, ".md" for commands/agents
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
RESOURCE_CONFIGS: dict[ResourceType, ResourceConfig] = {
|
|
43
|
+
ResourceType.SKILL: ResourceConfig(
|
|
44
|
+
resource_type=ResourceType.SKILL,
|
|
45
|
+
source_subdir=".claude/skills",
|
|
46
|
+
dest_subdir="skills",
|
|
47
|
+
is_directory=True,
|
|
48
|
+
file_extension=None,
|
|
49
|
+
),
|
|
50
|
+
ResourceType.COMMAND: ResourceConfig(
|
|
51
|
+
resource_type=ResourceType.COMMAND,
|
|
52
|
+
source_subdir=".claude/commands",
|
|
53
|
+
dest_subdir="commands",
|
|
54
|
+
is_directory=False,
|
|
55
|
+
file_extension=".md",
|
|
56
|
+
),
|
|
57
|
+
ResourceType.AGENT: ResourceConfig(
|
|
58
|
+
resource_type=ResourceType.AGENT,
|
|
59
|
+
source_subdir=".claude/agents",
|
|
60
|
+
dest_subdir="agents",
|
|
61
|
+
is_directory=False,
|
|
62
|
+
file_extension=".md",
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Discovery dataclasses for auto-detection
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class DiscoveredResource:
|
|
72
|
+
"""Holds information about a discovered resource."""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
resource_type: ResourceType
|
|
76
|
+
path_segments: list[str]
|
|
77
|
+
username: str | None = None # Username for namespaced resources
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class DiscoveryResult:
|
|
82
|
+
"""Result of resource discovery operation."""
|
|
83
|
+
|
|
84
|
+
resources: list[DiscoveredResource] = field(default_factory=list)
|
|
85
|
+
is_bundle: bool = False
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_unique(self) -> bool:
|
|
89
|
+
"""Return True if exactly one resource type was found (including bundle)."""
|
|
90
|
+
total = len(self.resources) + (1 if self.is_bundle else 0)
|
|
91
|
+
return total == 1
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_ambiguous(self) -> bool:
|
|
95
|
+
"""Return True if multiple resource types were found."""
|
|
96
|
+
total = len(self.resources) + (1 if self.is_bundle else 0)
|
|
97
|
+
return total > 1
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_empty(self) -> bool:
|
|
101
|
+
"""Return True if no resources were found."""
|
|
102
|
+
return len(self.resources) == 0 and not self.is_bundle
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def found_types(self) -> list[str]:
|
|
106
|
+
"""Return list of resource type names found."""
|
|
107
|
+
types = [r.resource_type.value for r in self.resources]
|
|
108
|
+
if self.is_bundle:
|
|
109
|
+
types.append("bundle")
|
|
110
|
+
return types
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_resource_path(base_dir: Path, config: ResourceConfig, path_segments: list[str]) -> Path:
|
|
114
|
+
"""Build a resource path from base directory and segments."""
|
|
115
|
+
if config.is_directory:
|
|
116
|
+
return base_dir / Path(*path_segments)
|
|
117
|
+
*parent_segments, base_name = path_segments
|
|
118
|
+
if parent_segments:
|
|
119
|
+
return base_dir / Path(*parent_segments) / f"{base_name}{config.file_extension}"
|
|
120
|
+
return base_dir / f"{base_name}{config.file_extension}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _download_and_extract_tarball(tarball_url: str, username: str, repo_name: str, tmp_path: Path) -> Path:
|
|
124
|
+
"""Download and extract a GitHub tarball, returning the repo directory path."""
|
|
125
|
+
tarball_path = tmp_path / "repo.tar.gz"
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
|
129
|
+
response = client.get(tarball_url)
|
|
130
|
+
if response.status_code == 404:
|
|
131
|
+
raise RepoNotFoundError(
|
|
132
|
+
f"Repository '{username}/{repo_name}' not found on GitHub."
|
|
133
|
+
)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
tarball_path.write_bytes(response.content)
|
|
136
|
+
except httpx.HTTPStatusError as e:
|
|
137
|
+
raise AgrError(f"Failed to download repository: {e}")
|
|
138
|
+
except httpx.RequestError as e:
|
|
139
|
+
raise AgrError(f"Network error: {e}")
|
|
140
|
+
|
|
141
|
+
extract_path = tmp_path / "extracted"
|
|
142
|
+
with tarfile.open(tarball_path, "r:gz") as tar:
|
|
143
|
+
tar.extractall(extract_path)
|
|
144
|
+
|
|
145
|
+
return extract_path / f"{repo_name}-main"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def downloaded_repo(
|
|
150
|
+
username: str, repo_name: str
|
|
151
|
+
) -> Generator[Path, None, None]:
|
|
152
|
+
"""
|
|
153
|
+
Context manager that downloads a repo tarball once and yields the repo directory.
|
|
154
|
+
|
|
155
|
+
This allows both discovery and fetching to happen within the same temporary directory,
|
|
156
|
+
avoiding double downloads.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
username: GitHub username
|
|
160
|
+
repo_name: GitHub repository name
|
|
161
|
+
|
|
162
|
+
Yields:
|
|
163
|
+
Path to the extracted repository directory
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
RepoNotFoundError: If the repository doesn't exist
|
|
167
|
+
"""
|
|
168
|
+
tarball_url = (
|
|
169
|
+
f"https://github.com/{username}/{repo_name}/archive/refs/heads/main.tar.gz"
|
|
170
|
+
)
|
|
171
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
172
|
+
repo_dir = _download_and_extract_tarball(
|
|
173
|
+
tarball_url, username, repo_name, Path(tmp_dir)
|
|
174
|
+
)
|
|
175
|
+
yield repo_dir
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def discover_resource_type_from_dir(
|
|
179
|
+
repo_dir: Path,
|
|
180
|
+
name: str,
|
|
181
|
+
path_segments: list[str],
|
|
182
|
+
) -> DiscoveryResult:
|
|
183
|
+
"""
|
|
184
|
+
Search all resource directories to find matching resources.
|
|
185
|
+
|
|
186
|
+
Priority order for detection:
|
|
187
|
+
1. Skill (.claude/skills/{name}/SKILL.md or .claude/skills/{path}/SKILL.md)
|
|
188
|
+
2. Command (.claude/commands/{name}.md or .claude/commands/{path}.md)
|
|
189
|
+
3. Agent (.claude/agents/{name}.md or .claude/agents/{path}.md)
|
|
190
|
+
4. Bundle (.claude/*/name/ directories with resources)
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
repo_dir: Path to extracted repository
|
|
194
|
+
name: Display name of the resource
|
|
195
|
+
path_segments: Path segments for the resource
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
DiscoveryResult with list of discovered resources
|
|
199
|
+
"""
|
|
200
|
+
result = DiscoveryResult()
|
|
201
|
+
|
|
202
|
+
# Check for skill (directory with SKILL.md)
|
|
203
|
+
skill_config = RESOURCE_CONFIGS[ResourceType.SKILL]
|
|
204
|
+
skill_path = _build_resource_path(
|
|
205
|
+
repo_dir / skill_config.source_subdir, skill_config, path_segments
|
|
206
|
+
)
|
|
207
|
+
if skill_path.is_dir() and (skill_path / "SKILL.md").exists():
|
|
208
|
+
result.resources.append(
|
|
209
|
+
DiscoveredResource(
|
|
210
|
+
name=name,
|
|
211
|
+
resource_type=ResourceType.SKILL,
|
|
212
|
+
path_segments=path_segments,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Check for command (markdown file)
|
|
217
|
+
command_config = RESOURCE_CONFIGS[ResourceType.COMMAND]
|
|
218
|
+
command_path = _build_resource_path(
|
|
219
|
+
repo_dir / command_config.source_subdir, command_config, path_segments
|
|
220
|
+
)
|
|
221
|
+
if command_path.is_file():
|
|
222
|
+
result.resources.append(
|
|
223
|
+
DiscoveredResource(
|
|
224
|
+
name=name,
|
|
225
|
+
resource_type=ResourceType.COMMAND,
|
|
226
|
+
path_segments=path_segments,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Check for agent (markdown file)
|
|
231
|
+
agent_config = RESOURCE_CONFIGS[ResourceType.AGENT]
|
|
232
|
+
agent_path = _build_resource_path(
|
|
233
|
+
repo_dir / agent_config.source_subdir, agent_config, path_segments
|
|
234
|
+
)
|
|
235
|
+
if agent_path.is_file():
|
|
236
|
+
result.resources.append(
|
|
237
|
+
DiscoveredResource(
|
|
238
|
+
name=name,
|
|
239
|
+
resource_type=ResourceType.AGENT,
|
|
240
|
+
path_segments=path_segments,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Check for bundle (directory with resources in any of the three locations)
|
|
245
|
+
bundle_name = path_segments[-1] if path_segments else name
|
|
246
|
+
bundle_contents = discover_bundle_contents(repo_dir, bundle_name)
|
|
247
|
+
if not bundle_contents.is_empty:
|
|
248
|
+
result.is_bundle = True
|
|
249
|
+
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _is_bundle(repo_dir: Path, path_segments: list[str]) -> bool:
|
|
254
|
+
"""Check if a name refers to a bundle in the repo."""
|
|
255
|
+
bundle_name = path_segments[-1] if path_segments else ""
|
|
256
|
+
if not bundle_name:
|
|
257
|
+
return False
|
|
258
|
+
bundle_contents = discover_bundle_contents(repo_dir, bundle_name)
|
|
259
|
+
return not bundle_contents.is_empty
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def fetch_resource_from_repo_dir(
|
|
263
|
+
repo_dir: Path,
|
|
264
|
+
name: str,
|
|
265
|
+
path_segments: list[str],
|
|
266
|
+
dest: Path,
|
|
267
|
+
resource_type: ResourceType,
|
|
268
|
+
overwrite: bool = False,
|
|
269
|
+
username: str | None = None,
|
|
270
|
+
) -> Path:
|
|
271
|
+
"""
|
|
272
|
+
Fetch a resource from an already-downloaded repo directory.
|
|
273
|
+
|
|
274
|
+
This avoids double downloads when used with downloaded_repo context manager.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
repo_dir: Path to extracted repository
|
|
278
|
+
name: Display name of the resource
|
|
279
|
+
path_segments: Path segments for the resource
|
|
280
|
+
dest: Destination directory (e.g., .claude/skills/)
|
|
281
|
+
resource_type: Type of resource
|
|
282
|
+
overwrite: Whether to overwrite existing resource
|
|
283
|
+
username: GitHub username for namespaced installation (e.g., "kasperjunge")
|
|
284
|
+
When provided, installs to dest/username/name/ instead of dest/name/
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Path to the installed resource
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
ResourceNotFoundError: If the resource doesn't exist in the repo
|
|
291
|
+
ResourceExistsError: If resource exists locally and overwrite=False
|
|
292
|
+
"""
|
|
293
|
+
config = RESOURCE_CONFIGS[resource_type]
|
|
294
|
+
|
|
295
|
+
# Build destination path - namespaced if username provided
|
|
296
|
+
if username:
|
|
297
|
+
# Namespaced path: .claude/skills/username/name/
|
|
298
|
+
namespaced_dest = dest / username
|
|
299
|
+
resource_dest = _build_resource_path(namespaced_dest, config, path_segments)
|
|
300
|
+
else:
|
|
301
|
+
# Flat path (backward compat): .claude/skills/name/
|
|
302
|
+
resource_dest = _build_resource_path(dest, config, path_segments)
|
|
303
|
+
|
|
304
|
+
# Check if resource already exists locally
|
|
305
|
+
if resource_dest.exists() and not overwrite:
|
|
306
|
+
raise ResourceExistsError(
|
|
307
|
+
f"{resource_type.value.capitalize()} '{name}' already exists at {resource_dest}\n"
|
|
308
|
+
f"Use --overwrite to replace it."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
source_base = repo_dir / config.source_subdir
|
|
312
|
+
resource_source = _build_resource_path(source_base, config, path_segments)
|
|
313
|
+
|
|
314
|
+
if not resource_source.exists():
|
|
315
|
+
nested_path = "/".join(path_segments)
|
|
316
|
+
if config.is_directory:
|
|
317
|
+
expected_location = f"{config.source_subdir}/{nested_path}/"
|
|
318
|
+
else:
|
|
319
|
+
expected_location = f"{config.source_subdir}/{nested_path}{config.file_extension}"
|
|
320
|
+
raise ResourceNotFoundError(
|
|
321
|
+
f"{resource_type.value.capitalize()} '{name}' not found.\n"
|
|
322
|
+
f"Expected location: {expected_location}"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Remove existing if overwriting
|
|
326
|
+
if resource_dest.exists():
|
|
327
|
+
if config.is_directory:
|
|
328
|
+
shutil.rmtree(resource_dest)
|
|
329
|
+
else:
|
|
330
|
+
resource_dest.unlink()
|
|
331
|
+
|
|
332
|
+
# Ensure destination parent exists
|
|
333
|
+
resource_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
|
|
335
|
+
# Copy resource to destination
|
|
336
|
+
if config.is_directory:
|
|
337
|
+
shutil.copytree(resource_source, resource_dest)
|
|
338
|
+
else:
|
|
339
|
+
shutil.copy2(resource_source, resource_dest)
|
|
340
|
+
|
|
341
|
+
return resource_dest
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def fetch_bundle_from_repo_dir(
|
|
345
|
+
repo_dir: Path,
|
|
346
|
+
bundle_name: str,
|
|
347
|
+
dest_base: Path,
|
|
348
|
+
overwrite: bool = False,
|
|
349
|
+
) -> "BundleInstallResult":
|
|
350
|
+
"""
|
|
351
|
+
Fetch and install a bundle from an already-downloaded repo directory.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
repo_dir: Path to extracted repository
|
|
355
|
+
bundle_name: Name of the bundle directory
|
|
356
|
+
dest_base: Base destination directory (e.g., .claude/)
|
|
357
|
+
overwrite: Whether to overwrite existing resources
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
BundleInstallResult with installed and skipped resources
|
|
361
|
+
|
|
362
|
+
Raises:
|
|
363
|
+
BundleNotFoundError: If bundle directory doesn't exist
|
|
364
|
+
"""
|
|
365
|
+
contents = discover_bundle_contents(repo_dir, bundle_name)
|
|
366
|
+
|
|
367
|
+
if contents.is_empty:
|
|
368
|
+
raise BundleNotFoundError(
|
|
369
|
+
f"Bundle '{bundle_name}' not found.\n"
|
|
370
|
+
f"Expected one of:\n"
|
|
371
|
+
f" - .claude/skills/{bundle_name}/*/SKILL.md\n"
|
|
372
|
+
f" - .claude/commands/{bundle_name}/*.md\n"
|
|
373
|
+
f" - .claude/agents/{bundle_name}/*.md"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
result = BundleInstallResult()
|
|
377
|
+
|
|
378
|
+
# Install skills (directories)
|
|
379
|
+
result.installed_skills, result.skipped_skills = _install_bundle_directory(
|
|
380
|
+
contents.skills,
|
|
381
|
+
repo_dir / ".claude" / "skills" / bundle_name,
|
|
382
|
+
dest_base / "skills",
|
|
383
|
+
bundle_name,
|
|
384
|
+
overwrite,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Install commands (files)
|
|
388
|
+
result.installed_commands, result.skipped_commands = _install_bundle_files(
|
|
389
|
+
contents.commands,
|
|
390
|
+
repo_dir / ".claude" / "commands" / bundle_name,
|
|
391
|
+
dest_base / "commands",
|
|
392
|
+
bundle_name,
|
|
393
|
+
overwrite,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Install agents (files)
|
|
397
|
+
result.installed_agents, result.skipped_agents = _install_bundle_files(
|
|
398
|
+
contents.agents,
|
|
399
|
+
repo_dir / ".claude" / "agents" / bundle_name,
|
|
400
|
+
dest_base / "agents",
|
|
401
|
+
bundle_name,
|
|
402
|
+
overwrite,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def fetch_resource(
|
|
409
|
+
repo_username: str,
|
|
410
|
+
repo_name: str,
|
|
411
|
+
name: str,
|
|
412
|
+
path_segments: list[str],
|
|
413
|
+
dest: Path,
|
|
414
|
+
resource_type: ResourceType,
|
|
415
|
+
overwrite: bool = False,
|
|
416
|
+
username: str | None = None,
|
|
417
|
+
) -> Path:
|
|
418
|
+
"""
|
|
419
|
+
Fetch a resource from a user's GitHub repo and copy it to dest.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
repo_username: GitHub username (repo owner)
|
|
423
|
+
repo_name: GitHub repository name
|
|
424
|
+
name: Display name of the resource (may contain colons for nested paths)
|
|
425
|
+
path_segments: Path segments for the resource (e.g., ['dir', 'hello-world'])
|
|
426
|
+
dest: Destination directory (e.g., .claude/skills/, .claude/commands/)
|
|
427
|
+
resource_type: Type of resource (SKILL, COMMAND, or AGENT)
|
|
428
|
+
overwrite: Whether to overwrite existing resource
|
|
429
|
+
username: GitHub username for namespaced installation (when provided,
|
|
430
|
+
installs to dest/username/name/ instead of dest/name/)
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Path to the installed resource
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
RepoNotFoundError: If the repository doesn't exist
|
|
437
|
+
ResourceNotFoundError: If the resource doesn't exist in the repo
|
|
438
|
+
ResourceExistsError: If resource exists locally and overwrite=False
|
|
439
|
+
"""
|
|
440
|
+
config = RESOURCE_CONFIGS[resource_type]
|
|
441
|
+
|
|
442
|
+
# Build destination path - namespaced if username provided
|
|
443
|
+
if username:
|
|
444
|
+
namespaced_dest = dest / username
|
|
445
|
+
resource_dest = _build_resource_path(namespaced_dest, config, path_segments)
|
|
446
|
+
else:
|
|
447
|
+
resource_dest = _build_resource_path(dest, config, path_segments)
|
|
448
|
+
|
|
449
|
+
# Check if resource already exists locally
|
|
450
|
+
if resource_dest.exists() and not overwrite:
|
|
451
|
+
raise ResourceExistsError(
|
|
452
|
+
f"{resource_type.value.capitalize()} '{name}' already exists at {resource_dest}\n"
|
|
453
|
+
f"Use --overwrite to replace it."
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Download tarball
|
|
457
|
+
tarball_url = (
|
|
458
|
+
f"https://github.com/{repo_username}/{repo_name}/archive/refs/heads/main.tar.gz"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
462
|
+
repo_dir = _download_and_extract_tarball(tarball_url, repo_username, repo_name, Path(tmp_dir))
|
|
463
|
+
source_base = repo_dir / config.source_subdir
|
|
464
|
+
resource_source = _build_resource_path(source_base, config, path_segments)
|
|
465
|
+
|
|
466
|
+
if not resource_source.exists():
|
|
467
|
+
# Build display path for error message
|
|
468
|
+
nested_path = "/".join(path_segments)
|
|
469
|
+
if config.is_directory:
|
|
470
|
+
expected_location = f"{config.source_subdir}/{nested_path}/"
|
|
471
|
+
else:
|
|
472
|
+
expected_location = f"{config.source_subdir}/{nested_path}{config.file_extension}"
|
|
473
|
+
raise ResourceNotFoundError(
|
|
474
|
+
f"{resource_type.value.capitalize()} '{name}' not found in {repo_username}/{repo_name}.\n"
|
|
475
|
+
f"Expected location: {expected_location}"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Remove existing if overwriting
|
|
479
|
+
if resource_dest.exists():
|
|
480
|
+
if config.is_directory:
|
|
481
|
+
shutil.rmtree(resource_dest)
|
|
482
|
+
else:
|
|
483
|
+
resource_dest.unlink()
|
|
484
|
+
|
|
485
|
+
# Ensure destination parent exists (including nested directories)
|
|
486
|
+
resource_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
|
|
488
|
+
# Copy resource to destination
|
|
489
|
+
if config.is_directory:
|
|
490
|
+
shutil.copytree(resource_source, resource_dest)
|
|
491
|
+
else:
|
|
492
|
+
shutil.copy2(resource_source, resource_dest)
|
|
493
|
+
|
|
494
|
+
return resource_dest
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# Bundle-related dataclasses and functions
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@dataclass
|
|
501
|
+
class BundleContents:
|
|
502
|
+
"""Discovered resources in a bundle."""
|
|
503
|
+
|
|
504
|
+
bundle_name: str
|
|
505
|
+
skills: list[str] = field(default_factory=list)
|
|
506
|
+
commands: list[str] = field(default_factory=list)
|
|
507
|
+
agents: list[str] = field(default_factory=list)
|
|
508
|
+
|
|
509
|
+
@property
|
|
510
|
+
def is_empty(self) -> bool:
|
|
511
|
+
return not (self.skills or self.commands or self.agents)
|
|
512
|
+
|
|
513
|
+
@property
|
|
514
|
+
def total_count(self) -> int:
|
|
515
|
+
return len(self.skills) + len(self.commands) + len(self.agents)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@dataclass
|
|
519
|
+
class BundleInstallResult:
|
|
520
|
+
"""Result of bundle installation."""
|
|
521
|
+
|
|
522
|
+
installed_skills: list[str] = field(default_factory=list)
|
|
523
|
+
installed_commands: list[str] = field(default_factory=list)
|
|
524
|
+
installed_agents: list[str] = field(default_factory=list)
|
|
525
|
+
skipped_skills: list[str] = field(default_factory=list)
|
|
526
|
+
skipped_commands: list[str] = field(default_factory=list)
|
|
527
|
+
skipped_agents: list[str] = field(default_factory=list)
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def total_installed(self) -> int:
|
|
531
|
+
return (
|
|
532
|
+
len(self.installed_skills)
|
|
533
|
+
+ len(self.installed_commands)
|
|
534
|
+
+ len(self.installed_agents)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
@property
|
|
538
|
+
def total_skipped(self) -> int:
|
|
539
|
+
return (
|
|
540
|
+
len(self.skipped_skills)
|
|
541
|
+
+ len(self.skipped_commands)
|
|
542
|
+
+ len(self.skipped_agents)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@dataclass
|
|
547
|
+
class BundleRemoveResult:
|
|
548
|
+
"""Result of bundle removal."""
|
|
549
|
+
|
|
550
|
+
removed_skills: list[str] = field(default_factory=list)
|
|
551
|
+
removed_commands: list[str] = field(default_factory=list)
|
|
552
|
+
removed_agents: list[str] = field(default_factory=list)
|
|
553
|
+
|
|
554
|
+
@property
|
|
555
|
+
def is_empty(self) -> bool:
|
|
556
|
+
return not (self.removed_skills or self.removed_commands or self.removed_agents)
|
|
557
|
+
|
|
558
|
+
@property
|
|
559
|
+
def total_removed(self) -> int:
|
|
560
|
+
return (
|
|
561
|
+
len(self.removed_skills)
|
|
562
|
+
+ len(self.removed_commands)
|
|
563
|
+
+ len(self.removed_agents)
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def discover_bundle_contents(repo_dir: Path, bundle_name: str) -> BundleContents:
|
|
568
|
+
"""
|
|
569
|
+
Discover all resources within a bundle directory.
|
|
570
|
+
|
|
571
|
+
Looks for:
|
|
572
|
+
- .claude/skills/{bundle_name}/*/SKILL.md -> skill directories
|
|
573
|
+
- .claude/commands/{bundle_name}/*.md -> command files
|
|
574
|
+
- .claude/agents/{bundle_name}/*.md -> agent files
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
repo_dir: Path to extracted repository
|
|
578
|
+
bundle_name: Name of the bundle directory
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
BundleContents with lists of discovered resources
|
|
582
|
+
"""
|
|
583
|
+
contents = BundleContents(bundle_name=bundle_name)
|
|
584
|
+
|
|
585
|
+
# Discover skills: look for subdirectories with SKILL.md
|
|
586
|
+
skills_bundle_dir = repo_dir / ".claude" / "skills" / bundle_name
|
|
587
|
+
if skills_bundle_dir.is_dir():
|
|
588
|
+
for skill_dir in skills_bundle_dir.iterdir():
|
|
589
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
590
|
+
contents.skills.append(skill_dir.name)
|
|
591
|
+
|
|
592
|
+
# Discover commands: look for .md files
|
|
593
|
+
commands_bundle_dir = repo_dir / ".claude" / "commands" / bundle_name
|
|
594
|
+
if commands_bundle_dir.is_dir():
|
|
595
|
+
for cmd_file in commands_bundle_dir.glob("*.md"):
|
|
596
|
+
contents.commands.append(cmd_file.stem)
|
|
597
|
+
|
|
598
|
+
# Discover agents: look for .md files
|
|
599
|
+
agents_bundle_dir = repo_dir / ".claude" / "agents" / bundle_name
|
|
600
|
+
if agents_bundle_dir.is_dir():
|
|
601
|
+
for agent_file in agents_bundle_dir.glob("*.md"):
|
|
602
|
+
contents.agents.append(agent_file.stem)
|
|
603
|
+
|
|
604
|
+
return contents
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _install_bundle_directory(
|
|
608
|
+
names: list[str],
|
|
609
|
+
src_base: Path,
|
|
610
|
+
dest_base: Path,
|
|
611
|
+
bundle_name: str,
|
|
612
|
+
overwrite: bool,
|
|
613
|
+
) -> tuple[list[str], list[str]]:
|
|
614
|
+
"""Install directory-based resources (skills) from a bundle."""
|
|
615
|
+
installed = []
|
|
616
|
+
skipped = []
|
|
617
|
+
for name in names:
|
|
618
|
+
dest_path = dest_base / bundle_name / name
|
|
619
|
+
src_path = src_base / name
|
|
620
|
+
|
|
621
|
+
if dest_path.exists() and not overwrite:
|
|
622
|
+
skipped.append(name)
|
|
623
|
+
continue
|
|
624
|
+
|
|
625
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
626
|
+
if dest_path.exists():
|
|
627
|
+
shutil.rmtree(dest_path)
|
|
628
|
+
shutil.copytree(src_path, dest_path)
|
|
629
|
+
installed.append(name)
|
|
630
|
+
return installed, skipped
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _install_bundle_files(
|
|
634
|
+
names: list[str],
|
|
635
|
+
src_base: Path,
|
|
636
|
+
dest_base: Path,
|
|
637
|
+
bundle_name: str,
|
|
638
|
+
overwrite: bool,
|
|
639
|
+
) -> tuple[list[str], list[str]]:
|
|
640
|
+
"""Install file-based resources (commands, agents) from a bundle."""
|
|
641
|
+
installed = []
|
|
642
|
+
skipped = []
|
|
643
|
+
for name in names:
|
|
644
|
+
dest_path = dest_base / bundle_name / f"{name}.md"
|
|
645
|
+
src_path = src_base / f"{name}.md"
|
|
646
|
+
|
|
647
|
+
if dest_path.exists() and not overwrite:
|
|
648
|
+
skipped.append(name)
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
if dest_path.exists():
|
|
653
|
+
dest_path.unlink()
|
|
654
|
+
shutil.copy2(src_path, dest_path)
|
|
655
|
+
installed.append(name)
|
|
656
|
+
return installed, skipped
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def fetch_bundle(
|
|
660
|
+
username: str,
|
|
661
|
+
repo_name: str,
|
|
662
|
+
bundle_name: str,
|
|
663
|
+
dest_base: Path,
|
|
664
|
+
overwrite: bool = False,
|
|
665
|
+
) -> BundleInstallResult:
|
|
666
|
+
"""
|
|
667
|
+
Fetch and install all resources from a bundle.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
username: GitHub username
|
|
671
|
+
repo_name: GitHub repository name
|
|
672
|
+
bundle_name: Name of the bundle directory
|
|
673
|
+
dest_base: Base destination directory (e.g., .claude/)
|
|
674
|
+
overwrite: Whether to overwrite existing resources
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
BundleInstallResult with installed and skipped resources
|
|
678
|
+
|
|
679
|
+
Raises:
|
|
680
|
+
RepoNotFoundError: If the repository doesn't exist
|
|
681
|
+
BundleNotFoundError: If bundle directory doesn't exist in any location
|
|
682
|
+
"""
|
|
683
|
+
tarball_url = (
|
|
684
|
+
f"https://github.com/{username}/{repo_name}/archive/refs/heads/main.tar.gz"
|
|
685
|
+
)
|
|
686
|
+
result = BundleInstallResult()
|
|
687
|
+
|
|
688
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
689
|
+
repo_dir = _download_and_extract_tarball(
|
|
690
|
+
tarball_url, username, repo_name, Path(tmp_dir)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
contents = discover_bundle_contents(repo_dir, bundle_name)
|
|
694
|
+
|
|
695
|
+
if contents.is_empty:
|
|
696
|
+
raise BundleNotFoundError(
|
|
697
|
+
f"Bundle '{bundle_name}' not found in {username}/{repo_name}.\n"
|
|
698
|
+
f"Expected one of:\n"
|
|
699
|
+
f" - .claude/skills/{bundle_name}/*/SKILL.md\n"
|
|
700
|
+
f" - .claude/commands/{bundle_name}/*.md\n"
|
|
701
|
+
f" - .claude/agents/{bundle_name}/*.md"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Install skills (directories)
|
|
705
|
+
result.installed_skills, result.skipped_skills = _install_bundle_directory(
|
|
706
|
+
contents.skills,
|
|
707
|
+
repo_dir / ".claude" / "skills" / bundle_name,
|
|
708
|
+
dest_base / "skills",
|
|
709
|
+
bundle_name,
|
|
710
|
+
overwrite,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
# Install commands (files)
|
|
714
|
+
result.installed_commands, result.skipped_commands = _install_bundle_files(
|
|
715
|
+
contents.commands,
|
|
716
|
+
repo_dir / ".claude" / "commands" / bundle_name,
|
|
717
|
+
dest_base / "commands",
|
|
718
|
+
bundle_name,
|
|
719
|
+
overwrite,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# Install agents (files)
|
|
723
|
+
result.installed_agents, result.skipped_agents = _install_bundle_files(
|
|
724
|
+
contents.agents,
|
|
725
|
+
repo_dir / ".claude" / "agents" / bundle_name,
|
|
726
|
+
dest_base / "agents",
|
|
727
|
+
bundle_name,
|
|
728
|
+
overwrite,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
return result
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def remove_bundle(bundle_name: str, dest_base: Path) -> BundleRemoveResult:
|
|
735
|
+
"""
|
|
736
|
+
Remove all local resources for a bundle.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
bundle_name: Name of the bundle to remove
|
|
740
|
+
dest_base: Base directory (e.g., .claude/)
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
BundleRemoveResult with lists of removed resources
|
|
744
|
+
|
|
745
|
+
Raises:
|
|
746
|
+
BundleNotFoundError: If bundle doesn't exist locally
|
|
747
|
+
"""
|
|
748
|
+
result = BundleRemoveResult()
|
|
749
|
+
|
|
750
|
+
# Check and remove skills bundle directory
|
|
751
|
+
skills_bundle_dir = dest_base / "skills" / bundle_name
|
|
752
|
+
if skills_bundle_dir.is_dir():
|
|
753
|
+
for skill_dir in skills_bundle_dir.iterdir():
|
|
754
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
755
|
+
result.removed_skills.append(skill_dir.name)
|
|
756
|
+
shutil.rmtree(skills_bundle_dir)
|
|
757
|
+
|
|
758
|
+
# Check and remove commands bundle directory
|
|
759
|
+
commands_bundle_dir = dest_base / "commands" / bundle_name
|
|
760
|
+
if commands_bundle_dir.is_dir():
|
|
761
|
+
for cmd_file in commands_bundle_dir.glob("*.md"):
|
|
762
|
+
result.removed_commands.append(cmd_file.stem)
|
|
763
|
+
shutil.rmtree(commands_bundle_dir)
|
|
764
|
+
|
|
765
|
+
# Check and remove agents bundle directory
|
|
766
|
+
agents_bundle_dir = dest_base / "agents" / bundle_name
|
|
767
|
+
if agents_bundle_dir.is_dir():
|
|
768
|
+
for agent_file in agents_bundle_dir.glob("*.md"):
|
|
769
|
+
result.removed_agents.append(agent_file.stem)
|
|
770
|
+
shutil.rmtree(agents_bundle_dir)
|
|
771
|
+
|
|
772
|
+
if result.is_empty:
|
|
773
|
+
raise BundleNotFoundError(
|
|
774
|
+
f"Bundle '{bundle_name}' not found locally.\n"
|
|
775
|
+
f"Expected one of:\n"
|
|
776
|
+
f" - {dest_base}/skills/{bundle_name}/\n"
|
|
777
|
+
f" - {dest_base}/commands/{bundle_name}/\n"
|
|
778
|
+
f" - {dest_base}/agents/{bundle_name}/"
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
return result
|