claude-mpm 4.9.0__py3-none-any.whl → 4.11.0__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.
@@ -16,8 +16,7 @@ from rich.panel import Panel
16
16
  from rich.syntax import Syntax
17
17
  from rich.table import Table
18
18
 
19
- from claude_mpm.cli.utils import handle_async_errors
20
- from claude_mpm.services.service_container import get_service_container
19
+ from claude_mpm.services.core.service_container import get_global_container
21
20
 
22
21
  console = Console()
23
22
 
@@ -27,8 +26,9 @@ class MCPSearchInterface:
27
26
 
28
27
  def __init__(self):
29
28
  """Initialize the search interface."""
30
- self.container = get_service_container()
29
+ self.container = get_global_container()
31
30
  self.mcp_gateway = None
31
+ self.vector_search_available = False
32
32
 
33
33
  async def initialize(self):
34
34
  """Initialize the MCP gateway connection."""
@@ -39,10 +39,127 @@ class MCPSearchInterface:
39
39
  if not self.mcp_gateway:
40
40
  self.mcp_gateway = MCPGatewayService()
41
41
  await self.mcp_gateway.initialize()
42
+
43
+ # Check if vector search is available
44
+ self.vector_search_available = await self._check_vector_search_available()
45
+
42
46
  except Exception as e:
43
47
  console.print(f"[red]Failed to initialize MCP gateway: {e}[/red]")
44
48
  raise
45
49
 
