claude-mpm 5.4.22__py3-none-any.whl → 5.4.48__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.

Potentially problematic release.


This version of claude-mpm might be problematic. Click here for more details.

Files changed (119) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/BASE_AGENT.md +164 -0
  3. claude_mpm/agents/BASE_ENGINEER.md +658 -0
  4. claude_mpm/agents/MEMORY.md +1 -1
  5. claude_mpm/agents/PM_INSTRUCTIONS.md +739 -1052
  6. claude_mpm/agents/WORKFLOW.md +5 -254
  7. claude_mpm/agents/agent_loader.py +1 -1
  8. claude_mpm/agents/base_agent.json +31 -0
  9. claude_mpm/agents/frontmatter_validator.py +2 -2
  10. claude_mpm/cli/commands/agent_state_manager.py +10 -10
  11. claude_mpm/cli/commands/agents.py +9 -9
  12. claude_mpm/cli/commands/auto_configure.py +4 -4
  13. claude_mpm/cli/commands/configure.py +1 -1
  14. claude_mpm/cli/commands/configure_agent_display.py +10 -0
  15. claude_mpm/cli/commands/mpm_init/core.py +65 -0
  16. claude_mpm/cli/commands/postmortem.py +1 -1
  17. claude_mpm/cli/commands/profile.py +277 -0
  18. claude_mpm/cli/commands/skills.py +14 -18
  19. claude_mpm/cli/executor.py +10 -0
  20. claude_mpm/cli/interactive/agent_wizard.py +2 -2
  21. claude_mpm/cli/parsers/base_parser.py +7 -0
  22. claude_mpm/cli/parsers/profile_parser.py +148 -0
  23. claude_mpm/cli/parsers/skills_parser.py +0 -6
  24. claude_mpm/cli/startup.py +346 -75
  25. claude_mpm/commands/mpm-config.md +13 -250
  26. claude_mpm/commands/mpm-doctor.md +9 -22
  27. claude_mpm/commands/mpm-help.md +5 -206
  28. claude_mpm/commands/mpm-init.md +81 -507
  29. claude_mpm/commands/mpm-monitor.md +15 -402
  30. claude_mpm/commands/mpm-organize.md +61 -441
  31. claude_mpm/commands/mpm-postmortem.md +6 -108
  32. claude_mpm/commands/mpm-session-resume.md +12 -363
  33. claude_mpm/commands/mpm-status.md +5 -69
  34. claude_mpm/commands/mpm-ticket-view.md +52 -495
  35. claude_mpm/commands/mpm-version.md +5 -107
  36. claude_mpm/core/config.py +2 -4
  37. claude_mpm/core/framework/loaders/agent_loader.py +1 -1
  38. claude_mpm/core/framework/loaders/instruction_loader.py +52 -11
  39. claude_mpm/core/optimized_startup.py +59 -0
  40. claude_mpm/core/shared/config_loader.py +1 -1
  41. claude_mpm/core/unified_agent_registry.py +1 -1
  42. claude_mpm/dashboard/static/svelte-build/_app/env.js +1 -0
  43. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.B_FtCwCQ.css +1 -0
  44. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.Cl_eSA4x.css +1 -0
  45. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BgChzWQ1.js +1 -0
  46. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIXEwuWe.js +1 -0
  47. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CWc5urbQ.js +1 -0
  48. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DMkZpdF2.js +2 -0
  49. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DjhvlsAc.js +1 -0
  50. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/N4qtv3Hx.js +2 -0
  51. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/uj46x2Wr.js +1 -0
  52. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.DTL5mJO-.js +2 -0
  53. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.DzuEhzqh.js +1 -0
  54. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/0.CAGBuiOw.js +1 -0
  55. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.DFLC8jdE.js +1 -0
  56. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.DPvEihJJ.js +10 -0
  57. claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -0
  58. claude_mpm/dashboard/static/svelte-build/favicon.svg +7 -0
  59. claude_mpm/dashboard/static/svelte-build/index.html +36 -0
  60. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  61. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  62. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  63. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  64. claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
  65. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  66. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  67. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  68. claude_mpm/hooks/claude_hooks/hook_handler.py +149 -1
  69. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  70. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  71. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  72. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  73. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  74. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  75. claude_mpm/hooks/claude_hooks/services/connection_manager.py +26 -6
  76. claude_mpm/hooks/kuzu_memory_hook.py +5 -5
  77. claude_mpm/init.py +63 -0
  78. claude_mpm/models/git_repository.py +3 -3
  79. claude_mpm/scripts/start_activity_logging.py +0 -0
  80. claude_mpm/services/agents/agent_builder.py +3 -3
  81. claude_mpm/services/agents/cache_git_manager.py +6 -6
  82. claude_mpm/services/agents/deployment/agent_deployment.py +29 -7
  83. claude_mpm/services/agents/deployment/agent_discovery_service.py +2 -2
  84. claude_mpm/services/agents/deployment/agent_format_converter.py +23 -13
  85. claude_mpm/services/agents/deployment/agent_template_builder.py +29 -19
  86. claude_mpm/services/agents/deployment/agents_directory_resolver.py +2 -2
  87. claude_mpm/services/agents/deployment/async_agent_deployment.py +31 -27
  88. claude_mpm/services/agents/deployment/local_template_deployment.py +3 -1
  89. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +169 -26
  90. claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +98 -75
  91. claude_mpm/services/agents/git_source_manager.py +19 -4
  92. claude_mpm/services/agents/recommender.py +5 -3
  93. claude_mpm/services/agents/single_tier_deployment_service.py +2 -2
  94. claude_mpm/services/agents/sources/git_source_sync_service.py +112 -6
  95. claude_mpm/services/agents/startup_sync.py +22 -2
  96. claude_mpm/services/diagnostics/checks/agent_check.py +2 -2
  97. claude_mpm/services/diagnostics/checks/agent_sources_check.py +1 -1
  98. claude_mpm/services/git/git_operations_service.py +8 -8
  99. claude_mpm/services/monitor/management/lifecycle.py +8 -1
  100. claude_mpm/services/monitor/server.py +473 -3
  101. claude_mpm/services/pm_skills_deployer.py +711 -0
  102. claude_mpm/services/profile_manager.py +331 -0
  103. claude_mpm/services/skills/git_skill_source_manager.py +101 -3
  104. claude_mpm/services/skills_deployer.py +4 -3
  105. claude_mpm/services/socketio/dashboard_server.py +1 -0
  106. claude_mpm/services/socketio/event_normalizer.py +37 -6
  107. claude_mpm/services/socketio/server/core.py +262 -123
  108. claude_mpm/skills/skill_manager.py +92 -3
  109. claude_mpm/utils/agent_dependency_loader.py +14 -2
  110. claude_mpm/utils/agent_filters.py +1 -1
  111. claude_mpm/utils/migration.py +4 -4
  112. claude_mpm/utils/robust_installer.py +47 -3
  113. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/METADATA +7 -4
  114. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/RECORD +118 -79
  115. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/WHEEL +0 -0
  116. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/entry_points.txt +0 -0
  117. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/licenses/LICENSE +0 -0
  118. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  119. {claude_mpm-5.4.22.dist-info → claude_mpm-5.4.48.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,711 @@
1
+ """PM Skills Deployer Service - Deploy bundled PM skills to projects.
2
+
3
+ WHY: PM agents require specific templates and skills for proper operation.
4
+ This service manages deployment of bundled PM skills from the claude-mpm
5
+ package to individual project .claude-mpm directories with version tracking.
6
+
7
+ DESIGN DECISIONS:
8
+ - Deploys from src/claude_mpm/skills/bundled/pm/ to .claude-mpm/skills/pm/
9
+ - Uses package-relative paths (works for both installed and dev mode)
10
+ - Supports two skill formats:
11
+ 1. Directory structure: pm-skill-name/SKILL.md (new format)
12
+ 2. Flat files: skill-name.md (legacy format in .claude-mpm/templates/)
13
+ - Per-project deployment (NOT global like Claude Code skills)
14
+ - Version tracking via .claude-mpm/pm_skills_registry.yaml
15
+ - Checksum validation for integrity verification
16
+ - Non-blocking verification (returns warnings, doesn't halt execution)
17
+ - Force flag to redeploy even if versions match
18
+
19
+ ARCHITECTURE:
20
+ 1. Discovery: Find bundled PM skills in package (skills/bundled/pm/)
21
+ 2. Deployment: Copy SKILL.md files to .claude-mpm/skills/pm/{name}.md
22
+ 3. Registry: Track deployed versions and checksums
23
+ 4. Verification: Check deployment status (non-blocking)
24
+ 5. Updates: Compare bundled vs deployed versions
25
+
26
+ PATH RESOLUTION:
27
+ - Installed package: Uses __file__ to find skills/bundled/pm/
28
+ - Dev mode fallback: .claude-mpm/templates/ at project root
29
+ - Works correctly in both site-packages and development environments
30
+
31
+ References:
32
+ - Parent Service: src/claude_mpm/services/skills_deployer.py
33
+ - Skills Service: src/claude_mpm/skills/skills_service.py
34
+ """
35
+
36
+ import hashlib
37
+ import shutil
38
+ from dataclasses import dataclass
39
+ from datetime import datetime
40
+ from pathlib import Path
41
+ from typing import Any, Callable, Dict, List, Optional
42
+
43
+ import yaml
44
+
45
+ from claude_mpm.core.mixins import LoggerMixin
46
+
47
+ # Security constants
48
+ MAX_YAML_SIZE = 10 * 1024 * 1024 # 10MB limit to prevent YAML bombs
49
+
50
+
51
+ @dataclass
52
+ class PMSkillInfo:
53
+ """Information about a deployed PM skill.
54
+
55
+ Attributes:
56
+ name: Skill name (directory/file name)
57
+ version: Skill version from metadata
58
+ deployed_at: ISO timestamp of deployment
59
+ checksum: SHA256 checksum of skill content
60
+ source_path: Original bundled skill path
61
+ deployed_path: Deployed skill path
62
+ """
63
+
64
+ name: str
65
+ version: str
66
+ deployed_at: str
67
+ checksum: str
68
+ source_path: Path
69
+ deployed_path: Path
70
+
71
+
72
+ @dataclass
73
+ class DeploymentResult:
74
+ """Result of skill deployment operation.
75
+
76
+ Attributes:
77
+ success: Whether deployment succeeded
78
+ deployed: List of successfully deployed skill names
79
+ skipped: List of skipped skill names (already deployed)
80
+ errors: List of dicts with 'skill' and 'error' keys
81
+ message: Summary message
82
+ """
83
+
84
+ success: bool
85
+ deployed: List[str]
86
+ skipped: List[str]
87
+ errors: List[Dict[str, str]]
88
+ message: str
89
+
90
+
91
+ @dataclass
92
+ class VerificationResult:
93
+ """Result of skill verification operation.
94
+
95
+ Attributes:
96
+ verified: Whether all skills are properly deployed
97
+ warnings: List of warning messages
98
+ missing_skills: List of missing skill names
99
+ outdated_skills: List of outdated skill names
100
+ message: Summary message
101
+ """
102
+
103
+ verified: bool
104
+ warnings: List[str]
105
+ missing_skills: List[str]
106
+ outdated_skills: List[str]
107
+ message: str
108
+
109
+
110
+ @dataclass
111
+ class UpdateInfo:
112
+ """Information about available skill update.
113
+
114
+ Attributes:
115
+ skill_name: Name of skill with update available
116
+ current_version: Currently deployed version
117
+ new_version: Available bundled version
118
+ checksum_changed: Whether content changed (even if version same)
119
+ """
120
+
121
+ skill_name: str
122
+ current_version: str
123
+ new_version: str
124
+ checksum_changed: bool
125
+
126
+
127
+ class PMSkillsDeployerService(LoggerMixin):
128
+ """Deploy and manage PM skills from bundled sources to projects.
129
+
130
+ This service provides:
131
+ - Discovery of bundled PM skills (templates)
132
+ - Deployment to .claude-mpm/skills/pm/
133
+ - Version tracking via pm_skills_registry.yaml
134
+ - Checksum validation for integrity
135
+ - Non-blocking verification (warnings only)
136
+ - Update detection and deployment
137
+
138
+ Example:
139
+ >>> deployer = PMSkillsDeployerService()
140
+ >>> result = deployer.deploy_pm_skills(Path("/project/root"))
141
+ >>> print(f"Deployed {len(result.deployed)} skills")
142
+ >>>
143
+ >>> verify_result = deployer.verify_pm_skills(Path("/project/root"))
144
+ >>> if not verify_result.verified:
145
+ ... print(f"Warnings: {verify_result.warnings}")
146
+ """
147
+
148
+ REGISTRY_VERSION = "1.0.0"
149
+ REGISTRY_FILENAME = "pm_skills_registry.yaml"
150
+
151
+ def __init__(self) -> None:
152
+ """Initialize PM Skills Deployer Service.
153
+
154
+ Sets up paths for:
155
+ - bundled_pm_skills_path: Source bundled PM skills (skills/bundled/pm/)
156
+ - Deployment paths are project-specific (passed to methods)
157
+ """
158
+ super().__init__()
159
+
160
+ # Bundled PM skills are in the package's skills/bundled/pm/ directory
161
+ # This works for both installed packages and development mode
162
+ package_dir = Path(__file__).resolve().parent.parent # Go up to claude_mpm
163
+ self.bundled_pm_skills_path = package_dir / "skills" / "bundled" / "pm"
164
+
165
+ if not self.bundled_pm_skills_path.exists():
166
+ # Fallback: try .claude-mpm/templates/ at project root for dev mode
167
+ self.project_root = self._find_project_root()
168
+ alt_path = self.project_root / ".claude-mpm" / "templates"
169
+ if alt_path.exists():
170
+ self.bundled_pm_skills_path = alt_path
171
+ self.logger.debug(f"Using dev templates path: {alt_path}")
172
+ else:
173
+ self.logger.warning(
174
+ f"PM skills templates path not found (non-critical, uses defaults)"
175
+ )
176
+
177
+ def _find_project_root(self) -> Path:
178
+ """Find project root by traversing up from current file.
179
+
180
+ Returns:
181
+ Path to project root (directory containing .git or pyproject.toml)
182
+ """
183
+ current = Path(__file__).resolve()
184
+
185
+ # Traverse up to find project root markers
186
+ for parent in [current] + list(current.parents):
187
+ if (parent / ".git").exists() or (parent / "pyproject.toml").exists():
188
+ return parent
189
+
190
+ # Fallback to current working directory
191
+ return Path.cwd()
192
+
193
+ def _validate_safe_path(self, base: Path, target: Path) -> bool:
194
+ """Ensure target path is within base directory to prevent path traversal.
195
+
196
+ Args:
197
+ base: Base directory that should contain the target
198
+ target: Target path to validate
199
+
200
+ Returns:
201
+ True if path is safe, False otherwise
202
+ """
203
+ try:
204
+ target.resolve().relative_to(base.resolve())
205
+ return True
206
+ except ValueError:
207
+ return False
208
+
209
+ def _compute_checksum(self, file_path: Path) -> str:
210
+ """Compute SHA256 checksum of file content.
211
+
212
+ Args:
213
+ file_path: Path to file to checksum
214
+
215
+ Returns:
216
+ Hex string of SHA256 checksum
217
+ """
218
+ sha256 = hashlib.sha256()
219
+ try:
220
+ with open(file_path, "rb") as f:
221
+ # Read in 64KB chunks to handle large files
222
+ for chunk in iter(lambda: f.read(65536), b""):
223
+ sha256.update(chunk)
224
+ return sha256.hexdigest()
225
+ except OSError as e:
226
+ self.logger.error(f"Failed to compute checksum for {file_path}: {e}")
227
+ return ""
228
+
229
+ def _get_registry_path(self, project_dir: Path) -> Path:
230
+ """Get path to PM skills registry file.
231
+
232
+ Args:
233
+ project_dir: Project root directory
234
+
235
+ Returns:
236
+ Path to pm_skills_registry.yaml
237
+ """
238
+ return project_dir / ".claude-mpm" / self.REGISTRY_FILENAME
239
+
240
+ def _get_deployment_dir(self, project_dir: Path) -> Path:
241
+ """Get deployment directory for PM skills.
242
+
243
+ Args:
244
+ project_dir: Project root directory
245
+
246
+ Returns:
247
+ Path to .claude-mpm/skills/pm/
248
+ """
249
+ return project_dir / ".claude-mpm" / "skills" / "pm"
250
+
251
+ def _load_registry(self, project_dir: Path) -> Dict[str, Any]:
252
+ """Load PM skills registry with security checks.
253
+
254
+ Args:
255
+ project_dir: Project root directory
256
+
257
+ Returns:
258
+ Dict containing registry data, or empty dict if not found/invalid
259
+ """
260
+ registry_path = self._get_registry_path(project_dir)
261
+
262
+ if not registry_path.exists():
263
+ self.logger.debug(f"PM skills registry not found: {registry_path}")
264
+ return {}
265
+
266
+ # Check file size to prevent YAML bomb
267
+ try:
268
+ file_size = registry_path.stat().st_size
269
+ if file_size > MAX_YAML_SIZE:
270
+ self.logger.error(
271
+ f"Registry file too large: {file_size} bytes (max {MAX_YAML_SIZE})"
272
+ )
273
+ return {}
274
+ except OSError as e:
275
+ self.logger.error(f"Failed to stat registry file: {e}")
276
+ return {}
277
+
278
+ try:
279
+ with open(registry_path, encoding="utf-8") as f:
280
+ registry = yaml.safe_load(f)
281
+ if not registry:
282
+ self.logger.warning(f"Empty registry file: {registry_path}")
283
+ return {}
284
+ self.logger.debug(f"Loaded PM skills registry from {registry_path}")
285
+ return registry
286
+ except yaml.YAMLError as e:
287
+ self.logger.error(f"Invalid YAML in registry: {e}")
288
+ return {}
289
+ except OSError as e:
290
+ self.logger.error(f"Failed to read registry file: {e}")
291
+ return {}
292
+
293
+ def _save_registry(self, project_dir: Path, registry: Dict[str, Any]) -> bool:
294
+ """Save PM skills registry to file.
295
+
296
+ Args:
297
+ project_dir: Project root directory
298
+ registry: Registry data to save
299
+
300
+ Returns:
301
+ True if save succeeded, False otherwise
302
+ """
303
+ registry_path = self._get_registry_path(project_dir)
304
+
305
+ try:
306
+ # Ensure parent directory exists
307
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
308
+
309
+ with open(registry_path, "w", encoding="utf-8") as f:
310
+ yaml.safe_dump(
311
+ registry, f, default_flow_style=False, allow_unicode=True
312
+ )
313
+
314
+ self.logger.debug(f"Saved PM skills registry to {registry_path}")
315
+ return True
316
+ except (OSError, yaml.YAMLError) as e:
317
+ self.logger.error(f"Failed to save registry: {e}")
318
+ return False
319
+
320
+ def _discover_bundled_pm_skills(self) -> List[Dict[str, Any]]:
321
+ """Discover all PM skills in bundled templates directory.
322
+
323
+ PM skills can be in two formats:
324
+ 1. Directory structure: pm-skill-name/SKILL.md (new format)
325
+ 2. Flat files: skill-name.md (legacy format for .claude-mpm/templates/)
326
+
327
+ Returns:
328
+ List of skill dictionaries containing:
329
+ - name: Skill name (directory/filename without extension)
330
+ - path: Full path to skill file (SKILL.md or .md file)
331
+ - type: File type (always 'md')
332
+ """
333
+ skills = []
334
+
335
+ if not self.bundled_pm_skills_path.exists():
336
+ self.logger.warning(
337
+ f"Bundled PM skills path not found: {self.bundled_pm_skills_path}"
338
+ )
339
+ return skills
340
+
341
+ # Scan for skill directories containing SKILL.md (new format)
342
+ for skill_dir in self.bundled_pm_skills_path.iterdir():
343
+ if not skill_dir.is_dir() or skill_dir.name.startswith("."):
344
+ continue
345
+
346
+ skill_file = skill_dir / "SKILL.md"
347
+ if skill_file.exists():
348
+ skills.append(
349
+ {
350
+ "name": skill_dir.name,
351
+ "path": skill_file,
352
+ "type": "md",
353
+ }
354
+ )
355
+
356
+ # Fallback: Scan for .md files directly (legacy format)
357
+ for skill_file in self.bundled_pm_skills_path.glob("*.md"):
358
+ if skill_file.name.startswith("."):
359
+ continue
360
+
361
+ skills.append(
362
+ {
363
+ "name": skill_file.stem,
364
+ "path": skill_file,
365
+ "type": "md",
366
+ }
367
+ )
368
+
369
+ self.logger.info(f"Discovered {len(skills)} bundled PM skills")
370
+ return skills
371
+
372
+ def deploy_pm_skills(
373
+ self,
374
+ project_dir: Path,
375
+ force: bool = False,
376
+ progress_callback: Optional[Callable[[str, int, int], None]] = None,
377
+ ) -> DeploymentResult:
378
+ """Deploy bundled PM skills to project directory.
379
+
380
+ Copies PM skills from bundled templates to .claude-mpm/skills/pm/
381
+ and updates registry with version and checksum information.
382
+
383
+ Args:
384
+ project_dir: Project root directory
385
+ force: If True, redeploy even if skill already exists
386
+ progress_callback: Optional callback(skill_name, current, total) for progress
387
+
388
+ Returns:
389
+ DeploymentResult with deployment status and details
390
+
391
+ Example:
392
+ >>> result = deployer.deploy_pm_skills(Path("/project"), force=True)
393
+ >>> print(f"Deployed: {len(result.deployed)}")
394
+ """
395
+ skills = self._discover_bundled_pm_skills()
396
+ deployed = []
397
+ skipped = []
398
+ errors = []
399
+
400
+ if not skills:
401
+ return DeploymentResult(
402
+ success=True,
403
+ deployed=[],
404
+ skipped=[],
405
+ errors=[],
406
+ message="No PM skills found to deploy",
407
+ )
408
+
409
+ # Ensure deployment directory exists
410
+ deployment_dir = self._get_deployment_dir(project_dir)
411
+ deployment_dir.mkdir(parents=True, exist_ok=True)
412
+
413
+ # SECURITY: Validate deployment path
414
+ if not self._validate_safe_path(project_dir, deployment_dir):
415
+ return DeploymentResult(
416
+ success=False,
417
+ deployed=[],
418
+ skipped=[],
419
+ errors=[
420
+ {
421
+ "skill": "all",
422
+ "error": "Path traversal attempt detected in deployment directory",
423
+ }
424
+ ],
425
+ message="Security check failed",
426
+ )
427
+
428
+ # Load existing registry
429
+ registry = self._load_registry(project_dir)
430
+ deployed_skills = registry.get("skills", [])
431
+
432
+ # Create lookup for existing deployments
433
+ existing_deployments = {
434
+ skill["name"]: skill for skill in deployed_skills
435
+ }
436
+
437
+ new_deployed_skills = []
438
+ timestamp = datetime.utcnow().isoformat() + "Z"
439
+ total_skills = len(skills)
440
+
441
+ for idx, skill in enumerate(skills):
442
+ try:
443
+ skill_name = skill["name"]
444
+ source_path = skill["path"]
445
+
446
+ # Report progress if callback provided
447
+ if progress_callback:
448
+ progress_callback(skill_name, idx + 1, total_skills)
449
+
450
+ # Use skill name for target file (e.g., pm-delegation-patterns.md)
451
+ target_path = deployment_dir / f"{skill_name}.md"
452
+
453
+ # SECURITY: Validate target path
454
+ if not self._validate_safe_path(deployment_dir, target_path):
455
+ raise ValueError(f"Path traversal attempt detected: {target_path}")
456
+
457
+ # Compute checksum of source
458
+ checksum = self._compute_checksum(source_path)
459
+
460
+ # Check if already deployed
461
+ if skill_name in existing_deployments and not force:
462
+ existing = existing_deployments[skill_name]
463
+ if existing.get("checksum") == checksum:
464
+ skipped.append(skill_name)
465
+ new_deployed_skills.append(existing) # Keep existing entry
466
+ self.logger.debug(
467
+ f"Skipped {skill_name} (already deployed with same checksum)"
468
+ )
469
+ continue
470
+
471
+ # Deploy skill
472
+ shutil.copy2(source_path, target_path)
473
+
474
+ # Add to deployed list
475
+ deployed.append(skill_name)
476
+
477
+ # Update registry entry
478
+ skill_entry = {
479
+ "name": skill_name,
480
+ "version": "1.0.0", # PM templates don't have versions yet
481
+ "deployed_at": timestamp,
482
+ "checksum": checksum,
483
+ }
484
+ new_deployed_skills.append(skill_entry)
485
+
486
+ self.logger.debug(f"Deployed PM skill: {skill_name}")
487
+
488
+ except (ValueError, OSError) as e:
489
+ self.logger.error(f"Failed to deploy {skill['name']}: {e}")
490
+ errors.append({"skill": skill["name"], "error": str(e)})
491
+
492
+ # Update registry
493
+ updated_registry = {
494
+ "version": self.REGISTRY_VERSION,
495
+ "deployed_at": timestamp,
496
+ "skills": new_deployed_skills,
497
+ }
498
+
499
+ if not self._save_registry(project_dir, updated_registry):
500
+ errors.append(
501
+ {
502
+ "skill": "registry",
503
+ "error": "Failed to save registry after deployment",
504
+ }
505
+ )
506
+
507
+ success = len(errors) == 0
508
+ message = (
509
+ f"Deployed {len(deployed)} skills, skipped {len(skipped)}, "
510
+ f"{len(errors)} errors"
511
+ )
512
+
513
+ self.logger.info(message)
514
+
515
+ return DeploymentResult(
516
+ success=success,
517
+ deployed=deployed,
518
+ skipped=skipped,
519
+ errors=errors,
520
+ message=message,
521
+ )
522
+
523
+ def verify_pm_skills(self, project_dir: Path) -> VerificationResult:
524
+ """Verify PM skills are properly deployed (non-blocking).
525
+
526
+ Checks deployment status and returns warnings without halting execution.
527
+ This allows graceful degradation if PM skills are missing.
528
+
529
+ Args:
530
+ project_dir: Project root directory
531
+
532
+ Returns:
533
+ VerificationResult with verification status and warnings
534
+
535
+ Example:
536
+ >>> result = deployer.verify_pm_skills(Path("/project"))
537
+ >>> if not result.verified:
538
+ ... for warning in result.warnings:
539
+ ... print(f"WARNING: {warning}")
540
+ """
541
+ warnings = []
542
+ missing_skills = []
543
+ outdated_skills = []
544
+
545
+ # Check if registry exists
546
+ registry = self._load_registry(project_dir)
547
+ if not registry:
548
+ warnings.append("PM skills registry not found or invalid")
549
+ missing_skills.append("all")
550
+ return VerificationResult(
551
+ verified=False,
552
+ warnings=warnings,
553
+ missing_skills=missing_skills,
554
+ outdated_skills=outdated_skills,
555
+ message="PM skills not deployed. Run 'claude-mpm init' to deploy.",
556
+ )
557
+
558
+ # Check each registered skill exists
559
+ deployment_dir = self._get_deployment_dir(project_dir)
560
+ deployed_skills = registry.get("skills", [])
561
+
562
+ for skill in deployed_skills:
563
+ skill_name = skill["name"]
564
+ skill_file = deployment_dir / f"{skill_name}.md"
565
+
566
+ if not skill_file.exists():
567
+ warnings.append(f"Deployed skill file missing: {skill_name}")
568
+ missing_skills.append(skill_name)
569
+ continue
570
+
571
+ # Verify checksum
572
+ current_checksum = self._compute_checksum(skill_file)
573
+ expected_checksum = skill.get("checksum", "")
574
+
575
+ if current_checksum != expected_checksum:
576
+ warnings.append(
577
+ f"Skill checksum mismatch: {skill_name} (file may be corrupted)"
578
+ )
579
+ outdated_skills.append(skill_name)
580
+
581
+ # Check for available updates
582
+ bundled_skills = {s["name"]: s for s in self._discover_bundled_pm_skills()}
583
+ for skill_name, bundled_skill in bundled_skills.items():
584
+ # Find corresponding deployed skill
585
+ deployed_skill = next(
586
+ (s for s in deployed_skills if s["name"] == skill_name), None
587
+ )
588
+
589
+ if not deployed_skill:
590
+ warnings.append(f"New PM skill available: {skill_name}")
591
+ missing_skills.append(skill_name)
592
+ continue
593
+
594
+ # Check if checksums differ
595
+ bundled_checksum = self._compute_checksum(bundled_skill["path"])
596
+ deployed_checksum = deployed_skill.get("checksum", "")
597
+
598
+ if bundled_checksum != deployed_checksum:
599
+ warnings.append(f"PM skill update available: {skill_name}")
600
+ outdated_skills.append(skill_name)
601
+
602
+ verified = len(warnings) == 0
603
+
604
+ if verified:
605
+ message = "All PM skills verified and up-to-date"
606
+ else:
607
+ message = f"{len(warnings)} verification warnings found"
608
+
609
+ return VerificationResult(
610
+ verified=verified,
611
+ warnings=warnings,
612
+ missing_skills=missing_skills,
613
+ outdated_skills=outdated_skills,
614
+ message=message,
615
+ )
616
+
617
+ def get_deployed_skills(self, project_dir: Path) -> List[PMSkillInfo]:
618
+ """Get list of deployed PM skills with metadata.
619
+
620
+ Args:
621
+ project_dir: Project root directory
622
+
623
+ Returns:
624
+ List of PMSkillInfo objects for deployed skills
625
+
626
+ Example:
627
+ >>> skills = deployer.get_deployed_skills(Path("/project"))
628
+ >>> for skill in skills:
629
+ ... print(f"{skill.name} v{skill.version} ({skill.deployed_at})")
630
+ """
631
+ registry = self._load_registry(project_dir)
632
+ deployment_dir = self._get_deployment_dir(project_dir)
633
+
634
+ skills = []
635
+ for skill_data in registry.get("skills", []):
636
+ skill_name = skill_data["name"]
637
+ deployed_path = deployment_dir / f"{skill_name}.md"
638
+
639
+ # Find source path (may not exist if bundled skills changed)
640
+ source_path = self.bundled_pm_skills_path / f"{skill_name}.md"
641
+
642
+ skills.append(
643
+ PMSkillInfo(
644
+ name=skill_name,
645
+ version=skill_data.get("version", "1.0.0"),
646
+ deployed_at=skill_data.get("deployed_at", "unknown"),
647
+ checksum=skill_data.get("checksum", ""),
648
+ source_path=source_path,
649
+ deployed_path=deployed_path,
650
+ )
651
+ )
652
+
653
+ return skills
654
+
655
+ def check_updates_available(self, project_dir: Path) -> List[UpdateInfo]:
656
+ """Check for available PM skill updates.
657
+
658
+ Compares bundled skills against deployed skills to identify updates.
659
+
660
+ Args:
661
+ project_dir: Project root directory
662
+
663
+ Returns:
664
+ List of UpdateInfo objects for skills with updates available
665
+
666
+ Example:
667
+ >>> updates = deployer.check_updates_available(Path("/project"))
668
+ >>> for update in updates:
669
+ ... print(f"{update.skill_name}: {update.current_version} -> {update.new_version}")
670
+ """
671
+ registry = self._load_registry(project_dir)
672
+ deployed_skills = {
673
+ skill["name"]: skill for skill in registry.get("skills", [])
674
+ }
675
+
676
+ bundled_skills = self._discover_bundled_pm_skills()
677
+
678
+ updates = []
679
+ for bundled_skill in bundled_skills:
680
+ skill_name = bundled_skill["name"]
681
+
682
+ # Compute bundled checksum
683
+ bundled_checksum = self._compute_checksum(bundled_skill["path"])
684
+
685
+ if skill_name not in deployed_skills:
686
+ # New skill available
687
+ updates.append(
688
+ UpdateInfo(
689
+ skill_name=skill_name,
690
+ current_version="not deployed",
691
+ new_version="1.0.0",
692
+ checksum_changed=True,
693
+ )
694
+ )
695
+ continue
696
+
697
+ # Check if checksum differs
698
+ deployed_skill = deployed_skills[skill_name]
699
+ deployed_checksum = deployed_skill.get("checksum", "")
700
+
701
+ if bundled_checksum != deployed_checksum:
702
+ updates.append(
703
+ UpdateInfo(
704
+ skill_name=skill_name,
705
+ current_version=deployed_skill.get("version", "1.0.0"),
706
+ new_version="1.0.0",
707
+ checksum_changed=True,
708
+ )
709
+ )
710
+
711
+ return updates