claude-mpm 5.4.36__py3-none-any.whl → 5.4.59__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 (137) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/PM_INSTRUCTIONS.md +489 -177
  3. claude_mpm/agents/base_agent.json +1 -1
  4. claude_mpm/agents/frontmatter_validator.py +2 -2
  5. claude_mpm/cli/commands/configure_agent_display.py +12 -0
  6. claude_mpm/cli/commands/mpm_init/core.py +72 -0
  7. claude_mpm/cli/commands/profile.py +276 -0
  8. claude_mpm/cli/commands/skills.py +14 -18
  9. claude_mpm/cli/executor.py +10 -0
  10. claude_mpm/cli/parsers/base_parser.py +7 -0
  11. claude_mpm/cli/parsers/profile_parser.py +147 -0
  12. claude_mpm/cli/parsers/skills_parser.py +0 -6
  13. claude_mpm/cli/startup.py +433 -147
  14. claude_mpm/commands/mpm-config.md +13 -250
  15. claude_mpm/commands/mpm-doctor.md +9 -22
  16. claude_mpm/commands/mpm-help.md +5 -206
  17. claude_mpm/commands/mpm-init.md +81 -507
  18. claude_mpm/commands/mpm-monitor.md +15 -402
  19. claude_mpm/commands/mpm-organize.md +61 -441
  20. claude_mpm/commands/mpm-postmortem.md +6 -108
  21. claude_mpm/commands/mpm-session-resume.md +12 -363
  22. claude_mpm/commands/mpm-status.md +5 -69
  23. claude_mpm/commands/mpm-ticket-view.md +52 -495
  24. claude_mpm/commands/mpm-version.md +5 -107
  25. claude_mpm/core/optimized_startup.py +61 -0
  26. claude_mpm/core/shared/config_loader.py +3 -1
  27. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.DWzvg0-y.css +1 -0
  28. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.ThTw9_ym.css +1 -0
  29. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{CWc5urbQ.js → 4TdZjIqw.js} +1 -1
  30. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/5shd3_w0.js +24 -0
  31. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/B0uc0UOD.js +36 -0
  32. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/B7RN905-.js +1 -0
  33. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/B7xVLGWV.js +2 -0
  34. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BIF9m_hv.js +61 -0
  35. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BKjSRqUr.js +1 -0
  36. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BPYeabCQ.js +1 -0
  37. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BQaXIfA_.js +331 -0
  38. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{uj46x2Wr.js → BSNlmTZj.js} +1 -1
  39. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Be7GpZd6.js +7 -0
  40. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Bh0LDWpI.js +145 -0
  41. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BofRWZRR.js +10 -0
  42. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BovzEFCE.js +30 -0
  43. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C30mlcqg.js +165 -0
  44. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C4B-KCzX.js +1 -0
  45. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C4JcI4KD.js +122 -0
  46. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CBBdVcY8.js +1 -0
  47. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CDuw-vjf.js +1 -0
  48. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C_Usid8X.js +15 -0
  49. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Cfqx1Qun.js +10 -0
  50. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CiIAseT4.js +128 -0
  51. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CmKTTxBW.js +1 -0
  52. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CnA0NrzZ.js +1 -0
  53. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Cs_tUR18.js +24 -0
  54. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Cu_Erd72.js +261 -0
  55. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CyWMqx4W.js +43 -0
  56. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CzZX-COe.js +220 -0
  57. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CzeYkLYB.js +65 -0
  58. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/D3k0OPJN.js +4 -0
  59. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/D9lljYKQ.js +1 -0
  60. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DGkLK5U1.js +267 -0
  61. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DI7hHRFL.js +1 -0
  62. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DLVjFsZ3.js +139 -0
  63. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DUrLdbGD.js +89 -0
  64. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DVp1hx9R.js +1 -0
  65. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DY1XQ8fi.js +2 -0
  66. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DZX00Y4g.js +1 -0
  67. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Da0KfYnO.js +1 -0
  68. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DaimHw_p.js +68 -0
  69. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Dfy6j1xT.js +323 -0
  70. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Dhb8PKl3.js +1 -0
  71. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Dle-35c7.js +64 -0
  72. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DmxopI1J.js +1 -0
  73. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DwBR2MJi.js +60 -0
  74. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/GYwsonyD.js +1 -0
  75. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Gi6I4Gst.js +1 -0
  76. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DjhvlsAc.js → NqQ1dWOy.js} +1 -1
  77. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/RJiighC3.js +1 -0
  78. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{N4qtv3Hx.js → Vzk33B_K.js} +1 -1
  79. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/ZGh7QtNv.js +7 -0
  80. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/bT1r9zLR.js +1 -0
  81. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/bTOqqlTd.js +1 -0
  82. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/eNVUfhuA.js +1 -0
  83. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/iEWssX7S.js +162 -0
  84. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/sQeU3Y1z.js +1 -0
  85. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/uuIeMWc-.js +1 -0
  86. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.D6-I5TpK.js +2 -0
  87. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.NWzMBYRp.js +1 -0
  88. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/{0.CAGBuiOw.js → 0.m1gL8KXf.js} +1 -1
  89. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.CgNOuw-d.js +1 -0
  90. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.C0GcWctS.js +1 -0
  91. claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -1
  92. claude_mpm/dashboard/static/svelte-build/index.html +10 -10
  93. claude_mpm/dashboard-svelte/node_modules/katex/src/fonts/generate_fonts.py +58 -0
  94. claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/extract_tfms.py +114 -0
  95. claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/extract_ttfs.py +122 -0
  96. claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/format_json.py +28 -0
  97. claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/parse_tfm.py +211 -0
  98. claude_mpm/hooks/kuzu_memory_hook.py +5 -5
  99. claude_mpm/init.py +276 -0
  100. claude_mpm/scripts/start_activity_logging.py +0 -0
  101. claude_mpm/services/agents/agent_builder.py +3 -3
  102. claude_mpm/services/agents/deployment/agent_deployment.py +22 -0
  103. claude_mpm/services/agents/deployment/agent_discovery_service.py +3 -1
  104. claude_mpm/services/agents/deployment/agent_format_converter.py +25 -13
  105. claude_mpm/services/agents/deployment/agent_template_builder.py +29 -17
  106. claude_mpm/services/agents/deployment/async_agent_deployment.py +31 -27
  107. claude_mpm/services/agents/deployment/local_template_deployment.py +3 -1
  108. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +149 -4
  109. claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +47 -26
  110. claude_mpm/services/agents/git_source_manager.py +21 -2
  111. claude_mpm/services/agents/sources/git_source_sync_service.py +116 -5
  112. claude_mpm/services/monitor/management/lifecycle.py +7 -1
  113. claude_mpm/services/pm_skills_deployer.py +711 -0
  114. claude_mpm/services/profile_manager.py +337 -0
  115. claude_mpm/services/skills/git_skill_source_manager.py +148 -11
  116. claude_mpm/services/skills/selective_skill_deployer.py +97 -48
  117. claude_mpm/services/skills_deployer.py +161 -65
  118. claude_mpm/skills/bundled/security-scanning.md +112 -0
  119. claude_mpm/skills/skill_manager.py +98 -3
  120. claude_mpm/templates/.pre-commit-config.yaml +112 -0
  121. {claude_mpm-5.4.36.dist-info → claude_mpm-5.4.59.dist-info}/METADATA +3 -2
  122. {claude_mpm-5.4.36.dist-info → claude_mpm-5.4.59.dist-info}/RECORD +126 -67
  123. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.B_FtCwCQ.css +0 -1
  124. claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.Cl_eSA4x.css +0 -1
  125. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BgChzWQ1.js +0 -1
  126. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIXEwuWe.js +0 -1
  127. claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DMkZpdF2.js +0 -2
  128. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.DTL5mJO-.js +0 -2
  129. claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.DzuEhzqh.js +0 -1
  130. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.DFLC8jdE.js +0 -1
  131. claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.DPvEihJJ.js +0 -10
  132. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
  133. {claude_mpm-5.4.36.dist-info → claude_mpm-5.4.59.dist-info}/WHEEL +0 -0
  134. {claude_mpm-5.4.36.dist-info → claude_mpm-5.4.59.dist-info}/entry_points.txt +0 -0
  135. {claude_mpm-5.4.36.dist-info → claude_mpm-5.4.59.dist-info}/licenses/LICENSE +0 -0
  136. {claude_mpm-5.4.36.dist-info → claude_mpm-5.4.59.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  137. {claude_mpm-5.4.36.dist-info → claude_mpm-5.4.59.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, timezone
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
+ skill_count: Total number of deployed skills
102
+ """
103
+
104
+ verified: bool
105
+ warnings: List[str]
106
+ missing_skills: List[str]
107
+ outdated_skills: List[str]
108
+ message: str
109
+ skill_count: int = 0
110
+
111
+
112
+ @dataclass
113
+ class UpdateInfo:
114
+ """Information about available skill update.
115
+
116
+ Attributes:
117
+ skill_name: Name of skill with update available
118
+ current_version: Currently deployed version
119
+ new_version: Available bundled version
120
+ checksum_changed: Whether content changed (even if version same)
121
+ """
122
+
123
+ skill_name: str
124
+ current_version: str
125
+ new_version: str
126
+ checksum_changed: bool
127
+
128
+
129
+ class PMSkillsDeployerService(LoggerMixin):
130
+ """Deploy and manage PM skills from bundled sources to projects.
131
+
132
+ This service provides:
133
+ - Discovery of bundled PM skills (templates)
134
+ - Deployment to .claude-mpm/skills/pm/
135
+ - Version tracking via pm_skills_registry.yaml
136
+ - Checksum validation for integrity
137
+ - Non-blocking verification (warnings only)
138
+ - Update detection and deployment
139
+
140
+ Example:
141
+ >>> deployer = PMSkillsDeployerService()
142
+ >>> result = deployer.deploy_pm_skills(Path("/project/root"))
143
+ >>> print(f"Deployed {len(result.deployed)} skills")
144
+ >>>
145
+ >>> verify_result = deployer.verify_pm_skills(Path("/project/root"))
146
+ >>> if not verify_result.verified:
147
+ ... print(f"Warnings: {verify_result.warnings}")
148
+ """
149
+
150
+ REGISTRY_VERSION = "1.0.0"
151
+ REGISTRY_FILENAME = "pm_skills_registry.yaml"
152
+
153
+ def __init__(self) -> None:
154
+ """Initialize PM Skills Deployer Service.
155
+
156
+ Sets up paths for:
157
+ - bundled_pm_skills_path: Source bundled PM skills (skills/bundled/pm/)
158
+ - Deployment paths are project-specific (passed to methods)
159
+ """
160
+ super().__init__()
161
+
162
+ # Bundled PM skills are in the package's skills/bundled/pm/ directory
163
+ # This works for both installed packages and development mode
164
+ package_dir = Path(__file__).resolve().parent.parent # Go up to claude_mpm
165
+ self.bundled_pm_skills_path = package_dir / "skills" / "bundled" / "pm"
166
+
167
+ if not self.bundled_pm_skills_path.exists():
168
+ # Fallback: try .claude-mpm/templates/ at project root for dev mode
169
+ self.project_root = self._find_project_root()
170
+ alt_path = self.project_root / ".claude-mpm" / "templates"
171
+ if alt_path.exists():
172
+ self.bundled_pm_skills_path = alt_path
173
+ self.logger.debug(f"Using dev templates path: {alt_path}")
174
+ else:
175
+ self.logger.warning(
176
+ "PM skills templates path not found (non-critical, uses defaults)"
177
+ )
178
+
179
+ def _find_project_root(self) -> Path:
180
+ """Find project root by traversing up from current file.
181
+
182
+ Returns:
183
+ Path to project root (directory containing .git or pyproject.toml)
184
+ """
185
+ current = Path(__file__).resolve()
186
+
187
+ # Traverse up to find project root markers
188
+ for parent in [current] + list(current.parents):
189
+ if (parent / ".git").exists() or (parent / "pyproject.toml").exists():
190
+ return parent
191
+
192
+ # Fallback to current working directory
193
+ return Path.cwd()
194
+
195
+ def _validate_safe_path(self, base: Path, target: Path) -> bool:
196
+ """Ensure target path is within base directory to prevent path traversal.
197
+
198
+ Args:
199
+ base: Base directory that should contain the target
200
+ target: Target path to validate
201
+
202
+ Returns:
203
+ True if path is safe, False otherwise
204
+ """
205
+ try:
206
+ target.resolve().relative_to(base.resolve())
207
+ return True
208
+ except ValueError:
209
+ return False
210
+
211
+ def _compute_checksum(self, file_path: Path) -> str:
212
+ """Compute SHA256 checksum of file content.
213
+
214
+ Args:
215
+ file_path: Path to file to checksum
216
+
217
+ Returns:
218
+ Hex string of SHA256 checksum
219
+ """
220
+ sha256 = hashlib.sha256()
221
+ try:
222
+ with open(file_path, "rb") as f:
223
+ # Read in 64KB chunks to handle large files
224
+ for chunk in iter(lambda: f.read(65536), b""):
225
+ sha256.update(chunk)
226
+ return sha256.hexdigest()
227
+ except OSError as e:
228
+ self.logger.error(f"Failed to compute checksum for {file_path}: {e}")
229
+ return ""
230
+
231
+ def _get_registry_path(self, project_dir: Path) -> Path:
232
+ """Get path to PM skills registry file.
233
+
234
+ Args:
235
+ project_dir: Project root directory
236
+
237
+ Returns:
238
+ Path to pm_skills_registry.yaml
239
+ """
240
+ return project_dir / ".claude-mpm" / self.REGISTRY_FILENAME
241
+
242
+ def _get_deployment_dir(self, project_dir: Path) -> Path:
243
+ """Get deployment directory for PM skills.
244
+
245
+ Args:
246
+ project_dir: Project root directory
247
+
248
+ Returns:
249
+ Path to .claude-mpm/skills/pm/
250
+ """
251
+ return project_dir / ".claude-mpm" / "skills" / "pm"
252
+
253
+ def _load_registry(self, project_dir: Path) -> Dict[str, Any]:
254
+ """Load PM skills registry with security checks.
255
+
256
+ Args:
257
+ project_dir: Project root directory
258
+
259
+ Returns:
260
+ Dict containing registry data, or empty dict if not found/invalid
261
+ """
262
+ registry_path = self._get_registry_path(project_dir)
263
+
264
+ if not registry_path.exists():
265
+ self.logger.debug(f"PM skills registry not found: {registry_path}")
266
+ return {}
267
+
268
+ # Check file size to prevent YAML bomb
269
+ try:
270
+ file_size = registry_path.stat().st_size
271
+ if file_size > MAX_YAML_SIZE:
272
+ self.logger.error(
273
+ f"Registry file too large: {file_size} bytes (max {MAX_YAML_SIZE})"
274
+ )
275
+ return {}
276
+ except OSError as e:
277
+ self.logger.error(f"Failed to stat registry file: {e}")
278
+ return {}
279
+
280
+ try:
281
+ with open(registry_path, encoding="utf-8") as f:
282
+ registry = yaml.safe_load(f)
283
+ if not registry:
284
+ self.logger.warning(f"Empty registry file: {registry_path}")
285
+ return {}
286
+ self.logger.debug(f"Loaded PM skills registry from {registry_path}")
287
+ return registry
288
+ except yaml.YAMLError as e:
289
+ self.logger.error(f"Invalid YAML in registry: {e}")
290
+ return {}
291
+ except OSError as e:
292
+ self.logger.error(f"Failed to read registry file: {e}")
293
+ return {}
294
+
295
+ def _save_registry(self, project_dir: Path, registry: Dict[str, Any]) -> bool:
296
+ """Save PM skills registry to file.
297
+
298
+ Args:
299
+ project_dir: Project root directory
300
+ registry: Registry data to save
301
+
302
+ Returns:
303
+ True if save succeeded, False otherwise
304
+ """
305
+ registry_path = self._get_registry_path(project_dir)
306
+
307
+ try:
308
+ # Ensure parent directory exists
309
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
310
+
311
+ with open(registry_path, "w", encoding="utf-8") as f:
312
+ yaml.safe_dump(
313
+ registry, f, default_flow_style=False, allow_unicode=True
314
+ )
315
+
316
+ self.logger.debug(f"Saved PM skills registry to {registry_path}")
317
+ return True
318
+ except (OSError, yaml.YAMLError) as e:
319
+ self.logger.error(f"Failed to save registry: {e}")
320
+ return False
321
+
322
+ def _discover_bundled_pm_skills(self) -> List[Dict[str, Any]]:
323
+ """Discover all PM skills in bundled templates directory.
324
+
325
+ PM skills can be in two formats:
326
+ 1. Directory structure: pm-skill-name/SKILL.md (new format)
327
+ 2. Flat files: skill-name.md (legacy format for .claude-mpm/templates/)
328
+
329
+ Returns:
330
+ List of skill dictionaries containing:
331
+ - name: Skill name (directory/filename without extension)
332
+ - path: Full path to skill file (SKILL.md or .md file)
333
+ - type: File type (always 'md')
334
+ """
335
+ skills = []
336
+
337
+ if not self.bundled_pm_skills_path.exists():
338
+ self.logger.warning(
339
+ f"Bundled PM skills path not found: {self.bundled_pm_skills_path}"
340
+ )
341
+ return skills
342
+
343
+ # Scan for skill directories containing SKILL.md (new format)
344
+ for skill_dir in self.bundled_pm_skills_path.iterdir():
345
+ if not skill_dir.is_dir() or skill_dir.name.startswith("."):
346
+ continue
347
+
348
+ skill_file = skill_dir / "SKILL.md"
349
+ if skill_file.exists():
350
+ skills.append(
351
+ {
352
+ "name": skill_dir.name,
353
+ "path": skill_file,
354
+ "type": "md",
355
+ }
356
+ )
357
+
358
+ # Fallback: Scan for .md files directly (legacy format)
359
+ for skill_file in self.bundled_pm_skills_path.glob("*.md"):
360
+ if skill_file.name.startswith("."):
361
+ continue
362
+
363
+ skills.append(
364
+ {
365
+ "name": skill_file.stem,
366
+ "path": skill_file,
367
+ "type": "md",
368
+ }
369
+ )
370
+
371
+ self.logger.info(f"Discovered {len(skills)} bundled PM skills")
372
+ return skills
373
+
374
+ def deploy_pm_skills(
375
+ self,
376
+ project_dir: Path,
377
+ force: bool = False,
378
+ progress_callback: Optional[Callable[[str, int, int], None]] = None,
379
+ ) -> DeploymentResult:
380
+ """Deploy bundled PM skills to project directory.
381
+
382
+ Copies PM skills from bundled templates to .claude-mpm/skills/pm/
383
+ and updates registry with version and checksum information.
384
+
385
+ Args:
386
+ project_dir: Project root directory
387
+ force: If True, redeploy even if skill already exists
388
+ progress_callback: Optional callback(skill_name, current, total) for progress
389
+
390
+ Returns:
391
+ DeploymentResult with deployment status and details
392
+
393
+ Example:
394
+ >>> result = deployer.deploy_pm_skills(Path("/project"), force=True)
395
+ >>> print(f"Deployed: {len(result.deployed)}")
396
+ """
397
+ skills = self._discover_bundled_pm_skills()
398
+ deployed = []
399
+ skipped = []
400
+ errors = []
401
+
402
+ if not skills:
403
+ return DeploymentResult(
404
+ success=True,
405
+ deployed=[],
406
+ skipped=[],
407
+ errors=[],
408
+ message="No PM skills found to deploy",
409
+ )
410
+
411
+ # Ensure deployment directory exists
412
+ deployment_dir = self._get_deployment_dir(project_dir)
413
+ deployment_dir.mkdir(parents=True, exist_ok=True)
414
+
415
+ # SECURITY: Validate deployment path
416
+ if not self._validate_safe_path(project_dir, deployment_dir):
417
+ return DeploymentResult(
418
+ success=False,
419
+ deployed=[],
420
+ skipped=[],
421
+ errors=[
422
+ {
423
+ "skill": "all",
424
+ "error": "Path traversal attempt detected in deployment directory",
425
+ }
426
+ ],
427
+ message="Security check failed",
428
+ )
429
+
430
+ # Load existing registry
431
+ registry = self._load_registry(project_dir)
432
+ deployed_skills = registry.get("skills", [])
433
+
434
+ # Create lookup for existing deployments
435
+ existing_deployments = {skill["name"]: skill for skill in deployed_skills}
436
+
437
+ new_deployed_skills = []
438
+ timestamp = datetime.now(tz=timezone.utc).isoformat()
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
+ skill_count=0,
557
+ )
558
+
559
+ # Check each registered skill exists
560
+ deployment_dir = self._get_deployment_dir(project_dir)
561
+ deployed_skills = registry.get("skills", [])
562
+
563
+ for skill in deployed_skills:
564
+ skill_name = skill["name"]
565
+ skill_file = deployment_dir / f"{skill_name}.md"
566
+
567
+ if not skill_file.exists():
568
+ warnings.append(f"Deployed skill file missing: {skill_name}")
569
+ missing_skills.append(skill_name)
570
+ continue
571
+
572
+ # Verify checksum
573
+ current_checksum = self._compute_checksum(skill_file)
574
+ expected_checksum = skill.get("checksum", "")
575
+
576
+ if current_checksum != expected_checksum:
577
+ warnings.append(
578
+ f"Skill checksum mismatch: {skill_name} (file may be corrupted)"
579
+ )
580
+ outdated_skills.append(skill_name)
581
+
582
+ # Check for available updates
583
+ bundled_skills = {s["name"]: s for s in self._discover_bundled_pm_skills()}
584
+ for skill_name, bundled_skill in bundled_skills.items():
585
+ # Find corresponding deployed skill
586
+ deployed_skill = next(
587
+ (s for s in deployed_skills if s["name"] == skill_name), None
588
+ )
589
+
590
+ if not deployed_skill:
591
+ warnings.append(f"New PM skill available: {skill_name}")
592
+ missing_skills.append(skill_name)
593
+ continue
594
+
595
+ # Check if checksums differ
596
+ bundled_checksum = self._compute_checksum(bundled_skill["path"])
597
+ deployed_checksum = deployed_skill.get("checksum", "")
598
+
599
+ if bundled_checksum != deployed_checksum:
600
+ warnings.append(f"PM skill update available: {skill_name}")
601
+ outdated_skills.append(skill_name)
602
+
603
+ verified = len(warnings) == 0
604
+
605
+ if verified:
606
+ message = "All PM skills verified and up-to-date"
607
+ else:
608
+ message = f"{len(warnings)} verification warnings found"
609
+
610
+ return VerificationResult(
611
+ verified=verified,
612
+ warnings=warnings,
613
+ missing_skills=missing_skills,
614
+ outdated_skills=outdated_skills,
615
+ message=message,
616
+ skill_count=len(deployed_skills),
617
+ )
618
+
619
+ def get_deployed_skills(self, project_dir: Path) -> List[PMSkillInfo]:
620
+ """Get list of deployed PM skills with metadata.
621
+
622
+ Args:
623
+ project_dir: Project root directory
624
+
625
+ Returns:
626
+ List of PMSkillInfo objects for deployed skills
627
+
628
+ Example:
629
+ >>> skills = deployer.get_deployed_skills(Path("/project"))
630
+ >>> for skill in skills:
631
+ ... print(f"{skill.name} v{skill.version} ({skill.deployed_at})")
632
+ """
633
+ registry = self._load_registry(project_dir)
634
+ deployment_dir = self._get_deployment_dir(project_dir)
635
+
636
+ skills = []
637
+ for skill_data in registry.get("skills", []):
638
+ skill_name = skill_data["name"]
639
+ deployed_path = deployment_dir / f"{skill_name}.md"
640
+
641
+ # Find source path (may not exist if bundled skills changed)
642
+ source_path = self.bundled_pm_skills_path / f"{skill_name}.md"
643
+
644
+ skills.append(
645
+ PMSkillInfo(
646
+ name=skill_name,
647
+ version=skill_data.get("version", "1.0.0"),
648
+ deployed_at=skill_data.get("deployed_at", "unknown"),
649
+ checksum=skill_data.get("checksum", ""),
650
+ source_path=source_path,
651
+ deployed_path=deployed_path,
652
+ )
653
+ )
654
+
655
+ return skills
656
+
657
+ def check_updates_available(self, project_dir: Path) -> List[UpdateInfo]:
658
+ """Check for available PM skill updates.
659
+
660
+ Compares bundled skills against deployed skills to identify updates.
661
+
662
+ Args:
663
+ project_dir: Project root directory
664
+
665
+ Returns:
666
+ List of UpdateInfo objects for skills with updates available
667
+
668
+ Example:
669
+ >>> updates = deployer.check_updates_available(Path("/project"))
670
+ >>> for update in updates:
671
+ ... print(f"{update.skill_name}: {update.current_version} -> {update.new_version}")
672
+ """
673
+ registry = self._load_registry(project_dir)
674
+ deployed_skills = {skill["name"]: skill for skill in registry.get("skills", [])}
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