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/cli/common.py ADDED
@@ -0,0 +1,1085 @@
1
+ """Shared CLI utilities for agr commands."""
2
+
3
+ import random
4
+ import shutil
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.live import Live
11
+ from rich.spinner import Spinner
12
+
13
+ from agr.config import AgrConfig, DependencySpec, get_or_create_config
14
+ from agr.exceptions import (
15
+ AgrError,
16
+ BundleNotFoundError,
17
+ MultipleResourcesFoundError,
18
+ RepoNotFoundError,
19
+ ResourceExistsError,
20
+ ResourceNotFoundError,
21
+ )
22
+ from agr.fetcher import (
23
+ BundleInstallResult,
24
+ BundleRemoveResult,
25
+ DiscoveredResource,
26
+ DiscoveryResult,
27
+ RESOURCE_CONFIGS,
28
+ ResourceType,
29
+ discover_resource_type_from_dir,
30
+ downloaded_repo,
31
+ fetch_bundle,
32
+ fetch_bundle_from_repo_dir,
33
+ fetch_resource,
34
+ fetch_resource_from_repo_dir,
35
+ remove_bundle,
36
+ )
37
+
38
+ console = Console()
39
+
40
+ # Default repository name when not specified
41
+ DEFAULT_REPO_NAME = "agent-resources"
42
+
43
+
44
+ def parse_nested_name(name: str) -> tuple[str, list[str]]:
45
+ """
46
+ Parse a resource name that may contain colon-delimited path segments.
47
+
48
+ Args:
49
+ name: Resource name, possibly with colons (e.g., "dir:hello-world")
50
+
51
+ Returns:
52
+ Tuple of (base_name, path_segments) where:
53
+ - base_name is the final segment (e.g., "hello-world")
54
+ - path_segments is the full list of segments (e.g., ["dir", "hello-world"])
55
+
56
+ Raises:
57
+ typer.BadParameter: If the name has invalid colon usage
58
+ """
59
+ if not name:
60
+ raise typer.BadParameter("Resource name cannot be empty")
61
+
62
+ if name.startswith(":") or name.endswith(":"):
63
+ raise typer.BadParameter(
64
+ f"Invalid resource name '{name}': cannot start or end with ':'"
65
+ )
66
+
67
+ segments = name.split(":")
68
+
69
+ # Check for empty segments (consecutive colons)
70
+ if any(not seg for seg in segments):
71
+ raise typer.BadParameter(
72
+ f"Invalid resource name '{name}': contains empty path segments"
73
+ )
74
+
75
+ base_name = segments[-1]
76
+ return base_name, segments
77
+
78
+
79
+ def parse_resource_ref(ref: str) -> tuple[str, str, str, list[str]]:
80
+ """
81
+ Parse resource reference into components.
82
+
83
+ Supports two formats:
84
+ - '<username>/<name>' -> uses default 'agent-resources' repo
85
+ - '<username>/<repo>/<name>' -> uses custom repo
86
+
87
+ The name component can contain colons for nested paths:
88
+ - 'dir:hello-world' -> path segments ['dir', 'hello-world']
89
+
90
+ Args:
91
+ ref: Resource reference
92
+
93
+ Returns:
94
+ Tuple of (username, repo_name, resource_name, path_segments)
95
+ - resource_name: the full name with colons (for display)
96
+ - path_segments: list of path components (for file operations)
97
+
98
+ Raises:
99
+ typer.BadParameter: If the format is invalid
100
+ """
101
+ parts = ref.split("/")
102
+
103
+ if len(parts) == 2:
104
+ username, name = parts
105
+ repo = DEFAULT_REPO_NAME
106
+ elif len(parts) == 3:
107
+ username, repo, name = parts
108
+ else:
109
+ raise typer.BadParameter(
110
+ f"Invalid format: '{ref}'. Expected: <username>/<name> or <username>/<repo>/<name>"
111
+ )
112
+
113
+ if not username or not name or (len(parts) == 3 and not repo):
114
+ raise typer.BadParameter(
115
+ f"Invalid format: '{ref}'. Expected: <username>/<name> or <username>/<repo>/<name>"
116
+ )
117
+
118
+ # Parse nested path from name
119
+ _base_name, path_segments = parse_nested_name(name)
120
+
121
+ return username, repo, name, path_segments
122
+
123
+
124
+ def get_base_path(global_install: bool) -> Path:
125
+ """Get the base .claude directory path."""
126
+ if global_install:
127
+ return Path.home() / ".claude"
128
+ return Path.cwd() / ".claude"
129
+
130
+
131
+ def get_destination(resource_subdir: str, global_install: bool) -> Path:
132
+ """
133
+ Get the destination directory for a resource.
134
+
135
+ Args:
136
+ resource_subdir: The subdirectory name (e.g., "skills", "commands", "agents")
137
+ global_install: If True, install to ~/.claude/, else to ./.claude/
138
+
139
+ Returns:
140
+ Path to the destination directory
141
+ """
142
+ return get_base_path(global_install) / resource_subdir
143
+
144
+
145
+ def get_namespaced_destination(
146
+ username: str,
147
+ resource_name: str,
148
+ resource_subdir: str,
149
+ global_install: bool,
150
+ ) -> Path:
151
+ """
152
+ Get the namespaced destination path for a resource.
153
+
154
+ Namespaced paths include the username:
155
+ .claude/{subdir}/{username}/{name}/
156
+
157
+ Args:
158
+ username: GitHub username (e.g., "kasperjunge")
159
+ resource_name: Name of the resource (e.g., "commit")
160
+ resource_subdir: The subdirectory name (e.g., "skills", "commands", "agents")
161
+ global_install: If True, use ~/.claude/, else ./.claude/
162
+
163
+ Returns:
164
+ Path to the namespaced destination (e.g., .claude/skills/kasperjunge/commit/)
165
+ """
166
+ base = get_base_path(global_install)
167
+ return base / resource_subdir / username / resource_name
168
+
169
+
170
+ @contextmanager
171
+ def fetch_spinner():
172
+ """Show spinner during fetch operation."""
173
+ with Live(Spinner("dots", text="Fetching..."), console=console, transient=True):
174
+ yield
175
+
176
+
177
+ def print_success_message(resource_type: str, name: str, username: str, repo: str) -> None:
178
+ """Print branded success message with rotating CTA."""
179
+ console.print(f"[green]Added {resource_type} '{name}'[/green]")
180
+
181
+ # Build share reference based on whether custom repo was used
182
+ if repo == DEFAULT_REPO_NAME:
183
+ share_ref = f"{username}/{name}"
184
+ else:
185
+ share_ref = f"{username}/{repo}/{name}"
186
+
187
+ ctas = [
188
+ f"Create your own {resource_type} library: agr init repo agent-resources",
189
+ "Star: https://github.com/kasperjunge/agent-resources",
190
+ f"Share: agr add {resource_type} {share_ref}",
191
+ ]
192
+ console.print(f"[dim]{random.choice(ctas)}[/dim]")
193
+
194
+
195
+ def handle_add_resource(
196
+ resource_ref: str,
197
+ resource_type: ResourceType,
198
+ resource_subdir: str,
199
+ overwrite: bool = False,
200
+ global_install: bool = False,
201
+ username: str | None = None,
202
+ ) -> None:
203
+ """
204
+ Generic handler for adding any resource type.
205
+
206
+ Args:
207
+ resource_ref: Resource reference (e.g., "username/resource-name")
208
+ resource_type: Type of resource (SKILL, COMMAND, or AGENT)
209
+ resource_subdir: Destination subdirectory (e.g., "skills", "commands", "agents")
210
+ overwrite: Whether to overwrite existing resource
211
+ global_install: If True, install to ~/.claude/, else to ./.claude/
212
+ username: GitHub username for namespaced installation
213
+ """
214
+ try:
215
+ parsed_username, repo_name, name, path_segments = parse_resource_ref(resource_ref)
216
+ except typer.BadParameter as e:
217
+ typer.echo(f"Error: {e}", err=True)
218
+ raise typer.Exit(1)
219
+
220
+ # Use parsed username if not provided
221
+ install_username = username or parsed_username
222
+
223
+ dest = get_destination(resource_subdir, global_install)
224
+
225
+ try:
226
+ with fetch_spinner():
227
+ fetch_resource(
228
+ parsed_username, repo_name, name, path_segments, dest, resource_type, overwrite,
229
+ username=install_username,
230
+ )
231
+ print_success_message(resource_type.value, name, parsed_username, repo_name)
232
+ except (RepoNotFoundError, ResourceNotFoundError, ResourceExistsError, AgrError) as e:
233
+ typer.echo(f"Error: {e}", err=True)
234
+ raise typer.Exit(1)
235
+
236
+
237
+ def get_local_resource_path(
238
+ name: str,
239
+ resource_subdir: str,
240
+ global_install: bool,
241
+ ) -> Path:
242
+ """
243
+ Build the local path for a resource based on its name and type.
244
+
245
+ Args:
246
+ name: Resource name (e.g., "hello-world")
247
+ resource_subdir: Subdirectory type ("skills", "commands", or "agents")
248
+ global_install: If True, look in ~/.claude/, else ./.claude/
249
+
250
+ Returns:
251
+ Path to the local resource (directory for skills, file for commands/agents)
252
+ """
253
+ dest = get_destination(resource_subdir, global_install)
254
+
255
+ if resource_subdir == "skills":
256
+ return dest / name
257
+ else:
258
+ # commands and agents are .md files
259
+ return dest / f"{name}.md"
260
+
261
+
262
+ def handle_update_resource(
263
+ resource_ref: str,
264
+ resource_type: ResourceType,
265
+ resource_subdir: str,
266
+ global_install: bool = False,
267
+ ) -> None:
268
+ """
269
+ Generic handler for updating any resource type.
270
+
271
+ Re-fetches the resource from GitHub and overwrites the local copy.
272
+
273
+ Args:
274
+ resource_ref: Resource reference (e.g., "username/resource-name")
275
+ resource_type: Type of resource (SKILL, COMMAND, or AGENT)
276
+ resource_subdir: Destination subdirectory (e.g., "skills", "commands", "agents")
277
+ global_install: If True, update in ~/.claude/, else in ./.claude/
278
+ """
279
+ try:
280
+ username, repo_name, name, path_segments = parse_resource_ref(resource_ref)
281
+ except typer.BadParameter as e:
282
+ typer.echo(f"Error: {e}", err=True)
283
+ raise typer.Exit(1)
284
+
285
+ # Get local resource path to verify it exists
286
+ local_path = get_local_resource_path(name, resource_subdir, global_install)
287
+
288
+ if not local_path.exists():
289
+ typer.echo(
290
+ f"Error: {resource_type.value.capitalize()} '{name}' not found locally at {local_path}",
291
+ err=True,
292
+ )
293
+ raise typer.Exit(1)
294
+
295
+ dest = get_destination(resource_subdir, global_install)
296
+
297
+ try:
298
+ with fetch_spinner():
299
+ fetch_resource(
300
+ username, repo_name, name, path_segments, dest, resource_type, overwrite=True
301
+ )
302
+ console.print(f"[green]Updated {resource_type.value} '{name}'[/green]")
303
+ except (RepoNotFoundError, ResourceNotFoundError, AgrError) as e:
304
+ typer.echo(f"Error: {e}", err=True)
305
+ raise typer.Exit(1)
306
+
307
+
308
+ def _get_namespaced_resource_path(
309
+ name: str,
310
+ username: str,
311
+ resource_subdir: str,
312
+ global_install: bool,
313
+ ) -> Path:
314
+ """Build the namespaced local path for a resource."""
315
+ dest = get_destination(resource_subdir, global_install)
316
+ if resource_subdir == "skills":
317
+ return dest / username / name
318
+ else:
319
+ return dest / username / f"{name}.md"
320
+
321
+
322
+ def _remove_from_agr_toml(
323
+ name: str,
324
+ username: str | None = None,
325
+ global_install: bool = False,
326
+ ) -> None:
327
+ """
328
+ Remove a dependency from agr.toml after removing resource.
329
+
330
+ Args:
331
+ name: Resource name
332
+ username: GitHub username (for building ref)
333
+ global_install: If True, don't update agr.toml
334
+ """
335
+ if global_install:
336
+ return
337
+
338
+ try:
339
+ from agr.config import find_config, AgrConfig
340
+
341
+ config_path = find_config()
342
+ if not config_path:
343
+ return
344
+
345
+ config = AgrConfig.load(config_path)
346
+
347
+ # Try to find and remove matching dependency
348
+ # Could be "username/name" or "username/repo/name"
349
+ refs_to_check = []
350
+ if username:
351
+ refs_to_check.append(f"{username}/{name}")
352
+ # Also check all refs ending with /name
353
+ for ref in list(config.dependencies.keys()):
354
+ if ref.endswith(f"/{name}"):
355
+ refs_to_check.append(ref)
356
+
357
+ removed = False
358
+ for ref in refs_to_check:
359
+ if ref in config.dependencies:
360
+ config.remove_dependency(ref)
361
+ removed = True
362
+ break
363
+
364
+ if removed:
365
+ config.save(config_path)
366
+ console.print(f"[dim]Removed from agr.toml[/dim]")
367
+ except Exception:
368
+ # Don't fail the remove if agr.toml update fails
369
+ pass
370
+
371
+
372
+ def _find_namespaced_resource(
373
+ name: str,
374
+ resource_subdir: str,
375
+ global_install: bool,
376
+ ) -> tuple[Path | None, str | None]:
377
+ """
378
+ Search all namespaced directories for a resource.
379
+
380
+ Returns:
381
+ Tuple of (path, username) if found, (None, None) otherwise
382
+ """
383
+ dest = get_destination(resource_subdir, global_install)
384
+ if not dest.exists():
385
+ return None, None
386
+
387
+ for username_dir in dest.iterdir():
388
+ if username_dir.is_dir():
389
+ if resource_subdir == "skills":
390
+ resource_path = username_dir / name
391
+ if resource_path.is_dir() and (resource_path / "SKILL.md").exists():
392
+ return resource_path, username_dir.name
393
+ else:
394
+ resource_path = username_dir / f"{name}.md"
395
+ if resource_path.is_file():
396
+ return resource_path, username_dir.name
397
+
398
+ return None, None
399
+
400
+
401
+ def handle_remove_resource(
402
+ name: str,
403
+ resource_type: ResourceType,
404
+ resource_subdir: str,
405
+ global_install: bool = False,
406
+ username: str | None = None,
407
+ ) -> None:
408
+ """
409
+ Generic handler for removing any resource type.
410
+
411
+ Removes the resource immediately without confirmation.
412
+ Searches namespaced paths first, then falls back to flat paths.
413
+
414
+ Args:
415
+ name: Name of the resource to remove
416
+ resource_type: Type of resource (SKILL, COMMAND, or AGENT)
417
+ resource_subdir: Destination subdirectory (e.g., "skills", "commands", "agents")
418
+ global_install: If True, remove from ~/.claude/, else from ./.claude/
419
+ username: GitHub username for namespaced path lookup
420
+ """
421
+ local_path = None
422
+ found_username = username
423
+
424
+ # Try namespaced path first if username provided
425
+ if username:
426
+ namespaced_path = _get_namespaced_resource_path(name, username, resource_subdir, global_install)
427
+ if namespaced_path.exists():
428
+ local_path = namespaced_path
429
+
430
+ # If not found and no username, search all namespaced directories
431
+ if local_path is None and username is None:
432
+ local_path, found_username = _find_namespaced_resource(name, resource_subdir, global_install)
433
+
434
+ # If still not found, try flat path
435
+ if local_path is None:
436
+ flat_path = get_local_resource_path(name, resource_subdir, global_install)
437
+ if flat_path.exists():
438
+ local_path = flat_path
439
+ found_username = None # Flat path, no username
440
+
441
+ if local_path is None:
442
+ typer.echo(
443
+ f"Error: {resource_type.value.capitalize()} '{name}' not found locally",
444
+ err=True,
445
+ )
446
+ raise typer.Exit(1)
447
+
448
+ try:
449
+ if local_path.is_dir():
450
+ shutil.rmtree(local_path)
451
+ else:
452
+ local_path.unlink()
453
+
454
+ # Clean up empty username directory if this was a namespaced resource
455
+ if found_username:
456
+ username_dir = local_path.parent
457
+ if username_dir.exists() and not any(username_dir.iterdir()):
458
+ username_dir.rmdir()
459
+
460
+ console.print(f"[green]Removed {resource_type.value} '{name}'[/green]")
461
+
462
+ # Update agr.toml
463
+ _remove_from_agr_toml(name, found_username, global_install)
464
+
465
+ except OSError as e:
466
+ typer.echo(f"Error: Failed to remove resource: {e}", err=True)
467
+ raise typer.Exit(1)
468
+
469
+
470
+ # Bundle handlers
471
+
472
+
473
+ def print_installed_resources(result: BundleInstallResult) -> None:
474
+ """Print the list of installed resources from a bundle result."""
475
+ if result.installed_skills:
476
+ skills_str = ", ".join(result.installed_skills)
477
+ console.print(f" [cyan]Skills ({len(result.installed_skills)}):[/cyan] {skills_str}")
478
+ if result.installed_commands:
479
+ commands_str = ", ".join(result.installed_commands)
480
+ console.print(f" [cyan]Commands ({len(result.installed_commands)}):[/cyan] {commands_str}")
481
+ if result.installed_agents:
482
+ agents_str = ", ".join(result.installed_agents)
483
+ console.print(f" [cyan]Agents ({len(result.installed_agents)}):[/cyan] {agents_str}")
484
+
485
+
486
+ def print_bundle_success_message(
487
+ bundle_name: str,
488
+ result: BundleInstallResult,
489
+ username: str,
490
+ repo: str,
491
+ ) -> None:
492
+ """Print detailed success message for bundle installation."""
493
+ console.print(f"[green]Installed bundle '{bundle_name}'[/green]")
494
+ print_installed_resources(result)
495
+
496
+ if result.total_skipped > 0:
497
+ console.print(
498
+ f"[yellow]Skipped {result.total_skipped} existing resource(s). "
499
+ "Use --overwrite to replace.[/yellow]"
500
+ )
501
+ if result.skipped_skills:
502
+ console.print(f" [dim]Skipped skills: {', '.join(result.skipped_skills)}[/dim]")
503
+ if result.skipped_commands:
504
+ console.print(f" [dim]Skipped commands: {', '.join(result.skipped_commands)}[/dim]")
505
+ if result.skipped_agents:
506
+ console.print(f" [dim]Skipped agents: {', '.join(result.skipped_agents)}[/dim]")
507
+
508
+ # Build share reference
509
+ if repo == DEFAULT_REPO_NAME:
510
+ share_ref = f"{username}/{bundle_name}"
511
+ else:
512
+ share_ref = f"{username}/{repo}/{bundle_name}"
513
+
514
+ ctas = [
515
+ f"Create your own bundle: organize resources under .claude/*/bundle-name/",
516
+ "Star: https://github.com/kasperjunge/agent-resources",
517
+ f"Share: agr add bundle {share_ref}",
518
+ ]
519
+ console.print(f"[dim]{random.choice(ctas)}[/dim]")
520
+
521
+
522
+ def print_bundle_remove_message(bundle_name: str, result: BundleRemoveResult) -> None:
523
+ """Print detailed message for bundle removal."""
524
+ console.print(f"[green]Removed bundle '{bundle_name}'[/green]")
525
+
526
+ if result.removed_skills:
527
+ skills_str = ", ".join(result.removed_skills)
528
+ console.print(f" [dim]Skills ({len(result.removed_skills)}): {skills_str}[/dim]")
529
+ if result.removed_commands:
530
+ commands_str = ", ".join(result.removed_commands)
531
+ console.print(f" [dim]Commands ({len(result.removed_commands)}): {commands_str}[/dim]")
532
+ if result.removed_agents:
533
+ agents_str = ", ".join(result.removed_agents)
534
+ console.print(f" [dim]Agents ({len(result.removed_agents)}): {agents_str}[/dim]")
535
+
536
+
537
+ def handle_add_bundle(
538
+ bundle_ref: str,
539
+ overwrite: bool = False,
540
+ global_install: bool = False,
541
+ ) -> None:
542
+ """
543
+ Handler for adding a bundle of resources.
544
+
545
+ Args:
546
+ bundle_ref: Bundle reference (e.g., "username/bundle-name")
547
+ overwrite: Whether to overwrite existing resources
548
+ global_install: If True, install to ~/.claude/, else to ./.claude/
549
+ """
550
+ try:
551
+ username, repo_name, bundle_name, _path_segments = parse_resource_ref(bundle_ref)
552
+ except typer.BadParameter as e:
553
+ typer.echo(f"Error: {e}", err=True)
554
+ raise typer.Exit(1)
555
+
556
+ dest_base = get_base_path(global_install)
557
+
558
+ try:
559
+ with fetch_spinner():
560
+ result = fetch_bundle(username, repo_name, bundle_name, dest_base, overwrite)
561
+
562
+ if result.total_installed == 0 and result.total_skipped > 0:
563
+ console.print(f"[yellow]No new resources installed from bundle '{bundle_name}'.[/yellow]")
564
+ console.print("[yellow]All resources already exist. Use --overwrite to replace.[/yellow]")
565
+ else:
566
+ print_bundle_success_message(bundle_name, result, username, repo_name)
567
+
568
+ except (RepoNotFoundError, BundleNotFoundError, AgrError) as e:
569
+ typer.echo(f"Error: {e}", err=True)
570
+ raise typer.Exit(1)
571
+
572
+
573
+ def handle_update_bundle(
574
+ bundle_ref: str,
575
+ global_install: bool = False,
576
+ ) -> None:
577
+ """
578
+ Handler for updating a bundle by re-fetching from GitHub.
579
+
580
+ Args:
581
+ bundle_ref: Bundle reference (e.g., "username/bundle-name")
582
+ global_install: If True, update in ~/.claude/, else in ./.claude/
583
+ """
584
+ try:
585
+ username, repo_name, bundle_name, _path_segments = parse_resource_ref(bundle_ref)
586
+ except typer.BadParameter as e:
587
+ typer.echo(f"Error: {e}", err=True)
588
+ raise typer.Exit(1)
589
+
590
+ dest_base = get_base_path(global_install)
591
+
592
+ try:
593
+ with fetch_spinner():
594
+ result = fetch_bundle(username, repo_name, bundle_name, dest_base, overwrite=True)
595
+
596
+ console.print(f"[green]Updated bundle '{bundle_name}'[/green]")
597
+ print_installed_resources(result)
598
+
599
+ except (RepoNotFoundError, BundleNotFoundError, AgrError) as e:
600
+ typer.echo(f"Error: {e}", err=True)
601
+ raise typer.Exit(1)
602
+
603
+
604
+ def handle_remove_bundle(
605
+ bundle_name: str,
606
+ global_install: bool = False,
607
+ ) -> None:
608
+ """
609
+ Handler for removing a bundle.
610
+
611
+ Args:
612
+ bundle_name: Name of the bundle to remove
613
+ global_install: If True, remove from ~/.claude/, else from ./.claude/
614
+ """
615
+ dest_base = get_base_path(global_install)
616
+
617
+ try:
618
+ result = remove_bundle(bundle_name, dest_base)
619
+ print_bundle_remove_message(bundle_name, result)
620
+ except BundleNotFoundError as e:
621
+ typer.echo(f"Error: {e}", err=True)
622
+ raise typer.Exit(1)
623
+ except OSError as e:
624
+ typer.echo(f"Error: Failed to remove bundle: {e}", err=True)
625
+ raise typer.Exit(1)
626
+
627
+
628
+ # Unified handlers for auto-detection
629
+
630
+
631
+ def discover_local_resource_type(name: str, global_install: bool) -> DiscoveryResult:
632
+ """
633
+ Discover which resource types exist locally for a given name.
634
+
635
+ Searches both namespaced paths (.claude/skills/username/name/) and
636
+ flat paths (.claude/skills/name/) for backward compatibility.
637
+
638
+ The name can be:
639
+ - Simple name: "commit" - searches all usernames and flat path
640
+ - Full ref: "kasperjunge/commit" - searches specific username only
641
+
642
+ Args:
643
+ name: Resource name or full ref (username/name) to search for
644
+ global_install: If True, search in ~/.claude/, else in ./.claude/
645
+
646
+ Returns:
647
+ DiscoveryResult with list of found resource types
648
+ """
649
+ result = DiscoveryResult()
650
+ base_path = get_base_path(global_install)
651
+
652
+ # Check if name is a full ref (username/name)
653
+ if "/" in name:
654
+ parts = name.split("/")
655
+ if len(parts) == 2:
656
+ username, resource_name = parts
657
+ # Search only in specific namespace
658
+ _discover_in_namespace(
659
+ base_path, resource_name, username, result
660
+ )
661
+ return result
662
+
663
+ # Simple name - search both namespaced and flat paths
664
+ # First check namespaced paths (.claude/skills/*/name/)
665
+ _discover_in_all_namespaces(base_path, name, result)
666
+
667
+ # Then check flat paths (.claude/skills/name/) for backward compat
668
+ _discover_in_flat_path(base_path, name, result)
669
+
670
+ return result
671
+
672
+
673
+ def _discover_in_namespace(
674
+ base_path: Path,
675
+ name: str,
676
+ username: str,
677
+ result: DiscoveryResult,
678
+ ) -> None:
679
+ """Discover resources in a specific username namespace."""
680
+ # Check for skill
681
+ skill_path = base_path / "skills" / username / name
682
+ if skill_path.is_dir() and (skill_path / "SKILL.md").exists():
683
+ result.resources.append(
684
+ DiscoveredResource(
685
+ name=name,
686
+ resource_type=ResourceType.SKILL,
687
+ path_segments=[name],
688
+ username=username,
689
+ )
690
+ )
691
+
692
+ # Check for command
693
+ command_path = base_path / "commands" / username / f"{name}.md"
694
+ if command_path.is_file():
695
+ result.resources.append(
696
+ DiscoveredResource(
697
+ name=name,
698
+ resource_type=ResourceType.COMMAND,
699
+ path_segments=[name],
700
+ username=username,
701
+ )
702
+ )
703
+
704
+ # Check for agent
705
+ agent_path = base_path / "agents" / username / f"{name}.md"
706
+ if agent_path.is_file():
707
+ result.resources.append(
708
+ DiscoveredResource(
709
+ name=name,
710
+ resource_type=ResourceType.AGENT,
711
+ path_segments=[name],
712
+ username=username,
713
+ )
714
+ )
715
+
716
+
717
+ def _discover_in_all_namespaces(
718
+ base_path: Path,
719
+ name: str,
720
+ result: DiscoveryResult,
721
+ ) -> None:
722
+ """Discover resources across all username namespaces."""
723
+ # Check skills namespaces
724
+ skills_dir = base_path / "skills"
725
+ if skills_dir.is_dir():
726
+ for username_dir in skills_dir.iterdir():
727
+ if username_dir.is_dir():
728
+ skill_path = username_dir / name
729
+ if skill_path.is_dir() and (skill_path / "SKILL.md").exists():
730
+ result.resources.append(
731
+ DiscoveredResource(
732
+ name=name,
733
+ resource_type=ResourceType.SKILL,
734
+ path_segments=[name],
735
+ username=username_dir.name,
736
+ )
737
+ )
738
+
739
+ # Check commands namespaces
740
+ commands_dir = base_path / "commands"
741
+ if commands_dir.is_dir():
742
+ for username_dir in commands_dir.iterdir():
743
+ if username_dir.is_dir():
744
+ command_path = username_dir / f"{name}.md"
745
+ if command_path.is_file():
746
+ result.resources.append(
747
+ DiscoveredResource(
748
+ name=name,
749
+ resource_type=ResourceType.COMMAND,
750
+ path_segments=[name],
751
+ username=username_dir.name,
752
+ )
753
+ )
754
+
755
+ # Check agents namespaces
756
+ agents_dir = base_path / "agents"
757
+ if agents_dir.is_dir():
758
+ for username_dir in agents_dir.iterdir():
759
+ if username_dir.is_dir():
760
+ agent_path = username_dir / f"{name}.md"
761
+ if agent_path.is_file():
762
+ result.resources.append(
763
+ DiscoveredResource(
764
+ name=name,
765
+ resource_type=ResourceType.AGENT,
766
+ path_segments=[name],
767
+ username=username_dir.name,
768
+ )
769
+ )
770
+
771
+
772
+ def _discover_in_flat_path(
773
+ base_path: Path,
774
+ name: str,
775
+ result: DiscoveryResult,
776
+ ) -> None:
777
+ """Discover resources in flat (non-namespaced) paths for backward compat."""
778
+ # Check for skill (directory with SKILL.md)
779
+ skill_path = base_path / "skills" / name
780
+ if skill_path.is_dir() and (skill_path / "SKILL.md").exists():
781
+ result.resources.append(
782
+ DiscoveredResource(
783
+ name=name,
784
+ resource_type=ResourceType.SKILL,
785
+ path_segments=[name],
786
+ username=None,
787
+ )
788
+ )
789
+
790
+ # Check for command (markdown file)
791
+ command_path = base_path / "commands" / f"{name}.md"
792
+ if command_path.is_file():
793
+ result.resources.append(
794
+ DiscoveredResource(
795
+ name=name,
796
+ resource_type=ResourceType.COMMAND,
797
+ path_segments=[name],
798
+ username=None,
799
+ )
800
+ )
801
+
802
+ # Check for agent (markdown file)
803
+ agent_path = base_path / "agents" / f"{name}.md"
804
+ if agent_path.is_file():
805
+ result.resources.append(
806
+ DiscoveredResource(
807
+ name=name,
808
+ resource_type=ResourceType.AGENT,
809
+ path_segments=[name],
810
+ username=None,
811
+ )
812
+ )
813
+
814
+
815
+
816
+ def _build_dependency_ref(username: str, repo_name: str, name: str) -> str:
817
+ """Build the dependency reference for agr.toml."""
818
+ if repo_name == DEFAULT_REPO_NAME:
819
+ return f"{username}/{name}"
820
+ return f"{username}/{repo_name}/{name}"
821
+
822
+
823
+ def _add_to_agr_toml(
824
+ resource_ref: str,
825
+ resource_type: ResourceType | None = None,
826
+ global_install: bool = False,
827
+ ) -> None:
828
+ """
829
+ Add a dependency to agr.toml after successful install.
830
+
831
+ Args:
832
+ resource_ref: The dependency reference (e.g., "kasperjunge/commit")
833
+ resource_type: Optional type hint for the dependency
834
+ global_install: If True, don't update agr.toml (global resources aren't tracked)
835
+ """
836
+ # Don't track global installs in agr.toml
837
+ if global_install:
838
+ return
839
+
840
+ try:
841
+ config_path, config = get_or_create_config()
842
+ spec = DependencySpec(type=resource_type.value if resource_type else None)
843
+ config.add_dependency(resource_ref, spec)
844
+ config.save(config_path)
845
+ console.print(f"[dim]Added to agr.toml[/dim]")
846
+ except Exception:
847
+ # Don't fail the install if agr.toml update fails
848
+ pass
849
+
850
+
851
+ def handle_add_unified(
852
+ resource_ref: str,
853
+ resource_type: str | None = None,
854
+ overwrite: bool = False,
855
+ global_install: bool = False,
856
+ ) -> None:
857
+ """
858
+ Unified handler for adding any resource with auto-detection.
859
+
860
+ Installs resources to namespaced paths (.claude/skills/username/name/)
861
+ and tracks them in agr.toml.
862
+
863
+ Args:
864
+ resource_ref: Resource reference (e.g., "username/resource-name")
865
+ resource_type: Optional explicit type ("skill", "command", "agent", "bundle")
866
+ overwrite: Whether to overwrite existing resource
867
+ global_install: If True, install to ~/.claude/, else to ./.claude/
868
+ """
869
+ try:
870
+ username, repo_name, name, path_segments = parse_resource_ref(resource_ref)
871
+ except typer.BadParameter as e:
872
+ typer.echo(f"Error: {e}", err=True)
873
+ raise typer.Exit(1)
874
+
875
+ # Build dependency ref for agr.toml
876
+ dep_ref = _build_dependency_ref(username, repo_name, name)
877
+
878
+ # If explicit type provided, delegate to specific handler
879
+ if resource_type:
880
+ type_lower = resource_type.lower()
881
+ type_map = {
882
+ "skill": (ResourceType.SKILL, "skills"),
883
+ "command": (ResourceType.COMMAND, "commands"),
884
+ "agent": (ResourceType.AGENT, "agents"),
885
+ }
886
+
887
+ if type_lower == "bundle":
888
+ handle_add_bundle(resource_ref, overwrite, global_install)
889
+ return
890
+
891
+ if type_lower not in type_map:
892
+ typer.echo(f"Error: Unknown resource type '{resource_type}'. Use: skill, command, agent, or bundle.", err=True)
893
+ raise typer.Exit(1)
894
+
895
+ res_type, subdir = type_map[type_lower]
896
+ handle_add_resource(resource_ref, res_type, subdir, overwrite, global_install, username=username)
897
+ _add_to_agr_toml(dep_ref, res_type, global_install)
898
+ return
899
+
900
+ # Auto-detect type by downloading repo once
901
+ try:
902
+ with fetch_spinner():
903
+ with downloaded_repo(username, repo_name) as repo_dir:
904
+ discovery = discover_resource_type_from_dir(repo_dir, name, path_segments)
905
+
906
+ if discovery.is_empty:
907
+ typer.echo(
908
+ f"Error: Resource '{name}' not found in {username}/{repo_name}.\n"
909
+ f"Searched in: skills, commands, agents, bundles.",
910
+ err=True,
911
+ )
912
+ raise typer.Exit(1)
913
+
914
+ if discovery.is_ambiguous:
915
+ # Build helpful example commands for each type found
916
+ ref = f"{username}/{name}" if repo_name == DEFAULT_REPO_NAME else f"{username}/{repo_name}/{name}"
917
+ examples = "\n".join(
918
+ f" agr add {ref} --type {t}" for t in discovery.found_types
919
+ )
920
+ raise MultipleResourcesFoundError(
921
+ f"Resource '{name}' found in multiple types: {', '.join(discovery.found_types)}.\n"
922
+ f"Use --type to specify which one to install:\n{examples}"
923
+ )
924
+
925
+ # Install the unique resource
926
+ dest_base = get_base_path(global_install)
927
+
928
+ if discovery.is_bundle:
929
+ bundle_name = path_segments[-1] if path_segments else name
930
+ result = fetch_bundle_from_repo_dir(repo_dir, bundle_name, dest_base, overwrite)
931
+ print_bundle_success_message(bundle_name, result, username, repo_name)
932
+ # Bundles are deprecated, don't add to agr.toml
933
+ else:
934
+ resource = discovery.resources[0]
935
+ res_config = RESOURCE_CONFIGS[resource.resource_type]
936
+ dest = dest_base / res_config.dest_subdir
937
+ # Use namespaced path with username
938
+ fetch_resource_from_repo_dir(
939
+ repo_dir, name, path_segments, dest, resource.resource_type, overwrite,
940
+ username=username,
941
+ )
942
+ print_success_message(resource.resource_type.value, name, username, repo_name)
943
+ # Add to agr.toml
944
+ _add_to_agr_toml(dep_ref, resource.resource_type, global_install)
945
+
946
+ except (RepoNotFoundError, ResourceExistsError, BundleNotFoundError, MultipleResourcesFoundError) as e:
947
+ typer.echo(f"Error: {e}", err=True)
948
+ raise typer.Exit(1)
949
+ except AgrError as e:
950
+ typer.echo(f"Error: {e}", err=True)
951
+ raise typer.Exit(1)
952
+
953
+
954
+ def handle_remove_unified(
955
+ name: str,
956
+ resource_type: str | None = None,
957
+ global_install: bool = False,
958
+ ) -> None:
959
+ """
960
+ Unified handler for removing any resource with auto-detection.
961
+
962
+ Supports both simple names ("commit") and full refs ("kasperjunge/commit").
963
+ Searches namespaced paths first, then falls back to flat paths.
964
+
965
+ Args:
966
+ name: Resource name or full ref (username/name) to remove
967
+ resource_type: Optional explicit type ("skill", "command", "agent", "bundle")
968
+ global_install: If True, remove from ~/.claude/, else from ./.claude/
969
+ """
970
+ # Parse if name is a full ref
971
+ parsed_username = None
972
+ resource_name = name
973
+ if "/" in name:
974
+ parts = name.split("/")
975
+ if len(parts) == 2:
976
+ parsed_username, resource_name = parts
977
+
978
+ # If explicit type provided, delegate to specific handler
979
+ if resource_type:
980
+ type_lower = resource_type.lower()
981
+ type_map = {
982
+ "skill": (ResourceType.SKILL, "skills"),
983
+ "command": (ResourceType.COMMAND, "commands"),
984
+ "agent": (ResourceType.AGENT, "agents"),
985
+ }
986
+
987
+ if type_lower == "bundle":
988
+ handle_remove_bundle(resource_name, global_install)
989
+ return
990
+
991
+ if type_lower not in type_map:
992
+ typer.echo(f"Error: Unknown resource type '{resource_type}'. Use: skill, command, agent, or bundle.", err=True)
993
+ raise typer.Exit(1)
994
+
995
+ res_type, subdir = type_map[type_lower]
996
+ handle_remove_resource(resource_name, res_type, subdir, global_install, username=parsed_username)
997
+ return
998
+
999
+ # Auto-detect type from local files
1000
+ discovery = discover_local_resource_type(name, global_install)
1001
+
1002
+ if discovery.is_empty:
1003
+ typer.echo(
1004
+ f"Error: Resource '{name}' not found locally.\n"
1005
+ f"Searched in: skills, commands, agents.",
1006
+ err=True,
1007
+ )
1008
+ raise typer.Exit(1)
1009
+
1010
+ if discovery.is_ambiguous:
1011
+ # Build helpful example commands for each type found
1012
+ examples = "\n".join(
1013
+ f" agr remove {name} --type {t}" for t in discovery.found_types
1014
+ )
1015
+ typer.echo(
1016
+ f"Error: Resource '{name}' found in multiple types: {', '.join(discovery.found_types)}.\n"
1017
+ f"Use --type to specify which one to remove:\n{examples}",
1018
+ err=True,
1019
+ )
1020
+ raise typer.Exit(1)
1021
+
1022
+ # Remove the unique resource
1023
+ resource = discovery.resources[0]
1024
+ # Pass username from discovery (could be from namespaced path or parsed ref)
1025
+ username = resource.username or parsed_username
1026
+ handle_remove_resource(
1027
+ resource_name,
1028
+ resource.resource_type,
1029
+ RESOURCE_CONFIGS[resource.resource_type].dest_subdir,
1030
+ global_install,
1031
+ username=username,
1032
+ )
1033
+
1034
+
1035
+ def discover_runnable_resource(
1036
+ repo_dir: Path,
1037
+ name: str,
1038
+ path_segments: list[str],
1039
+ ) -> DiscoveryResult:
1040
+ """
1041
+ Discover runnable resources (skills and commands only, not agents/bundles).
1042
+
1043
+ Used by agrx to determine what type of resource to run.
1044
+
1045
+ Args:
1046
+ repo_dir: Path to extracted repository
1047
+ name: Display name of the resource
1048
+ path_segments: Path segments for the resource
1049
+
1050
+ Returns:
1051
+ DiscoveryResult with list of discovered runnable resources
1052
+ """
1053
+ result = DiscoveryResult()
1054
+
1055
+ # Check for skill (directory with SKILL.md)
1056
+ skill_config = RESOURCE_CONFIGS[ResourceType.SKILL]
1057
+ skill_path = repo_dir / skill_config.source_subdir
1058
+ for segment in path_segments:
1059
+ skill_path = skill_path / segment
1060
+ if skill_path.is_dir() and (skill_path / "SKILL.md").exists():
1061
+ result.resources.append(
1062
+ DiscoveredResource(
1063
+ name=name,
1064
+ resource_type=ResourceType.SKILL,
1065
+ path_segments=path_segments,
1066
+ )
1067
+ )
1068
+
1069
+ # Check for command (markdown file)
1070
+ command_config = RESOURCE_CONFIGS[ResourceType.COMMAND]
1071
+ command_path = repo_dir / command_config.source_subdir
1072
+ for segment in path_segments[:-1]:
1073
+ command_path = command_path / segment
1074
+ if path_segments:
1075
+ command_path = command_path / f"{path_segments[-1]}.md"
1076
+ if command_path.is_file():
1077
+ result.resources.append(
1078
+ DiscoveredResource(
1079
+ name=name,
1080
+ resource_type=ResourceType.COMMAND,
1081
+ path_segments=path_segments,
1082
+ )
1083
+ )
1084
+
1085
+ return result