50
+ async def _check_vector_search_available(self) -> bool:
51
+ """Check if mcp-vector-search is available and offer installation if not."""
52
+ import importlib.util
53
+
54
+ # Check if package is installed
55
+ spec = importlib.util.find_spec("mcp_vector_search")
56
+ if spec is not None:
57
+ return True
58
+
59
+ # Package not found - offer installation
60
+ console.print("\n[yellow]⚠️ mcp-vector-search not found[/yellow]")
61
+ console.print("This package enables semantic code search (optional feature).")
62
+ console.print("\nInstallation options:")
63
+ console.print(" 1. Install via pip (recommended for this project)")
64
+ console.print(" 2. Install via pipx (isolated, system-wide)")
65
+ console.print(" 3. Skip (use traditional grep/glob instead)")
66
+
67
+ try:
68
+ choice = input("\nChoose option (1/2/3) [3]: ").strip() or "3"
69
+
70
+ if choice == "1":
71
+ return await self._install_via_pip()
72
+ if choice == "2":
73
+ return await self._install_via_pipx()
74
+ console.print(
75
+ "[dim]Continuing with fallback search methods (grep/glob)[/dim]"
76
+ )
77
+ return False
78
+
79
+ except (EOFError, KeyboardInterrupt):
80
+ console.print("\n[dim]Installation cancelled, using fallback methods[/dim]")
81
+ return False
82
+
83
+ async def _install_via_pip(self) -> bool:
84
+ """Install mcp-vector-search via pip."""
85
+ import subprocess
86
+
87
+ try:
88
+ console.print("\n[cyan]📦 Installing mcp-vector-search via pip...[/cyan]")
89
+ result = subprocess.run(
90
+ [sys.executable, "-m", "pip", "install", "mcp-vector-search"],
91
+ capture_output=True,
92
+ text=True,
93
+ timeout=120,
94
+ check=False,
95
+ )
96
+
97
+ if result.returncode == 0:
98
+ console.print(
99
+ "[green]✓ Successfully installed mcp-vector-search[/green]"
100
+ )
101
+ return True
102
+
103
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
104
+ console.print(f"[red]✗ Installation failed: {error_msg}[/red]")
105
+ return False
106
+
107
+ except subprocess.TimeoutExpired:
108
+ console.print("[red]✗ Installation timed out[/red]")
109
+ return False
110
+ except Exception as e:
111
+ console.print(f"[red]✗ Installation error: {e}[/red]")
112
+ return False
113
+
114
+ async def _install_via_pipx(self) -> bool:
115
+ """Install mcp-vector-search via pipx."""
116
+ import subprocess
117
+
118
+ try:
119
+ # Check if pipx is available
120
+ pipx_check = subprocess.run(
121
+ ["pipx", "--version"],
122
+ capture_output=True,
123
+ text=True,
124
+ timeout=5,
125
+ check=False,
126
+ )
127
+
128
+ if pipx_check.returncode != 0:
129
+ console.print("[red]✗ pipx is not installed[/red]")
130
+ console.print("Install pipx first: python -m pip install pipx")
131
+ return False
132
+
133
+ console.print("\n[cyan]📦 Installing mcp-vector-search via pipx...[/cyan]")
134
+ result = subprocess.run(
135
+ ["pipx", "install", "mcp-vector-search"],
136
+ capture_output=True,
137
+ text=True,
138
+ timeout=120,
139
+ check=False,
140
+ )
141
+
142
+ if result.returncode == 0:
143
+ console.print(
144
+ "[green]✓ Successfully installed mcp-vector-search[/green]"
145
+ )
146
+ return True
147
+
148
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
149
+ console.print(f"[red]✗ Installation failed: {error_msg}[/red]")
150
+ return False
151
+
152
+ except FileNotFoundError:
153
+ console.print("[red]✗ pipx command not found[/red]")
154
+ console.print("Install pipx first: python -m pip install pipx")
155
+ return False
156
+ except subprocess.TimeoutExpired:
157
+ console.print("[red]✗ Installation timed out[/red]")
158
+ return False
159
+ except Exception as e:
160
+ console.print(f"[red]✗ Installation error: {e}[/red]")
161
+ return False
162
+
46
163
  async def search_code(
47
164
  self,
48
165
  query: str,
@@ -125,6 +242,12 @@ class MCPSearchInterface:
125
242
  if not self.mcp_gateway:
126
243
  await self.initialize()
127
244
 
245
+ # Check if vector search is available
246
+ if not self.vector_search_available:
247
+ return {
248
+ "error": "mcp-vector-search is not available. Use traditional grep/glob tools instead, or run command again to install."
249
+ }
250
+
128
251
  try:
129
252
  return await self.mcp_gateway.call_tool(tool_name, params)
130
253
  except Exception as e:
@@ -196,7 +319,6 @@ def display_search_results(results: Dict[str, Any], output_format: str = "rich")
196
319
  @click.option("--focus", multiple=True, help="Focus areas (with --context)")
197
320
  @click.option("--force", is_flag=True, help="Force reindexing (with --index)")
198
321
  @click.option("--json", "output_json", is_flag=True, help="Output results as JSON")
199
- @handle_async_errors
200
322
  async def search_command(
201
323
  query: Optional[str],
202
324
  similar: Optional[str],
@@ -228,8 +350,24 @@ async def search_command(
228
350
  output_format = "json" if output_json else "rich"
229
351
 
230
352
  try:
353
+ # Show first-time usage tips if vector search is available
354
+ if search.vector_search_available and not (index or status):
355
+ console.print(
356
+ "\n[dim]💡 Tip: Vector search provides semantic code understanding.[/dim]"
357
+ )
358
+ console.print(
359
+ "[dim] Run with --index first to index your project.[/dim]\n"
360
+ )
361
+
231
362
  # Handle different operation modes
232
363
  if index:
364
+ if not search.vector_search_available:
365
+ console.print("[red]✗ mcp-vector-search is required for indexing[/red]")
366
+ console.print(
367
+ "[dim]Install it or use traditional grep/glob for search[/dim]"
368
+ )
369
+ sys.exit(1)
370
+
233
371
  console.print("[cyan]Indexing project...[/cyan]")
234
372
  result = await search.index_project(
235
373
  force=force, file_extensions=list(extensions) if extensions else None
@@ -239,10 +377,23 @@ async def search_command(
239
377
  display_search_results(result, output_format)
240
378
 
241
379
  elif status:
380
+ if not search.vector_search_available:
381
+ console.print(
382
+ "[red]✗ mcp-vector-search is required for status check[/red]"
383
+ )
384
+ console.print("[dim]Install it to use vector search features[/dim]")
385
+ sys.exit(1)
386
+
242
387
  result = await search.get_status()
243
388
  display_search_results(result, output_format)
244
389
 
245
390
  elif similar:
391
+ if not search.vector_search_available:
392
+ console.print("[yellow]⚠️ Vector search not available[/yellow]")
393
+ console.print("[dim]Similarity search requires mcp-vector-search[/dim]")
394
+ console.print("[dim]Falling back to basic file search...[/dim]")
395
+ sys.exit(1)
396
+
246
397
  result = await search.search_similar(
247
398
  file_path=similar,
248
399
  function_name=function,
@@ -252,6 +403,12 @@ async def search_command(
252
403
  display_search_results(result, output_format)
253
404
 
254
405
  elif context:
406
+ if not search.vector_search_available:
407
+ console.print("[yellow]⚠️ Vector search not available[/yellow]")
408
+ console.print("[dim]Context search requires mcp-vector-search[/dim]")
409
+ console.print("[dim]Try using grep for text-based search instead[/dim]")
410
+ sys.exit(1)
411
+
255
412
  result = await search.search_context(
256
413
  description=context,
257
414
  focus_areas=list(focus) if focus else None,
@@ -260,6 +417,15 @@ async def search_command(
260
417
  display_search_results(result, output_format)
261
418
 
262
419
  elif query:
420
+ if not search.vector_search_available:
421
+ console.print("[yellow]⚠️ Vector search not available[/yellow]")
422
+ console.print("[dim]Code search requires mcp-vector-search[/dim]")
423
+ console.print(
424
+ "\n[cyan]Alternative: Use grep for pattern matching:[/cyan]"
425
+ )
426
+ console.print(f" grep -r '{query}' .")
427
+ sys.exit(1)
428
+
263
429
  result = await search.search_code(
264
430
  query=query,
265
431
  limit=limit,
@@ -0,0 +1,389 @@
1
+ """
2
+ Session Pause Manager - Captures and saves session state for later resumption.
3
+
4
+ This module provides functionality to pause a Claude MPM session by capturing:
5
+ - Conversation context and progress
6
+ - Git repository state
7
+ - Todo list status
8
+ - Working directory changes
9
+
10
+ The saved state enables seamless session resumption with full context.
11
+ """
12
+
13
+ import subprocess
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+
21
+ from claude_mpm.core.logging_utils import get_logger
22
+ from claude_mpm.storage.state_storage import StateStorage
23
+
24
+ logger = get_logger(__name__)
25
+ console = Console()
26
+
27
+
28
+ class SessionPauseManager:
29
+ """Manages pausing and saving session state."""
30
+
31
+ def __init__(self, project_path: Path):
32
+ """Initialize Session Pause Manager.
33
+
34
+ Args:
35
+ project_path: Path to the project directory
36
+ """
37
+ self.project_path = project_path
38
+ self.session_dir = project_path / ".claude-mpm" / "sessions" / "pause"
39
+ self.storage = StateStorage()
40
+
41
+ def pause_session(
42
+ self,
43
+ conversation_summary: Optional[str] = None,
44
+ accomplishments: Optional[List[str]] = None,
45
+ next_steps: Optional[List[str]] = None,
46
+ todos_active: Optional[List[Dict[str, str]]] = None,
47
+ todos_completed: Optional[List[Dict[str, str]]] = None,
48
+ ) -> Dict[str, Any]:
49
+ """Pause the current session and save state.
50
+
51
+ Args:
52
+ conversation_summary: Summary of what was being worked on
53
+ accomplishments: List of things accomplished in this session
54
+ next_steps: List of next steps to continue work
55
+ todos_active: Active todo items
56
+ todos_completed: Completed todo items
57
+
58
+ Returns:
59
+ Dict containing pause result with session_file path
60
+ """
61
+ try:
62
+ # Ensure session directory exists
63
+ self.session_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Generate session ID
66
+ timestamp = datetime.now(timezone.utc)
67
+ session_id = f"session-{timestamp.strftime('%Y%m%d-%H%M%S')}"
68
+
69
+ # Capture git context
70
+ git_context = self._capture_git_context()
71
+
72
+ # Build session state
73
+ session_state = {
74
+ "session_id": session_id,
75
+ "paused_at": timestamp.isoformat(),
76
+ "conversation": {
77
+ "summary": conversation_summary
78
+ or "Session paused - context not provided",
79
+ "accomplishments": accomplishments or [],
80
+ "next_steps": next_steps or [],
81
+ },
82
+ "git_context": git_context,
83
+ "todos": {
84
+ "active": todos_active or [],
85
+ "completed": todos_completed or [],
86
+ },
87
+ "version": self._get_version(),
88
+ "build": self._get_build_number(),
89
+ "project_path": str(self.project_path),
90
+ }
91
+
92
+ # Save session state to file
93
+ session_file = self.session_dir / f"{session_id}.json"
94
+ success = self.storage.write_json(
95
+ session_state, session_file, atomic=True, compress=False
96
+ )
97
+
98
+ if not success:
99
+ return {
100
+ "status": "error",
101
+ "message": "Failed to write session state file",
102
+ }
103
+
104
+ # Create git commit with session state
105
+ commit_success = self._create_pause_commit(session_id, conversation_summary)
106
+
107
+ # Display success message
108
+ self._display_pause_success(session_id, session_file, commit_success)
109
+
110
+ return {
111
+ "status": "success",
112
+ "session_id": session_id,
113
+ "session_file": str(session_file),
114
+ "git_commit_created": commit_success,
115
+ "message": f"Session paused: {session_id}",
116
+ }
117
+
118
+ except Exception as e:
119
+ logger.error(f"Failed to pause session: {e}")
120
+ return {"status": "error", "message": str(e)}
121
+
122
+ def _capture_git_context(self) -> Dict[str, Any]:
123
+ """Capture current git repository state.
124
+
125
+ Returns:
126
+ Dict containing git context information
127
+ """
128
+ git_context: Dict[str, Any] = {
129
+ "is_git_repo": False,
130
+ "branch": None,
131
+ "recent_commits": [],
132
+ "status": {"clean": True, "modified_files": [], "untracked_files": []},
133
+ }
134
+
135
+ try:
136
+ # Check if git repo
137
+ result = subprocess.run(
138
+ ["git", "rev-parse", "--git-dir"],
139
+ cwd=str(self.project_path),
140
+ capture_output=True,
141
+ text=True,
142
+ check=False,
143
+ )
144
+
145
+ if result.returncode != 0:
146
+ return git_context
147
+
148
+ git_context["is_git_repo"] = True
149
+
150
+ # Get current branch
151
+ result = subprocess.run(
152
+ ["git", "branch", "--show-current"],
153
+ cwd=str(self.project_path),
154
+ capture_output=True,
155
+ text=True,
156
+ check=True,
157
+ )
158
+ git_context["branch"] = result.stdout.strip()
159
+
160
+ # Get recent commits (last 10)
161
+ result = subprocess.run(
162
+ ["git", "log", "--format=%h|%an|%ai|%s", "-10"],
163
+ cwd=str(self.project_path),
164
+ capture_output=True,
165
+ text=True,
166
+ check=True,
167
+ )
168
+
169
+ commits = []
170
+ for line in result.stdout.strip().split("\n"):
171
+ if not line:
172
+ continue
173
+ parts = line.split("|", 3)
174
+ if len(parts) == 4:
175
+ sha, author, timestamp_str, message = parts
176
+ commits.append(
177
+ {
178
+ "sha": sha,
179
+ "author": author,
180
+ "timestamp": timestamp_str,
181
+ "message": message,
182
+ }
183
+ )
184
+ git_context["recent_commits"] = commits
185
+
186
+ # Get status
187
+ result = subprocess.run(
188
+ ["git", "status", "--porcelain"],
189
+ cwd=str(self.project_path),
190
+ capture_output=True,
191
+ text=True,
192
+ check=True,
193
+ )
194
+
195
+ modified_files = []
196
+ untracked_files = []
197
+
198
+ for line in result.stdout.strip().split("\n"):
199
+ if not line:
200
+ continue
201
+ status_code = line[:2]
202
+ file_path = line[3:]
203
+
204
+ if status_code.startswith("??"):
205
+ untracked_files.append(file_path)
206
+ else:
207
+ modified_files.append(file_path)
208
+
209
+ git_context["status"] = {
210
+ "clean": len(modified_files) == 0 and len(untracked_files) == 0,
211
+ "modified_files": modified_files,
212
+ "untracked_files": untracked_files,
213
+ }
214
+
215
+ except Exception as e:
216
+ logger.warning(f"Could not capture git context: {e}")
217
+
218
+ return git_context
219
+
220
+ def _get_version(self) -> str:
221
+ """Get Claude MPM version.
222
+
223
+ Returns:
224
+ Version string or "unknown"
225
+ """
226
+ try:
227
+ version_file = Path(__file__).parent.parent.parent.parent.parent / "VERSION"
228
+ if version_file.exists():
229
+ return version_file.read_text().strip()
230
+ except Exception:
231
+ pass
232
+ return "unknown"
233
+
234
+ def _get_build_number(self) -> str:
235
+ """Get Claude MPM build number.
236
+
237
+ Returns:
238
+ Build number string or "unknown"
239
+ """
240
+ try:
241
+ build_file = (
242
+ Path(__file__).parent.parent.parent.parent.parent / "BUILD_NUMBER"
243
+ )
244
+ if build_file.exists():
245
+ return build_file.read_text().strip()
246
+ except Exception:
247
+ pass
248
+ return "unknown"
249
+
250
+ def _create_pause_commit(self, session_id: str, summary: Optional[str]) -> bool:
251
+ """Create git commit with session pause information.
252
+
253
+ Args:
254
+ session_id: The session identifier
255
+ summary: Optional summary of what was being worked on
256
+
257
+ Returns:
258
+ True if commit was created successfully
259
+ """
260
+ try:
261
+ # Check if we're in a git repo
262
+ result = subprocess.run(
263
+ ["git", "rev-parse", "--git-dir"],
264
+ cwd=str(self.project_path),
265
+ capture_output=True,
266
+ text=True,
267
+ check=False,
268
+ )
269
+
270
+ if result.returncode != 0:
271
+ logger.debug("Not a git repository, skipping commit")
272
+ return False
273
+
274
+ # Add session files to git
275
+ subprocess.run(
276
+ ["git", "add", ".claude-mpm/sessions/pause/"],
277
+ cwd=str(self.project_path),
278
+ capture_output=True,
279
+ check=False,
280
+ )
281
+
282
+ # Check if there are changes to commit
283
+ result = subprocess.run(
284
+ ["git", "diff", "--cached", "--quiet"],
285
+ cwd=str(self.project_path),
286
+ check=False,
287
+ )
288
+
289
+ if result.returncode == 0:
290
+ logger.debug("No changes to commit")
291
+ return False
292
+
293
+ # Build commit message
294
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
295
+ context = summary or "Session paused"
296
+
297
+ commit_message = f"""session: pause at {timestamp}
298
+
299
+ Session ID: {session_id}
300
+ Context: {context}
301
+
302
+ 🤖👥 Generated with [Claude MPM](https://github.com/bobmatnyc/claude-mpm)
303
+
304
+ Co-Authored-By: Claude <noreply@anthropic.com>"""
305
+
306
+ # Create commit
307
+ result = subprocess.run(
308
+ ["git", "commit", "-m", commit_message],
309
+ cwd=str(self.project_path),
310
+ capture_output=True,
311
+ text=True,
312
+ check=False,
313
+ )
314
+
315
+ if result.returncode == 0:
316
+ logger.info(f"Created pause commit for session {session_id}")
317
+ return True
318
+ logger.warning(f"Could not create commit: {result.stderr}")
319
+ return False
320
+
321
+ except Exception as e:
322
+ logger.warning(f"Could not create pause commit: {e}")
323
+ return False
324
+
325
+ def _display_pause_success(
326
+ self, session_id: str, session_file: Path, commit_created: bool
327
+ ) -> None:
328
+ """Display success message for paused session.
329
+
330
+ Args:
331
+ session_id: The session identifier
332
+ session_file: Path to the session state file
333
+ commit_created: Whether git commit was created
334
+ """
335
+ console.print()
336
+ console.print(
337
+ Panel(
338
+ f"[bold green]Session Paused Successfully[/bold green]\n\n"
339
+ f"[cyan]Session ID:[/cyan] {session_id}\n"
340
+ f"[cyan]State saved:[/cyan] {session_file}\n"
341
+ f"[cyan]Git commit:[/cyan] {'✓ Created' if commit_created else '✗ Not created (no git repo or no changes)'}\n\n"
342
+ f"[dim]To resume this session later, run:[/dim]\n"
343
+ f"[yellow] claude-mpm mpm-init pause resume[/yellow]",
344
+ title="🔴 Session Pause",
345
+ border_style="green",
346
+ )
347
+ )
348
+ console.print()
349
+
350
+ def list_paused_sessions(self) -> List[Dict[str, Any]]:
351
+ """List all paused sessions.
352
+
353
+ Returns:
354
+ List of session information dicts
355
+ """
356
+ if not self.session_dir.exists():
357
+ return []
358
+
359
+ sessions = []
360
+ for session_file in sorted(self.session_dir.glob("session-*.json")):
361
+ try:
362
+ state = self.storage.read_json(session_file)
363
+ if state:
364
+ sessions.append(
365
+ {
366
+ "session_id": state.get("session_id"),
367
+ "paused_at": state.get("paused_at"),
368
+ "summary": state.get("conversation", {}).get("summary"),
369
+ "file_path": str(session_file),
370
+ }
371
+ )
372
+ except Exception as e:
373
+ logger.warning(f"Could not read session file {session_file}: {e}")
374
+
375
+ return sessions
376
+
377
+ def get_latest_session(self) -> Optional[Dict[str, Any]]:
378
+ """Get the most recent paused session.
379
+
380
+ Returns:
381
+ Session state dict or None if no sessions found
382
+ """
383
+ sessions = self.list_paused_sessions()
384
+ if not sessions:
385
+ return None
386
+
387
+ # Return the most recent (last in sorted list)
388
+ latest_file = Path(sessions[-1]["file_path"])
389
+ return self.storage.read_json(latest_file)