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