claude-mpm 5.4.74__py3-none-any.whl → 5.4.81__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.
Files changed (42) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/cli/commands/configure.py +549 -115
  3. claude_mpm/cli/startup.py +7 -4
  4. claude_mpm/core/claude_runner.py +2 -2
  5. claude_mpm/core/interactive_session.py +7 -7
  6. claude_mpm/core/output_style_manager.py +7 -7
  7. claude_mpm/core/unified_config.py +16 -0
  8. claude_mpm/core/unified_paths.py +30 -13
  9. claude_mpm/services/agents/deployment/deployment_reconciler.py +39 -1
  10. claude_mpm/services/skills/git_skill_source_manager.py +5 -1
  11. claude_mpm/services/skills/selective_skill_deployer.py +0 -10
  12. {claude_mpm-5.4.74.dist-info → claude_mpm-5.4.81.dist-info}/METADATA +1 -1
  13. {claude_mpm-5.4.74.dist-info → claude_mpm-5.4.81.dist-info}/RECORD +18 -42
  14. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  15. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  16. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  17. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  18. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-312.pyc +0 -0
  19. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  20. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-312.pyc +0 -0
  21. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  22. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  23. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-312.pyc +0 -0
  24. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  25. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-312.pyc +0 -0
  26. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  27. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-312.pyc +0 -0
  28. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  29. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-312.pyc +0 -0
  30. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  31. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-312.pyc +0 -0
  32. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  33. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-312.pyc +0 -0
  34. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  35. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-312.pyc +0 -0
  36. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  37. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-312.pyc +0 -0
  38. {claude_mpm-5.4.74.dist-info → claude_mpm-5.4.81.dist-info}/WHEEL +0 -0
  39. {claude_mpm-5.4.74.dist-info → claude_mpm-5.4.81.dist-info}/entry_points.txt +0 -0
  40. {claude_mpm-5.4.74.dist-info → claude_mpm-5.4.81.dist-info}/licenses/LICENSE +0 -0
  41. {claude_mpm-5.4.74.dist-info → claude_mpm-5.4.81.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  42. {claude_mpm-5.4.74.dist-info → claude_mpm-5.4.81.dist-info}/top_level.txt +0 -0
@@ -26,6 +26,7 @@ from rich.prompt import Confirm, Prompt
26
26
  from rich.text import Text
27
27
 
28
28
  from ...core.config import Config
29
+ from ...core.unified_config import UnifiedConfig
29
30
  from ...services.agents.agent_recommendation_service import AgentRecommendationService
30
31
  from ...services.version_service import VersionService
31
32
  from ...utils.agent_filters import apply_all_filters, get_deployed_agent_ids
@@ -79,6 +80,7 @@ class ConfigureCommand(BaseCommand):
79
80
  self._template_editor = None # Lazy-initialized
80
81
  self._startup_manager = None # Lazy-initialized
81
82
  self._recommendation_service = None # Lazy-initialized
83
+ self._unified_config = None # Lazy-initialized
82
84
 
83
85
  def validate_args(self, args) -> Optional[str]:
84
86
  """Validate command arguments."""
@@ -162,6 +164,18 @@ class ConfigureCommand(BaseCommand):
162
164
  self._recommendation_service = AgentRecommendationService()
163
165
  return self._recommendation_service
164
166
 
167
+ @property
168
+ def unified_config(self) -> UnifiedConfig:
169
+ """Lazy-initialize unified config."""
170
+ if self._unified_config is None:
171
+ try:
172
+ self._unified_config = UnifiedConfig()
173
+ except Exception as e:
174
+ self.logger.warning(f"Failed to load unified config: {e}")
175
+ # Fallback to default config
176
+ self._unified_config = UnifiedConfig()
177
+ return self._unified_config
178
+
165
179
  def run(self, args) -> CommandResult:
166
180
  """Execute the configure command."""
167
181
  # Set configuration scope
@@ -656,25 +670,22 @@ class ConfigureCommand(BaseCommand):
656
670
  self.behavior_manager.manage_behaviors()
657
671
 
658
672
  def _manage_skills(self) -> None:
659
- """Skills management interface with table display."""
673
+ """Skills management interface with questionary checkbox selection."""
660
674
  from ...cli.interactive.skills_wizard import SkillsWizard
661
- from ...skills.registry import get_registry
662
675
  from ...skills.skill_manager import get_manager
663
676
 
664
677
  wizard = SkillsWizard()
665
678
  manager = get_manager()
666
- registry = get_registry()
667
679
 
668
680
  while True:
669
681
  self.console.clear()
670
682
  self._display_header()
671
683
 
672
- # Display skills table
673
- self._display_skills_table(registry)
684
+ self.console.print("\n[bold]Skills Management[/bold]")
674
685
 
675
686
  # Show action options
676
687
  self.console.print("\n[bold]Actions:[/bold]")
677
- self.console.print(" [1] Toggle skill installation")
688
+ self.console.print(" [1] Install/Uninstall skills")
678
689
  self.console.print(" [2] Configure skills for agents")
679
690
  self.console.print(" [3] View current skill mappings")
680
691
  self.console.print(" [4] Auto-link skills to agents")
@@ -684,57 +695,8 @@ class ConfigureCommand(BaseCommand):
684
695
  choice = Prompt.ask("[bold blue]Select an option[/bold blue]", default="b")
685
696
 
686
697
  if choice == "1":
687
- # Toggle skill installation
688
- self.console.clear()
689
- self._display_header()
690
- self._display_skills_table(registry)
691
-
692
- skill_num = Prompt.ask(
693
- "\n[bold blue]Enter skill number to toggle (or 'b' to go back)[/bold blue]",
694
- default="b",
695
- )
696
-
697
- if skill_num == "b":
698
- continue
699
-
700
- try:
701
- skill_idx = int(skill_num) - 1
702
- all_skills = self._get_all_skills_sorted(registry)
703
-
704
- if 0 <= skill_idx < len(all_skills):
705
- skill = all_skills[skill_idx]
706
- deployed_ids = self._get_deployed_skill_ids()
707
-
708
- if skill.skill_id in deployed_ids:
709
- # Uninstall
710
- confirm = Confirm.ask(
711
- f"\n[yellow]Uninstall skill '{skill.name}'?[/yellow]",
712
- default=False,
713
- )
714
- if confirm:
715
- self._uninstall_skill(skill)
716
- self.console.print(
717
- f"\n[green]✓ Skill '{skill.name}' uninstalled[/green]"
718
- )
719
- else:
720
- # Install
721
- confirm = Confirm.ask(
722
- f"\n[cyan]Install skill '{skill.name}'?[/cyan]",
723
- default=True,
724
- )
725
- if confirm:
726
- self._install_skill(skill)
727
- self.console.print(
728
- f"\n[green]✓ Skill '{skill.name}' installed[/green]"
729
- )
730
- else:
731
- self.console.print("[red]Invalid skill number[/red]")
732
- except ValueError:
733
- self.console.print(
734
- "[red]Invalid input. Please enter a number.[/red]"
735
- )
736
-
737
- Prompt.ask("\nPress Enter to continue")
698
+ # Install/Uninstall skills with category-based selection
699
+ self._manage_skill_installation()
738
700
 
739
701
  elif choice == "2":
740
702
  # Configure skills interactively
@@ -857,72 +819,461 @@ class ConfigureCommand(BaseCommand):
857
819
  self.console.print("[red]Invalid choice. Please try again.[/red]")
858
820
  Prompt.ask("\nPress Enter to continue")
859
821
 
860
- def _display_skills_table(self, registry) -> None:
861
- """Display skills in a table format like agents."""
862
- from rich import box
863
- from rich.table import Table
822
+ def _detect_skill_patterns(self, skills: list[dict]) -> dict[str, list[dict]]:
823
+ """Group skills by detected common prefixes.
864
824
 
865
- # Get all skills and deployed skill IDs
866
- all_skills = self._get_all_skills_sorted(registry)
867
- deployed_ids = self._get_deployed_skill_ids()
825
+ Args:
826
+ skills: List of skill dictionaries
868
827
 
869
- # Create table with same styling as agents table
870
- table = Table(show_header=True, header_style="bold cyan", box=box.ROUNDED)
871
- table.add_column("#", style="bright_black", width=6)
872
- table.add_column("Skill ID", style="bright_black", overflow="ellipsis")
873
- table.add_column("Name", style="bright_cyan", overflow="ellipsis")
874
- table.add_column("Source", style="bright_yellow")
875
- table.add_column("Status", style="bright_black")
876
-
877
- # Populate table
878
- for i, skill in enumerate(all_skills, 1):
879
- # Determine status
880
- if skill.skill_id in deployed_ids:
881
- status = "[green]Installed[/green]"
882
- else:
883
- status = "Available"
828
+ Returns:
829
+ Dict mapping pattern prefix to list of skills.
830
+ Skills without pattern match go under "" (empty string) key.
831
+ """
832
+ from collections import defaultdict
884
833
 
885
- # Determine source label
886
- if skill.source == "bundled":
887
- source = "MPM Skills"
888
- elif skill.source == "user":
889
- source = "User Skills"
890
- elif skill.source == "project":
891
- source = "Project Skills"
892
- else:
893
- source = skill.source.title()
834
+ # Count prefix occurrences (try 1-segment and 2-segment prefixes)
835
+ prefix_counts = defaultdict(list)
894
836
 
895
- # Get display name (fallback to skill_id with formatting)
896
- name = skill.name or skill.skill_id.replace("-", " ").title()
837
+ for skill in skills:
838
+ skill_id = skill.get("name", skill.get("skill_id", ""))
897
839
 
898
- table.add_row(str(i), skill.skill_id, name, source, status)
840
+ # Try to extract prefixes (split by hyphen)
841
+ parts = skill_id.split("-")
899
842
 
900
- self.console.print(table)
843
+ if len(parts) >= 2:
844
+ # Try 2-segment prefix first (e.g., "toolchains-universal")
845
+ two_seg_prefix = f"{parts[0]}-{parts[1]}"
846
+ prefix_counts[two_seg_prefix].append(skill)
901
847
 
902
- # Show summary
903
- installed_count = len([s for s in all_skills if s.skill_id in deployed_ids])
904
- self.console.print(
905
- f"\nShowing {len(all_skills)} skills ({installed_count} installed)"
906
- )
848
+ # Also try 1-segment prefix (e.g., "digitalocean")
849
+ one_seg_prefix = parts[0]
850
+ if one_seg_prefix != two_seg_prefix:
851
+ prefix_counts[one_seg_prefix].append(skill)
852
+
853
+ # Build pattern groups (require at least 2 skills per pattern)
854
+ pattern_groups = defaultdict(list)
855
+ used_skills = set()
856
+
857
+ # Prefer longer (more specific) prefixes
858
+ sorted_prefixes = sorted(prefix_counts.keys(), key=lambda x: (-len(x), x))
859
+
860
+ for prefix in sorted_prefixes:
861
+ matching_skills = prefix_counts[prefix]
862
+
863
+ # Only create a pattern group if we have 2+ skills and they're not already grouped
864
+ available_skills = [s for s in matching_skills if id(s) not in used_skills]
865
+
866
+ if len(available_skills) >= 2:
867
+ pattern_groups[prefix] = available_skills
868
+ used_skills.update(id(s) for s in available_skills)
869
+
870
+ # Add ungrouped skills to "" (Other) group
871
+ for skill in skills:
872
+ if id(skill) not in used_skills:
873
+ pattern_groups[""].append(skill)
874
+
875
+ return dict(pattern_groups)
876
+
877
+ def _get_pattern_icon(self, prefix: str) -> str:
878
+ """Get icon for a pattern prefix.
879
+
880
+ Args:
881
+ prefix: Pattern prefix (e.g., "digitalocean", "vercel")
882
+
883
+ Returns:
884
+ Emoji icon for the pattern
885
+ """
886
+ pattern_icons = {
887
+ "digitalocean": "🌊",
888
+ "aws": "☁️",
889
+ "github": "🐙",
890
+ "google": "🔍",
891
+ "vercel": "▲",
892
+ "netlify": "🦋",
893
+ "universal-testing": "🧪",
894
+ "universal-debugging": "🐛",
895
+ "universal-security": "🔒",
896
+ "toolchains-python": "🐍",
897
+ "toolchains-typescript": "📘",
898
+ "toolchains-javascript": "📒",
899
+ }
900
+ return pattern_icons.get(prefix, "📦")
901
+
902
+ def _manage_skill_installation(self) -> None:
903
+ """Manage skill installation with category-based questionary checkbox selection."""
904
+ import questionary
905
+
906
+ # Get all skills
907
+ all_skills = self._get_all_skills_from_git()
908
+ if not all_skills:
909
+ self.console.print(
910
+ "[yellow]No skills available. Try syncing skills first.[/yellow]"
911
+ )
912
+ Prompt.ask("\nPress Enter to continue")
913
+ return
914
+
915
+ # Get deployed skills
916
+ deployed = self._get_deployed_skill_ids()
917
+
918
+ # Group by category
919
+ grouped = {}
920
+ for skill in all_skills:
921
+ # Try to get category from tags or use toolchain
922
+ category = None
923
+ tags = skill.get("tags", [])
924
+
925
+ # Look for category tag
926
+ for tag in tags:
927
+ if tag in [
928
+ "universal",
929
+ "python",
930
+ "typescript",
931
+ "javascript",
932
+ "go",
933
+ "rust",
934
+ ]:
935
+ category = tag
936
+ break
937
+
938
+ # Fallback to toolchain or universal
939
+ if not category:
940
+ category = skill.get("toolchain", "universal")
941
+
942
+ if category not in grouped:
943
+ grouped[category] = []
944
+ grouped[category].append(skill)
945
+
946
+ # Category icons
947
+ icons = {
948
+ "universal": "🌐",
949
+ "python": "🐍",
950
+ "typescript": "📘",
951
+ "javascript": "📒",
952
+ "go": "🔷",
953
+ "rust": "⚙️",
954
+ }
955
+
956
+ # Sort categories: universal first, then alphabetically
957
+ categories = sorted(grouped.keys(), key=lambda x: (x != "universal", x))
958
+
959
+ while True:
960
+ # Show category selection first
961
+ self.console.clear()
962
+ self._display_header()
963
+ self.console.print("\n[bold cyan]Skills Management[/bold cyan]")
964
+ self.console.print(
965
+ f"[dim]{len(all_skills)} skills available, {len(deployed)} installed[/dim]\n"
966
+ )
967
+
968
+ cat_choices = [
969
+ Choice(
970
+ title=f"{icons.get(cat, '📦')} {cat.title()} ({len(grouped[cat])} skills)",
971
+ value=cat,
972
+ )
973
+ for cat in categories
974
+ ]
975
+ cat_choices.append(Choice(title="← Back to main menu", value="back"))
976
+
977
+ selected_cat = questionary.select(
978
+ "Select a category:", choices=cat_choices, style=self.QUESTIONARY_STYLE
979
+ ).ask()
980
+
981
+ if selected_cat is None or selected_cat == "back":
982
+ return
983
+
984
+ # Show skills in category with checkbox selection
985
+ category_skills = grouped[selected_cat]
986
+
987
+ # Detect pattern groups within category
988
+ pattern_groups = self._detect_skill_patterns(category_skills)
989
+
990
+ # Build choices with pattern grouping and installation status
991
+ skill_choices = []
992
+
993
+ # Track which skills belong to which group for expansion later
994
+ group_to_skills = {}
995
+
996
+ # Sort pattern groups: "" (Other) last, rest alphabetically
997
+ sorted_patterns = sorted(pattern_groups.keys(), key=lambda x: (x == "", x))
998
+
999
+ for pattern in sorted_patterns:
1000
+ pattern_skills = pattern_groups[pattern]
1001
+
1002
+ # Skip empty groups
1003
+ if not pattern_skills:
1004
+ continue
1005
+
1006
+ # Collect skill IDs in this group
1007
+ skill_ids_in_group = []
1008
+ for skill in pattern_skills:
1009
+ skill_id = skill.get("name", skill.get("skill_id", "unknown"))
1010
+ skill_ids_in_group.append(skill_id)
1011
+
1012
+ # Check if all skills in group are installed
1013
+ all_installed = all(
1014
+ skill.get(
1015
+ "deployment_name", skill.get("name", skill.get("skill_id"))
1016
+ )
1017
+ in deployed
1018
+ or skill.get("name", skill.get("skill_id")) in deployed
1019
+ for skill in pattern_skills
1020
+ )
1021
+
1022
+ # Add pattern group header as selectable choice
1023
+ if pattern:
1024
+ # Named pattern group
1025
+ pattern_icon = self._get_pattern_icon(pattern)
1026
+ skill_count = len(pattern_skills)
1027
+ group_key = f"__group__:{pattern}"
1028
+ group_to_skills[group_key] = skill_ids_in_group
1029
+
1030
+ skill_choices.append(
1031
+ Choice(
1032
+ title=f"{pattern_icon} {pattern} ({skill_count} skills) [Select All]",
1033
+ value=group_key,
1034
+ checked=all_installed,
1035
+ )
1036
+ )
1037
+ elif pattern_skills:
1038
+ # "Other" group - only show if there are skills
1039
+ group_key = "__group__:Other"
1040
+ group_to_skills[group_key] = skill_ids_in_group
1041
+
1042
+ skill_choices.append(
1043
+ Choice(
1044
+ title=f"📦 Other ({len(pattern_skills)} skills) [Select All]",
1045
+ value=group_key,
1046
+ checked=all_installed,
1047
+ )
1048
+ )
1049
+
1050
+ # Add skills in this pattern group
1051
+ for skill in sorted(pattern_skills, key=lambda x: x.get("name", "")):
1052
+ skill_id = skill.get("name", skill.get("skill_id", "unknown"))
1053
+ deploy_name = skill.get("deployment_name", skill_id)
1054
+ description = skill.get("description", "")[:50]
1055
+
1056
+ # Check if installed
1057
+ is_installed = deploy_name in deployed or skill_id in deployed
1058
+
1059
+ # Add indentation for pattern-grouped skills (all skills are indented)
1060
+ skill_choices.append(
1061
+ Choice(
1062
+ title=f" {skill_id} - {description}",
1063
+ value=skill_id,
1064
+ checked=is_installed,
1065
+ )
1066
+ )
1067
+
1068
+ # Add spacing between pattern groups (not after last group)
1069
+ if pattern != sorted_patterns[-1]:
1070
+ skill_choices.append(Separator())
1071
+
1072
+ self.console.clear()
1073
+ self._display_header()
1074
+ self.console.print(
1075
+ f"\n{icons.get(selected_cat, '📦')} [bold]{selected_cat.title()}[/bold]"
1076
+ )
1077
+ self.console.print(
1078
+ "[dim]Use spacebar to toggle individual skills or entire groups, enter to confirm[/dim]\n"
1079
+ )
1080
+
1081
+ selected = questionary.checkbox(
1082
+ "Select skills to install:",
1083
+ choices=skill_choices,
1084
+ style=self.QUESTIONARY_STYLE,
1085
+ ).ask()
907
1086
 
908
- def _get_all_skills_sorted(self, registry):
909
- """Get all skills from registry, sorted by source and name."""
910
- # Get skills from all sources
911
- bundled = registry.list_skills(source="bundled")
912
- user = registry.list_skills(source="user")
913
- project = registry.list_skills(source="project")
1087
+ if selected is None:
1088
+ continue # User cancelled, go back to category selection
914
1089
 
915
- # Combine and sort: bundled first, then user, then project
916
- # Within each group, sort by name
917
- all_skills = []
918
- all_skills.extend(sorted(bundled, key=lambda s: s.name.lower()))
919
- all_skills.extend(sorted(user, key=lambda s: s.name.lower()))
920
- all_skills.extend(sorted(project, key=lambda s: s.name.lower()))
1090
+ # Process group selections - expand to individual skills
1091
+ selected_set = set()
1092
+ for item in selected:
1093
+ if item.startswith("__group__:"):
1094
+ # Expand group selection to all skills in that group
1095
+ selected_set.update(group_to_skills[item])
1096
+ else:
1097
+ # Individual skill selection
1098
+ selected_set.add(item)
1099
+
1100
+ current_in_cat = set()
1101
+
1102
+ # Find currently installed skills in this category
1103
+ for skill in category_skills:
1104
+ skill_id = skill.get("name", skill.get("skill_id", "unknown"))
1105
+ deploy_name = skill.get("deployment_name", skill_id)
1106
+ if deploy_name in deployed or skill_id in deployed:
1107
+ current_in_cat.add(skill_id)
1108
+
1109
+ # Install newly selected
1110
+ to_install = selected_set - current_in_cat
1111
+ for skill_id in to_install:
1112
+ skill = next(
1113
+ (
1114
+ s
1115
+ for s in category_skills
1116
+ if s.get("name") == skill_id or s.get("skill_id") == skill_id
1117
+ ),
1118
+ None,
1119
+ )
1120
+ if skill:
1121
+ self._install_skill_from_dict(skill)
1122
+ self.console.print(f"[green]✓ Installed {skill_id}[/green]")
1123
+
1124
+ # Uninstall deselected
1125
+ to_uninstall = current_in_cat - selected_set
1126
+ for skill_id in to_uninstall:
1127
+ # Find the skill to get deployment_name
1128
+ skill = next(
1129
+ (
1130
+ s
1131
+ for s in category_skills
1132
+ if s.get("name") == skill_id or s.get("skill_id") == skill_id
1133
+ ),
1134
+ None,
1135
+ )
1136
+ if skill:
1137
+ deploy_name = skill.get("deployment_name", skill_id)
1138
+ # Use the name that's actually in deployed set
1139
+ name_to_uninstall = (
1140
+ deploy_name if deploy_name in deployed else skill_id
1141
+ )
1142
+ self._uninstall_skill_by_name(name_to_uninstall)
1143
+ self.console.print(f"[yellow]✗ Uninstalled {skill_id}[/yellow]")
921
1144
 
922
- return all_skills
1145
+ # Update deployed set for next iteration
1146
+ deployed = self._get_deployed_skill_ids()
1147
+
1148
+ # Show completion message
1149
+ if to_install or to_uninstall:
1150
+ Prompt.ask("\nPress Enter to continue")
1151
+
1152
+ def _get_all_skills_from_git(self) -> list:
1153
+ """Get all skills from Git-based skill manager.
1154
+
1155
+ Returns:
1156
+ List of skill dicts with full metadata from GitSkillSourceManager.
1157
+ """
1158
+ from ...config.skill_sources import SkillSourceConfiguration
1159
+ from ...services.skills.git_skill_source_manager import GitSkillSourceManager
1160
+
1161
+ try:
1162
+ config = SkillSourceConfiguration()
1163
+ manager = GitSkillSourceManager(config)
1164
+ return manager.get_all_skills()
1165
+ except Exception as e:
1166
+ self.console.print(
1167
+ f"[yellow]Warning: Could not load Git skills: {e}[/yellow]"
1168
+ )
1169
+ return []
1170
+
1171
+ def _display_skills_table_grouped(self) -> None:
1172
+ """Display skills in a table grouped by category, like agents."""
1173
+ from rich import box
1174
+ from rich.table import Table
1175
+
1176
+ # Get all skills from Git manager
1177
+ all_skills = self._get_all_skills_from_git()
1178
+ deployed_ids = self._get_deployed_skill_ids()
1179
+
1180
+ if not all_skills:
1181
+ self.console.print(
1182
+ "[yellow]No skills available. Try syncing skills first.[/yellow]"
1183
+ )
1184
+ return
1185
+
1186
+ # Group skills by category/toolchain
1187
+ grouped = {}
1188
+ for skill in all_skills:
1189
+ # Try to get category from tags or use toolchain
1190
+ category = None
1191
+ tags = skill.get("tags", [])
1192
+
1193
+ # Look for category tag
1194
+ for tag in tags:
1195
+ if tag in [
1196
+ "universal",
1197
+ "python",
1198
+ "typescript",
1199
+ "javascript",
1200
+ "go",
1201
+ "rust",
1202
+ ]:
1203
+ category = tag
1204
+ break
1205
+
1206
+ # Fallback to toolchain or universal
1207
+ if not category:
1208
+ category = skill.get("toolchain", "universal")
1209
+
1210
+ if category not in grouped:
1211
+ grouped[category] = []
1212
+ grouped[category].append(skill)
1213
+
1214
+ # Sort categories: universal first, then alphabetically
1215
+ categories = sorted(grouped.keys(), key=lambda x: (x != "universal", x))
1216
+
1217
+ # Track global skill number across all categories
1218
+ skill_counter = 0
1219
+
1220
+ for category in categories:
1221
+ category_skills = grouped[category]
1222
+
1223
+ # Category header with icon
1224
+ icons = {
1225
+ "universal": "🌐",
1226
+ "python": "🐍",
1227
+ "typescript": "📘",
1228
+ "javascript": "📒",
1229
+ "go": "🔷",
1230
+ "rust": "⚙️",
1231
+ }
1232
+ icon = icons.get(category, "📦")
1233
+ self.console.print(
1234
+ f"\n{icon} [bold cyan]{category.title()}[/bold cyan] ({len(category_skills)} skills)"
1235
+ )
1236
+
1237
+ # Create table for this category
1238
+ table = Table(show_header=True, header_style="bold", box=box.SIMPLE)
1239
+ table.add_column("#", style="dim", width=4)
1240
+ table.add_column("Skill ID", style="cyan", width=35)
1241
+ table.add_column("Description", style="white", width=45)
1242
+ table.add_column("Status", style="green", width=12)
1243
+
1244
+ for skill in sorted(category_skills, key=lambda x: x.get("name", "")):
1245
+ skill_counter += 1
1246
+ skill_id = skill.get("name", skill.get("skill_id", "unknown"))
1247
+ # Use deployment_name for matching if available
1248
+ deploy_name = skill.get("deployment_name", skill_id)
1249
+ description = skill.get("description", "")[:45]
1250
+
1251
+ # Check if installed - handle both deployment_name and skill_id
1252
+ is_installed = deploy_name in deployed_ids or skill_id in deployed_ids
1253
+ status = "[green]✓ Installed[/green]" if is_installed else "Available"
1254
+
1255
+ table.add_row(str(skill_counter), skill_id, description, status)
1256
+
1257
+ self.console.print(table)
1258
+
1259
+ # Summary
1260
+ total = len(all_skills)
1261
+ installed = sum(
1262
+ 1
1263
+ for s in all_skills
1264
+ if s.get("deployment_name", s.get("name", "")) in deployed_ids
1265
+ or s.get("name", "") in deployed_ids
1266
+ )
1267
+ self.console.print(
1268
+ f"\n[dim]Showing {total} skills ({installed} installed)[/dim]"
1269
+ )
923
1270
 
924
1271
  def _get_deployed_skill_ids(self) -> set:
925
- """Get set of deployed skill IDs from .claude/skills/ directory."""
1272
+ """Get set of deployed skill IDs from .claude/skills/ directory.
1273
+
1274
+ Returns:
1275
+ Set of skill directory names and common variations for matching.
1276
+ """
926
1277
  from pathlib import Path
927
1278
 
928
1279
  skills_dir = Path.cwd() / ".claude" / "skills"
@@ -933,7 +1284,11 @@ class ConfigureCommand(BaseCommand):
933
1284
  deployed_ids = set()
934
1285
  for skill_dir in skills_dir.iterdir():
935
1286
  if skill_dir.is_dir() and not skill_dir.name.startswith("."):
1287
+ # Add both the directory name and common variations
936
1288
  deployed_ids.add(skill_dir.name)
1289
+ # Also add without prefix for matching (e.g., universal-testing -> testing)
1290
+ if skill_dir.name.startswith("universal-"):
1291
+ deployed_ids.add(skill_dir.name.replace("universal-", "", 1))
937
1292
 
938
1293
  return deployed_ids
939
1294
 
@@ -967,6 +1322,45 @@ class ConfigureCommand(BaseCommand):
967
1322
  if target_dir.exists():
968
1323
  shutil.rmtree(target_dir)
969
1324
 
1325
+ def _install_skill_from_dict(self, skill_dict: dict) -> None:
1326
+ """Install a skill from Git skill dict to .claude/skills/ directory.
1327
+
1328
+ Args:
1329
+ skill_dict: Skill metadata dict from GitSkillSourceManager.get_all_skills()
1330
+ """
1331
+ from pathlib import Path
1332
+
1333
+ skill_id = skill_dict.get("name", skill_dict.get("skill_id", "unknown"))
1334
+ content = skill_dict.get("content", "")
1335
+
1336
+ if not content:
1337
+ self.console.print(
1338
+ f"[yellow]Warning: Skill '{skill_id}' has no content[/yellow]"
1339
+ )
1340
+ return
1341
+
1342
+ # Target directory using deployment_name if available
1343
+ deploy_name = skill_dict.get("deployment_name", skill_id)
1344
+ target_dir = Path.cwd() / ".claude" / "skills" / deploy_name
1345
+ target_dir.mkdir(parents=True, exist_ok=True)
1346
+
1347
+ # Write skill content to skill.md
1348
+ skill_file = target_dir / "skill.md"
1349
+ skill_file.write_text(content, encoding="utf-8")
1350
+
1351
+ def _uninstall_skill_by_name(self, skill_name: str) -> None:
1352
+ """Uninstall a skill by name from .claude/skills/ directory.
1353
+
1354
+ Args:
1355
+ skill_name: Name of skill directory to remove
1356
+ """
1357
+ import shutil
1358
+ from pathlib import Path
1359
+
1360
+ target_dir = Path.cwd() / ".claude" / "skills" / skill_name
1361
+ if target_dir.exists():
1362
+ shutil.rmtree(target_dir)
1363
+
970
1364
  def _display_behavior_files(self) -> None:
971
1365
  """Display current behavior files."""
972
1366
  self.behavior_manager.display_behavior_files()
@@ -1682,18 +2076,32 @@ class ConfigureCommand(BaseCommand):
1682
2076
  )
