ai-config-cli 0.1.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.
ai_config/cli.py ADDED
@@ -0,0 +1,729 @@
1
+ """CLI for ai-config."""
2
+
3
+ import json
4
+ import signal
5
+ import sys
6
+ from pathlib import Path
7
+ from threading import Event
8
+
9
+ import click
10
+ from rich.panel import Panel
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn
12
+ from rich.table import Table
13
+
14
+ from ai_config.cli_theme import SYMBOLS, create_console
15
+ from ai_config.config import (
16
+ ConfigError,
17
+ find_config_file,
18
+ load_config,
19
+ validate_marketplace_references,
20
+ )
21
+ from ai_config.operations import (
22
+ get_status,
23
+ sync_config,
24
+ update_plugins,
25
+ verify_sync,
26
+ )
27
+ from ai_config.scaffold import create_plugin
28
+ from ai_config.validators import VALIDATORS, run_validators_sync
29
+
30
+ console = create_console()
31
+ error_console = create_console(stderr=True)
32
+
33
+ # Command order for --help display (logical workflow order)
34
+ COMMAND_ORDER = ["init", "sync", "status", "watch", "update", "doctor", "plugin", "cache"]
35
+
36
+
37
+ class OrderedGroup(click.Group):
38
+ """Click group that displays commands in a defined order."""
39
+
40
+ def list_commands(self, ctx: click.Context) -> list[str]:
41
+ """Return commands in logical workflow order."""
42
+ commands = super().list_commands(ctx)
43
+ # Sort by COMMAND_ORDER index, unknown commands go to end
44
+ return sorted(commands, key=lambda x: COMMAND_ORDER.index(x) if x in COMMAND_ORDER else 999)
45
+
46
+
47
+ @click.group(cls=OrderedGroup)
48
+ @click.version_option(package_name="ai-config-cli")
49
+ def main() -> None:
50
+ """ai-config: Declarative plugin manager for Claude Code."""
51
+ pass
52
+
53
+
54
+ @main.command()
55
+ @click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path))
56
+ @click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
57
+ @click.option("--fresh", is_flag=True, help="Clear cache before syncing")
58
+ @click.option("--verify", is_flag=True, help="Verify sync after completion")
59
+ def sync(
60
+ config_path: Path | None,
61
+ dry_run: bool,
62
+ fresh: bool,
63
+ verify: bool,
64
+ ) -> None:
65
+ """Sync plugins and marketplaces to match config.
66
+
67
+ \b
68
+ When to use:
69
+ - After editing .ai-config/config.yaml to add/remove plugins
70
+ - After cloning a repo with an existing ai-config setup
71
+ - To fix drift between config and installed state
72
+
73
+ \b
74
+ What you'll see:
75
+ - Table of actions taken (install/enable/disable)
76
+ - "No changes needed" means config already matches reality
77
+ - Errors show which plugins/marketplaces failed
78
+
79
+ \b
80
+ Typical workflow:
81
+ 1. Edit config.yaml
82
+ 2. Run: ai-config sync --dry-run (preview changes)
83
+ 3. Run: ai-config sync (apply changes)
84
+ 4. Verify: ai-config doctor (check health)
85
+ """
86
+ try:
87
+ config = load_config(config_path)
88
+ except ConfigError as e:
89
+ error_console.print(f"[error]Error loading config:[/error] {e}")
90
+ sys.exit(1)
91
+
92
+ # Validate marketplace references
93
+ ref_errors = validate_marketplace_references(config)
94
+ if ref_errors:
95
+ error_console.print("[error]Config validation errors:[/error]")
96
+ for error in ref_errors:
97
+ error_console.print(f" {SYMBOLS['bullet']} {error}")
98
+ sys.exit(1)
99
+
100
+ if dry_run:
101
+ console.print("[warning]Dry run mode - no changes will be made[/warning]")
102
+ results = sync_config(config, dry_run=dry_run, fresh=fresh)
103
+ else:
104
+ # Use spinner for actual sync operations
105
+ with Progress(
106
+ SpinnerColumn(),
107
+ TextColumn("[progress.description]{task.description}"),
108
+ console=console,
109
+ transient=True,
110
+ ) as progress:
111
+ progress.add_task("Syncing plugins...", total=None)
112
+ results = sync_config(config, dry_run=dry_run, fresh=fresh)
113
+
114
+ for target_type, result in results.items():
115
+ console.print(f"\n[subheader]Target: {target_type}[/subheader]")
116
+
117
+ if result.actions_taken:
118
+ # Use a table for actions
119
+ table = Table(show_header=True, header_style="bold", box=None)
120
+ table.add_column("Action", style="key")
121
+ table.add_column("Target")
122
+ table.add_column("Scope", style="info")
123
+
124
+ for action in result.actions_taken:
125
+ table.add_row(
126
+ action.action,
127
+ action.target,
128
+ action.scope or "-",
129
+ )
130
+ console.print(table)
131
+ else:
132
+ console.print(f" [success]{SYMBOLS['pass']}[/success] No changes needed")
133
+
134
+ if result.errors:
135
+ console.print("[error]Errors:[/error]")
136
+ for error in result.errors:
137
+ console.print(f" {SYMBOLS['fail']} {error}")
138
+
139
+ # Verify if requested
140
+ if verify and not dry_run:
141
+ console.print("\n[subheader]Verification:[/subheader]")
142
+ discrepancies = verify_sync(config)
143
+ if discrepancies:
144
+ console.print("[error]Out of sync:[/error]")
145
+ for d in discrepancies:
146
+ console.print(f" {SYMBOLS['fail']} {d}")
147
+ sys.exit(1)
148
+ else:
149
+ console.print(f"[success]{SYMBOLS['pass']} All in sync![/success]")
150
+
151
+
152
+ @main.command()
153
+ @click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path))
154
+ @click.option("--verify", is_flag=True, help="Verify current state matches config")
155
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
156
+ def status(
157
+ config_path: Path | None,
158
+ verify: bool,
159
+ as_json: bool,
160
+ ) -> None:
161
+ """Show current plugin and marketplace status.
162
+
163
+ \b
164
+ When to use:
165
+ - See what plugins are currently installed in Claude Code
166
+ - Check if plugins are enabled or disabled
167
+ - Compare actual state with config using --verify
168
+
169
+ \b
170
+ What you'll see:
171
+ - Table of installed plugins with ID, version, scope, and enabled status
172
+ - List of registered marketplaces
173
+ - Use --json for machine-readable output
174
+
175
+ \b
176
+ Typical workflow:
177
+ 1. Run: ai-config status (see current state)
178
+ 2. Run: ai-config status --verify (compare with config)
179
+ 3. Run: ai-config sync (if out of sync)
180
+ """
181
+ result = get_status()
182
+
183
+ if as_json:
184
+ output = {
185
+ "target": result.target_type,
186
+ "plugins": [
187
+ {
188
+ "id": p.id,
189
+ "installed": p.installed,
190
+ "enabled": p.enabled,
191
+ "scope": p.scope,
192
+ "version": p.version,
193
+ }
194
+ for p in result.plugins
195
+ ],
196
+ "marketplaces": result.marketplaces,
197
+ "errors": result.errors,
198
+ }
199
+ console.print_json(json.dumps(output))
200
+ return
201
+
202
+ # Display plugins table
203
+ console.print("\n[subheader]Installed Plugins:[/subheader]")
204
+ if result.plugins:
205
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
206
+ table.add_column("ID", style="key")
207
+ table.add_column("Version")
208
+ table.add_column("Scope")
209
+ table.add_column("Enabled")
210
+
211
+ for plugin in result.plugins:
212
+ if plugin.enabled:
213
+ enabled_str = f"[success]{SYMBOLS['pass']}[/success]"
214
+ else:
215
+ enabled_str = f"[error]{SYMBOLS['fail']}[/error]"
216
+ table.add_row(
217
+ plugin.id,
218
+ plugin.version or "-",
219
+ plugin.scope or "-",
220
+ enabled_str,
221
+ )
222
+ console.print(table)
223
+ else:
224
+ console.print(" No plugins installed")
225
+
226
+ # Display marketplaces
227
+ console.print("\n[subheader]Registered Marketplaces:[/subheader]")
228
+ if result.marketplaces:
229
+ for mp in result.marketplaces:
230
+ console.print(f" {SYMBOLS['bullet']} {mp}")
231
+ else:
232
+ console.print(" No marketplaces registered")
233
+
234
+ # Show errors if any
235
+ if result.errors:
236
+ console.print("\n[error]Errors:[/error]")
237
+ for error in result.errors:
238
+ console.print(f" {SYMBOLS['fail']} {error}")
239
+
240
+ # Verify against config if requested
241
+ if verify:
242
+ console.print("\n[subheader]Verification:[/subheader]")
243
+ try:
244
+ config = load_config(config_path)
245
+ discrepancies = verify_sync(config)
246
+ if discrepancies:
247
+ console.print("[error]Out of sync:[/error]")
248
+ for d in discrepancies:
249
+ console.print(f" {SYMBOLS['fail']} {d}")
250
+ sys.exit(1)
251
+ else:
252
+ console.print(f"[success]{SYMBOLS['pass']} All in sync![/success]")
253
+ except ConfigError as e:
254
+ error_console.print(f"[error]Cannot verify - config error:[/error] {e}")
255
+ sys.exit(1)
256
+
257
+
258
+ @main.command()
259
+ @click.option("--all", "update_all", is_flag=True, help="Update all plugins")
260
+ @click.option("--fresh", is_flag=True, help="Clear cache before updating")
261
+ @click.argument("plugins", nargs=-1)
262
+ def update(
263
+ update_all: bool,
264
+ fresh: bool,
265
+ plugins: tuple[str, ...],
266
+ ) -> None:
267
+ """Update plugins to latest versions.
268
+
269
+ \b
270
+ When to use:
271
+ - Get latest plugin versions from marketplaces
272
+ - Update a specific plugin after upstream changes
273
+ - Refresh all plugins with --all
274
+
275
+ \b
276
+ What you'll see:
277
+ - Lists plugins that were updated
278
+ - Shows errors for failed updates
279
+
280
+ \b
281
+ Typical workflow:
282
+ 1. Run: ai-config update --all (update everything)
283
+ 2. Run: ai-config update plugin1 (update specific plugin)
284
+ 3. Run: ai-config doctor (verify health after update)
285
+ """
286
+ if not update_all and not plugins:
287
+ error_console.print("[error]Specify plugins to update or use --all[/error]")
288
+ sys.exit(1)
289
+
290
+ plugin_ids = None if update_all else list(plugins)
291
+
292
+ # Use spinner for update operation
293
+ with Progress(
294
+ SpinnerColumn(),
295
+ TextColumn("[progress.description]{task.description}"),
296
+ console=console,
297
+ transient=True,
298
+ ) as progress:
299
+ progress.add_task("Updating plugins...", total=None)
300
+ result = update_plugins(plugin_ids=plugin_ids, fresh=fresh)
301
+
302
+ if result.actions_taken:
303
+ console.print(f"[success]{SYMBOLS['pass']} Updated plugins:[/success]")
304
+ for action in result.actions_taken:
305
+ console.print(f" {SYMBOLS['arrow']} {action.target}")
306
+
307
+ if result.errors:
308
+ console.print("[error]Errors:[/error]")
309
+ for error in result.errors:
310
+ console.print(f" {SYMBOLS['fail']} {error}")
311
+
312
+ if not result.success:
313
+ sys.exit(1)
314
+
315
+
316
+ @main.group()
317
+ def cache() -> None:
318
+ """Manage plugin cache.
319
+
320
+ USE CASES:
321
+ - Clear stale plugin data with 'cache clear'
322
+ - Force re-download of plugins on next sync
323
+ """
324
+ pass
325
+
326
+
327
+ @cache.command(name="clear")
328
+ def cache_clear() -> None:
329
+ """Clear the plugin cache.
330
+
331
+ \b
332
+ When to use:
333
+ - When plugins seem stale or out of date
334
+ - After changing marketplace URLs
335
+ - When sync doesn't pick up expected changes
336
+
337
+ \b
338
+ What you'll see:
339
+ - Success message when cache is cleared
340
+ - Error message if clearing fails
341
+
342
+ \b
343
+ Typical workflow:
344
+ 1. Run: ai-config cache clear
345
+ 2. Run: ai-config sync --fresh (re-fetch everything)
346
+ """
347
+ from ai_config.adapters import claude
348
+
349
+ with Progress(
350
+ SpinnerColumn(),
351
+ TextColumn("[progress.description]{task.description}"),
352
+ console=console,
353
+ transient=True,
354
+ ) as progress:
355
+ progress.add_task("Clearing cache...", total=None)
356
+ result = claude.clear_cache()
357
+
358
+ if result.success:
359
+ console.print(f"[success]{SYMBOLS['pass']} Cache cleared successfully[/success]")
360
+ else:
361
+ error_console.print(f"[error]Failed to clear cache:[/error] {result.stderr}")
362
+ sys.exit(1)
363
+
364
+
365
+ @main.group()
366
+ def plugin() -> None:
367
+ """Plugin management commands.
368
+
369
+ Subcommands:
370
+ create - Scaffold a new plugin
371
+ """
372
+ pass
373
+
374
+
375
+ @plugin.command(name="create")
376
+ @click.argument("name")
377
+ @click.option(
378
+ "--path",
379
+ type=click.Path(path_type=Path),
380
+ help="Base path for plugin directory",
381
+ )
382
+ def plugin_create(name: str, path: Path | None) -> None:
383
+ """Create a new plugin scaffold.
384
+
385
+ \b
386
+ When to use:
387
+ - Start a new plugin project from scratch
388
+ - Create a local plugin for testing skills/hooks
389
+
390
+ \b
391
+ What you'll see:
392
+ - Creates directory with manifest.yaml, skills/, and hooks/
393
+ - Shows next steps for plugin development
394
+
395
+ \b
396
+ Typical workflow:
397
+ 1. Run: ai-config plugin create my-plugin
398
+ 2. Edit my-plugin/manifest.yaml
399
+ 3. Add skills to my-plugin/skills/
400
+ 4. Add to config.yaml as local marketplace
401
+ 5. Run: ai-config sync
402
+ """
403
+ plugin_dir = create_plugin(name, path)
404
+ console.print(f"[success]{SYMBOLS['pass']} Created plugin scaffold at:[/success] {plugin_dir}")
405
+ console.print("\n[subheader]Next steps:[/subheader]")
406
+ console.print(f" 1. Edit {plugin_dir}/manifest.yaml")
407
+ console.print(f" 2. Add skills to {plugin_dir}/skills/")
408
+ console.print(f" 3. Add hooks to {plugin_dir}/hooks/")
409
+
410
+
411
+ @main.command()
412
+ @click.option("--output", "-o", type=click.Path(path_type=Path), help="Output path for config file")
413
+ @click.option("--non-interactive", is_flag=True, help="Create minimal config without prompts")
414
+ def init(output: Path | None, non_interactive: bool) -> None:
415
+ """Create a new ai-config configuration file interactively.
416
+
417
+ \b
418
+ When to use:
419
+ - First-time setup of ai-config in a new project
420
+ - Starting fresh with a new plugin configuration
421
+ - Creating config without writing YAML manually
422
+
423
+ \b
424
+ What you'll see:
425
+ - Interactive wizard walks through marketplace/plugin selection
426
+ - Creates .ai-config/config.yaml (or custom path with -o)
427
+ - Use --non-interactive for minimal empty config
428
+
429
+ \b
430
+ Typical workflow:
431
+ 1. Run: ai-config init (interactive wizard)
432
+ 2. Follow prompts to add marketplaces and plugins
433
+ 3. Run: ai-config sync (install plugins)
434
+ 4. Run: ai-config doctor (verify setup)
435
+ """
436
+ from ai_config.init import create_minimal_config, run_init_wizard, write_config
437
+
438
+ if non_interactive:
439
+ # Generate minimal config without prompts
440
+ init_config = create_minimal_config(output)
441
+ path = write_config(init_config)
442
+ console.print(f"[success]{SYMBOLS['pass']} Created minimal config at {path}[/success]")
443
+ console.print("\n[subheader]Next steps:[/subheader]")
444
+ console.print(" 1. Edit the config file to add marketplaces and plugins")
445
+ console.print(" 2. Run: ai-config sync")
446
+ return
447
+
448
+ result = run_init_wizard(console, output)
449
+ if result is None:
450
+ console.print("[warning]Cancelled[/warning]")
451
+ sys.exit(1)
452
+
453
+ assert result is not None # Type narrowing for type checker
454
+ path = write_config(result)
455
+ console.print()
456
+ console.print(f"[success]{SYMBOLS['pass']} Config created at {path}[/success]")
457
+ console.print("\n[subheader]Next steps:[/subheader]")
458
+ console.print(" ai-config sync # Install plugins")
459
+ console.print(" ai-config doctor # Verify setup")
460
+
461
+
462
+ @main.command()
463
+ @click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path))
464
+ @click.option(
465
+ "--category",
466
+ type=click.Choice(list(VALIDATORS.keys())),
467
+ multiple=True,
468
+ help="Run only specific validation categories",
469
+ )
470
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
471
+ @click.option("--verbose", "-v", is_flag=True, help="Show all checks including passed")
472
+ def doctor(
473
+ config_path: Path | None,
474
+ category: tuple[str, ...],
475
+ as_json: bool,
476
+ verbose: bool,
477
+ ) -> None:
478
+ """Diagnose plugin, marketplace, and component issues.
479
+
480
+ \b
481
+ When to use:
482
+ - Verify setup after sync or update
483
+ - Debug why a plugin or skill isn't working
484
+ - Check for configuration drift or missing dependencies
485
+
486
+ \b
487
+ What you'll see:
488
+ - Shows pass/fail/warn status for each check
489
+ - Failed checks include fix_hint with remediation steps
490
+ - Use --verbose to see all checks (including passed)
491
+ - Use --json for machine-readable output
492
+
493
+ \b
494
+ Checks performed:
495
+ - Marketplace registration and accessibility
496
+ - Plugin installation and enabled state
497
+ - Skill file validity and frontmatter
498
+ - Hook configuration and script existence
499
+ - MCP server configuration
500
+
501
+ \b
502
+ Typical workflow:
503
+ 1. Run: ai-config doctor (check health)
504
+ 2. Read fix_hint for any failures
505
+ 3. Run suggested commands to fix issues
506
+ 4. Re-run: ai-config doctor (verify fixes)
507
+ """
508
+ from ai_config.cli_render import render_doctor_output
509
+
510
+ try:
511
+ actual_config_path = find_config_file(config_path)
512
+ config = load_config(config_path)
513
+ except ConfigError as e:
514
+ error_console.print(f"[error]Error loading config:[/error] {e}")
515
+ sys.exit(1)
516
+
517
+ # Run validators
518
+ categories_to_run = list(category) if category else None
519
+ reports = run_validators_sync(config, actual_config_path, categories_to_run)
520
+
521
+ if as_json:
522
+ output = {
523
+ "reports": {
524
+ cat: {
525
+ "target": report.target,
526
+ "passed": report.passed,
527
+ "has_warnings": report.has_warnings,
528
+ "results": [
529
+ {
530
+ "check_name": r.check_name,
531
+ "status": r.status,
532
+ "message": r.message,
533
+ "details": r.details,
534
+ "fix_hint": r.fix_hint,
535
+ }
536
+ for r in report.results
537
+ ],
538
+ }
539
+ for cat, report in reports.items()
540
+ }
541
+ }
542
+ console.print_json(json.dumps(output))
543
+ return
544
+
545
+ console.print()
546
+ console.print(Panel.fit("[header]ai-config doctor[/header]", border_style="cyan"))
547
+ console.print()
548
+
549
+ _total_pass, _total_warn, total_fail = render_doctor_output(reports, config, console, verbose)
550
+
551
+ if total_fail > 0:
552
+ sys.exit(1)
553
+
554
+
555
+ @main.command()
556
+ @click.option(
557
+ "--config",
558
+ "-c",
559
+ "config_path",
560
+ type=click.Path(exists=True, path_type=Path),
561
+ help="Path to config file",
562
+ )
563
+ @click.option(
564
+ "--debounce",
565
+ type=float,
566
+ default=1.5,
567
+ help="Seconds to wait after changes before syncing",
568
+ )
569
+ @click.option(
570
+ "--dry-run",
571
+ is_flag=True,
572
+ help="Show changes without syncing",
573
+ )
574
+ @click.option(
575
+ "--verbose",
576
+ "-v",
577
+ is_flag=True,
578
+ help="Show all file events",
579
+ )
580
+ def watch(
581
+ config_path: Path | None,
582
+ debounce: float,
583
+ dry_run: bool,
584
+ verbose: bool,
585
+ ) -> None:
586
+ """Watch config and plugin directories, auto-sync on changes.
587
+
588
+ \b
589
+ When to use:
590
+ - During plugin development to auto-sync on skill/hook edits
591
+ - When iterating on config to see changes applied immediately
592
+ - Keeping plugins in sync while editing across multiple files
593
+
594
+ \b
595
+ What you'll see:
596
+ - Which paths are being watched (config + plugin directories)
597
+ - Detected changes grouped by type (config vs plugin)
598
+ - Sync results after each batch of changes
599
+
600
+ \b
601
+ How it works:
602
+ 1. Start: ai-config watch
603
+ 2. Edit your plugin files or config
604
+ 3. Changes are batched (1.5s debounce)
605
+ 4. Sync runs automatically
606
+ 5. Press Ctrl+C to stop
607
+
608
+ \b
609
+ Important limitation:
610
+ Claude Code only loads plugins at session start. After syncing,
611
+ you must restart Claude Code for changes to take effect.
612
+ Use: claude --resume to continue your previous session.
613
+ """
614
+ from ai_config.watch import FileChange, collect_watch_paths, run_watch_loop
615
+
616
+ try:
617
+ actual_config_path = find_config_file(config_path)
618
+ config = load_config(config_path)
619
+ except ConfigError as e:
620
+ error_console.print(f"[error]Error loading config:[/error] {e}")
621
+ sys.exit(1)
622
+
623
+ watch_config = collect_watch_paths(config, actual_config_path)
624
+
625
+ # Display watch info
626
+ console.print()
627
+ console.print(Panel.fit("[header]ai-config watch[/header]", border_style="cyan"))
628
+ console.print()
629
+
630
+ console.print("[subheader]Watching:[/subheader]")
631
+ console.print(f" {SYMBOLS['bullet']} Config: {watch_config.config_path}")
632
+ for plugin_dir in watch_config.plugin_directories:
633
+ console.print(f" {SYMBOLS['bullet']} Plugin: {plugin_dir}")
634
+
635
+ if not watch_config.plugin_directories:
636
+ console.print(" [info](no local plugin directories)[/info]")
637
+
638
+ console.print()
639
+ if dry_run:
640
+ console.print("[warning]Dry run: true[/warning]")
641
+ console.print("[info]Press Ctrl+C to stop[/info]")
642
+ console.print()
643
+ console.print(
644
+ "[dim]Note: Claude Code loads plugins at session start. "
645
+ "After changes sync, restart Claude Code to apply them.[/dim]"
646
+ )
647
+ console.print("[dim]Tip: Use 'claude --resume' to continue your previous session.[/dim]")
648
+ console.print()
649
+
650
+ # Track sync count
651
+ sync_count = 0
652
+
653
+ def on_changes(changes: list[FileChange]) -> None:
654
+ """Handle detected changes."""
655
+ nonlocal sync_count
656
+ sync_count += 1
657
+
658
+ config_changes = [c for c in changes if c.change_type == "config"]
659
+ plugin_changes = [c for c in changes if c.change_type == "plugin_directory"]
660
+
661
+ console.print(f"[subheader]Changes detected (batch #{sync_count}):[/subheader]")
662
+ if config_changes:
663
+ console.print(f" Config: {len(config_changes)} change(s)")
664
+ if verbose:
665
+ for c in config_changes:
666
+ console.print(f" {SYMBOLS['arrow']} {c.event_type}: {c.path}")
667
+ if plugin_changes:
668
+ console.print(f" Plugins: {len(plugin_changes)} change(s)")
669
+ if verbose:
670
+ for c in plugin_changes:
671
+ console.print(f" {SYMBOLS['arrow']} {c.event_type}: {c.path}")
672
+
673
+ # Reload config if it changed
674
+ try:
675
+ current_config = load_config(config_path)
676
+ except ConfigError as e:
677
+ error_console.print(f"[error]Config error:[/error] {e}")
678
+ return
679
+
680
+ if dry_run:
681
+ console.print("[warning]Dry run - no sync performed[/warning]")
682
+ return
683
+
684
+ # Sync
685
+ with Progress(
686
+ SpinnerColumn(),
687
+ TextColumn("[progress.description]{task.description}"),
688
+ console=console,
689
+ transient=True,
690
+ ) as progress:
691
+ progress.add_task("Syncing...", total=None)
692
+ results = sync_config(current_config, dry_run=False, fresh=False)
693
+
694
+ total_actions = sum(len(r.actions_taken) for r in results.values())
695
+ if total_actions > 0:
696
+ console.print(f"[success]{SYMBOLS['pass']} Synced {total_actions} action(s)[/success]")
697
+ else:
698
+ console.print(f"[info]{SYMBOLS['pass']} No sync needed[/info]")
699
+
700
+ for result in results.values():
701
+ if result.errors:
702
+ for error in result.errors:
703
+ error_console.print(f" [error]{SYMBOLS['fail']}[/error] {error}")
704
+
705
+ console.print()
706
+
707
+ # Setup signal handler for graceful shutdown
708
+ stop_event = Event()
709
+
710
+ def signal_handler(signum: int, frame: object) -> None:
711
+ console.print("\n[info]Stopping...[/info]")
712
+ stop_event.set()
713
+
714
+ signal.signal(signal.SIGINT, signal_handler)
715
+ signal.signal(signal.SIGTERM, signal_handler)
716
+
717
+ # Run the watch loop
718
+ run_watch_loop(
719
+ watch_config=watch_config,
720
+ on_changes=on_changes,
721
+ stop_event=stop_event,
722
+ debounce_seconds=debounce,
723
+ )
724
+
725
+ console.print(f"[success]{SYMBOLS['pass']} Watch stopped[/success]")
726
+
727
+
728
+ if __name__ == "__main__":
729
+ main()