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/run.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""agrx - Run skills and commands without permanent installation."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, List, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from agr.cli.common import (
|
|
14
|
+
DEFAULT_REPO_NAME,
|
|
15
|
+
discover_runnable_resource,
|
|
16
|
+
fetch_spinner,
|
|
17
|
+
get_destination,
|
|
18
|
+
parse_resource_ref,
|
|
19
|
+
)
|
|
20
|
+
from agr.exceptions import AgrError, MultipleResourcesFoundError
|
|
21
|
+
from agr.fetcher import RESOURCE_CONFIGS, ResourceType, downloaded_repo, fetch_resource, fetch_resource_from_repo_dir
|
|
22
|
+
|
|
23
|
+
# Deprecated subcommand names
|
|
24
|
+
DEPRECATED_SUBCOMMANDS = {"skill", "command"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_type_from_args(
|
|
28
|
+
args: list[str] | None, explicit_type: str | None
|
|
29
|
+
) -> tuple[list[str], str | None]:
|
|
30
|
+
"""
|
|
31
|
+
Extract --type/-t option from args list if present.
|
|
32
|
+
|
|
33
|
+
When --type or -t appears after the resource reference, Typer captures it
|
|
34
|
+
as part of the variadic args list. This function extracts it.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
args: The argument list (may contain --type/-t)
|
|
38
|
+
explicit_type: The resource_type value from Typer (may be None if type was in args)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (cleaned_args, resource_type)
|
|
42
|
+
"""
|
|
43
|
+
if not args or explicit_type is not None:
|
|
44
|
+
return args or [], explicit_type
|
|
45
|
+
|
|
46
|
+
cleaned_args = []
|
|
47
|
+
resource_type = None
|
|
48
|
+
i = 0
|
|
49
|
+
while i < len(args):
|
|
50
|
+
if args[i] in ("--type", "-t") and i + 1 < len(args):
|
|
51
|
+
resource_type = args[i + 1]
|
|
52
|
+
i += 2 # Skip both --type and its value
|
|
53
|
+
else:
|
|
54
|
+
cleaned_args.append(args[i])
|
|
55
|
+
i += 1
|
|
56
|
+
|
|
57
|
+
return cleaned_args, resource_type
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
app = typer.Typer(
|
|
61
|
+
name="agrx",
|
|
62
|
+
help="Run skills and commands without permanent installation",
|
|
63
|
+
)
|
|
64
|
+
console = Console()
|
|
65
|
+
|
|
66
|
+
AGRX_PREFIX = "_agrx_" # Prefix for temporary resources to avoid conflicts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _check_claude_cli() -> None:
|
|
70
|
+
"""Check if Claude CLI is installed."""
|
|
71
|
+
if shutil.which("claude") is None:
|
|
72
|
+
console.print("[red]Error: Claude CLI not found.[/red]")
|
|
73
|
+
console.print("Install it from: https://claude.ai/download")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cleanup_resource(local_path: Path) -> None:
|
|
78
|
+
"""Clean up the temporary resource."""
|
|
79
|
+
if local_path.exists():
|
|
80
|
+
if local_path.is_dir():
|
|
81
|
+
shutil.rmtree(local_path)
|
|
82
|
+
else:
|
|
83
|
+
local_path.unlink()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_local_path(dest_dir: Path, prefixed_name: str, resource_type: ResourceType) -> Path:
|
|
87
|
+
"""Build the local path for a resource based on its type."""
|
|
88
|
+
config = RESOURCE_CONFIGS[resource_type]
|
|
89
|
+
if config.is_directory:
|
|
90
|
+
return dest_dir / prefixed_name
|
|
91
|
+
return dest_dir / f"{prefixed_name}{config.file_extension}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_resource(
|
|
95
|
+
ref: str,
|
|
96
|
+
resource_type: ResourceType,
|
|
97
|
+
prompt_or_args: str | None,
|
|
98
|
+
interactive: bool,
|
|
99
|
+
global_install: bool,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Download, run, and clean up a resource.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
ref: Resource reference (e.g., "username/skill-name")
|
|
106
|
+
resource_type: Type of resource (SKILL or COMMAND)
|
|
107
|
+
prompt_or_args: Optional prompt or arguments to pass
|
|
108
|
+
interactive: If True, start interactive Claude session
|
|
109
|
+
global_install: If True, install to ~/.claude/ instead of ./.claude/
|
|
110
|
+
"""
|
|
111
|
+
_check_claude_cli()
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
username, repo_name, name, path_segments = parse_resource_ref(ref)
|
|
115
|
+
except typer.BadParameter as e:
|
|
116
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
|
|
119
|
+
config = RESOURCE_CONFIGS[resource_type]
|
|
120
|
+
resource_name = path_segments[-1]
|
|
121
|
+
prefixed_name = f"{AGRX_PREFIX}{resource_name}"
|
|
122
|
+
|
|
123
|
+
dest_dir = get_destination(config.dest_subdir, global_install)
|
|
124
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
local_path = _build_local_path(dest_dir, prefixed_name, resource_type)
|
|
127
|
+
|
|
128
|
+
# Set up signal handlers for cleanup on interrupt
|
|
129
|
+
cleanup_done = False
|
|
130
|
+
|
|
131
|
+
def cleanup_handler(signum, frame):
|
|
132
|
+
nonlocal cleanup_done
|
|
133
|
+
if not cleanup_done:
|
|
134
|
+
cleanup_done = True
|
|
135
|
+
_cleanup_resource(local_path)
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
original_sigint = signal.signal(signal.SIGINT, cleanup_handler)
|
|
139
|
+
original_sigterm = signal.signal(signal.SIGTERM, cleanup_handler)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# Fetch the resource to original name first
|
|
143
|
+
with fetch_spinner():
|
|
144
|
+
fetch_resource(
|
|
145
|
+
username,
|
|
146
|
+
repo_name,
|
|
147
|
+
name,
|
|
148
|
+
path_segments,
|
|
149
|
+
dest_dir,
|
|
150
|
+
resource_type,
|
|
151
|
+
overwrite=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Rename to prefixed name to avoid conflicts
|
|
155
|
+
original_path = _build_local_path(dest_dir, resource_name, resource_type)
|
|
156
|
+
|
|
157
|
+
if original_path.exists() and original_path != local_path:
|
|
158
|
+
if local_path.exists():
|
|
159
|
+
_cleanup_resource(local_path)
|
|
160
|
+
original_path.rename(local_path)
|
|
161
|
+
|
|
162
|
+
console.print(f"[dim]Running {resource_type.value} '{name}'...[/dim]")
|
|
163
|
+
|
|
164
|
+
if interactive:
|
|
165
|
+
# Start interactive Claude session
|
|
166
|
+
subprocess.run(["claude"], check=False)
|
|
167
|
+
else:
|
|
168
|
+
# Build prompt: /<prefixed_name> [prompt_or_args]
|
|
169
|
+
claude_prompt = f"/{prefixed_name}"
|
|
170
|
+
if prompt_or_args:
|
|
171
|
+
claude_prompt += f" {prompt_or_args}"
|
|
172
|
+
subprocess.run(["claude", "-p", claude_prompt], check=False)
|
|
173
|
+
|
|
174
|
+
except AgrError as e:
|
|
175
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
finally:
|
|
178
|
+
# Restore original signal handlers
|
|
179
|
+
signal.signal(signal.SIGINT, original_sigint)
|
|
180
|
+
signal.signal(signal.SIGTERM, original_sigterm)
|
|
181
|
+
|
|
182
|
+
# Cleanup the resource
|
|
183
|
+
if not cleanup_done:
|
|
184
|
+
_cleanup_resource(local_path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _run_resource_unified(
|
|
188
|
+
ref: str,
|
|
189
|
+
prompt_or_args: str | None,
|
|
190
|
+
interactive: bool,
|
|
191
|
+
global_install: bool,
|
|
192
|
+
resource_type: str | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Download, run, and clean up a resource with auto-detection.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
ref: Resource reference (e.g., "username/skill-name")
|
|
199
|
+
prompt_or_args: Optional prompt or arguments to pass
|
|
200
|
+
interactive: If True, start interactive Claude session
|
|
201
|
+
global_install: If True, install to ~/.claude/ instead of ./.claude/
|
|
202
|
+
resource_type: Optional explicit type ("skill" or "command")
|
|
203
|
+
"""
|
|
204
|
+
_check_claude_cli()
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
username, repo_name, name, path_segments = parse_resource_ref(ref)
|
|
208
|
+
except typer.BadParameter as e:
|
|
209
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
# If explicit type provided, use existing handler
|
|
213
|
+
if resource_type:
|
|
214
|
+
type_lower = resource_type.lower()
|
|
215
|
+
if type_lower == "skill":
|
|
216
|
+
_run_resource(ref, ResourceType.SKILL, prompt_or_args, interactive, global_install)
|
|
217
|
+
return
|
|
218
|
+
elif type_lower == "command":
|
|
219
|
+
_run_resource(ref, ResourceType.COMMAND, prompt_or_args, interactive, global_install)
|
|
220
|
+
return
|
|
221
|
+
else:
|
|
222
|
+
console.print(f"[red]Error: Unknown resource type '{resource_type}'. Use: skill or command.[/red]")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
# Auto-detect type by downloading repo
|
|
226
|
+
try:
|
|
227
|
+
with fetch_spinner():
|
|
228
|
+
with downloaded_repo(username, repo_name) as repo_dir:
|
|
229
|
+
discovery = discover_runnable_resource(repo_dir, name, path_segments)
|
|
230
|
+
|
|
231
|
+
if discovery.is_empty:
|
|
232
|
+
console.print(
|
|
233
|
+
f"[red]Error: Resource '{name}' not found in {username}/{repo_name}.[/red]\n"
|
|
234
|
+
f"Searched in: skills, commands.",
|
|
235
|
+
)
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
|
|
238
|
+
if discovery.is_ambiguous:
|
|
239
|
+
# Build helpful example commands for each type found
|
|
240
|
+
ref = f"{username}/{name}" if repo_name == DEFAULT_REPO_NAME else f"{username}/{repo_name}/{name}"
|
|
241
|
+
examples = "\n".join(
|
|
242
|
+
f" agrx {ref} --type {t}" for t in discovery.found_types
|
|
243
|
+
)
|
|
244
|
+
console.print(
|
|
245
|
+
f"[red]Error: Resource '{name}' found in multiple types: {', '.join(discovery.found_types)}.[/red]\n"
|
|
246
|
+
f"Use --type to specify which one to run:\n{examples}",
|
|
247
|
+
)
|
|
248
|
+
raise typer.Exit(1)
|
|
249
|
+
|
|
250
|
+
# Use the discovered resource type
|
|
251
|
+
detected_type = discovery.resources[0].resource_type
|
|
252
|
+
config = RESOURCE_CONFIGS[detected_type]
|
|
253
|
+
resource_name = path_segments[-1]
|
|
254
|
+
prefixed_name = f"{AGRX_PREFIX}{resource_name}"
|
|
255
|
+
|
|
256
|
+
dest_dir = get_destination(config.dest_subdir, global_install)
|
|
257
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
|
|
259
|
+
local_path = _build_local_path(dest_dir, prefixed_name, detected_type)
|
|
260
|
+
|
|
261
|
+
# Fetch the resource from the already-downloaded repo
|
|
262
|
+
fetch_resource_from_repo_dir(
|
|
263
|
+
repo_dir, name, path_segments, dest_dir, detected_type, overwrite=True
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Rename to prefixed name to avoid conflicts
|
|
267
|
+
original_path = _build_local_path(dest_dir, resource_name, detected_type)
|
|
268
|
+
if original_path.exists() and original_path != local_path:
|
|
269
|
+
if local_path.exists():
|
|
270
|
+
_cleanup_resource(local_path)
|
|
271
|
+
original_path.rename(local_path)
|
|
272
|
+
|
|
273
|
+
# Set up signal handlers for cleanup on interrupt
|
|
274
|
+
cleanup_done = False
|
|
275
|
+
|
|
276
|
+
def cleanup_handler(signum, frame):
|
|
277
|
+
nonlocal cleanup_done
|
|
278
|
+
if not cleanup_done:
|
|
279
|
+
cleanup_done = True
|
|
280
|
+
_cleanup_resource(local_path)
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
|
|
283
|
+
original_sigint = signal.signal(signal.SIGINT, cleanup_handler)
|
|
284
|
+
original_sigterm = signal.signal(signal.SIGTERM, cleanup_handler)
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
console.print(f"[dim]Running {detected_type.value} '{name}'...[/dim]")
|
|
288
|
+
|
|
289
|
+
if interactive:
|
|
290
|
+
subprocess.run(["claude"], check=False)
|
|
291
|
+
else:
|
|
292
|
+
claude_prompt = f"/{prefixed_name}"
|
|
293
|
+
if prompt_or_args:
|
|
294
|
+
claude_prompt += f" {prompt_or_args}"
|
|
295
|
+
subprocess.run(["claude", "-p", claude_prompt], check=False)
|
|
296
|
+
finally:
|
|
297
|
+
signal.signal(signal.SIGINT, original_sigint)
|
|
298
|
+
signal.signal(signal.SIGTERM, original_sigterm)
|
|
299
|
+
if not cleanup_done:
|
|
300
|
+
_cleanup_resource(local_path)
|
|
301
|
+
|
|
302
|
+
except AgrError as e:
|
|
303
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
304
|
+
raise typer.Exit(1)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@app.callback(invoke_without_command=True)
|
|
308
|
+
def run_unified(
|
|
309
|
+
ctx: typer.Context,
|
|
310
|
+
args: Annotated[
|
|
311
|
+
Optional[List[str]],
|
|
312
|
+
typer.Argument(help="Resource reference and optional prompt"),
|
|
313
|
+
] = None,
|
|
314
|
+
resource_type: Annotated[
|
|
315
|
+
Optional[str],
|
|
316
|
+
typer.Option(
|
|
317
|
+
"--type",
|
|
318
|
+
"-t",
|
|
319
|
+
help="Explicit resource type: skill or command",
|
|
320
|
+
),
|
|
321
|
+
] = None,
|
|
322
|
+
interactive: Annotated[
|
|
323
|
+
bool,
|
|
324
|
+
typer.Option(
|
|
325
|
+
"--interactive",
|
|
326
|
+
"-i",
|
|
327
|
+
help="Start interactive Claude session",
|
|
328
|
+
),
|
|
329
|
+
] = False,
|
|
330
|
+
global_install: Annotated[
|
|
331
|
+
bool,
|
|
332
|
+
typer.Option(
|
|
333
|
+
"--global",
|
|
334
|
+
"-g",
|
|
335
|
+
help="Install temporarily to ~/.claude/ instead of ./.claude/",
|
|
336
|
+
),
|
|
337
|
+
] = False,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Run a skill or command temporarily without permanent installation.
|
|
340
|
+
|
|
341
|
+
Auto-detects the resource type (skill or command).
|
|
342
|
+
Use --type to explicitly specify when a name exists in multiple types.
|
|
343
|
+
|
|
344
|
+
Examples:
|
|
345
|
+
agrx kasperjunge/hello-world
|
|
346
|
+
agrx kasperjunge/hello-world "my prompt"
|
|
347
|
+
agrx kasperjunge/my-repo/hello-world --type skill
|
|
348
|
+
agrx kasperjunge/hello-world --interactive
|
|
349
|
+
"""
|
|
350
|
+
# Extract --type/-t from args if it was captured there (happens when type comes after ref)
|
|
351
|
+
cleaned_args, resource_type = extract_type_from_args(args, resource_type)
|
|
352
|
+
|
|
353
|
+
if not cleaned_args:
|
|
354
|
+
console.print(ctx.get_help())
|
|
355
|
+
raise typer.Exit(0)
|
|
356
|
+
|
|
357
|
+
first_arg = cleaned_args[0]
|
|
358
|
+
|
|
359
|
+
# Handle deprecated subcommand syntax: agrx skill <ref>
|
|
360
|
+
if first_arg in DEPRECATED_SUBCOMMANDS:
|
|
361
|
+
if len(cleaned_args) < 2:
|
|
362
|
+
console.print(f"[red]Error: Missing resource reference after '{first_arg}'.[/red]")
|
|
363
|
+
raise typer.Exit(1)
|
|
364
|
+
|
|
365
|
+
resource_ref = cleaned_args[1]
|
|
366
|
+
prompt_or_args = cleaned_args[2] if len(cleaned_args) > 2 else None
|
|
367
|
+
console.print(
|
|
368
|
+
f"[yellow]Warning: 'agrx {first_arg}' is deprecated. "
|
|
369
|
+
f"Use 'agrx {resource_ref}' instead.[/yellow]"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if first_arg == "skill":
|
|
373
|
+
_run_resource(resource_ref, ResourceType.SKILL, prompt_or_args, interactive, global_install)
|
|
374
|
+
elif first_arg == "command":
|
|
375
|
+
_run_resource(resource_ref, ResourceType.COMMAND, prompt_or_args, interactive, global_install)
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
# Normal unified run: agrx <ref> [prompt]
|
|
379
|
+
resource_ref = first_arg
|
|
380
|
+
prompt_or_args = cleaned_args[1] if len(cleaned_args) > 1 else None
|
|
381
|
+
_run_resource_unified(resource_ref, prompt_or_args, interactive, global_install, resource_type)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
if __name__ == "__main__":
|
|
385
|
+
app()
|
agr/cli/sync.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Sync command for agr."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from agr.config import AgrConfig, find_config
|
|
10
|
+
from agr.exceptions import (
|
|
11
|
+
AgrError,
|
|
12
|
+
RepoNotFoundError,
|
|
13
|
+
ResourceNotFoundError,
|
|
14
|
+
)
|
|
15
|
+
from agr.fetcher import (
|
|
16
|
+
RESOURCE_CONFIGS,
|
|
17
|
+
ResourceType,
|
|
18
|
+
fetch_resource,
|
|
19
|
+
)
|
|
20
|
+
from agr.cli.common import (
|
|
21
|
+
DEFAULT_REPO_NAME,
|
|
22
|
+
fetch_spinner,
|
|
23
|
+
get_base_path,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
app = typer.Typer()
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_dependency_ref(ref: str) -> tuple[str, str, str]:
|
|
31
|
+
"""
|
|
32
|
+
Parse a dependency reference from agr.toml.
|
|
33
|
+
|
|
34
|
+
Supports:
|
|
35
|
+
- "username/name" -> username, DEFAULT_REPO_NAME, name
|
|
36
|
+
- "username/repo/name" -> username, repo, name
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Tuple of (username, repo_name, resource_name)
|
|
40
|
+
"""
|
|
41
|
+
parts = ref.split("/")
|
|
42
|
+
if len(parts) == 2:
|
|
43
|
+
return parts[0], DEFAULT_REPO_NAME, parts[1]
|
|
44
|
+
elif len(parts) == 3:
|
|
45
|
+
return parts[0], parts[1], parts[2]
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError(f"Invalid dependency reference: {ref}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_resource_installed(
|
|
51
|
+
username: str,
|
|
52
|
+
name: str,
|
|
53
|
+
resource_type: ResourceType,
|
|
54
|
+
base_path: Path,
|
|
55
|
+
) -> bool:
|
|
56
|
+
"""Check if a resource is installed at the namespaced path."""
|
|
57
|
+
config = RESOURCE_CONFIGS[resource_type]
|
|
58
|
+
|
|
59
|
+
if config.is_directory:
|
|
60
|
+
# Skills: .claude/skills/username/name/SKILL.md
|
|
61
|
+
resource_path = base_path / config.dest_subdir / username / name
|
|
62
|
+
return resource_path.is_dir() and (resource_path / "SKILL.md").exists()
|
|
63
|
+
else:
|
|
64
|
+
# Commands/Agents: .claude/commands/username/name.md
|
|
65
|
+
resource_path = base_path / config.dest_subdir / username / f"{name}.md"
|
|
66
|
+
return resource_path.is_file()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _type_string_to_enum(type_str: str) -> ResourceType | None:
|
|
70
|
+
"""Convert type string to ResourceType enum, or None if unknown."""
|
|
71
|
+
type_map = {
|
|
72
|
+
"skill": ResourceType.SKILL,
|
|
73
|
+
"command": ResourceType.COMMAND,
|
|
74
|
+
"agent": ResourceType.AGENT,
|
|
75
|
+
}
|
|
76
|
+
return type_map.get(type_str.lower())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _discover_installed_namespaced_resources(
|
|
80
|
+
base_path: Path,
|
|
81
|
+
) -> set[str]:
|
|
82
|
+
"""
|
|
83
|
+
Discover all installed namespaced resources.
|
|
84
|
+
|
|
85
|
+
Returns set of dependency refs like "username/name".
|
|
86
|
+
"""
|
|
87
|
+
installed = set()
|
|
88
|
+
|
|
89
|
+
# Check skills
|
|
90
|
+
skills_dir = base_path / "skills"
|
|
91
|
+
if skills_dir.is_dir():
|
|
92
|
+
for username_dir in skills_dir.iterdir():
|
|
93
|
+
if username_dir.is_dir():
|
|
94
|
+
for skill_dir in username_dir.iterdir():
|
|
95
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
96
|
+
installed.add(f"{username_dir.name}/{skill_dir.name}")
|
|
97
|
+
|
|
98
|
+
# Check commands
|
|
99
|
+
commands_dir = base_path / "commands"
|
|
100
|
+
if commands_dir.is_dir():
|
|
101
|
+
for username_dir in commands_dir.iterdir():
|
|
102
|
+
if username_dir.is_dir():
|
|
103
|
+
for cmd_file in username_dir.glob("*.md"):
|
|
104
|
+
installed.add(f"{username_dir.name}/{cmd_file.stem}")
|
|
105
|
+
|
|
106
|
+
# Check agents
|
|
107
|
+
agents_dir = base_path / "agents"
|
|
108
|
+
if agents_dir.is_dir():
|
|
109
|
+
for username_dir in agents_dir.iterdir():
|
|
110
|
+
if username_dir.is_dir():
|
|
111
|
+
for agent_file in username_dir.glob("*.md"):
|
|
112
|
+
installed.add(f"{username_dir.name}/{agent_file.stem}")
|
|
113
|
+
|
|
114
|
+
return installed
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cleanup_empty_parent(path: Path) -> None:
|
|
118
|
+
"""Remove the parent directory if it's empty."""
|
|
119
|
+
parent = path.parent
|
|
120
|
+
if parent.exists() and not any(parent.iterdir()):
|
|
121
|
+
parent.rmdir()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _remove_namespaced_resource(
|
|
125
|
+
username: str,
|
|
126
|
+
name: str,
|
|
127
|
+
base_path: Path,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Remove a namespaced resource from disk."""
|
|
130
|
+
# Try removing as skill (directory)
|
|
131
|
+
skill_path = base_path / "skills" / username / name
|
|
132
|
+
if skill_path.is_dir():
|
|
133
|
+
shutil.rmtree(skill_path)
|
|
134
|
+
_cleanup_empty_parent(skill_path)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Try removing as command (file)
|
|
138
|
+
command_path = base_path / "commands" / username / f"{name}.md"
|
|
139
|
+
if command_path.is_file():
|
|
140
|
+
command_path.unlink()
|
|
141
|
+
_cleanup_empty_parent(command_path)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Try removing as agent (file)
|
|
145
|
+
agent_path = base_path / "agents" / username / f"{name}.md"
|
|
146
|
+
if agent_path.is_file():
|
|
147
|
+
agent_path.unlink()
|
|
148
|
+
_cleanup_empty_parent(agent_path)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def sync(
|
|
153
|
+
global_install: bool = typer.Option(
|
|
154
|
+
False, "--global", "-g",
|
|
155
|
+
help="Sync to global ~/.claude/ directory",
|
|
156
|
+
),
|
|
157
|
+
prune: bool = typer.Option(
|
|
158
|
+
False, "--prune",
|
|
159
|
+
help="Remove resources not listed in agr.toml",
|
|
160
|
+
),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Synchronize installed resources with agr.toml.
|
|
164
|
+
|
|
165
|
+
Installs any missing dependencies and optionally removes
|
|
166
|
+
resources not listed in agr.toml.
|
|
167
|
+
"""
|
|
168
|
+
# Find agr.toml
|
|
169
|
+
config_path = find_config()
|
|
170
|
+
if not config_path:
|
|
171
|
+
console.print("[red]Error: No agr.toml found.[/red]")
|
|
172
|
+
console.print("Run 'agr add <ref>' to create one, or create it manually.")
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
|
|
175
|
+
config = AgrConfig.load(config_path)
|
|
176
|
+
base_path = get_base_path(global_install)
|
|
177
|
+
|
|
178
|
+
# Track stats
|
|
179
|
+
installed_count = 0
|
|
180
|
+
skipped_count = 0
|
|
181
|
+
failed_count = 0
|
|
182
|
+
pruned_count = 0
|
|
183
|
+
|
|
184
|
+
# Install missing dependencies
|
|
185
|
+
for dep_ref, spec in config.dependencies.items():
|
|
186
|
+
try:
|
|
187
|
+
username, repo_name, name = _parse_dependency_ref(dep_ref)
|
|
188
|
+
except ValueError as e:
|
|
189
|
+
console.print(f"[yellow]Skipping invalid dependency '{dep_ref}': {e}[/yellow]")
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Determine resource type
|
|
193
|
+
resource_type = None
|
|
194
|
+
if spec.type:
|
|
195
|
+
resource_type = _type_string_to_enum(spec.type)
|
|
196
|
+
|
|
197
|
+
# For now, default to skill if no type specified
|
|
198
|
+
# In future, could auto-detect from repo
|
|
199
|
+
if resource_type is None:
|
|
200
|
+
resource_type = ResourceType.SKILL
|
|
201
|
+
|
|
202
|
+
# Check if already installed
|
|
203
|
+
if _is_resource_installed(username, name, resource_type, base_path):
|
|
204
|
+
skipped_count += 1
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Install the resource
|
|
208
|
+
try:
|
|
209
|
+
res_config = RESOURCE_CONFIGS[resource_type]
|
|
210
|
+
dest = base_path / res_config.dest_subdir
|
|
211
|
+
|
|
212
|
+
with fetch_spinner():
|
|
213
|
+
fetch_resource(
|
|
214
|
+
username, repo_name, name, [name], dest, resource_type,
|
|
215
|
+
overwrite=False, username=username,
|
|
216
|
+
)
|
|
217
|
+
console.print(f"[green]Installed {resource_type.value} '{name}'[/green]")
|
|
218
|
+
installed_count += 1
|
|
219
|
+
except (RepoNotFoundError, ResourceNotFoundError, AgrError) as e:
|
|
220
|
+
console.print(f"[red]Failed to install '{dep_ref}': {e}[/red]")
|
|
221
|
+
failed_count += 1
|
|
222
|
+
|
|
223
|
+
# Prune unlisted resources if requested
|
|
224
|
+
if prune:
|
|
225
|
+
# Get all dependencies as short refs (username/name)
|
|
226
|
+
expected_refs = set()
|
|
227
|
+
for dep_ref in config.dependencies.keys():
|
|
228
|
+
try:
|
|
229
|
+
username, _, name = _parse_dependency_ref(dep_ref)
|
|
230
|
+
expected_refs.add(f"{username}/{name}")
|
|
231
|
+
except ValueError:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Find installed namespaced resources
|
|
235
|
+
installed_refs = _discover_installed_namespaced_resources(base_path)
|
|
236
|
+
|
|
237
|
+
# Remove resources not in toml
|
|
238
|
+
for ref in installed_refs:
|
|
239
|
+
if ref not in expected_refs:
|
|
240
|
+
parts = ref.split("/")
|
|
241
|
+
if len(parts) == 2:
|
|
242
|
+
username, name = parts
|
|
243
|
+
_remove_namespaced_resource(username, name, base_path)
|
|
244
|
+
console.print(f"[yellow]Pruned '{ref}'[/yellow]")
|
|
245
|
+
pruned_count += 1
|
|
246
|
+
|
|
247
|
+
# Print summary
|
|
248
|
+
if installed_count > 0 or skipped_count > 0 or pruned_count > 0:
|
|
249
|
+
parts = []
|
|
250
|
+
if installed_count > 0:
|
|
251
|
+
parts.append(f"{installed_count} installed")
|
|
252
|
+
if skipped_count > 0:
|
|
253
|
+
parts.append(f"{skipped_count} up-to-date")
|
|
254
|
+
if pruned_count > 0:
|
|
255
|
+
parts.append(f"{pruned_count} pruned")
|
|
256
|
+
if failed_count > 0:
|
|
257
|
+
parts.append(f"[red]{failed_count} failed[/red]")
|
|
258
|
+
console.print(f"[dim]Sync complete: {', '.join(parts)}[/dim]")
|
|
259
|
+
else:
|
|
260
|
+
console.print("[dim]Nothing to sync.[/dim]")
|
|
261
|
+
|
|
262
|
+
if failed_count > 0:
|
|
263
|
+
raise typer.Exit(1)
|