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.
@@ -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