claude-mpm 4.1.7__py3-none-any.whl → 4.1.8__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,455 @@
1
+ """
2
+ Hook installer for Claude MPM integration with Claude Code.
3
+
4
+ This module provides functionality to install, update, and manage
5
+ claude-mpm hooks in the Claude Code environment.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import shutil
11
+ import stat
12
+ from pathlib import Path
13
+ from typing import Dict, List, Tuple
14
+
15
+ from ...core.logger import get_logger
16
+
17
+
18
+ class HookInstaller:
19
+ """Manages installation and configuration of Claude MPM hooks."""
20
+
21
+ # Smart hook script template
22
+ SMART_HOOK_SCRIPT = """#!/bin/bash
23
+ # Claude MPM Smart Hook Handler
24
+ # This script dynamically finds and routes hook events to claude-mpm
25
+ # Works with pip installations, local development, and virtual environments
26
+
27
+ # Function to find claude-mpm installation
28
+ find_claude_mpm() {
29
+ # Method 1: Check if claude-mpm is installed via pip
30
+ if command -v claude-mpm &> /dev/null; then
31
+ # Get the actual path of the claude-mpm command
32
+ local cmd_path=$(command -v claude-mpm)
33
+ if [ -L "$cmd_path" ]; then
34
+ # Follow symlink
35
+ cmd_path=$(readlink -f "$cmd_path")
36
+ fi
37
+ # Extract the base directory (usually site-packages or venv)
38
+ local base_dir=$(python3 -c "import claude_mpm; import os; print(os.path.dirname(os.path.dirname(claude_mpm.__file__)))" 2>/dev/null)
39
+ if [ -n "$base_dir" ]; then
40
+ echo "$base_dir"
41
+ return 0
42
+ fi
43
+ fi
44
+
45
+ # Method 2: Check common development locations
46
+ local dev_locations=(
47
+ "$HOME/Projects/claude-mpm"
48
+ "$HOME/projects/claude-mpm"
49
+ "$HOME/dev/claude-mpm"
50
+ "$HOME/Development/claude-mpm"
51
+ "$HOME/src/claude-mpm"
52
+ "$HOME/code/claude-mpm"
53
+ "$HOME/workspace/claude-mpm"
54
+ "$HOME/claude-mpm"
55
+ "$(pwd)/claude-mpm"
56
+ "$(pwd)"
57
+ )
58
+
59
+ for loc in "${dev_locations[@]}"; do
60
+ if [ -f "$loc/src/claude_mpm/__init__.py" ]; then
61
+ echo "$loc"
62
+ return 0
63
+ fi
64
+ done
65
+
66
+ # Method 3: Try to find via Python import
67
+ local python_path=$(python3 -c "
68
+ try:
69
+ import claude_mpm
70
+ import os
71
+ # Get the package directory
72
+ pkg_dir = os.path.dirname(claude_mpm.__file__)
73
+ # Check if we're in a development install (src directory)
74
+ if 'src' in pkg_dir:
75
+ # Go up to find the project root
76
+ parts = pkg_dir.split(os.sep)
77
+ if 'src' in parts:
78
+ src_idx = parts.index('src')
79
+ project_root = os.sep.join(parts[:src_idx])
80
+ print(project_root)
81
+ else:
82
+ print(os.path.dirname(os.path.dirname(pkg_dir)))
83
+ else:
84
+ # Installed package - just return the package location
85
+ print(os.path.dirname(pkg_dir))
86
+ except:
87
+ pass
88
+ " 2>/dev/null)
89
+
90
+ if [ -n "$python_path" ]; then
91
+ echo "$python_path"
92
+ return 0
93
+ fi
94
+
95
+ # Method 4: Search in PATH for claude-mpm installations
96
+ local IFS=':'
97
+ for path_dir in $PATH; do
98
+ if [ -f "$path_dir/claude-mpm" ]; then
99
+ # Found claude-mpm executable, try to find its package
100
+ local pkg_dir=$(cd "$path_dir" && python3 -c "import claude_mpm; import os; print(os.path.dirname(os.path.dirname(claude_mpm.__file__)))" 2>/dev/null)
101
+ if [ -n "$pkg_dir" ]; then
102
+ echo "$pkg_dir"
103
+ return 0
104
+ fi
105
+ fi
106
+ done
107
+
108
+ return 1
109
+ }
110
+
111
+ # Function to setup Python environment
112
+ setup_python_env() {
113
+ local project_dir="$1"
114
+
115
+ # Check for virtual environment in the project
116
+ if [ -f "$project_dir/venv/bin/activate" ]; then
117
+ source "$project_dir/venv/bin/activate"
118
+ export PYTHON_CMD="$project_dir/venv/bin/python"
119
+ elif [ -f "$project_dir/.venv/bin/activate" ]; then
120
+ source "$project_dir/.venv/bin/activate"
121
+ export PYTHON_CMD="$project_dir/.venv/bin/python"
122
+ elif [ -n "$VIRTUAL_ENV" ]; then
123
+ # Already in a virtual environment
124
+ export PYTHON_CMD="$VIRTUAL_ENV/bin/python"
125
+ elif command -v python3 &> /dev/null; then
126
+ export PYTHON_CMD="python3"
127
+ else
128
+ export PYTHON_CMD="python"
129
+ fi
130
+
131
+ # Set PYTHONPATH for development installs
132
+ if [ -d "$project_dir/src" ]; then
133
+ export PYTHONPATH="$project_dir/src:$PYTHONPATH"
134
+ fi
135
+ }
136
+
137
+ # Main execution
138
+ main() {
139
+ # Debug mode (can be disabled in production)
140
+ if [ "${CLAUDE_MPM_HOOK_DEBUG}" = "true" ]; then
141
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)] Smart hook starting..." >> /tmp/claude-mpm-hook.log
142
+ fi
143
+
144
+ # Find claude-mpm installation
145
+ PROJECT_DIR=$(find_claude_mpm)
146
+
147
+ if [ -z "$PROJECT_DIR" ]; then
148
+ # Claude MPM not found - return continue to not block Claude
149
+ if [ "${CLAUDE_MPM_HOOK_DEBUG}" = "true" ]; then
150
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)] Claude MPM not found, continuing..." >> /tmp/claude-mpm-hook.log
151
+ fi
152
+ echo '{"action": "continue"}'
153
+ exit 0
154
+ fi
155
+
156
+ if [ "${CLAUDE_MPM_HOOK_DEBUG}" = "true" ]; then
157
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)] Found claude-mpm at: $PROJECT_DIR" >> /tmp/claude-mpm-hook.log
158
+ fi
159
+
160
+ # Setup Python environment
161
+ setup_python_env "$PROJECT_DIR"
162
+
163
+ # Debug logging
164
+ if [ "${CLAUDE_MPM_HOOK_DEBUG}" = "true" ]; then
165
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)] PYTHON_CMD: $PYTHON_CMD" >> /tmp/claude-mpm-hook.log
166
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)] PYTHONPATH: $PYTHONPATH" >> /tmp/claude-mpm-hook.log
167
+ fi
168
+
169
+ # Set Socket.IO configuration for hook events
170
+ export CLAUDE_MPM_SOCKETIO_PORT="${CLAUDE_MPM_SOCKETIO_PORT:-8765}"
171
+
172
+ # Run the hook handler
173
+ if ! "$PYTHON_CMD" -m claude_mpm.hooks.claude_hooks.hook_handler "$@" 2>/tmp/claude-mpm-hook-error.log; then
174
+ # If the Python handler fails, always return continue to not block Claude
175
+ if [ "${CLAUDE_MPM_HOOK_DEBUG}" = "true" ]; then
176
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)] Hook handler failed, see /tmp/claude-mpm-hook-error.log" >> /tmp/claude-mpm-hook.log
177
+ echo "[$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)] Error: $(cat /tmp/claude-mpm-hook-error.log 2>/dev/null | head -5)" >> /tmp/claude-mpm-hook.log
178
+ fi
179
+ echo '{"action": "continue"}'
180
+ exit 0
181
+ fi
182
+
183
+ # Success
184
+ exit 0
185
+ }
186
+
187
+ # Run main function
188
+ main "$@"
189
+ """
190
+
191
+ def __init__(self):
192
+ """Initialize the hook installer."""
193
+ self.logger = get_logger(__name__)
194
+ self.claude_dir = Path.home() / ".claude"
195
+ self.hooks_dir = self.claude_dir / "hooks"
196
+ self.settings_file = self.claude_dir / "settings.json"
197
+
198
+ def install_hooks(self, force: bool = False) -> bool:
199
+ """
200
+ Install Claude MPM hooks.
201
+
202
+ Args:
203
+ force: Force reinstallation even if hooks already exist
204
+
205
+ Returns:
206
+ True if installation successful, False otherwise
207
+ """
208
+ try:
209
+ self.logger.info("Starting hook installation...")
210
+
211
+ # Create directories
212
+ self.claude_dir.mkdir(exist_ok=True)
213
+ self.hooks_dir.mkdir(exist_ok=True)
214
+
215
+ # Install smart hook script
216
+ hook_script_path = self.hooks_dir / "claude-mpm-hook.sh"
217
+ if hook_script_path.exists() and not force:
218
+ self.logger.info(
219
+ "Hook script already exists. Use --force to overwrite."
220
+ )
221
+ else:
222
+ self._install_smart_hook_script(hook_script_path)
223
+
224
+ # Update Claude settings
225
+ self._update_claude_settings(hook_script_path)
226
+
227
+ # Install commands if available
228
+ self._install_commands()
229
+
230
+ self.logger.info("Hook installation completed successfully!")
231
+ return True
232
+
233
+ except Exception as e:
234
+ self.logger.error(f"Hook installation failed: {e}")
235
+ return False
236
+
237
+ def _install_smart_hook_script(self, hook_script_path: Path) -> None:
238
+ """Install the smart hook script that dynamically finds claude-mpm."""
239
+ self.logger.info(f"Installing smart hook script to {hook_script_path}")
240
+
241
+ # Write the smart hook script
242
+ with open(hook_script_path, "w") as f:
243
+ f.write(self.SMART_HOOK_SCRIPT)
244
+
245
+ # Make it executable
246
+ st = os.stat(hook_script_path)
247
+ os.chmod(hook_script_path, st.st_mode | stat.S_IEXEC)
248
+
249
+ self.logger.info("Smart hook script installed and made executable")
250
+
251
+ def _update_claude_settings(self, hook_script_path: Path) -> None:
252
+ """Update Claude settings to use the installed hook."""
253
+ self.logger.info("Updating Claude settings...")
254
+
255
+ # Load existing settings or create new
256
+ if self.settings_file.exists():
257
+ with open(self.settings_file) as f:
258
+ settings = json.load(f)
259
+ self.logger.info("Found existing Claude settings")
260
+ else:
261
+ settings = {}
262
+ self.logger.info("Creating new Claude settings")
263
+
264
+ # Configure hooks
265
+ hook_config = {
266
+ "matcher": "*",
267
+ "hooks": [{"type": "command", "command": str(hook_script_path.absolute())}],
268
+ }
269
+
270
+ # Update settings
271
+ if "hooks" not in settings:
272
+ settings["hooks"] = {}
273
+
274
+ # Add hooks for all event types
275
+ event_types = [
276
+ "UserPromptSubmit",
277
+ "PreToolUse",
278
+ "PostToolUse",
279
+ "Stop",
280
+ "SubagentStop",
281
+ ]
282
+
283
+ for event_type in event_types:
284
+ settings["hooks"][event_type] = [hook_config]
285
+
286
+ # Write settings
287
+ with open(self.settings_file, "w") as f:
288
+ json.dump(settings, f, indent=2)
289
+
290
+ self.logger.info(f"Updated Claude settings at {self.settings_file}")
291
+
292
+ def _install_commands(self) -> None:
293
+ """Install custom commands for Claude Code."""
294
+ # Find commands directory in the package
295
+ package_root = Path(__file__).parent.parent.parent.parent
296
+ commands_src = package_root / ".claude" / "commands"
297
+
298
+ if not commands_src.exists():
299
+ self.logger.debug(
300
+ "No commands directory found, skipping command installation"
301
+ )
302
+ return
303
+
304
+ commands_dst = self.claude_dir / "commands"
305
+ commands_dst.mkdir(exist_ok=True)
306
+
307
+ for cmd_file in commands_src.glob("*.md"):
308
+ dst_file = commands_dst / cmd_file.name
309
+ shutil.copy2(cmd_file, dst_file)
310
+ self.logger.info(f"Installed command: {cmd_file.name}")
311
+
312
+ def update_hooks(self) -> bool:
313
+ """Update existing hooks to the latest version."""
314
+ return self.install_hooks(force=True)
315
+
316
+ def verify_hooks(self) -> Tuple[bool, List[str]]:
317
+ """
318
+ Verify that hooks are properly installed.
319
+
320
+ Returns:
321
+ Tuple of (is_valid, list_of_issues)
322
+ """
323
+ issues = []
324
+
325
+ # Check hook script exists
326
+ hook_script_path = self.hooks_dir / "claude-mpm-hook.sh"
327
+ if not hook_script_path.exists():
328
+ issues.append(f"Hook script not found at {hook_script_path}")
329
+
330
+ # Check hook script is executable
331
+ elif not os.access(hook_script_path, os.X_OK):
332
+ issues.append(f"Hook script is not executable: {hook_script_path}")
333
+
334
+ # Check Claude settings
335
+ if not self.settings_file.exists():
336
+ issues.append(f"Claude settings file not found at {self.settings_file}")
337
+ else:
338
+ try:
339
+ with open(self.settings_file) as f:
340
+ settings = json.load(f)
341
+
342
+ if "hooks" not in settings:
343
+ issues.append("No hooks configured in Claude settings")
344
+ else:
345
+ # Check for required event types
346
+ required_events = ["Stop", "SubagentStop"]
347
+ for event in required_events:
348
+ if event not in settings["hooks"]:
349
+ issues.append(
350
+ f"Missing hook configuration for {event} event"
351
+ )
352
+
353
+ except json.JSONDecodeError as e:
354
+ issues.append(f"Invalid Claude settings JSON: {e}")
355
+
356
+ # Check if claude-mpm is accessible
357
+ try:
358
+ import claude_mpm
359
+ except ImportError:
360
+ issues.append("claude-mpm package not found in Python environment")
361
+
362
+ is_valid = len(issues) == 0
363
+ return is_valid, issues
364
+
365
+ def uninstall_hooks(self) -> bool:
366
+ """
367
+ Remove Claude MPM hooks.
368
+
369
+ Returns:
370
+ True if uninstallation successful, False otherwise
371
+ """
372
+ try:
373
+ self.logger.info("Uninstalling hooks...")
374
+
375
+ # Remove hook script
376
+ hook_script_path = self.hooks_dir / "claude-mpm-hook.sh"
377
+ if hook_script_path.exists():
378
+ hook_script_path.unlink()
379
+ self.logger.info(f"Removed hook script: {hook_script_path}")
380
+
381
+ # Remove from Claude settings
382
+ if self.settings_file.exists():
383
+ with open(self.settings_file) as f:
384
+ settings = json.load(f)
385
+
386
+ if "hooks" in settings:
387
+ # Remove claude-mpm hooks
388
+ for event_type in list(settings["hooks"].keys()):
389
+ hooks = settings["hooks"][event_type]
390
+ # Filter out claude-mpm hooks
391
+ filtered_hooks = [
392
+ h
393
+ for h in hooks
394
+ if not (
395
+ isinstance(h, dict)
396
+ and h.get("hooks", [{}])[0]
397
+ .get("command", "")
398
+ .endswith("claude-mpm-hook.sh")
399
+ )
400
+ ]
401
+
402
+ if filtered_hooks:
403
+ settings["hooks"][event_type] = filtered_hooks
404
+ else:
405
+ del settings["hooks"][event_type]
406
+
407
+ # Clean up empty hooks section
408
+ if not settings["hooks"]:
409
+ del settings["hooks"]
410
+
411
+ # Write back settings
412
+ with open(self.settings_file, "w") as f:
413
+ json.dump(settings, f, indent=2)
414
+
415
+ self.logger.info("Removed hooks from Claude settings")
416
+
417
+ self.logger.info("Hook uninstallation completed")
418
+ return True
419
+
420
+ except Exception as e:
421
+ self.logger.error(f"Hook uninstallation failed: {e}")
422
+ return False
423
+
424
+ def get_status(self) -> Dict[str, any]:
425
+ """
426
+ Get the current status of hook installation.
427
+
428
+ Returns:
429
+ Dictionary with status information
430
+ """
431
+ is_valid, issues = self.verify_hooks()
432
+
433
+ hook_script_path = self.hooks_dir / "claude-mpm-hook.sh"
434
+
435
+ status = {
436
+ "installed": hook_script_path.exists(),
437
+ "valid": is_valid,
438
+ "issues": issues,
439
+ "hook_script": str(hook_script_path) if hook_script_path.exists() else None,
440
+ "settings_file": (
441
+ str(self.settings_file) if self.settings_file.exists() else None
442
+ ),
443
+ }
444
+
445
+ # Check Claude settings for hook configuration
446
+ if self.settings_file.exists():
447
+ try:
448
+ with open(self.settings_file) as f:
449
+ settings = json.load(f)
450
+ if "hooks" in settings:
451
+ status["configured_events"] = list(settings["hooks"].keys())
452
+ except:
453
+ pass
454
+
455
+ return status