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/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