claude-mpm 5.4.70__py3-none-any.whl → 5.4.73__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.
- claude_mpm/VERSION +1 -1
- claude_mpm/cli/__init__.py +5 -1
- claude_mpm/cli/commands/agents.py +2 -4
- claude_mpm/cli/commands/agents_reconcile.py +197 -0
- claude_mpm/cli/commands/skills.py +166 -14
- claude_mpm/cli/executor.py +1 -0
- claude_mpm/cli/interactive/__init__.py +10 -0
- claude_mpm/cli/interactive/agent_wizard.py +30 -50
- claude_mpm/cli/interactive/questionary_styles.py +65 -0
- claude_mpm/cli/interactive/skill_selector.py +481 -0
- claude_mpm/cli/parsers/base_parser.py +5 -0
- claude_mpm/cli/startup.py +17 -8
- claude_mpm/constants.py +1 -0
- claude_mpm/core/unified_config.py +33 -8
- claude_mpm/services/agents/deployment/deployment_reconciler.py +539 -0
- claude_mpm/services/agents/deployment/startup_reconciliation.py +138 -0
- claude_mpm/services/agents/startup_sync.py +5 -2
- claude_mpm/utils/agent_dependency_loader.py +4 -2
- claude_mpm/utils/robust_installer.py +10 -6
- {claude_mpm-5.4.70.dist-info → claude_mpm-5.4.73.dist-info}/METADATA +1 -1
- {claude_mpm-5.4.70.dist-info → claude_mpm-5.4.73.dist-info}/RECORD +26 -21
- {claude_mpm-5.4.70.dist-info → claude_mpm-5.4.73.dist-info}/WHEEL +0 -0
- {claude_mpm-5.4.70.dist-info → claude_mpm-5.4.73.dist-info}/entry_points.txt +0 -0
- {claude_mpm-5.4.70.dist-info → claude_mpm-5.4.73.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-5.4.70.dist-info → claude_mpm-5.4.73.dist-info}/licenses/LICENSE-FAQ.md +0 -0
- {claude_mpm-5.4.70.dist-info → claude_mpm-5.4.73.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Interactive Skill Selector for Claude MPM.
|
|
2
|
+
|
|
3
|
+
This module provides a two-tier interactive skill selection wizard:
|
|
4
|
+
1. Select topic groups (toolchains) to explore
|
|
5
|
+
2. Multi-select skills within each topic group
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Groups skills by toolchain (universal, python, typescript, etc.)
|
|
9
|
+
- Shows skills auto-included by agent dependencies
|
|
10
|
+
- Displays token counts for each skill
|
|
11
|
+
- Uses questionary with cyan style for consistency
|
|
12
|
+
- Matches agent selector UI pattern with table display
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Dict, List, Optional, Set
|
|
19
|
+
|
|
20
|
+
import questionary
|
|
21
|
+
|
|
22
|
+
from claude_mpm.cli.interactive.questionary_styles import (
|
|
23
|
+
BANNER_WIDTH,
|
|
24
|
+
MPM_STYLE,
|
|
25
|
+
print_banner,
|
|
26
|
+
)
|
|
27
|
+
from claude_mpm.core.logging_utils import get_logger
|
|
28
|
+
from claude_mpm.core.unified_config import UnifiedConfig
|
|
29
|
+
from claude_mpm.core.unified_paths import get_path_manager
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
# Topic/toolchain icons
|
|
34
|
+
TOPIC_ICONS = {
|
|
35
|
+
"universal": "🌐",
|
|
36
|
+
"python": "🐍",
|
|
37
|
+
"typescript": "📘",
|
|
38
|
+
"javascript": "📒",
|
|
39
|
+
"rust": "⚙️",
|
|
40
|
+
"go": "🔷",
|
|
41
|
+
"java": "☕",
|
|
42
|
+
"ruby": "💎",
|
|
43
|
+
"php": "🐘",
|
|
44
|
+
"csharp": "🔷",
|
|
45
|
+
"cpp": "⚙️",
|
|
46
|
+
"swift": "🍎",
|
|
47
|
+
None: "🌐", # Default for null toolchain (universal)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SkillInfo:
|
|
53
|
+
"""Information about a skill from manifest."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
toolchain: Optional[str]
|
|
57
|
+
framework: Optional[str]
|
|
58
|
+
tags: List[str]
|
|
59
|
+
full_tokens: int
|
|
60
|
+
description: str = ""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def display_name(self) -> str:
|
|
64
|
+
"""Get display name with token count."""
|
|
65
|
+
tokens_k = self.full_tokens // 1000
|
|
66
|
+
return f"{self.name} ({tokens_k}K tokens)"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SkillSelector:
|
|
70
|
+
"""Interactive skill selector with topic grouping."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
skills_manifest: Dict,
|
|
75
|
+
agent_skill_deps: Optional[List[str]] = None,
|
|
76
|
+
deployed_skills: Optional[Set[str]] = None,
|
|
77
|
+
):
|
|
78
|
+
"""Initialize skill selector.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
skills_manifest: Full manifest dict with all skills
|
|
82
|
+
agent_skill_deps: Skills required by deployed agents (auto-included)
|
|
83
|
+
deployed_skills: Skills currently deployed in .claude/skills/
|
|
84
|
+
"""
|
|
85
|
+
self.manifest = skills_manifest
|
|
86
|
+
self.agent_skill_deps = set(agent_skill_deps or [])
|
|
87
|
+
self.deployed_skills = deployed_skills or set()
|
|
88
|
+
self.skills_by_toolchain: Dict[str, List[SkillInfo]] = {}
|
|
89
|
+
self._parse_manifest()
|
|
90
|
+
|
|
91
|
+
def _parse_manifest(self) -> None:
|
|
92
|
+
"""Parse manifest and group skills by toolchain."""
|
|
93
|
+
# Handle both old and new manifest formats
|
|
94
|
+
skills_data = self.manifest.get("skills", {})
|
|
95
|
+
|
|
96
|
+
# Flatten skills from grouped format to flat list
|
|
97
|
+
all_skills = []
|
|
98
|
+
if isinstance(skills_data, dict):
|
|
99
|
+
for toolchain, skills_list in skills_data.items():
|
|
100
|
+
all_skills.extend(skills_list)
|
|
101
|
+
elif isinstance(skills_data, list):
|
|
102
|
+
all_skills = skills_data
|
|
103
|
+
|
|
104
|
+
# Group by toolchain
|
|
105
|
+
for skill_data in all_skills:
|
|
106
|
+
try:
|
|
107
|
+
skill = SkillInfo(
|
|
108
|
+
name=skill_data.get("name", ""),
|
|
109
|
+
toolchain=skill_data.get("toolchain"),
|
|
110
|
+
framework=skill_data.get("framework"),
|
|
111
|
+
tags=skill_data.get("tags", []),
|
|
112
|
+
full_tokens=skill_data.get("full_tokens", 0),
|
|
113
|
+
description=skill_data.get("description", ""),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Group by toolchain (null -> universal)
|
|
117
|
+
toolchain_key = skill.toolchain or "universal"
|
|
118
|
+
|
|
119
|
+
if toolchain_key not in self.skills_by_toolchain:
|
|
120
|
+
self.skills_by_toolchain[toolchain_key] = []
|
|
121
|
+
|
|
122
|
+
self.skills_by_toolchain[toolchain_key].append(skill)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning(
|
|
125
|
+
f"Failed to parse skill: {skill_data.get('name', 'unknown')}: {e}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Sort skills within each toolchain by name
|
|
129
|
+
for toolchain in self.skills_by_toolchain:
|
|
130
|
+
self.skills_by_toolchain[toolchain].sort(key=lambda s: s.name)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _calculate_column_widths(
|
|
134
|
+
terminal_width: int, columns: Dict[str, int]
|
|
135
|
+
) -> Dict[str, int]:
|
|
136
|
+
"""Calculate dynamic column widths based on terminal size.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
terminal_width: Current terminal width in characters
|
|
140
|
+
columns: Dict mapping column names to minimum widths
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Dict mapping column names to calculated widths
|
|
144
|
+
|
|
145
|
+
Design:
|
|
146
|
+
- Ensures minimum widths are respected
|
|
147
|
+
- Distributes extra space proportionally
|
|
148
|
+
- Handles narrow terminals gracefully (minimum 80 chars)
|
|
149
|
+
"""
|
|
150
|
+
# Ensure minimum terminal width
|
|
151
|
+
min_terminal_width = 80
|
|
152
|
+
terminal_width = max(terminal_width, min_terminal_width)
|
|
153
|
+
|
|
154
|
+
# Calculate total minimum width needed
|
|
155
|
+
total_min_width = sum(columns.values())
|
|
156
|
+
|
|
157
|
+
# Account for spacing between columns
|
|
158
|
+
overhead = len(columns) + 1
|
|
159
|
+
available_width = terminal_width - overhead
|
|
160
|
+
|
|
161
|
+
# If we have extra space, distribute proportionally
|
|
162
|
+
if available_width > total_min_width:
|
|
163
|
+
extra_space = available_width - total_min_width
|
|
164
|
+
total_weight = sum(columns.values())
|
|
165
|
+
|
|
166
|
+
result = {}
|
|
167
|
+
for col_name, min_width in columns.items():
|
|
168
|
+
# Distribute extra space based on minimum width proportion
|
|
169
|
+
proportion = min_width / total_weight
|
|
170
|
+
extra = int(extra_space * proportion)
|
|
171
|
+
result[col_name] = min_width + extra
|
|
172
|
+
return result
|
|
173
|
+
# Terminal too narrow, use minimum widths
|
|
174
|
+
return columns.copy()
|
|
175
|
+
|
|
176
|
+
def _display_skills_table(self, skills: List[SkillInfo]) -> None:
|
|
177
|
+
"""Display skills in a table with status (matches agent selector pattern).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
skills: List of skills to display
|
|
181
|
+
"""
|
|
182
|
+
if not skills:
|
|
183
|
+
print("\n📭 No skills found.")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Calculate dynamic column widths based on terminal size
|
|
187
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
188
|
+
min_widths = {
|
|
189
|
+
"#": 4,
|
|
190
|
+
"Skill ID": 30,
|
|
191
|
+
"Description": 35,
|
|
192
|
+
"Toolchain": 12,
|
|
193
|
+
"Status": 12,
|
|
194
|
+
}
|
|
195
|
+
widths = self._calculate_column_widths(terminal_width, min_widths)
|
|
196
|
+
|
|
197
|
+
# Print header with dynamic widths
|
|
198
|
+
print(
|
|
199
|
+
f"\n{'#':<{widths['#']}} "
|
|
200
|
+
f"{'Skill ID':<{widths['Skill ID']}} "
|
|
201
|
+
f"{'Description':<{widths['Description']}} "
|
|
202
|
+
f"{'Toolchain':<{widths['Toolchain']}} "
|
|
203
|
+
f"{'Status':<{widths['Status']}}"
|
|
204
|
+
)
|
|
205
|
+
separator_width = sum(widths.values()) + len(widths) - 1
|
|
206
|
+
print("-" * separator_width)
|
|
207
|
+
|
|
208
|
+
for i, skill in enumerate(skills, 1):
|
|
209
|
+
# Truncate to fit dynamic width
|
|
210
|
+
skill_id = skill.name
|
|
211
|
+
if len(skill_id) > widths["Skill ID"]:
|
|
212
|
+
skill_id = skill_id[: widths["Skill ID"] - 1] + "…"
|
|
213
|
+
|
|
214
|
+
description = skill.description or skill.name
|
|
215
|
+
if len(description) > widths["Description"]:
|
|
216
|
+
description = description[: widths["Description"] - 1] + "…"
|
|
217
|
+
|
|
218
|
+
toolchain = skill.toolchain or "universal"
|
|
219
|
+
if len(toolchain) > widths["Toolchain"]:
|
|
220
|
+
toolchain = toolchain[: widths["Toolchain"] - 1] + "…"
|
|
221
|
+
|
|
222
|
+
# Determine status
|
|
223
|
+
if skill.name in self.agent_skill_deps:
|
|
224
|
+
status = "✓ Required"
|
|
225
|
+
elif skill.name in self.deployed_skills:
|
|
226
|
+
status = "✓ Installed"
|
|
227
|
+
else:
|
|
228
|
+
status = "Available"
|
|
229
|
+
|
|
230
|
+
print(
|
|
231
|
+
f"{i:<{widths['#']}} "
|
|
232
|
+
f"{skill_id:<{widths['Skill ID']}} "
|
|
233
|
+
f"{description:<{widths['Description']}} "
|
|
234
|
+
f"{toolchain:<{widths['Toolchain']}} "
|
|
235
|
+
f"{status:<{widths['Status']}}"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def select_skills(self) -> List[str]:
|
|
239
|
+
"""Run interactive selection and return selected skill IDs.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
List of selected skill IDs (names)
|
|
243
|
+
"""
|
|
244
|
+
print_banner("SKILL CONFIGURATION", width=BANNER_WIDTH)
|
|
245
|
+
|
|
246
|
+
# Show agent-required skills (auto-included)
|
|
247
|
+
if self.agent_skill_deps:
|
|
248
|
+
self._show_agent_required_skills()
|
|
249
|
+
|
|
250
|
+
# Get all skills for table display
|
|
251
|
+
all_skills = []
|
|
252
|
+
for toolchain_skills in self.skills_by_toolchain.values():
|
|
253
|
+
all_skills.extend(toolchain_skills)
|
|
254
|
+
all_skills.sort(key=lambda s: s.name)
|
|
255
|
+
|
|
256
|
+
# Display skills table
|
|
257
|
+
print(f"\n📋 Found {len(all_skills)} skill(s) available:")
|
|
258
|
+
self._display_skills_table(all_skills)
|
|
259
|
+
|
|
260
|
+
# Select topic groups to explore
|
|
261
|
+
selected_groups = self._select_topic_groups()
|
|
262
|
+
|
|
263
|
+
if not selected_groups:
|
|
264
|
+
print("\n⚠️ No topic groups selected. Using only agent-required skills.")
|
|
265
|
+
return list(self.agent_skill_deps)
|
|
266
|
+
|
|
267
|
+
# Multi-select skills from each group
|
|
268
|
+
selected_skills = set(self.agent_skill_deps) # Start with auto-included
|
|
269
|
+
|
|
270
|
+
for group in selected_groups:
|
|
271
|
+
group_skills = self._select_skills_from_group(group)
|
|
272
|
+
selected_skills.update(group_skills)
|
|
273
|
+
|
|
274
|
+
# Confirm selection
|
|
275
|
+
print(f"\n✅ Total skills selected: {len(selected_skills)}")
|
|
276
|
+
print(f" - Auto-included (from agents): {len(self.agent_skill_deps)}")
|
|
277
|
+
print(f" - Manually selected: {len(selected_skills - self.agent_skill_deps)}")
|
|
278
|
+
|
|
279
|
+
return list(selected_skills)
|
|
280
|
+
|
|
281
|
+
def _show_agent_required_skills(self) -> None:
|
|
282
|
+
"""Display skills that are auto-included from agent dependencies."""
|
|
283
|
+
print("\n📦 Agent-Required Skills (auto-included):")
|
|
284
|
+
for skill_name in sorted(self.agent_skill_deps):
|
|
285
|
+
print(f" ✓ {skill_name}")
|
|
286
|
+
print()
|
|
287
|
+
|
|
288
|
+
def _select_topic_groups(self) -> List[str]:
|
|
289
|
+
"""First tier: Select which toolchain groups to browse.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
List of selected toolchain keys
|
|
293
|
+
"""
|
|
294
|
+
# Build choices with counts
|
|
295
|
+
choices = []
|
|
296
|
+
for toolchain in sorted(self.skills_by_toolchain.keys()):
|
|
297
|
+
skills = self.skills_by_toolchain[toolchain]
|
|
298
|
+
icon = TOPIC_ICONS.get(toolchain, "📦")
|
|
299
|
+
display_name = toolchain.capitalize() if toolchain else "Universal"
|
|
300
|
+
choice_text = f"{icon} {display_name} ({len(skills)} skills)"
|
|
301
|
+
choices.append(questionary.Choice(title=choice_text, value=toolchain))
|
|
302
|
+
|
|
303
|
+
if not choices:
|
|
304
|
+
print("\n⚠️ No skills available in manifest.")
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
# Multi-select groups
|
|
308
|
+
selected = questionary.checkbox(
|
|
309
|
+
"📂 Select Topic Groups to Add Skills From:",
|
|
310
|
+
choices=choices,
|
|
311
|
+
style=MPM_STYLE,
|
|
312
|
+
).ask()
|
|
313
|
+
|
|
314
|
+
if selected is None: # User cancelled
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
return selected
|
|
318
|
+
|
|
319
|
+
def _select_skills_from_group(self, toolchain: str) -> List[str]:
|
|
320
|
+
"""Second tier: Multi-select skills within a toolchain group.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
toolchain: Toolchain key to select from
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
List of selected skill names
|
|
327
|
+
"""
|
|
328
|
+
skills = self.skills_by_toolchain.get(toolchain, [])
|
|
329
|
+
if not skills:
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
icon = TOPIC_ICONS.get(toolchain, "📦")
|
|
333
|
+
display_name = toolchain.capitalize() if toolchain else "Universal"
|
|
334
|
+
|
|
335
|
+
print(f"\n{icon} {display_name} Skills:")
|
|
336
|
+
|
|
337
|
+
# Build choices with numbered format like agent selector
|
|
338
|
+
choices = []
|
|
339
|
+
for i, skill in enumerate(skills, 1):
|
|
340
|
+
# Mark if already selected (from agent deps)
|
|
341
|
+
already_selected = skill.name in self.agent_skill_deps
|
|
342
|
+
|
|
343
|
+
# Format: "1. skill-name - toolchain (XK tokens)"
|
|
344
|
+
tokens_k = skill.full_tokens // 1000
|
|
345
|
+
desc = skill.description[:50] if skill.description else skill.name
|
|
346
|
+
choice_text = f"{i}. {skill.name} - {desc}... ({tokens_k}K tokens)"
|
|
347
|
+
|
|
348
|
+
choice = questionary.Choice(
|
|
349
|
+
title=choice_text,
|
|
350
|
+
value=skill.name,
|
|
351
|
+
checked=already_selected,
|
|
352
|
+
)
|
|
353
|
+
choices.append(choice)
|
|
354
|
+
|
|
355
|
+
# Multi-select skills
|
|
356
|
+
selected = questionary.checkbox(
|
|
357
|
+
f"Select {display_name} skills to include:",
|
|
358
|
+
choices=choices,
|
|
359
|
+
style=MPM_STYLE,
|
|
360
|
+
).ask()
|
|
361
|
+
|
|
362
|
+
if selected is None: # User cancelled
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
return selected
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def load_skills_manifest() -> Optional[Dict]:
|
|
369
|
+
"""Load skills manifest from cache.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Manifest dict or None if not found
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
path_manager = get_path_manager()
|
|
376
|
+
manifest_path = (
|
|
377
|
+
path_manager.get_cache_dir() / "skills" / "system" / "manifest.json"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if not manifest_path.exists():
|
|
381
|
+
logger.error(f"Skills manifest not found at {manifest_path}")
|
|
382
|
+
print("\n❌ Skills manifest not found. Run 'claude-mpm skills sync' first.")
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
with open(manifest_path, encoding="utf-8") as f:
|
|
386
|
+
return json.load(f)
|
|
387
|
+
|
|
388
|
+
except Exception as e:
|
|
389
|
+
logger.error(f"Failed to load skills manifest: {e}")
|
|
390
|
+
print(f"\n❌ Failed to load skills manifest: {e}")
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def get_agent_skill_dependencies(config: UnifiedConfig) -> List[str]:
|
|
395
|
+
"""Get skill dependencies from deployed agents.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
config: UnifiedConfig instance
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
List of skill IDs required by enabled agents
|
|
402
|
+
"""
|
|
403
|
+
try:
|
|
404
|
+
from claude_mpm.services.agents.deployment.deployment_reconciler import (
|
|
405
|
+
DeploymentReconciler,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
reconciler = DeploymentReconciler(config)
|
|
409
|
+
enabled_agents = config.agents.enabled
|
|
410
|
+
|
|
411
|
+
if not enabled_agents:
|
|
412
|
+
logger.debug("No enabled agents, no skill dependencies")
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
# Get skill dependencies
|
|
416
|
+
skill_deps = reconciler._get_agent_skill_dependencies(enabled_agents)
|
|
417
|
+
return list(skill_deps)
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.warning(f"Failed to get agent skill dependencies: {e}")
|
|
421
|
+
return []
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def get_deployed_skills() -> Set[str]:
|
|
425
|
+
"""Get skills currently deployed in .claude/skills/ directory.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Set of deployed skill IDs
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
from claude_mpm.services.agents.deployment.deployment_reconciler import (
|
|
432
|
+
DeploymentReconciler,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
config = UnifiedConfig()
|
|
436
|
+
reconciler = DeploymentReconciler(config)
|
|
437
|
+
|
|
438
|
+
# Get path to deployed skills directory
|
|
439
|
+
path_manager = get_path_manager()
|
|
440
|
+
deploy_dir = path_manager.get_deploy_dir() / "skills"
|
|
441
|
+
|
|
442
|
+
# Use reconciler's method to list deployed skills
|
|
443
|
+
return reconciler._list_deployed_skills(deploy_dir)
|
|
444
|
+
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.warning(f"Failed to get deployed skills: {e}")
|
|
447
|
+
return set()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def run_skill_selector() -> Optional[List[str]]:
|
|
451
|
+
"""Main entry point for skill selector.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
List of selected skill IDs, or None if cancelled
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
# Load config
|
|
458
|
+
config = UnifiedConfig()
|
|
459
|
+
|
|
460
|
+
# Load manifest
|
|
461
|
+
manifest = load_skills_manifest()
|
|
462
|
+
if not manifest:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
# Get agent skill dependencies
|
|
466
|
+
agent_deps = get_agent_skill_dependencies(config)
|
|
467
|
+
|
|
468
|
+
# Get deployed skills
|
|
469
|
+
deployed = get_deployed_skills()
|
|
470
|
+
|
|
471
|
+
# Run selector
|
|
472
|
+
selector = SkillSelector(manifest, agent_deps, deployed)
|
|
473
|
+
return selector.select_skills()
|
|
474
|
+
|
|
475
|
+
except KeyboardInterrupt:
|
|
476
|
+
print("\n\n❌ Skill selection cancelled")
|
|
477
|
+
return None
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.error(f"Skill selection error: {e}", exc_info=True)
|
|
480
|
+
print(f"\n❌ Skill selection error: {e}")
|
|
481
|
+
return None
|
|
@@ -292,6 +292,11 @@ def add_top_level_run_arguments(parser: argparse.ArgumentParser) -> None:
|
|
|
292
292
|
action="store_true",
|
|
293
293
|
help="Force rebuild of all system agents by deleting local claude-mpm agents",
|
|
294
294
|
)
|
|
295
|
+
run_group.add_argument(
|
|
296
|
+
"--force-sync",
|
|
297
|
+
action="store_true",
|
|
298
|
+
help="Force refresh agents and skills from remote repos, bypassing ETag cache",
|
|
299
|
+
)
|
|
295
300
|
|
|
296
301
|
# Dependency checking options (for backward compatibility at top level)
|
|
297
302
|
dep_group_top = parser.add_argument_group(
|
claude_mpm/cli/startup.py
CHANGED
|
@@ -458,7 +458,7 @@ def _cleanup_orphaned_agents(deploy_target: Path, deployed_agents: list[str]) ->
|
|
|
458
458
|
return removed_count
|
|
459
459
|
|
|
460
460
|
|
|
461
|
-
def sync_remote_agents_on_startup():
|
|
461
|
+
def sync_remote_agents_on_startup(force_sync: bool = False):
|
|
462
462
|
"""
|
|
463
463
|
Synchronize agent templates from remote sources on startup.
|
|
464
464
|
|
|
@@ -476,6 +476,9 @@ def sync_remote_agents_on_startup():
|
|
|
476
476
|
3. Cleanup orphaned agents (ours but no longer deployed) - Phase 3
|
|
477
477
|
4. Cleanup legacy agent cache directories (after sync/deployment) - Phase 4
|
|
478
478
|
5. Log deployment results
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
force_sync: Force download even if cache is fresh (bypasses ETag).
|
|
479
482
|
"""
|
|
480
483
|
# DEPRECATED: Legacy warning - no-op function, kept for compatibility
|
|
481
484
|
check_legacy_cache()
|
|
@@ -511,7 +514,7 @@ def sync_remote_agents_on_startup():
|
|
|
511
514
|
)
|
|
512
515
|
|
|
513
516
|
# Phase 1: Sync files from Git sources
|
|
514
|
-
result = sync_agents_on_startup()
|
|
517
|
+
result = sync_agents_on_startup(force_refresh=force_sync)
|
|
515
518
|
|
|
516
519
|
# Only proceed with deployment if sync was enabled and ran
|
|
517
520
|
if result.get("enabled") and result.get("sources_synced", 0) > 0:
|
|
@@ -861,7 +864,7 @@ def sync_remote_agents_on_startup():
|
|
|
861
864
|
pass # Ignore cleanup errors
|
|
862
865
|
|
|
863
866
|
|
|
864
|
-
def sync_remote_skills_on_startup():
|
|
867
|
+
def sync_remote_skills_on_startup(force_sync: bool = False):
|
|
865
868
|
"""
|
|
866
869
|
Synchronize skill templates from remote sources on startup.
|
|
867
870
|
|
|
@@ -879,6 +882,9 @@ def sync_remote_skills_on_startup():
|
|
|
879
882
|
4. Apply profile filtering if active
|
|
880
883
|
5. Deploy resolved skills to ~/.claude/skills/ - Phase 2 progress bar
|
|
881
884
|
6. Log deployment results with source indication
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
force_sync: Force download even if cache is fresh (bypasses ETag).
|
|
882
888
|
"""
|
|
883
889
|
try:
|
|
884
890
|
from pathlib import Path
|
|
@@ -984,7 +990,7 @@ def sync_remote_skills_on_startup():
|
|
|
984
990
|
|
|
985
991
|
# Sync all sources with progress callback
|
|
986
992
|
results = manager.sync_all_sources(
|
|
987
|
-
force=
|
|
993
|
+
force=force_sync, progress_callback=sync_progress.update
|
|
988
994
|
)
|
|
989
995
|
|
|
990
996
|
# Finish sync progress bar with clear breakdown
|
|
@@ -1093,7 +1099,7 @@ def sync_remote_skills_on_startup():
|
|
|
1093
1099
|
# Deploy to project-local directory with cleanup
|
|
1094
1100
|
deployment_result = manager.deploy_skills(
|
|
1095
1101
|
target_dir=Path.cwd() / ".claude" / "skills",
|
|
1096
|
-
force=
|
|
1102
|
+
force=force_sync,
|
|
1097
1103
|
# CRITICAL FIX: Empty list should mean "deploy no skills", not "deploy all"
|
|
1098
1104
|
# When skills_to_deploy is [], we want skill_filter=set() NOT skill_filter=None
|
|
1099
1105
|
# None means "no filtering" (deploy all), empty set means "filter to nothing"
|
|
@@ -1437,7 +1443,7 @@ def auto_install_chrome_devtools_on_startup():
|
|
|
1437
1443
|
# Continue execution - chrome-devtools installation failure shouldn't block startup
|
|
1438
1444
|
|
|
1439
1445
|
|
|
1440
|
-
def run_background_services():
|
|
1446
|
+
def run_background_services(force_sync: bool = False):
|
|
1441
1447
|
"""
|
|
1442
1448
|
Initialize all background services on startup.
|
|
1443
1449
|
|
|
@@ -1448,6 +1454,9 @@ def run_background_services():
|
|
|
1448
1454
|
explicitly requests them via agent-manager commands. This prevents unwanted
|
|
1449
1455
|
file creation in project .claude/ directories.
|
|
1450
1456
|
See: SystemInstructionsDeployer and agent_deployment.py line 504-509
|
|
1457
|
+
|
|
1458
|
+
Args:
|
|
1459
|
+
force_sync: Force download even if cache is fresh (bypasses ETag).
|
|
1451
1460
|
"""
|
|
1452
1461
|
# Sync hooks early to ensure up-to-date configuration
|
|
1453
1462
|
# RATIONALE: Hooks should be synced before other services to fix stale configs
|
|
@@ -1458,7 +1467,7 @@ def run_background_services():
|
|
|
1458
1467
|
check_mcp_auto_configuration()
|
|
1459
1468
|
verify_mcp_gateway_startup()
|
|
1460
1469
|
check_for_updates_async()
|
|
1461
|
-
sync_remote_agents_on_startup() # Sync agents from remote sources
|
|
1470
|
+
sync_remote_agents_on_startup(force_sync=force_sync) # Sync agents from remote sources
|
|
1462
1471
|
show_agent_summary() # Display agent counts after deployment
|
|
1463
1472
|
|
|
1464
1473
|
# Skills deployment order (precedence: remote > bundled)
|
|
@@ -1467,7 +1476,7 @@ def run_background_services():
|
|
|
1467
1476
|
# 3. Discover and link runtime skills (user-added skills)
|
|
1468
1477
|
# This ensures remote skills take precedence over bundled skills when names conflict
|
|
1469
1478
|
deploy_bundled_skills() # Base layer: package-bundled skills
|
|
1470
|
-
sync_remote_skills_on_startup() # Override layer: Git-based skills (takes precedence)
|
|
1479
|
+
sync_remote_skills_on_startup(force_sync=force_sync) # Override layer: Git-based skills (takes precedence)
|
|
1471
1480
|
discover_and_link_runtime_skills() # Discovery: user-added skills
|
|
1472
1481
|
show_skill_summary() # Display skill counts after deployment
|
|
1473
1482
|
verify_and_show_pm_skills() # PM skills verification and status
|
claude_mpm/constants.py
CHANGED
|
@@ -169,6 +169,7 @@ class SkillsCommands(str, Enum):
|
|
|
169
169
|
INFO = "info"
|
|
170
170
|
CONFIG = "config"
|
|
171
171
|
CONFIGURE = "configure" # Interactive skills selection (like agents configure)
|
|
172
|
+
SELECT = "select" # Interactive topic-grouped skill selector
|
|
172
173
|
# GitHub deployment commands
|
|
173
174
|
DEPLOY_FROM_GITHUB = "deploy-github"
|
|
174
175
|
LIST_AVAILABLE = "list-available"
|
|
@@ -16,7 +16,7 @@ Design Principles:
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Any, Dict, List, Optional, Union
|
|
18
18
|
|
|
19
|
-
from pydantic import BaseModel, Field,
|
|
19
|
+
from pydantic import BaseModel, Field, field_validator
|
|
20
20
|
from pydantic_settings import BaseSettings
|
|
21
21
|
|
|
22
22
|
from .exceptions import ConfigurationError
|
|
@@ -54,8 +54,9 @@ class LoggingConfig(BaseModel):
|
|
|
54
54
|
default=True, description="Enable console logging"
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
-
@
|
|
58
|
-
|
|
57
|
+
@field_validator("level")
|
|
58
|
+
@classmethod
|
|
59
|
+
def validate_log_level(cls, v):
|
|
59
60
|
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
60
61
|
if v.upper() not in valid_levels:
|
|
61
62
|
raise ValueError(f"Invalid log level. Must be one of: {valid_levels}")
|
|
@@ -65,8 +66,15 @@ class LoggingConfig(BaseModel):
|
|
|
65
66
|
class AgentConfig(BaseModel):
|
|
66
67
|
"""Agent system configuration."""
|
|
67
68
|
|
|
69
|
+
# Explicit deployment lists (simplified model)
|
|
70
|
+
enabled: List[str] = Field(
|
|
71
|
+
default_factory=list,
|
|
72
|
+
description="Explicit list of agent IDs to deploy (empty = use auto_discover)",
|
|
73
|
+
)
|
|
74
|
+
|
|
68
75
|
auto_discover: bool = Field(
|
|
69
|
-
default=
|
|
76
|
+
default=False,
|
|
77
|
+
description="Enable automatic agent discovery (deprecated, use enabled list)",
|
|
70
78
|
)
|
|
71
79
|
precedence: List[str] = Field(
|
|
72
80
|
default=["project", "user", "system"], description="Agent precedence order"
|
|
@@ -239,6 +247,21 @@ class DocumentationConfig(BaseModel):
|
|
|
239
247
|
)
|
|
240
248
|
|
|
241
249
|
|
|
250
|
+
class SkillConfig(BaseModel):
|
|
251
|
+
"""Skill system configuration."""
|
|
252
|
+
|
|
253
|
+
# Explicit deployment lists (simplified model)
|
|
254
|
+
enabled: List[str] = Field(
|
|
255
|
+
default_factory=list,
|
|
256
|
+
description="Explicit list of skill IDs to deploy (includes agent dependencies)",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
auto_detect_dependencies: bool = Field(
|
|
260
|
+
default=True,
|
|
261
|
+
description="Automatically include skills required by enabled agents",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
242
265
|
class UnifiedConfig(BaseSettings):
|
|
243
266
|
"""
|
|
244
267
|
Unified configuration model for Claude MPM.
|
|
@@ -258,6 +281,7 @@ class UnifiedConfig(BaseSettings):
|
|
|
258
281
|
network: NetworkConfig = Field(default_factory=NetworkConfig)
|
|
259
282
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
260
283
|
agents: AgentConfig = Field(default_factory=AgentConfig)
|
|
284
|
+
skills: SkillConfig = Field(default_factory=SkillConfig)
|
|
261
285
|
memory: MemoryConfig = Field(default_factory=MemoryConfig)
|
|
262
286
|
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
|
263
287
|
performance: PerformanceConfig = Field(default_factory=PerformanceConfig)
|
|
@@ -287,8 +311,9 @@ class UnifiedConfig(BaseSettings):
|
|
|
287
311
|
validate_assignment = True
|
|
288
312
|
extra = "allow" # Allow extra fields for backward compatibility
|
|
289
313
|
|
|
290
|
-
@
|
|
291
|
-
|
|
314
|
+
@field_validator("environment")
|
|
315
|
+
@classmethod
|
|
316
|
+
def validate_environment(cls, v):
|
|
292
317
|
valid_envs = ["development", "testing", "production"]
|
|
293
318
|
if v not in valid_envs:
|
|
294
319
|
raise ValueError(f"Invalid environment. Must be one of: {valid_envs}")
|
|
@@ -554,12 +579,12 @@ class ConfigurationService:
|
|
|
554
579
|
import yaml
|
|
555
580
|
|
|
556
581
|
with file_path.open("w") as f:
|
|
557
|
-
yaml.dump(self._config.
|
|
582
|
+
yaml.dump(self._config.model_dump(), f, default_flow_style=False)
|
|
558
583
|
elif format.lower() == "json":
|
|
559
584
|
import json
|
|
560
585
|
|
|
561
586
|
with file_path.open("w") as f:
|
|
562
|
-
json.dump(self._config.
|
|
587
|
+
json.dump(self._config.model_dump(), f, indent=2)
|
|
563
588
|
else:
|
|
564
589
|
raise ConfigurationError(f"Unsupported export format: {format}")
|
|
565
590
|
|