1683
2077
  display_name = self._format_display_name(raw_display_name)
1684
2078
 
1685
- # Check if agent is deployed (exists in .claude/agents/)
2079
+ # Check if agent is required (cannot be unchecked)
2080
+ required_agents = set(self.unified_config.agents.required)
2081
+ is_required = (
2082
+ agent_leaf_name in required_agents
2083
+ or agent_id in required_agents
2084
+ )
2085
+
2086
+ # Format choice text with [Required] indicator
2087
+ if is_required:
2088
+ choice_text = f" {display_name} [Required]"
2089
+ else:
2090
+ choice_text = f" {display_name}"
1686
2091
 
1687
- # Format choice text (no asterisk needed)
1688
- choice_text = f" {display_name}"
2092
+ # Required agents are always selected
2093
+ is_selected = is_required or agent_id in current_selection
1689
2094
 
1690
- is_selected = agent_id in current_selection
2095
+ # Add to current selection if required
2096
+ if is_required:
2097
+ current_selection.add(agent_id)
1691
2098
 
1692
2099
  choices.append(
1693
2100
  Choice(
1694
2101
  title=choice_text,
1695
2102
  value=agent_id, # Use agent_id for value
1696
2103
  checked=is_selected,
2104
+ disabled=is_required, # Disable checkbox for required agents
1697
2105
  )
1698
2106
  )
