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