claude-mpm 4.2.23__py3-none-any.whl → 4.2.25__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 +10 -0
- claude_mpm/cli/commands/monitor.py +8 -6
- claude_mpm/cli/commands/uninstall.py +178 -0
- claude_mpm/cli/parsers/base_parser.py +8 -0
- claude_mpm/services/cli/unified_dashboard_manager.py +14 -7
- claude_mpm/services/hook_installer_service.py +507 -0
- claude_mpm/services/monitor/daemon.py +74 -18
- claude_mpm/services/monitor/management/lifecycle.py +176 -81
- claude_mpm/services/monitor/server.py +11 -8
- {claude_mpm-4.2.23.dist-info → claude_mpm-4.2.25.dist-info}/METADATA +1 -1
- {claude_mpm-4.2.23.dist-info → claude_mpm-4.2.25.dist-info}/RECORD +16 -14
- {claude_mpm-4.2.23.dist-info → claude_mpm-4.2.25.dist-info}/WHEEL +0 -0
- {claude_mpm-4.2.23.dist-info → claude_mpm-4.2.25.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.2.23.dist-info → claude_mpm-4.2.25.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.2.23.dist-info → claude_mpm-4.2.25.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,507 @@
|
|
1
|
+
"""Hook Installer Service for Claude MPM.
|
2
|
+
|
3
|
+
This service manages the automatic installation and removal of Claude Code hooks
|
4
|
+
to enable monitor event forwarding via Socket.IO.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import json
|
8
|
+
import os
|
9
|
+
import shutil
|
10
|
+
import stat
|
11
|
+
import sys
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import Any, Dict, Optional, Tuple
|
14
|
+
|
15
|
+
from ..core.logging_config import get_logger
|
16
|
+
|
17
|
+
|
18
|
+
class HookInstallerService:
|
19
|
+
"""Service for managing Claude Code hook installation and removal."""
|
20
|
+
|
21
|
+
def __init__(self):
|
22
|
+
"""Initialize the hook installer service."""
|
23
|
+
self.logger = get_logger(__name__)
|
24
|
+
self.claude_dir = Path.home() / ".claude"
|
25
|
+
self.settings_file = self.claude_dir / "settings.json"
|
26
|
+
|
27
|
+
def is_hooks_configured(self) -> bool:
|
28
|
+
"""Check if hooks are configured in Claude settings.
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
True if hooks are properly configured, False otherwise.
|
32
|
+
"""
|
33
|
+
try:
|
34
|
+
if not self.settings_file.exists():
|
35
|
+
self.logger.debug("Claude settings file does not exist")
|
36
|
+
return False
|
37
|
+
|
38
|
+
with open(self.settings_file) as f:
|
39
|
+
settings = json.load(f)
|
40
|
+
|
41
|
+
# Check if hooks section exists
|
42
|
+
if "hooks" not in settings:
|
43
|
+
self.logger.debug("No hooks section in Claude settings")
|
44
|
+
return False
|
45
|
+
|
46
|
+
# Check for required hook types
|
47
|
+
required_hooks = [
|
48
|
+
"UserPromptSubmit",
|
49
|
+
"PreToolUse",
|
50
|
+
"PostToolUse",
|
51
|
+
"Stop",
|
52
|
+
"SubagentStop",
|
53
|
+
]
|
54
|
+
|
55
|
+
for hook_type in required_hooks:
|
56
|
+
if hook_type not in settings["hooks"]:
|
57
|
+
self.logger.debug(f"Missing hook type: {hook_type}")
|
58
|
+
return False
|
59
|
+
|
60
|
+
# Check if hook is configured with our wrapper
|
61
|
+
hooks = settings["hooks"][hook_type]
|
62
|
+
if not hooks or not isinstance(hooks, list):
|
63
|
+
self.logger.debug(f"Invalid hooks for {hook_type}")
|
64
|
+
return False
|
65
|
+
|
66
|
+
# Look for our hook wrapper in the configuration
|
67
|
+
# Accept either hook_wrapper.sh or claude-hook-handler.sh
|
68
|
+
has_our_hook = False
|
69
|
+
for hook_config in hooks:
|
70
|
+
if "hooks" in hook_config and isinstance(
|
71
|
+
hook_config["hooks"], list
|
72
|
+
):
|
73
|
+
for hook in hook_config["hooks"]:
|
74
|
+
if hook.get("type") == "command":
|
75
|
+
command = hook.get("command", "")
|
76
|
+
if (
|
77
|
+
"hook_wrapper.sh" in command
|
78
|
+
or "claude-hook-handler.sh" in command
|
79
|
+
):
|
80
|
+
has_our_hook = True
|
81
|
+
break
|
82
|
+
if has_our_hook:
|
83
|
+
break
|
84
|
+
|
85
|
+
if not has_our_hook:
|
86
|
+
self.logger.debug(f"Our hook not found for {hook_type}")
|
87
|
+
return False
|
88
|
+
|
89
|
+
self.logger.info("Claude hooks are properly configured")
|
90
|
+
return True
|
91
|
+
|
92
|
+
except Exception as e:
|
93
|
+
self.logger.error(f"Error checking hook configuration: {e}")
|
94
|
+
return False
|
95
|
+
|
96
|
+
def _detect_package_origin(self) -> Tuple[str, Optional[Path]]:
|
97
|
+
"""Detect how claude-mpm was installed.
|
98
|
+
|
99
|
+
Returns:
|
100
|
+
Tuple of (origin_type, base_path) where origin_type is one of:
|
101
|
+
'local', 'pypi', 'pipx', 'npm', or 'unknown'
|
102
|
+
"""
|
103
|
+
# Check if we're in development mode (running from source)
|
104
|
+
current_file = Path(__file__).resolve()
|
105
|
+
project_root = current_file.parent.parent.parent.parent
|
106
|
+
if (project_root / "src" / "claude_mpm").exists() and (
|
107
|
+
project_root / "pyproject.toml"
|
108
|
+
).exists():
|
109
|
+
return "local", project_root
|
110
|
+
|
111
|
+
# Check for pipx installation
|
112
|
+
if "pipx/venvs/claude-mpm" in str(sys.executable):
|
113
|
+
# pipx installation - find the site-packages directory
|
114
|
+
import claude_mpm
|
115
|
+
|
116
|
+
package_path = Path(claude_mpm.__file__).parent
|
117
|
+
return "pipx", package_path
|
118
|
+
|
119
|
+
# Check for PyPI installation
|
120
|
+
try:
|
121
|
+
import claude_mpm
|
122
|
+
|
123
|
+
package_path = Path(claude_mpm.__file__).parent
|
124
|
+
if "site-packages" in str(package_path):
|
125
|
+
return "pypi", package_path
|
126
|
+
return "unknown", package_path
|
127
|
+
except ImportError:
|
128
|
+
pass
|
129
|
+
|
130
|
+
# Check for npm installation (node_modules)
|
131
|
+
node_modules_markers = [
|
132
|
+
Path.cwd() / "node_modules" / "claude-mpm",
|
133
|
+
Path.home() / "node_modules" / "claude-mpm",
|
134
|
+
Path("/usr/local/lib/node_modules/claude-mpm"),
|
135
|
+
]
|
136
|
+
for marker in node_modules_markers:
|
137
|
+
if marker.exists():
|
138
|
+
return "npm", marker
|
139
|
+
|
140
|
+
return "unknown", None
|
141
|
+
|
142
|
+
def _find_hook_script(self) -> Optional[Path]:
|
143
|
+
"""Find the hook script based on installation type.
|
144
|
+
|
145
|
+
Returns:
|
146
|
+
Path to the hook script (claude-hook-handler.sh), or None if not found.
|
147
|
+
"""
|
148
|
+
origin, base_path = self._detect_package_origin()
|
149
|
+
|
150
|
+
self.logger.debug(f"Package origin: {origin}, base_path: {base_path}")
|
151
|
+
|
152
|
+
# Primary script to look for (preferred)
|
153
|
+
primary_script = "claude-hook-handler.sh"
|
154
|
+
# Fallback script for backward compatibility
|
155
|
+
fallback_script = "hook_wrapper.sh"
|
156
|
+
|
157
|
+
if origin == "local":
|
158
|
+
# Development environment - script is in src/claude_mpm/scripts
|
159
|
+
script_path = base_path / "src" / "claude_mpm" / "scripts" / primary_script
|
160
|
+
if not script_path.exists():
|
161
|
+
# Try hooks directory as fallback
|
162
|
+
fallback_path = (
|
163
|
+
base_path
|
164
|
+
/ "src"
|
165
|
+
/ "claude_mpm"
|
166
|
+
/ "hooks"
|
167
|
+
/ "claude_hooks"
|
168
|
+
/ fallback_script
|
169
|
+
)
|
170
|
+
if fallback_path.exists():
|
171
|
+
script_path = fallback_path
|
172
|
+
self.logger.info("Package origin: Local development")
|
173
|
+
elif origin == "pipx":
|
174
|
+
# pipx installation - script should be in package/scripts
|
175
|
+
script_path = base_path / "scripts" / primary_script
|
176
|
+
if not script_path.exists():
|
177
|
+
# Try hooks directory as fallback
|
178
|
+
fallback_path = base_path / "hooks" / "claude_hooks" / fallback_script
|
179
|
+
if fallback_path.exists():
|
180
|
+
script_path = fallback_path
|
181
|
+
self.logger.info("Package origin: pipx")
|
182
|
+
elif origin == "pypi":
|
183
|
+
# PyPI installation
|
184
|
+
script_path = base_path / "scripts" / primary_script
|
185
|
+
if not script_path.exists():
|
186
|
+
# Try hooks directory as fallback
|
187
|
+
fallback_path = base_path / "hooks" / "claude_hooks" / fallback_script
|
188
|
+
if fallback_path.exists():
|
189
|
+
script_path = fallback_path
|
190
|
+
self.logger.info("Package origin: PyPI")
|
191
|
+
elif origin == "npm":
|
192
|
+
# npm installation
|
193
|
+
script_path = base_path / "dist" / "claude_mpm" / "scripts" / primary_script
|
194
|
+
if not script_path.exists():
|
195
|
+
# Try alternative npm structure
|
196
|
+
script_path = (
|
197
|
+
base_path / "src" / "claude_mpm" / "scripts" / primary_script
|
198
|
+
)
|
199
|
+
if not script_path.exists():
|
200
|
+
# Try hooks directory as fallback
|
201
|
+
fallback_path = (
|
202
|
+
base_path
|
203
|
+
/ "dist"
|
204
|
+
/ "claude_mpm"
|
205
|
+
/ "hooks"
|
206
|
+
/ "claude_hooks"
|
207
|
+
/ fallback_script
|
208
|
+
)
|
209
|
+
if fallback_path.exists():
|
210
|
+
script_path = fallback_path
|
211
|
+
self.logger.info("Package origin: npm")
|
212
|
+
else:
|
213
|
+
# Unknown, try to find it
|
214
|
+
self.logger.info("Package origin: Unknown, searching...")
|
215
|
+
possible_locations = [
|
216
|
+
Path(sys.prefix)
|
217
|
+
/ "lib"
|
218
|
+
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
|
219
|
+
/ "site-packages"
|
220
|
+
/ "claude_mpm"
|
221
|
+
/ "scripts"
|
222
|
+
/ primary_script,
|
223
|
+
Path(sys.prefix) / "claude_mpm" / "scripts" / primary_script,
|
224
|
+
# For pipx environments
|
225
|
+
Path(sys.executable).parent.parent
|
226
|
+
/ "lib"
|
227
|
+
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
|
228
|
+
/ "site-packages"
|
229
|
+
/ "claude_mpm"
|
230
|
+
/ "scripts"
|
231
|
+
/ primary_script,
|
232
|
+
# Fallback to hooks directory
|
233
|
+
Path(sys.prefix)
|
234
|
+
/ "lib"
|
235
|
+
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
|
236
|
+
/ "site-packages"
|
237
|
+
/ "claude_mpm"
|
238
|
+
/ "hooks"
|
239
|
+
/ "claude_hooks"
|
240
|
+
/ fallback_script,
|
241
|
+
]
|
242
|
+
for loc in possible_locations:
|
243
|
+
self.logger.debug(f"Checking location: {loc}")
|
244
|
+
if loc.exists():
|
245
|
+
script_path = loc
|
246
|
+
break
|
247
|
+
else:
|
248
|
+
return None
|
249
|
+
|
250
|
+
# Verify the script exists
|
251
|
+
if script_path and script_path.exists():
|
252
|
+
self.logger.info(f"Found hook script at: {script_path}")
|
253
|
+
return script_path
|
254
|
+
|
255
|
+
self.logger.warning(f"Hook script not found: {script_path}")
|
256
|
+
return None
|
257
|
+
|
258
|
+
def install_hooks(self, force: bool = False) -> bool:
|
259
|
+
"""Install hooks for Claude Code integration.
|
260
|
+
|
261
|
+
Args:
|
262
|
+
force: If True, reinstall even if already configured.
|
263
|
+
|
264
|
+
Returns:
|
265
|
+
True if hooks were installed successfully, False otherwise.
|
266
|
+
"""
|
267
|
+
try:
|
268
|
+
# Check if already configured
|
269
|
+
if not force and self.is_hooks_configured():
|
270
|
+
self.logger.info("Hooks are already configured")
|
271
|
+
return True
|
272
|
+
|
273
|
+
self.logger.info("Installing Claude Code hooks...")
|
274
|
+
|
275
|
+
# Find hook script
|
276
|
+
hook_script = self._find_hook_script()
|
277
|
+
if not hook_script:
|
278
|
+
self.logger.error("Could not find claude-mpm hook script!")
|
279
|
+
self.logger.error("Make sure claude-mpm is properly installed.")
|
280
|
+
return False
|
281
|
+
|
282
|
+
# Make sure the script is executable
|
283
|
+
st = os.stat(hook_script)
|
284
|
+
os.chmod(hook_script, st.st_mode | stat.S_IEXEC)
|
285
|
+
self.logger.debug(f"Made hook script executable: {hook_script}")
|
286
|
+
|
287
|
+
hook_script_path = str(hook_script.absolute())
|
288
|
+
self.logger.info(f"Hook script path: {hook_script_path}")
|
289
|
+
|
290
|
+
# Create claude directory if it doesn't exist
|
291
|
+
self.claude_dir.mkdir(exist_ok=True)
|
292
|
+
|
293
|
+
# Load existing settings or create new
|
294
|
+
if self.settings_file.exists():
|
295
|
+
with open(self.settings_file) as f:
|
296
|
+
settings = json.load(f)
|
297
|
+
self.logger.debug("Found existing Claude settings")
|
298
|
+
else:
|
299
|
+
settings = {}
|
300
|
+
self.logger.debug("Creating new Claude settings")
|
301
|
+
|
302
|
+
# Configure hooks
|
303
|
+
hook_config = {
|
304
|
+
"matcher": "*",
|
305
|
+
"hooks": [{"type": "command", "command": hook_script_path}],
|
306
|
+
}
|
307
|
+
|
308
|
+
# Update settings
|
309
|
+
if "hooks" not in settings:
|
310
|
+
settings["hooks"] = {}
|
311
|
+
|
312
|
+
# Add hooks for all event types
|
313
|
+
for event_type in [
|
314
|
+
"UserPromptSubmit",
|
315
|
+
"PreToolUse",
|
316
|
+
"PostToolUse",
|
317
|
+
"Stop",
|
318
|
+
"SubagentStop",
|
319
|
+
]:
|
320
|
+
settings["hooks"][event_type] = [hook_config]
|
321
|
+
|
322
|
+
# Write settings
|
323
|
+
with open(self.settings_file, "w") as f:
|
324
|
+
json.dump(settings, f, indent=2)
|
325
|
+
|
326
|
+
self.logger.info(f"Updated Claude settings at: {self.settings_file}")
|
327
|
+
|
328
|
+
# Copy commands if they exist (for local development)
|
329
|
+
origin, base_path = self._detect_package_origin()
|
330
|
+
if origin == "local" and base_path:
|
331
|
+
commands_src = base_path / "tools" / "dev" / ".claude" / "commands"
|
332
|
+
if commands_src.exists():
|
333
|
+
commands_dst = self.claude_dir / "commands"
|
334
|
+
commands_dst.mkdir(exist_ok=True)
|
335
|
+
|
336
|
+
for cmd_file in commands_src.glob("*.md"):
|
337
|
+
shutil.copy2(cmd_file, commands_dst / cmd_file.name)
|
338
|
+
self.logger.debug(f"Copied command: {cmd_file.name}")
|
339
|
+
|
340
|
+
self.logger.info("Hook installation complete!")
|
341
|
+
return True
|
342
|
+
|
343
|
+
except Exception as e:
|
344
|
+
self.logger.error(f"Error installing hooks: {e}")
|
345
|
+
return False
|
346
|
+
|
347
|
+
def _is_claude_mpm_hook(self, hook_config: Dict[str, Any]) -> bool:
|
348
|
+
"""Check if a hook configuration belongs to Claude MPM.
|
349
|
+
|
350
|
+
Args:
|
351
|
+
hook_config: The hook configuration to check.
|
352
|
+
|
353
|
+
Returns:
|
354
|
+
True if this is a Claude MPM hook, False otherwise.
|
355
|
+
"""
|
356
|
+
if "hooks" in hook_config and isinstance(hook_config["hooks"], list):
|
357
|
+
for hook in hook_config["hooks"]:
|
358
|
+
if hook.get("type") == "command":
|
359
|
+
command = hook.get("command", "")
|
360
|
+
# Check for known Claude MPM hook scripts and paths
|
361
|
+
claude_mpm_indicators = [
|
362
|
+
"hook_wrapper.sh",
|
363
|
+
"claude-hook-handler.sh",
|
364
|
+
"claude-mpm",
|
365
|
+
]
|
366
|
+
if any(indicator in command for indicator in claude_mpm_indicators):
|
367
|
+
return True
|
368
|
+
return False
|
369
|
+
|
370
|
+
def uninstall_hooks(self) -> bool:
|
371
|
+
"""Remove hooks from Claude settings.
|
372
|
+
|
373
|
+
Returns:
|
374
|
+
True if hooks were removed successfully, False otherwise.
|
375
|
+
"""
|
376
|
+
try:
|
377
|
+
if not self.settings_file.exists():
|
378
|
+
self.logger.info("No Claude settings file found, nothing to uninstall")
|
379
|
+
return True
|
380
|
+
|
381
|
+
self.logger.info("Removing Claude Code hooks...")
|
382
|
+
|
383
|
+
with open(self.settings_file) as f:
|
384
|
+
settings = json.load(f)
|
385
|
+
|
386
|
+
hooks_removed = 0
|
387
|
+
# Remove only our hooks, preserve other settings
|
388
|
+
if "hooks" in settings:
|
389
|
+
hook_types = [
|
390
|
+
"UserPromptSubmit",
|
391
|
+
"PreToolUse",
|
392
|
+
"PostToolUse",
|
393
|
+
"Stop",
|
394
|
+
"SubagentStop",
|
395
|
+
]
|
396
|
+
|
397
|
+
for hook_type in hook_types:
|
398
|
+
if hook_type in settings["hooks"]:
|
399
|
+
# Filter out our hooks
|
400
|
+
original_count = len(settings["hooks"][hook_type])
|
401
|
+
filtered_hooks = [
|
402
|
+
hook_config
|
403
|
+
for hook_config in settings["hooks"][hook_type]
|
404
|
+
if not self._is_claude_mpm_hook(hook_config)
|
405
|
+
]
|
406
|
+
|
407
|
+
removed_count = original_count - len(filtered_hooks)
|
408
|
+
if removed_count > 0:
|
409
|
+
hooks_removed += removed_count
|
410
|
+
self.logger.debug(
|
411
|
+
f"Removed {removed_count} hook(s) from {hook_type}"
|
412
|
+
)
|
413
|
+
|
414
|
+
# Update or remove the hook type
|
415
|
+
if filtered_hooks:
|
416
|
+
settings["hooks"][hook_type] = filtered_hooks
|
417
|
+
else:
|
418
|
+
del settings["hooks"][hook_type]
|
419
|
+
|
420
|
+
# If no hooks remain, remove the hooks section
|
421
|
+
if not settings["hooks"]:
|
422
|
+
del settings["hooks"]
|
423
|
+
|
424
|
+
# Write updated settings
|
425
|
+
with open(self.settings_file, "w") as f:
|
426
|
+
json.dump(settings, f, indent=2)
|
427
|
+
|
428
|
+
if hooks_removed > 0:
|
429
|
+
self.logger.info(
|
430
|
+
f"Successfully removed {hooks_removed} Claude MPM hook(s)"
|
431
|
+
)
|
432
|
+
else:
|
433
|
+
self.logger.info("No Claude MPM hooks found to remove")
|
434
|
+
|
435
|
+
# Optionally remove commands directory if it only contains our commands
|
436
|
+
commands_dir = self.claude_dir / "commands"
|
437
|
+
if commands_dir.exists():
|
438
|
+
our_commands = [
|
439
|
+
"mpm.md",
|
440
|
+
"mpm-status.md",
|
441
|
+
] # Add other command files as needed
|
442
|
+
all_files = list(commands_dir.glob("*.md"))
|
443
|
+
|
444
|
+
# Check if only our commands exist
|
445
|
+
if all(f.name in our_commands for f in all_files):
|
446
|
+
shutil.rmtree(commands_dir)
|
447
|
+
self.logger.debug("Removed commands directory")
|
448
|
+
|
449
|
+
return True
|
450
|
+
|
451
|
+
except Exception as e:
|
452
|
+
self.logger.error(f"Error uninstalling hooks: {e}")
|
453
|
+
return False
|
454
|
+
|
455
|
+
def get_hook_status(self) -> Dict[str, Any]:
|
456
|
+
"""Get detailed status of hook configuration.
|
457
|
+
|
458
|
+
Returns:
|
459
|
+
Dictionary with hook status information.
|
460
|
+
"""
|
461
|
+
status = {
|
462
|
+
"configured": False,
|
463
|
+
"settings_file": str(self.settings_file),
|
464
|
+
"settings_exists": self.settings_file.exists(),
|
465
|
+
"hook_types": {},
|
466
|
+
"hook_script_path": None,
|
467
|
+
"hook_wrapper_path": None, # Keep for backward compatibility
|
468
|
+
}
|
469
|
+
|
470
|
+
try:
|
471
|
+
if self.settings_file.exists():
|
472
|
+
with open(self.settings_file) as f:
|
473
|
+
settings = json.load(f)
|
474
|
+
|
475
|
+
if "hooks" in settings:
|
476
|
+
for hook_type in [
|
477
|
+
"UserPromptSubmit",
|
478
|
+
"PreToolUse",
|
479
|
+
"PostToolUse",
|
480
|
+
"Stop",
|
481
|
+
"SubagentStop",
|
482
|
+
]:
|
483
|
+
if hook_type in settings["hooks"]:
|
484
|
+
hooks = settings["hooks"][hook_type]
|
485
|
+
# Find our hook wrapper path
|
486
|
+
for hook_config in hooks:
|
487
|
+
if "hooks" in hook_config:
|
488
|
+
for hook in hook_config["hooks"]:
|
489
|
+
if hook.get("type") == "command":
|
490
|
+
command = hook.get("command", "")
|
491
|
+
if (
|
492
|
+
"hook_wrapper.sh" in command
|
493
|
+
or "claude-hook-handler.sh" in command
|
494
|
+
):
|
495
|
+
status["hook_script_path"] = command
|
496
|
+
status["hook_wrapper_path"] = (
|
497
|
+
command # For backward compatibility
|
498
|
+
)
|
499
|
+
status["hook_types"][hook_type] = True
|
500
|
+
break
|
501
|
+
|
502
|
+
status["configured"] = self.is_hooks_configured()
|
503
|
+
|
504
|
+
except Exception as e:
|
505
|
+
self.logger.error(f"Error getting hook status: {e}")
|
506
|
+
|
507
|
+
return status
|