1699
2107
 
@@ -1702,6 +2110,7 @@ class ConfigureCommand(BaseCommand):
1702
2110
  self.console.print(
1703
2111
  "[dim][ ] Unchecked = Available (check to install)[/dim]"
1704
2112
  )
2113
+ self.console.print("[dim][Required] = Core agents (always installed)[/dim]")
1705
2114
  self.console.print(
1706
2115
  "[dim]Use arrow keys to navigate, space to toggle, Enter to apply[/dim]\n"
1707
2116
  )
@@ -1788,12 +2197,37 @@ class ConfigureCommand(BaseCommand):
1788
2197
 
1789
2198
  # No controls selected - use the individual selections as final
1790
2199
  final_selection = set(selected_values)
2200
+
2201
+ # Ensure required agents are always in the final selection
2202
+ required_agents = set(self.unified_config.agents.required)
2203
+ for agent in agents:
2204
+ agent_id = getattr(agent, "agent_id", agent.name)
2205
+ agent_leaf_name = agent_id.split("/")[-1]
2206
+ if agent_leaf_name in required_agents or agent_id in required_agents:
2207
+ final_selection.add(agent_id)
2208
+
1791
2209
  break
1792
2210
 
1793
2211
  # Determine changes
1794
2212
  to_deploy = final_selection - deployed_full_paths
1795
2213
  to_remove = deployed_full_paths - final_selection
1796
2214
 
2215
+ # Prevent removal of required agents
2216
+ required_agents = set(self.unified_config.agents.required)
2217
+ to_remove_filtered = set()
2218
+ for agent_id in to_remove:
2219
+ agent_leaf_name = agent_id.split("/")[-1]
2220
+ if (
2221
+ agent_leaf_name not in required_agents
2222
+ and agent_id not in required_agents
2223
+ ):
2224
+ to_remove_filtered.add(agent_id)
2225
+ else:
2226
+ self.console.print(
2227
+ f"[yellow]⚠ Cannot remove required agent: {agent_id}[/yellow]"
2228
+ )
2229
+ to_remove = to_remove_filtered
2230
+
1797
2231
  if not to_deploy and not to_remove:
1798
2232
  self.console.print("[yellow]No changes needed[/yellow]")
1799
2233
  Prompt.ask("\nPress Enter to continue")