agent-skill-manager 0.1.0__py3-none-any.whl → 0.1.1__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.
skill_manager/cli.py ADDED
@@ -0,0 +1,1178 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Skill Manager - CLI tool for managing AI agent skills.
4
+
5
+ Usage: sm <command> or skill-manager <command>
6
+
7
+ Commands:
8
+ download - Download skills from GitHub
9
+ deploy - Deploy local skills to agents
10
+ install - Download and deploy skills in one step
11
+ uninstall - Remove skills from agents (safe delete)
12
+ restore - Restore deleted skills from trash
13
+ update - Update skills from GitHub
14
+ update --all - Update all skills from GitHub
15
+ list - List installed skills and versions
16
+ """
17
+
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from InquirerPy import inquirer
23
+ from InquirerPy.base.control import Choice
24
+ from InquirerPy.separator import Separator
25
+ from rich.console import Console
26
+ from rich.panel import Panel
27
+ from rich.progress import Progress, SpinnerColumn, TextColumn
28
+ from rich.table import Table
29
+
30
+ from .agents import AGENTS, detect_existing_agents, get_agent_name, get_agent_path
31
+ from .deployment import (
32
+ deploy_multiple_skills,
33
+ deploy_skill_to_agents,
34
+ update_all_skills,
35
+ update_skill,
36
+ )
37
+ from .github import download_skill_from_github, parse_github_url
38
+ from .metadata import (
39
+ list_updatable_skills,
40
+ read_skill_metadata,
41
+ save_skill_metadata,
42
+ )
43
+ from .removal import (
44
+ clean_trash,
45
+ hard_delete_skill,
46
+ list_installed_skills,
47
+ list_trashed_skills,
48
+ restore_skill,
49
+ soft_delete_skill,
50
+ )
51
+ from .validation import (
52
+ get_project_root,
53
+ get_skill_name,
54
+ scan_available_skills,
55
+ validate_skill,
56
+ )
57
+
58
+ console = Console()
59
+
60
+
61
+ def select_agents(existing_agents: dict[str, Path]) -> list[str]:
62
+ """
63
+ Interactive prompt to select agents for deployment.
64
+
65
+ Args:
66
+ existing_agents: Dictionary of detected agents
67
+
68
+ Returns:
69
+ List of selected agent IDs
70
+ """
71
+ choices = []
72
+
73
+ if existing_agents:
74
+ choices.append(Separator("--- Detected Agents ---"))
75
+ for agent_id in sorted(existing_agents.keys()):
76
+ info = AGENTS[agent_id]
77
+ choices.append(
78
+ Choice(
79
+ value=agent_id,
80
+ name=f"{info['name']} ({existing_agents[agent_id]})",
81
+ enabled=True,
82
+ )
83
+ )
84
+
85
+ other_agents = [aid for aid in AGENTS.keys() if aid not in existing_agents]
86
+ if other_agents:
87
+ choices.append(Separator("--- Other Available Agents ---"))
88
+ for agent_id in sorted(other_agents):
89
+ info = AGENTS[agent_id]
90
+ global_path = Path(info["global"]).expanduser()
91
+ choices.append(
92
+ Choice(
93
+ value=agent_id,
94
+ name=f"{info['name']} ({global_path})",
95
+ enabled=False,
96
+ )
97
+ )
98
+
99
+ selected = inquirer.checkbox(
100
+ message="Select agents to deploy to:",
101
+ choices=choices,
102
+ instruction="(Space to select, Enter to confirm)",
103
+ ).execute()
104
+
105
+ return selected
106
+
107
+
108
+ def select_deployment_type() -> str:
109
+ """
110
+ Interactive prompt to select deployment type.
111
+
112
+ Returns:
113
+ Either "global" or "project"
114
+ """
115
+ return inquirer.select(
116
+ message="Select deployment location:",
117
+ choices=[
118
+ Choice(value="global", name="Global directory (recommended) - Available to all projects"),
119
+ Choice(value="project", name="Project directory - Current project only"),
120
+ ],
121
+ default="global",
122
+ ).execute()
123
+
124
+
125
+ def select_skills(available_skills: list[Path]) -> list[Path]:
126
+ """
127
+ Interactive prompt to select skills for deployment.
128
+
129
+ Args:
130
+ available_skills: List of available skill paths
131
+
132
+ Returns:
133
+ List of selected skill paths
134
+ """
135
+ if not available_skills:
136
+ console.print("[yellow]No skills found[/yellow]")
137
+ return []
138
+
139
+ # Group by category
140
+ categories = {}
141
+ for skill in available_skills:
142
+ parts = skill.parts
143
+ if len(parts) > 1:
144
+ category = parts[0]
145
+ skill_name = "/".join(parts[1:])
146
+ else:
147
+ category = "Other"
148
+ skill_name = str(skill)
149
+
150
+ if category not in categories:
151
+ categories[category] = []
152
+ categories[category].append((skill, skill_name))
153
+
154
+ # Build choice list
155
+ choices = []
156
+ for category in sorted(categories.keys()):
157
+ choices.append(Separator(f"--- {category.upper()} ---"))
158
+ for skill, skill_name in sorted(categories[category], key=lambda x: x[1]):
159
+ choices.append(
160
+ Choice(
161
+ value=skill,
162
+ name=skill_name,
163
+ enabled=False,
164
+ )
165
+ )
166
+
167
+ selected = inquirer.checkbox(
168
+ message="Select skills to deploy:",
169
+ choices=choices,
170
+ instruction="(Space to select, Enter to confirm)",
171
+ ).execute()
172
+
173
+ return selected
174
+
175
+
176
+ def cmd_download() -> int:
177
+ """
178
+ Download command - Download skills from GitHub.
179
+
180
+ Returns:
181
+ Exit code (0 for success, non-zero for failure)
182
+ """
183
+ console.print(
184
+ Panel.fit(
185
+ "[bold cyan]Download Skill[/bold cyan]\n"
186
+ "Download a skill from GitHub",
187
+ border_style="cyan",
188
+ )
189
+ )
190
+
191
+ # Get GitHub URL
192
+ url = inquirer.text(
193
+ message="Enter GitHub skill URL:",
194
+ validate=lambda x: len(x) > 0,
195
+ invalid_message="URL cannot be empty",
196
+ ).execute()
197
+
198
+ console.print()
199
+
200
+ # Parse URL to show info
201
+ try:
202
+ owner, repo, branch, path = parse_github_url(url)
203
+ console.print(f"[dim]Repository: {owner}/{repo}[/dim]")
204
+ console.print(f"[dim]Branch: {branch}[/dim]")
205
+ console.print(f"[dim]Path: {path or '(root)'}[/dim]\n")
206
+ except ValueError as e:
207
+ console.print(f"[red]Error: {e}[/red]")
208
+ return 1
209
+
210
+ # Ask where to save
211
+ save_to_local = inquirer.confirm(
212
+ message="Save to local skills/ directory?",
213
+ default=True,
214
+ ).execute()
215
+
216
+ console.print()
217
+
218
+ # Determine destination
219
+ if save_to_local:
220
+ project_root = get_project_root()
221
+ skills_dir = project_root / "skills"
222
+
223
+ # Ask for category
224
+ category = inquirer.text(
225
+ message="Enter category name (leave empty for root):",
226
+ default="",
227
+ ).execute()
228
+
229
+ if category:
230
+ dest_dir = skills_dir / category
231
+ else:
232
+ dest_dir = skills_dir
233
+ else:
234
+ dest_dir = Path.cwd() / ".tmp_skills"
235
+
236
+ # Download
237
+ try:
238
+ with Progress(
239
+ SpinnerColumn(),
240
+ TextColumn("[progress.description]{task.description}"),
241
+ console=console,
242
+ ) as progress:
243
+ task = progress.add_task("Downloading skill...", total=None)
244
+ skill_dir, metadata = download_skill_from_github(url, dest_dir)
245
+ progress.update(task, completed=True)
246
+
247
+ # Save metadata
248
+ save_skill_metadata(
249
+ skill_dir,
250
+ url,
251
+ metadata["owner"],
252
+ metadata["repo"],
253
+ metadata["branch"],
254
+ metadata["path"],
255
+ )
256
+
257
+ # Validate
258
+ if not validate_skill(skill_dir):
259
+ console.print(
260
+ f"[yellow]Warning: {skill_dir} does not contain SKILL.md, may not be a valid skill[/yellow]"
261
+ )
262
+
263
+ console.print(f"[green]✓[/green] Skill downloaded to: {skill_dir}\n")
264
+ return 0
265
+
266
+ except Exception as e:
267
+ console.print(f"[red]Download failed: {e}[/red]")
268
+ return 1
269
+
270
+
271
+ def cmd_deploy() -> int:
272
+ """
273
+ Deploy command - Deploy local skills to agents.
274
+
275
+ Returns:
276
+ Exit code (0 for success, non-zero for failure)
277
+ """
278
+ console.print(
279
+ Panel.fit(
280
+ "[bold cyan]Deploy Skills[/bold cyan]\n"
281
+ "Deploy local skills to AI agents",
282
+ border_style="cyan",
283
+ )
284
+ )
285
+
286
+ # Get project root and skills directory
287
+ project_root = get_project_root()
288
+ skills_dir = project_root / "skills"
289
+
290
+ console.print(f"\n[dim]Project directory: {project_root}[/dim]")
291
+ console.print(f"[dim]Skills directory: {skills_dir}[/dim]\n")
292
+
293
+ # Scan for skills
294
+ with Progress(
295
+ SpinnerColumn(),
296
+ TextColumn("[progress.description]{task.description}"),
297
+ console=console,
298
+ ) as progress:
299
+ task = progress.add_task("Scanning for skills...", total=None)
300
+ available_skills = scan_available_skills(skills_dir)
301
+ progress.update(task, completed=True)
302
+
303
+ if not available_skills:
304
+ console.print("[red]No skills found. Exiting.[/red]")
305
+ return 1
306
+
307
+ console.print(f"[green]✓[/green] Found {len(available_skills)} skills\n")
308
+
309
+ # Detect agents
310
+ existing_agents = detect_existing_agents()
311
+ if existing_agents:
312
+ console.print(
313
+ f"[green]✓[/green] Detected {len(existing_agents)} installed agents\n"
314
+ )
315
+
316
+ # Select deployment type
317
+ deployment_type = select_deployment_type()
318
+ console.print()
319
+
320
+ # Select agents
321
+ selected_agents = select_agents(existing_agents)
322
+ if not selected_agents:
323
+ console.print("[yellow]No agents selected. Exiting.[/yellow]")
324
+ return 0
325
+
326
+ console.print()
327
+
328
+ # Select skills
329
+ selected_skills = select_skills(available_skills)
330
+ if not selected_skills:
331
+ console.print("[yellow]No skills selected. Exiting.[/yellow]")
332
+ return 0
333
+
334
+ console.print()
335
+
336
+ # Show summary
337
+ table = Table(title="Deployment Plan", show_header=True, header_style="bold magenta")
338
+ table.add_column("Agent", style="cyan", no_wrap=True)
339
+ table.add_column("Target Path", style="green")
340
+ table.add_column("Skills Count", justify="right", style="yellow")
341
+
342
+ for agent_id in selected_agents:
343
+ target_path = get_agent_path(agent_id, deployment_type, project_root)
344
+ table.add_row(
345
+ get_agent_name(agent_id),
346
+ str(target_path),
347
+ str(len(selected_skills)),
348
+ )
349
+
350
+ console.print(table)
351
+ console.print(f"\nWill deploy {len(selected_skills)} skills:")
352
+ for skill in selected_skills:
353
+ console.print(f" • {skill}")
354
+
355
+ console.print()
356
+
357
+ # Confirm
358
+ confirm = inquirer.confirm(
359
+ message="Confirm deployment?",
360
+ default=True,
361
+ ).execute()
362
+
363
+ if not confirm:
364
+ console.print("[yellow]Deployment cancelled.[/yellow]")
365
+ return 0
366
+
367
+ console.print()
368
+
369
+ # Deploy
370
+ with Progress(
371
+ SpinnerColumn(),
372
+ TextColumn("[progress.description]{task.description}"),
373
+ console=console,
374
+ ) as progress:
375
+ task = progress.add_task("Deploying skills...", total=len(selected_agents) * len(selected_skills))
376
+
377
+ def progress_callback(agent_id, skill_path):
378
+ progress.advance(task)
379
+
380
+ total_deployed, total_failed = deploy_multiple_skills(
381
+ selected_skills,
382
+ skills_dir,
383
+ selected_agents,
384
+ deployment_type,
385
+ project_root,
386
+ progress_callback,
387
+ )
388
+
389
+ # Show results
390
+ console.print()
391
+ if total_failed == 0:
392
+ console.print(
393
+ Panel.fit(
394
+ f"[bold green]✓ Deployment successful![/bold green]\n\n"
395
+ f"Deployed {total_deployed} skills to {len(selected_agents)} agents",
396
+ border_style="green",
397
+ )
398
+ )
399
+ else:
400
+ console.print(
401
+ Panel.fit(
402
+ f"[bold yellow]⚠ Deployment completed with errors[/bold yellow]\n\n"
403
+ f"Success: {total_deployed} | Failed: {total_failed}",
404
+ border_style="yellow",
405
+ )
406
+ )
407
+
408
+ return 0 if total_failed == 0 else 1
409
+
410
+
411
+ def cmd_install() -> int:
412
+ """
413
+ Install command - Download and deploy skills in one step.
414
+
415
+ Returns:
416
+ Exit code (0 for success, non-zero for failure)
417
+ """
418
+ console.print(
419
+ Panel.fit(
420
+ "[bold cyan]Install Skill[/bold cyan]\n"
421
+ "Download and deploy a skill from GitHub",
422
+ border_style="cyan",
423
+ )
424
+ )
425
+
426
+ # Get GitHub URL
427
+ url = inquirer.text(
428
+ message="Enter GitHub skill URL:",
429
+ validate=lambda x: len(x) > 0,
430
+ invalid_message="URL cannot be empty",
431
+ ).execute()
432
+
433
+ console.print()
434
+
435
+ # Parse URL to show info
436
+ try:
437
+ owner, repo, branch, path = parse_github_url(url)
438
+ console.print(f"[dim]Repository: {owner}/{repo}[/dim]")
439
+ console.print(f"[dim]Branch: {branch}[/dim]")
440
+ console.print(f"[dim]Path: {path or '(root)'}[/dim]\n")
441
+ except ValueError as e:
442
+ console.print(f"[red]Error: {e}[/red]")
443
+ return 1
444
+
445
+ # Ask whether to save locally
446
+ save_to_local = inquirer.confirm(
447
+ message="Save to local skills/ directory?",
448
+ default=True,
449
+ ).execute()
450
+
451
+ console.print()
452
+
453
+ # Determine destination
454
+ if save_to_local:
455
+ project_root = get_project_root()
456
+ skills_dir = project_root / "skills"
457
+
458
+ # Ask for category
459
+ category = inquirer.text(
460
+ message="Enter category name (leave empty for root):",
461
+ default="",
462
+ ).execute()
463
+
464
+ if category:
465
+ dest_dir = skills_dir / category
466
+ else:
467
+ dest_dir = skills_dir
468
+ else:
469
+ dest_dir = Path.cwd() / ".tmp_skills"
470
+
471
+ # Download skill
472
+ try:
473
+ with Progress(
474
+ SpinnerColumn(),
475
+ TextColumn("[progress.description]{task.description}"),
476
+ console=console,
477
+ ) as progress:
478
+ task = progress.add_task("Downloading skill...", total=None)
479
+ skill_dir, metadata = download_skill_from_github(url, dest_dir)
480
+ progress.update(task, completed=True)
481
+
482
+ # Save metadata
483
+ save_skill_metadata(
484
+ skill_dir,
485
+ url,
486
+ metadata["owner"],
487
+ metadata["repo"],
488
+ metadata["branch"],
489
+ metadata["path"],
490
+ )
491
+
492
+ # Validate
493
+ if not validate_skill(skill_dir):
494
+ console.print(
495
+ f"[yellow]Warning: {skill_dir} does not contain SKILL.md, may not be a valid skill[/yellow]"
496
+ )
497
+
498
+ console.print(f"[green]✓[/green] Skill downloaded to: {skill_dir}\n")
499
+
500
+ except Exception as e:
501
+ console.print(f"[red]Download failed: {e}[/red]")
502
+ return 1
503
+
504
+ # Ask whether to deploy
505
+ should_deploy = inquirer.confirm(
506
+ message="Deploy to AI agents?",
507
+ default=True,
508
+ ).execute()
509
+
510
+ if not should_deploy:
511
+ console.print("[yellow]Skipping deployment.[/yellow]")
512
+ return 0
513
+
514
+ console.print()
515
+
516
+ # Detect agents
517
+ existing_agents = detect_existing_agents()
518
+ if existing_agents:
519
+ console.print(
520
+ f"[green]✓[/green] Detected {len(existing_agents)} installed agents\n"
521
+ )
522
+
523
+ # Select deployment type
524
+ deployment_type = select_deployment_type()
525
+ console.print()
526
+
527
+ # Select agents
528
+ selected_agents = select_agents(existing_agents)
529
+ if not selected_agents:
530
+ console.print("[yellow]No agents selected.[/yellow]")
531
+ return 0
532
+
533
+ console.print()
534
+
535
+ # Deploy
536
+ success_count, fail_count = deploy_skill_to_agents(
537
+ skill_dir, selected_agents, deployment_type, get_project_root() if deployment_type == "project" else None
538
+ )
539
+
540
+ # Clean up temporary files
541
+ if not save_to_local and skill_dir.parent.name == ".tmp_skills":
542
+ shutil.rmtree(skill_dir.parent, ignore_errors=True)
543
+
544
+ console.print()
545
+
546
+ # Show results
547
+ if fail_count == 0:
548
+ console.print(
549
+ Panel.fit(
550
+ f"[bold green]✓ Installation successful![/bold green]\n\n"
551
+ f"Skill: {get_skill_name(skill_dir)}\n"
552
+ f"Deployed to {success_count} agents",
553
+ border_style="green",
554
+ )
555
+ )
556
+ else:
557
+ console.print(
558
+ Panel.fit(
559
+ f"[bold yellow]⚠ Installation completed with errors[/bold yellow]\n\n"
560
+ f"Success: {success_count} | Failed: {fail_count}",
561
+ border_style="yellow",
562
+ )
563
+ )
564
+
565
+ return 0 if fail_count == 0 else 1
566
+
567
+
568
+ def cmd_uninstall() -> int:
569
+ """
570
+ Uninstall command - Remove skills from agents.
571
+
572
+ Returns:
573
+ Exit code (0 for success, non-zero for failure)
574
+ """
575
+ console.print(
576
+ Panel.fit(
577
+ "[bold cyan]Uninstall Skills[/bold cyan]\n"
578
+ "Remove skills from AI agents",
579
+ border_style="cyan",
580
+ )
581
+ )
582
+
583
+ # Detect agents
584
+ existing_agents = detect_existing_agents()
585
+ if not existing_agents:
586
+ console.print("[red]No agents detected. Exiting.[/red]")
587
+ return 1
588
+
589
+ console.print(f"\n[green]✓[/green] Detected {len(existing_agents)} installed agents\n")
590
+
591
+ # Select deployment type
592
+ deployment_type = select_deployment_type()
593
+ console.print()
594
+
595
+ # Select agents
596
+ selected_agents = select_agents(existing_agents)
597
+ if not selected_agents:
598
+ console.print("[yellow]No agents selected. Exiting.[/yellow]")
599
+ return 0
600
+
601
+ console.print()
602
+
603
+ # For each selected agent, list installed skills
604
+ all_skills = {}
605
+ for agent_id in selected_agents:
606
+ project_root = get_project_root() if deployment_type == "project" else None
607
+ skills = list_installed_skills(agent_id, deployment_type, project_root)
608
+ if skills:
609
+ all_skills[agent_id] = skills
610
+
611
+ if not all_skills:
612
+ console.print("[yellow]No skills found in selected agents.[/yellow]")
613
+ return 0
614
+
615
+ # Build skill selection list grouped by agent
616
+ choices = []
617
+ for agent_id in sorted(all_skills.keys()):
618
+ agent_name = get_agent_name(agent_id)
619
+ choices.append(Separator(f"--- {agent_name} ---"))
620
+ for skill in all_skills[agent_id]:
621
+ choices.append(
622
+ Choice(
623
+ value=(agent_id, skill),
624
+ name=skill,
625
+ enabled=False,
626
+ )
627
+ )
628
+
629
+ selected_to_remove = inquirer.checkbox(
630
+ message="Select skills to uninstall:",
631
+ choices=choices,
632
+ instruction="(Space to select, Enter to confirm)",
633
+ ).execute()
634
+
635
+ if not selected_to_remove:
636
+ console.print("[yellow]No skills selected. Exiting.[/yellow]")
637
+ return 0
638
+
639
+ console.print()
640
+
641
+ # Ask for deletion type
642
+ deletion_type = inquirer.select(
643
+ message="Select deletion type:",
644
+ choices=[
645
+ Choice(value="soft", name="Safe delete (move to trash) - Can be restored later"),
646
+ Choice(value="hard", name="Hard delete (permanent) - Cannot be restored"),
647
+ ],
648
+ default="soft",
649
+ ).execute()
650
+
651
+ console.print()
652
+
653
+ # Show summary
654
+ console.print(f"[yellow]Will {deletion_type} delete {len(selected_to_remove)} skills:[/yellow]")
655
+ for agent_id, skill in selected_to_remove:
656
+ console.print(f" • {get_agent_name(agent_id)}: {skill}")
657
+
658
+ console.print()
659
+
660
+ # Confirm
661
+ confirm = inquirer.confirm(
662
+ message=f"Confirm {deletion_type} deletion?",
663
+ default=True,
664
+ ).execute()
665
+
666
+ if not confirm:
667
+ console.print("[yellow]Deletion cancelled.[/yellow]")
668
+ return 0
669
+
670
+ console.print()
671
+
672
+ # Perform deletion
673
+ success_count = 0
674
+ fail_count = 0
675
+ project_root = get_project_root() if deployment_type == "project" else None
676
+
677
+ with Progress(
678
+ SpinnerColumn(),
679
+ TextColumn("[progress.description]{task.description}"),
680
+ console=console,
681
+ ) as progress:
682
+ task = progress.add_task("Removing skills...", total=len(selected_to_remove))
683
+
684
+ for agent_id, skill in selected_to_remove:
685
+ if deletion_type == "soft":
686
+ success = soft_delete_skill(skill, agent_id, deployment_type, project_root)
687
+ else:
688
+ success = hard_delete_skill(skill, agent_id, deployment_type, project_root)
689
+
690
+ if success:
691
+ success_count += 1
692
+ else:
693
+ fail_count += 1
694
+ progress.advance(task)
695
+
696
+ # Show results
697
+ console.print()
698
+ if fail_count == 0:
699
+ console.print(
700
+ Panel.fit(
701
+ f"[bold green]✓ Uninstallation successful![/bold green]\n\n"
702
+ f"Removed {success_count} skills\n"
703
+ + (f"[dim]Skills moved to trash and can be restored with 'sm restore'[/dim]" if deletion_type == "soft" else ""),
704
+ border_style="green",
705
+ )
706
+ )
707
+ else:
708
+ console.print(
709
+ Panel.fit(
710
+ f"[bold yellow]⚠ Uninstallation completed with errors[/bold yellow]\n\n"
711
+ f"Success: {success_count} | Failed: {fail_count}",
712
+ border_style="yellow",
713
+ )
714
+ )
715
+
716
+ return 0 if fail_count == 0 else 1
717
+
718
+
719
+ def cmd_restore() -> int:
720
+ """
721
+ Restore command - Restore deleted skills from trash.
722
+
723
+ Returns:
724
+ Exit code (0 for success, non-zero for failure)
725
+ """
726
+ console.print(
727
+ Panel.fit(
728
+ "[bold cyan]Restore Skills[/bold cyan]\n"
729
+ "Restore deleted skills from trash",
730
+ border_style="cyan",
731
+ )
732
+ )
733
+
734
+ # Detect agents
735
+ existing_agents = detect_existing_agents()
736
+ if not existing_agents:
737
+ console.print("[red]No agents detected. Exiting.[/red]")
738
+ return 1
739
+
740
+ console.print(f"\n[green]✓[/green] Detected {len(existing_agents)} installed agents\n")
741
+
742
+ # Select deployment type
743
+ deployment_type = select_deployment_type()
744
+ console.print()
745
+
746
+ # Select agents
747
+ selected_agents = select_agents(existing_agents)
748
+ if not selected_agents:
749
+ console.print("[yellow]No agents selected. Exiting.[/yellow]")
750
+ return 0
751
+
752
+ console.print()
753
+
754
+ # For each selected agent, list trashed skills
755
+ all_trashed = {}
756
+ for agent_id in selected_agents:
757
+ project_root = get_project_root() if deployment_type == "project" else None
758
+ trashed = list_trashed_skills(agent_id, deployment_type, project_root)
759
+ if trashed:
760
+ all_trashed[agent_id] = trashed
761
+
762
+ if not all_trashed:
763
+ console.print("[yellow]No skills found in trash.[/yellow]")
764
+ return 0
765
+
766
+ # Build skill selection list grouped by agent
767
+ choices = []
768
+ for agent_id in sorted(all_trashed.keys()):
769
+ agent_name = get_agent_name(agent_id)
770
+ choices.append(Separator(f"--- {agent_name} ---"))
771
+ for skill_info in all_trashed[agent_id]:
772
+ skill_name = skill_info["skill_name"]
773
+ deleted_at = skill_info["deleted_at"]
774
+ timestamp = skill_info["timestamp_dir"]
775
+ choices.append(
776
+ Choice(
777
+ value=(agent_id, skill_name, timestamp),
778
+ name=f"{skill_name} (deleted: {deleted_at})",
779
+ enabled=False,
780
+ )
781
+ )
782
+
783
+ selected_to_restore = inquirer.checkbox(
784
+ message="Select skills to restore:",
785
+ choices=choices,
786
+ instruction="(Space to select, Enter to confirm)",
787
+ ).execute()
788
+
789
+ if not selected_to_restore:
790
+ console.print("[yellow]No skills selected. Exiting.[/yellow]")
791
+ return 0
792
+
793
+ console.print()
794
+
795
+ # Show summary
796
+ console.print(f"[yellow]Will restore {len(selected_to_restore)} skills:[/yellow]")
797
+ for agent_id, skill, _ in selected_to_restore:
798
+ console.print(f" • {get_agent_name(agent_id)}: {skill}")
799
+
800
+ console.print()
801
+
802
+ # Confirm
803
+ confirm = inquirer.confirm(
804
+ message="Confirm restoration?",
805
+ default=True,
806
+ ).execute()
807
+
808
+ if not confirm:
809
+ console.print("[yellow]Restoration cancelled.[/yellow]")
810
+ return 0
811
+
812
+ console.print()
813
+
814
+ # Perform restoration
815
+ success_count = 0
816
+ fail_count = 0
817
+ project_root = get_project_root() if deployment_type == "project" else None
818
+
819
+ with Progress(
820
+ SpinnerColumn(),
821
+ TextColumn("[progress.description]{task.description}"),
822
+ console=console,
823
+ ) as progress:
824
+ task = progress.add_task("Restoring skills...", total=len(selected_to_restore))
825
+
826
+ for agent_id, skill, timestamp in selected_to_restore:
827
+ if restore_skill(skill, timestamp, agent_id, deployment_type, project_root):
828
+ success_count += 1
829
+ else:
830
+ fail_count += 1
831
+ console.print(f"[red]Failed to restore {skill} (may already exist)[/red]")
832
+ progress.advance(task)
833
+
834
+ # Show results
835
+ console.print()
836
+ if fail_count == 0:
837
+ console.print(
838
+ Panel.fit(
839
+ f"[bold green]✓ Restoration successful![/bold green]\n\n"
840
+ f"Restored {success_count} skills",
841
+ border_style="green",
842
+ )
843
+ )
844
+ else:
845
+ console.print(
846
+ Panel.fit(
847
+ f"[bold yellow]⚠ Restoration completed with errors[/bold yellow]\n\n"
848
+ f"Success: {success_count} | Failed: {fail_count}",
849
+ border_style="yellow",
850
+ )
851
+ )
852
+
853
+ return 0 if fail_count == 0 else 1
854
+
855
+
856
+ def cmd_update() -> int:
857
+ """
858
+ Update command - Update skills from GitHub.
859
+
860
+ Returns:
861
+ Exit code (0 for success, non-zero for failure)
862
+ """
863
+ # Check for --all flag
864
+ update_all = len(sys.argv) > 2 and sys.argv[2] == "--all"
865
+
866
+ console.print(
867
+ Panel.fit(
868
+ "[bold cyan]Update Skills[/bold cyan]\n"
869
+ + ("Update all skills from GitHub" if update_all else "Update selected skills from GitHub"),
870
+ border_style="cyan",
871
+ )
872
+ )
873
+
874
+ # Detect agents
875
+ existing_agents = detect_existing_agents()
876
+ if not existing_agents:
877
+ console.print("[red]No agents detected. Exiting.[/red]")
878
+ return 1
879
+
880
+ console.print(f"\n[green]✓[/green] Detected {len(existing_agents)} installed agents\n")
881
+
882
+ # Select deployment type
883
+ deployment_type = select_deployment_type()
884
+ console.print()
885
+
886
+ # Select agents
887
+ selected_agents = select_agents(existing_agents)
888
+ if not selected_agents:
889
+ console.print("[yellow]No agents selected. Exiting.[/yellow]")
890
+ return 0
891
+
892
+ console.print()
893
+
894
+ # Collect updatable skills from selected agents
895
+ all_updatable = {}
896
+ for agent_id in selected_agents:
897
+ project_root = get_project_root() if deployment_type == "project" else None
898
+ agent_path = get_agent_path(agent_id, deployment_type, project_root)
899
+ updatable = list_updatable_skills(agent_path)
900
+ if updatable:
901
+ all_updatable[agent_id] = updatable
902
+
903
+ if not all_updatable:
904
+ console.print("[yellow]No updatable skills found (no GitHub metadata).[/yellow]")
905
+ return 0
906
+
907
+ # Select skills to update (unless --all flag)
908
+ if update_all:
909
+ skills_to_update = all_updatable
910
+ else:
911
+ # Build selection list
912
+ choices = []
913
+ for agent_id in sorted(all_updatable.keys()):
914
+ agent_name = get_agent_name(agent_id)
915
+ choices.append(Separator(f"--- {agent_name} ---"))
916
+ for skill_info in all_updatable[agent_id]:
917
+ skill_name = skill_info["skill_name"]
918
+ metadata = skill_info["metadata"]
919
+ github_url = metadata.get("github_url", "")
920
+ updated_at = metadata.get("updated_at", "unknown")
921
+ choices.append(
922
+ Choice(
923
+ value=(agent_id, skill_name),
924
+ name=f"{skill_name} (updated: {updated_at[:10]})",
925
+ enabled=False,
926
+ )
927
+ )
928
+
929
+ selected_to_update = inquirer.checkbox(
930
+ message="Select skills to update:",
931
+ choices=choices,
932
+ instruction="(Space to select, Enter to confirm)",
933
+ ).execute()
934
+
935
+ if not selected_to_update:
936
+ console.print("[yellow]No skills selected. Exiting.[/yellow]")
937
+ return 0
938
+
939
+ # Convert to dict format
940
+ skills_to_update = {}
941
+ for agent_id, skill_name in selected_to_update:
942
+ if agent_id not in skills_to_update:
943
+ skills_to_update[agent_id] = []
944
+ skills_to_update[agent_id].append(skill_name)
945
+
946
+ console.print()
947
+
948
+ # Show summary
949
+ total_count = sum(len(skills) if isinstance(skills, list) else len(all_updatable[agent_id]) for agent_id, skills in skills_to_update.items())
950
+ console.print(f"[yellow]Will update {total_count} skills:[/yellow]")
951
+ for agent_id in sorted(skills_to_update.keys()):
952
+ agent_name = get_agent_name(agent_id)
953
+ if isinstance(skills_to_update[agent_id], list):
954
+ skill_list = skills_to_update[agent_id]
955
+ else:
956
+ skill_list = [s["skill_name"] for s in all_updatable[agent_id]]
957
+ for skill in skill_list:
958
+ console.print(f" • {agent_name}: {skill}")
959
+
960
+ console.print()
961
+
962
+ # Confirm
963
+ confirm = inquirer.confirm(
964
+ message="Confirm update?",
965
+ default=True,
966
+ ).execute()
967
+
968
+ if not confirm:
969
+ console.print("[yellow]Update cancelled.[/yellow]")
970
+ return 0
971
+
972
+ console.print()
973
+
974
+ # Perform updates
975
+ total_success = 0
976
+ total_failed = 0
977
+ project_root = get_project_root() if deployment_type == "project" else None
978
+
979
+ with Progress(
980
+ SpinnerColumn(),
981
+ TextColumn("[progress.description]{task.description}"),
982
+ console=console,
983
+ ) as progress:
984
+ for agent_id in skills_to_update.keys():
985
+ if isinstance(skills_to_update[agent_id], list):
986
+ # Specific skills selected
987
+ skill_list = skills_to_update[agent_id]
988
+ task = progress.add_task(f"Updating {get_agent_name(agent_id)}...", total=len(skill_list))
989
+
990
+ for skill_name in skill_list:
991
+ if update_skill(skill_name, agent_id, deployment_type, project_root):
992
+ total_success += 1
993
+ else:
994
+ total_failed += 1
995
+ progress.advance(task)
996
+ else:
997
+ # Update all for this agent
998
+ task = progress.add_task(f"Updating {get_agent_name(agent_id)}...", total=None)
999
+ success, failed = update_all_skills(agent_id, deployment_type, project_root)
1000
+ total_success += success
1001
+ total_failed += failed
1002
+ progress.update(task, completed=True)
1003
+
1004
+ # Show results
1005
+ console.print()
1006
+ if total_failed == 0:
1007
+ console.print(
1008
+ Panel.fit(
1009
+ f"[bold green]✓ Update successful![/bold green]\n\n"
1010
+ f"Updated {total_success} skills",
1011
+ border_style="green",
1012
+ )
1013
+ )
1014
+ else:
1015
+ console.print(
1016
+ Panel.fit(
1017
+ f"[bold yellow]⚠ Update completed with errors[/bold yellow]\n\n"
1018
+ f"Success: {total_success} | Failed: {total_failed}",
1019
+ border_style="yellow",
1020
+ )
1021
+ )
1022
+
1023
+ return 0 if total_failed == 0 else 1
1024
+
1025
+
1026
+ def cmd_list() -> int:
1027
+ """
1028
+ List command - List installed skills and versions.
1029
+
1030
+ Returns:
1031
+ Exit code (0 for success, non-zero for failure)
1032
+ """
1033
+ console.print(
1034
+ Panel.fit(
1035
+ "[bold cyan]List Installed Skills[/bold cyan]\n"
1036
+ "Show all installed skills with version information",
1037
+ border_style="cyan",
1038
+ )
1039
+ )
1040
+
1041
+ # Detect agents
1042
+ existing_agents = detect_existing_agents()
1043
+ if not existing_agents:
1044
+ console.print("\n[red]No agents detected.[/red]")
1045
+ return 1
1046
+
1047
+ console.print(f"\n[green]✓[/green] Detected {len(existing_agents)} installed agents\n")
1048
+
1049
+ # Select deployment type
1050
+ deployment_type = select_deployment_type()
1051
+ console.print()
1052
+
1053
+ project_root = get_project_root() if deployment_type == "project" else None
1054
+
1055
+ # Collect all skills from all agents
1056
+ all_skills_data = {}
1057
+ for agent_id in sorted(existing_agents.keys()):
1058
+ agent_path = get_agent_path(agent_id, deployment_type, project_root)
1059
+ skills = list_installed_skills(agent_id, deployment_type, project_root)
1060
+
1061
+ if skills:
1062
+ all_skills_data[agent_id] = []
1063
+ for skill_name in skills:
1064
+ skill_dir = agent_path / skill_name
1065
+ metadata = read_skill_metadata(skill_dir)
1066
+
1067
+ if metadata:
1068
+ # Has GitHub metadata
1069
+ updated_at = metadata.get("updated_at", "unknown")
1070
+ github_url = metadata.get("github_url", "")
1071
+ version_info = updated_at[:19] if updated_at != "unknown" else "unknown"
1072
+ source = "GitHub"
1073
+ else:
1074
+ # No metadata, use file modification time
1075
+ skill_md = skill_dir / "SKILL.md"
1076
+ if skill_md.exists():
1077
+ mtime = skill_md.stat().st_mtime
1078
+ from datetime import datetime
1079
+ version_info = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
1080
+ else:
1081
+ version_info = "unknown"
1082
+ source = "Local"
1083
+ github_url = ""
1084
+
1085
+ all_skills_data[agent_id].append({
1086
+ "name": skill_name,
1087
+ "version": version_info,
1088
+ "source": source,
1089
+ "url": github_url,
1090
+ })
1091
+
1092
+ if not all_skills_data:
1093
+ console.print("[yellow]No skills found in selected agents.[/yellow]")
1094
+ return 0
1095
+
1096
+ # Display tables for each agent
1097
+ for agent_id in sorted(all_skills_data.keys()):
1098
+ agent_name = get_agent_name(agent_id)
1099
+ skills_list = all_skills_data[agent_id]
1100
+
1101
+ table = Table(
1102
+ title=f"{agent_name} ({len(skills_list)} skills)",
1103
+ show_header=True,
1104
+ header_style="bold cyan",
1105
+ )
1106
+ table.add_column("Skill Name", style="green", no_wrap=True)
1107
+ table.add_column("Version/Updated", style="yellow")
1108
+ table.add_column("Source", style="blue")
1109
+ table.add_column("GitHub URL", style="dim", overflow="fold")
1110
+
1111
+ for skill in sorted(skills_list, key=lambda x: x["name"]):
1112
+ table.add_row(
1113
+ skill["name"],
1114
+ skill["version"],
1115
+ skill["source"],
1116
+ skill["url"][:50] + "..." if len(skill["url"]) > 50 else skill["url"],
1117
+ )
1118
+
1119
+ console.print(table)
1120
+ console.print()
1121
+
1122
+ return 0
1123
+
1124
+
1125
+ def main() -> int:
1126
+ """
1127
+ Main CLI entry point.
1128
+
1129
+ Returns:
1130
+ Exit code
1131
+ """
1132
+ if len(sys.argv) < 2:
1133
+ console.print(
1134
+ Panel.fit(
1135
+ "[bold cyan]Skill Manager[/bold cyan]\n\n"
1136
+ "Usage:\n"
1137
+ " sm download - Download a skill from GitHub\n"
1138
+ " sm deploy - Deploy local skills to agents\n"
1139
+ " sm install - Download and deploy in one step\n"
1140
+ " sm uninstall - Remove skills from agents (safe delete)\n"
1141
+ " sm restore - Restore deleted skills from trash\n"
1142
+ " sm update - Update skills from GitHub\n"
1143
+ " sm update --all - Update all skills from GitHub\n"
1144
+ " sm list - List installed skills and versions\n\n"
1145
+ "[dim]Note: You can also use 'skill-manager' instead of 'sm'[/dim]",
1146
+ border_style="cyan",
1147
+ )
1148
+ )
1149
+ return 1
1150
+
1151
+ command = sys.argv[1]
1152
+
1153
+ try:
1154
+ if command == "download":
1155
+ return cmd_download()
1156
+ elif command == "deploy":
1157
+ return cmd_deploy()
1158
+ elif command == "install":
1159
+ return cmd_install()
1160
+ elif command == "uninstall":
1161
+ return cmd_uninstall()
1162
+ elif command == "restore":
1163
+ return cmd_restore()
1164
+ elif command == "update":
1165
+ return cmd_update()
1166
+ elif command == "list":
1167
+ return cmd_list()
1168
+ else:
1169
+ console.print(f"[red]Unknown command: {command}[/red]")
1170
+ console.print("Available commands: download, deploy, install, uninstall, restore, update, list")
1171
+ return 1
1172
+ except KeyboardInterrupt:
1173
+ console.print("\n[yellow]Cancelled[/yellow]")
1174
+ return 130
1175
+
1176
+
1177
+ if __name__ == "__main__":
1178
+ sys.exit(main())