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