claude-mpm 5.6.23__py3-none-any.whl → 5.6.72__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 (80) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/auth/__init__.py +35 -0
  3. claude_mpm/auth/callback_server.py +328 -0
  4. claude_mpm/auth/models.py +104 -0
  5. claude_mpm/auth/oauth_manager.py +266 -0
  6. claude_mpm/auth/providers/__init__.py +12 -0
  7. claude_mpm/auth/providers/base.py +165 -0
  8. claude_mpm/auth/providers/google.py +261 -0
  9. claude_mpm/auth/token_storage.py +252 -0
  10. claude_mpm/cli/commands/commander.py +6 -6
  11. claude_mpm/cli/commands/mcp.py +29 -17
  12. claude_mpm/cli/commands/mcp_command_router.py +39 -0
  13. claude_mpm/cli/commands/mcp_service_commands.py +304 -0
  14. claude_mpm/cli/commands/oauth.py +481 -0
  15. claude_mpm/cli/executor.py +9 -0
  16. claude_mpm/cli/helpers.py +1 -1
  17. claude_mpm/cli/parsers/base_parser.py +13 -0
  18. claude_mpm/cli/parsers/mcp_parser.py +79 -0
  19. claude_mpm/cli/parsers/oauth_parser.py +165 -0
  20. claude_mpm/cli/startup.py +150 -33
  21. claude_mpm/cli/startup_display.py +3 -2
  22. claude_mpm/commander/chat/cli.py +5 -2
  23. claude_mpm/commander/chat/commands.py +42 -16
  24. claude_mpm/commander/chat/repl.py +1581 -70
  25. claude_mpm/commander/events/manager.py +61 -1
  26. claude_mpm/commander/frameworks/base.py +87 -0
  27. claude_mpm/commander/frameworks/mpm.py +9 -14
  28. claude_mpm/commander/git/__init__.py +5 -0
  29. claude_mpm/commander/git/worktree_manager.py +212 -0
  30. claude_mpm/commander/instance_manager.py +428 -13
  31. claude_mpm/commander/models/events.py +6 -0
  32. claude_mpm/commander/persistence/state_store.py +95 -1
  33. claude_mpm/commander/tmux_orchestrator.py +3 -2
  34. claude_mpm/constants.py +5 -0
  35. claude_mpm/core/hook_manager.py +2 -1
  36. claude_mpm/core/logging_utils.py +4 -2
  37. claude_mpm/core/output_style_manager.py +5 -2
  38. claude_mpm/core/socketio_pool.py +34 -10
  39. claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-311.pyc +0 -0
  40. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  41. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  42. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  43. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  44. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +1 -1
  45. claude_mpm/hooks/claude_hooks/event_handlers.py +206 -94
  46. claude_mpm/hooks/claude_hooks/hook_handler.py +115 -32
  47. claude_mpm/hooks/claude_hooks/installer.py +175 -51
  48. claude_mpm/hooks/claude_hooks/memory_integration.py +1 -1
  49. claude_mpm/hooks/claude_hooks/response_tracking.py +1 -1
  50. claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
  51. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  52. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  53. claude_mpm/hooks/claude_hooks/services/__pycache__/container.cpython-311.pyc +0 -0
  54. claude_mpm/hooks/claude_hooks/services/__pycache__/protocols.cpython-311.pyc +0 -0
  55. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  56. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  57. claude_mpm/hooks/claude_hooks/services/connection_manager.py +2 -2
  58. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +2 -2
  59. claude_mpm/hooks/claude_hooks/services/container.py +326 -0
  60. claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
  61. claude_mpm/hooks/claude_hooks/services/state_manager.py +2 -2
  62. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +2 -2
  63. claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
  64. claude_mpm/hooks/templates/pre_tool_use_template.py +6 -6
  65. claude_mpm/init.py +21 -14
  66. claude_mpm/mcp/__init__.py +9 -0
  67. claude_mpm/mcp/google_workspace_server.py +610 -0
  68. claude_mpm/scripts/claude-hook-handler.sh +3 -3
  69. claude_mpm/services/command_deployment_service.py +44 -26
  70. claude_mpm/services/hook_installer_service.py +77 -8
  71. claude_mpm/services/mcp_config_manager.py +99 -19
  72. claude_mpm/services/mcp_service_registry.py +294 -0
  73. claude_mpm/services/monitor/server.py +6 -1
  74. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.72.dist-info}/METADATA +24 -1
  75. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.72.dist-info}/RECORD +80 -60
  76. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.72.dist-info}/WHEEL +1 -1
  77. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.72.dist-info}/entry_points.txt +2 -0
  78. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.72.dist-info}/licenses/LICENSE +0 -0
  79. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.72.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  80. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.72.dist-info}/top_level.txt +0 -0
@@ -1,29 +1,256 @@
1
1
  """Commander chat REPL interface."""
2
2
 
3
3
  import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
4
12
  from pathlib import Path
5
- from typing import Optional
13
+ from typing import TYPE_CHECKING, Optional
6
14
 
7
- from prompt_toolkit import PromptSession
15
+ from prompt_toolkit import PromptSession, prompt as pt_prompt
16
+ from prompt_toolkit.completion import Completer, Completion
8
17
  from prompt_toolkit.history import FileHistory
18
+ from prompt_toolkit.patch_stdout import patch_stdout
19
+
20
+
21
+ class RequestStatus(Enum):
22
+ """Status of a pending request."""
23
+
24
+ QUEUED = "queued"
25
+ SENDING = "sending"
26
+ WAITING = "waiting"
27
+ STARTING = "starting" # Instance starting up
28
+ COMPLETED = "completed"
29
+ ERROR = "error"
30
+
31
+
32
+ class RequestType(Enum):
33
+ """Type of pending request."""
34
+
35
+ MESSAGE = "message" # Message to instance
36
+ STARTUP = "startup" # Instance startup/ready wait
37
+
38
+
39
+ @dataclass
40
+ class PendingRequest:
41
+ """Tracks an in-flight request to an instance."""
42
+
43
+ id: str
44
+ target: str # Instance name
45
+ message: str
46
+ request_type: RequestType = RequestType.MESSAGE
47
+ status: RequestStatus = RequestStatus.QUEUED
48
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
49
+ response: Optional[str] = None
50
+ error: Optional[str] = None
51
+
52
+ def elapsed_seconds(self) -> int:
53
+ """Get elapsed time since request was created."""
54
+ return int((datetime.now(timezone.utc) - self.created_at).total_seconds())
55
+
56
+ def display_message(self, max_len: int = 40) -> str:
57
+ """Get truncated message for display."""
58
+ msg = self.message.replace("\n", " ")
59
+ if len(msg) > max_len:
60
+ return msg[: max_len - 3] + "..."
61
+ return msg
62
+
63
+
64
+ @dataclass
65
+ class SavedRegistration:
66
+ """A saved instance registration for persistence."""
67
+
68
+ name: str
69
+ path: str
70
+ framework: str # "cc" or "mpm"
71
+ registered_at: str # ISO timestamp
72
+
73
+ def to_dict(self) -> dict:
74
+ """Convert to dictionary for JSON serialization."""
75
+ return {
76
+ "name": self.name,
77
+ "path": self.path,
78
+ "framework": self.framework,
79
+ "registered_at": self.registered_at,
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict) -> "SavedRegistration":
84
+ """Create from dictionary."""
85
+ return cls(
86
+ name=data["name"],
87
+ path=data["path"],
88
+ framework=data["framework"],
89
+ registered_at=data.get(
90
+ "registered_at", datetime.now(timezone.utc).isoformat()
91
+ ),
92
+ )
93
+
9
94
 
10
95
  from claude_mpm.commander.instance_manager import InstanceManager
11
96
  from claude_mpm.commander.llm.openrouter_client import OpenRouterClient
97
+ from claude_mpm.commander.models.events import EventType
12
98
  from claude_mpm.commander.proxy.relay import OutputRelay
13
99
  from claude_mpm.commander.session.manager import SessionManager
14
100
 
15
101
  from .commands import Command, CommandParser, CommandType
16
102
 
103
+ if TYPE_CHECKING:
104
+ from claude_mpm.commander.events.manager import EventManager
105
+ from claude_mpm.commander.models.events import Event
106
+
107
+
108
+ class CommandCompleter(Completer):
109
+ """Autocomplete for slash commands and instance names."""
110
+
111
+ COMMANDS = [
112
+ ("register", "Register and start a new instance"),
113
+ ("start", "Start a registered instance"),
114
+ ("stop", "Stop a running instance"),
115
+ ("close", "Close instance and merge worktree"),
116
+ ("connect", "Connect to instance (starts from saved if needed)"),
117
+ ("disconnect", "Disconnect from current instance"),
118
+ ("switch", "Switch to another instance"),
119
+ ("list", "List all instances"),
120
+ ("ls", "List all instances (alias)"),
121
+ ("saved", "List saved registrations"),
122
+ ("forget", "Remove a saved registration"),
123
+ ("status", "Show connection status"),
124
+ ("cleanup", "Clean up orphan tmux panes"),
125
+ ("help", "Show help"),
126
+ ("exit", "Exit commander"),
127
+ ("quit", "Exit commander (alias)"),
128
+ ("q", "Exit commander (alias)"),
129
+ ]
130
+
131
+ def __init__(self, get_instances_func):
132
+ """Initialize with function to get instance names.
133
+
134
+ Args:
135
+ get_instances_func: Callable that returns list of instance names.
136
+ """
137
+ self.get_instances = get_instances_func
138
+
139
+ def get_completions(self, document, complete_event):
140
+ """Generate completions for the current input.
141
+
142
+ Args:
143
+ document: The document being edited.
144
+ complete_event: The completion event.
145
+
146
+ Yields:
147
+ Completion objects for matching commands or instance names.
148
+ """
149
+ text = document.text_before_cursor
150
+
151
+ # Complete slash commands
152
+ if text.startswith("/"):
153
+ cmd_text = text[1:].lower()
154
+ # Check if we're completing command args (has space after command)
155
+ if " " in cmd_text:
156
+ # Complete instance names after certain commands
157
+ parts = cmd_text.split()
158
+ cmd = parts[0]
159
+ partial = parts[-1] if len(parts) > 1 else ""
160
+ if cmd in ("start", "stop", "close", "connect", "switch"):
161
+ yield from self._complete_instance_names(partial)
162
+ else:
163
+ # Complete command names
164
+ for cmd, desc in self.COMMANDS:
165
+ if cmd.startswith(cmd_text):
166
+ yield Completion(
167
+ cmd,
168
+ start_position=-len(cmd_text),
169
+ display_meta=desc,
170
+ )
171
+
172
+ # Complete instance names after @ prefix
173
+ elif text.startswith("@"):
174
+ partial = text[1:]
175
+ yield from self._complete_instance_names(partial)
176
+
177
+ # Complete instance names inside parentheses
178
+ elif text.startswith("("):
179
+ # Extract partial name, stripping ) and : if present
180
+ partial = text[1:].rstrip("):")
181
+ yield from self._complete_instance_names(partial)
182
+
183
+ def _complete_instance_names(self, partial: str):
184
+ """Generate completions for instance names.
185
+
186
+ Args:
187
+ partial: Partial instance name typed so far.
188
+
189
+ Yields:
190
+ Completion objects for matching instance names.
191
+ """
192
+ try:
193
+ instances = self.get_instances()
194
+ for name in instances:
195
+ if name.lower().startswith(partial.lower()):
196
+ yield Completion(
197
+ name,
198
+ start_position=-len(partial),
199
+ display_meta="instance",
200
+ )
201
+ except Exception: # nosec B110 - Graceful fallback if instance lookup fails
202
+ pass
203
+
17
204
 
18
205
  class CommanderREPL:
19
206
  """Interactive REPL for Commander mode."""
20
207
 
208
+ CAPABILITIES_CONTEXT = """
209
+ MPM Commander Capabilities:
210
+
211
+ INSTANCE MANAGEMENT (use / prefix):
212
+ - /list, /ls: Show all running Claude Code instances with their status
213
+ - /register <path> <framework> <name>: Register, start, and auto-connect (creates worktree)
214
+ - /start <name>: Start a registered instance by name
215
+ - /start <path> [--framework cc|mpm] [--name name]: Start new instance (creates worktree)
216
+ - /stop <name>: Stop a running instance (keeps worktree)
217
+ - /close <name> [--no-merge]: Close instance, merge worktree to main, and cleanup
218
+ - /connect <name>: Connect to a specific instance for interactive chat
219
+ - /switch <name>: Alias for /connect
220
+ - /disconnect: Disconnect from current instance
221
+ - /status: Show current connection status
222
+
223
+ DIRECT MESSAGING (both syntaxes work the same):
224
+ - @<name> <message>: Send message directly to any instance
225
+ - (<name>) <message>: Same as @name (parentheses syntax)
226
+ - Instance names appear in responses: @myapp: response summary...
227
+
228
+ WHEN CONNECTED:
229
+ - Send natural language messages to Claude (no / prefix)
230
+ - Receive streaming responses
231
+ - Access instance memory and context
232
+ - Execute multi-turn conversations
233
+
234
+ BUILT-IN COMMANDS:
235
+ - /help: Show available commands
236
+ - /exit, /quit, /q: Exit Commander
237
+
238
+ FEATURES:
239
+ - Real-time streaming responses
240
+ - Direct @mention messaging to any instance
241
+ - Worktree isolation and merge workflow
242
+ - Instance discovery via daemon
243
+ - Automatic reconnection handling
244
+ - Session context preservation
245
+ """
246
+
21
247
  def __init__(
22
248
  self,
23
249
  instance_manager: InstanceManager,
24
250
  session_manager: SessionManager,
25
251
  output_relay: Optional[OutputRelay] = None,
26
252
  llm_client: Optional[OpenRouterClient] = None,
253
+ event_manager: Optional["EventManager"] = None,
27
254
  ):
28
255
  """Initialize REPL.
29
256
 
@@ -32,36 +259,157 @@ class CommanderREPL:
32
259
  session_manager: Manages chat session state.
33
260
  output_relay: Optional relay for instance output.
34
261
  llm_client: Optional OpenRouter client for chat.
262
+ event_manager: Optional event manager for notifications.
35
263
  """
36
264
  self.instances = instance_manager
37
265
  self.session = session_manager
38
266
  self.relay = output_relay
39
267
  self.llm = llm_client
268
+ self.event_manager = event_manager
40
269
  self.parser = CommandParser()
41
270
  self._running = False
271
+ self._instance_ready: dict[str, bool] = {}
272
+
273
+ # Async request tracking
274
+ self._pending_requests: dict[str, PendingRequest] = {}
275
+ self._request_queue: asyncio.Queue[PendingRequest] = asyncio.Queue()
276
+ self._response_task: Optional[asyncio.Task] = None
277
+ self._startup_tasks: dict[str, asyncio.Task] = {} # Background startup tasks
278
+ self._stdout_context = None # For patch_stdout
279
+
280
+ # Bottom toolbar status for spinners
281
+ self._toolbar_status = ""
282
+ self.prompt_session: Optional[PromptSession] = None
283
+
284
+ # Persistent registration config
285
+ self._config_dir = Path.cwd() / ".claude-mpm" / "commander"
286
+ self._config_file = self._config_dir / "registrations.json"
287
+ self._saved_registrations: dict[str, SavedRegistration] = {}
288
+ self._load_registrations()
289
+
290
+ def _get_bottom_toolbar(self) -> str:
291
+ """Get bottom toolbar status for prompt_toolkit.
292
+
293
+ Returns:
294
+ Status string for display in toolbar, or empty string if no status.
295
+ """
296
+ return self._toolbar_status
42
297
 
43
298
  async def run(self) -> None:
44
299
  """Start the REPL loop."""
45
300
  self._running = True
46
301
  self._print_welcome()
47
302
 
303
+ # Wire up EventManager to InstanceManager
304
+ if self.event_manager and self.instances:
305
+ self.instances.set_event_manager(self.event_manager)
306
+
307
+ # Subscribe to instance lifecycle events
308
+ if self.event_manager:
309
+ self.event_manager.subscribe(
310
+ EventType.INSTANCE_STARTING, self._on_instance_event
311
+ )
312
+ self.event_manager.subscribe(
313
+ EventType.INSTANCE_READY, self._on_instance_event
314
+ )
315
+ self.event_manager.subscribe(
316
+ EventType.INSTANCE_ERROR, self._on_instance_event
317
+ )
318
+
48
319
  # Setup history file
49
320
  history_path = Path.home() / ".claude-mpm" / "commander_history"
50
321
  history_path.parent.mkdir(parents=True, exist_ok=True)
51
322
 
52
- prompt = PromptSession(history=FileHistory(str(history_path)))
323
+ # Create completer for slash commands and instance names
324
+ completer = CommandCompleter(self._get_instance_names)
53
325
 
54
- while self._running:
326
+ self.prompt_session = PromptSession(
327
+ history=FileHistory(str(history_path)),
328
+ completer=completer,
329
+ complete_while_typing=False, # Only complete on Tab
330
+ bottom_toolbar=self._get_bottom_toolbar,
331
+ )
332
+
333
+ # Start background response processor
334
+ self._response_task = asyncio.create_task(self._process_responses())
335
+
336
+ # Use patch_stdout to allow printing above prompt
337
+ with patch_stdout():
338
+ while self._running:
339
+ try:
340
+ # Show pending requests status above prompt
341
+ self._render_pending_status()
342
+ user_input = await self.prompt_session.prompt_async(
343
+ self._get_prompt
344
+ )
345
+ await self._handle_input(user_input.strip())
346
+ except KeyboardInterrupt:
347
+ continue
348
+ except EOFError:
349
+ break
350
+
351
+ # Cleanup
352
+ if self._response_task:
353
+ self._response_task.cancel()
55
354
  try:
56
- user_input = await asyncio.to_thread(prompt.prompt, self._get_prompt())
57
- await self._handle_input(user_input.strip())
58
- except KeyboardInterrupt:
59
- continue
60
- except EOFError:
61
- break
355
+ await self._response_task
356
+ except asyncio.CancelledError:
357
+ pass
358
+
359
+ # Stop all running instances before exiting
360
+ instances_to_stop = self.instances.list_instances()
361
+ for instance in instances_to_stop:
362
+ try:
363
+ await self.instances.stop_instance(instance.name)
364
+ except Exception as e:
365
+ self._print(f"Warning: Failed to stop '{instance.name}': {e}")
62
366
 
63
367
  self._print("\nGoodbye!")
64
368
 
369
+ def _load_registrations(self) -> None:
370
+ """Load saved registrations from config file."""
371
+ if not self._config_file.exists():
372
+ return
373
+ try:
374
+ with self._config_file.open() as f:
375
+ data = json.load(f)
376
+ for reg_data in data.get("registrations", []):
377
+ reg = SavedRegistration.from_dict(reg_data)
378
+ self._saved_registrations[reg.name] = reg
379
+ except (json.JSONDecodeError, KeyError, OSError):
380
+ # Ignore corrupt/unreadable config
381
+ pass
382
+
383
+ def _save_registrations(self) -> None:
384
+ """Save registrations to config file."""
385
+ self._config_dir.mkdir(parents=True, exist_ok=True)
386
+ data = {
387
+ "registrations": [
388
+ reg.to_dict() for reg in self._saved_registrations.values()
389
+ ]
390
+ }
391
+ with self._config_file.open("w") as f:
392
+ json.dump(data, f, indent=2)
393
+
394
+ def _save_registration(self, name: str, path: str, framework: str) -> None:
395
+ """Save a single registration."""
396
+ reg = SavedRegistration(
397
+ name=name,
398
+ path=path,
399
+ framework=framework,
400
+ registered_at=datetime.now(timezone.utc).isoformat(),
401
+ )
402
+ self._saved_registrations[name] = reg
403
+ self._save_registrations()
404
+
405
+ def _forget_registration(self, name: str) -> bool:
406
+ """Remove a saved registration. Returns True if removed."""
407
+ if name in self._saved_registrations:
408
+ del self._saved_registrations[name]
409
+ self._save_registrations()
410
+ return True
411
+ return False
412
+
65
413
  async def _handle_input(self, input_text: str) -> None:
66
414
  """Handle user input - command or natural language.
67
415
 
@@ -71,12 +419,57 @@ class CommanderREPL:
71
419
  if not input_text:
72
420
  return
73
421
 
74
- # Check if it's a built-in command
422
+ # Check for direct @mention first (before any other parsing)
423
+ mention = self._parse_mention(input_text)
424
+ if mention:
425
+ target, message = mention
426
+ await self._cmd_message_instance(target, message)
427
+ return
428
+
429
+ # Check if it's a built-in slash command first
75
430
  command = self.parser.parse(input_text)
76
431
  if command:
77
432
  await self._execute_command(command)
433
+ return
434
+
435
+ # Use LLM to classify natural language input
436
+ intent_result = await self._classify_intent_llm(input_text)
437
+ intent = intent_result.get("intent", "chat")
438
+ args = intent_result.get("args", {})
439
+
440
+ # Handle command intents detected by LLM
441
+ if intent == "register":
442
+ await self._cmd_register_from_args(args)
443
+ elif intent == "start":
444
+ await self._cmd_start_from_args(args)
445
+ elif intent == "stop":
446
+ await self._cmd_stop_from_args(args)
447
+ elif intent in {"connect", "switch"}:
448
+ await self._cmd_connect_from_args(args)
449
+ elif intent == "disconnect":
450
+ await self._cmd_disconnect([])
451
+ elif intent == "list":
452
+ await self._cmd_list([])
453
+ elif intent == "status":
454
+ await self._cmd_status([])
455
+ elif intent == "help":
456
+ await self._cmd_help([])
457
+ elif intent == "exit":
458
+ await self._cmd_exit([])
459
+ elif intent == "capabilities":
460
+ await self._handle_capabilities(input_text)
461
+ elif intent == "greeting":
462
+ self._handle_greeting()
463
+ elif intent == "message":
464
+ # Handle @mention detected by LLM
465
+ target = args.get("target")
466
+ message = args.get("message")
467
+ if target and message:
468
+ await self._cmd_message_instance(target, message)
469
+ else:
470
+ await self._send_to_instance(input_text)
78
471
  else:
79
- # Natural language - send to connected instance
472
+ # Default to chat - send to connected instance
80
473
  await self._send_to_instance(input_text)
81
474
 
82
475
  async def _execute_command(self, cmd: Command) -> None:
@@ -89,39 +482,224 @@ class CommanderREPL:
89
482
  CommandType.LIST: self._cmd_list,
90
483
  CommandType.START: self._cmd_start,
91
484
  CommandType.STOP: self._cmd_stop,
485
+ CommandType.CLOSE: self._cmd_close,
486
+ CommandType.REGISTER: self._cmd_register,
92
487
  CommandType.CONNECT: self._cmd_connect,
93
488
  CommandType.DISCONNECT: self._cmd_disconnect,
489
+ CommandType.SAVED: self._cmd_saved,
490
+ CommandType.FORGET: self._cmd_forget,
94
491
  CommandType.STATUS: self._cmd_status,
95
492
  CommandType.HELP: self._cmd_help,
96
493
  CommandType.EXIT: self._cmd_exit,
494
+ CommandType.MPM_OAUTH: self._cmd_oauth,
495
+ CommandType.CLEANUP: self._cmd_cleanup,
97
496
  }
98
497
  handler = handlers.get(cmd.type)
99
498
  if handler:
100
499
  await handler(cmd.args)
101
500
 
102
- async def _cmd_list(self, args: list[str]) -> None:
103
- """List active instances."""
104
- instances = self.instances.list_instances()
105
- if not instances:
106
- self._print("No active instances.")
107
- else:
108
- self._print("Active instances:")
109
- for inst in instances:
110
- status = (
111
- "→" if inst.name == self.session.context.connected_instance else " "
501
+ def _classify_intent(self, text: str) -> str:
502
+ """Classify user input intent.
503
+
504
+ Args:
505
+ text: User input text.
506
+
507
+ Returns:
508
+ Intent type: 'greeting', 'capabilities', or 'chat'.
509
+ """
510
+ t = text.lower().strip()
511
+ if any(t.startswith(g) for g in ["hello", "hi", "hey", "howdy"]):
512
+ return "greeting"
513
+ if any(p in t for p in ["what can you", "can you", "help me", "how do i"]):
514
+ return "capabilities"
515
+ return "chat"
516
+
517
+ def _parse_mention(self, text: str) -> tuple[str, str] | None:
518
+ """Parse @name or (name) message patterns - both work the same.
519
+
520
+ Both syntaxes are equivalent:
521
+ @name message
522
+ (name) message
523
+ (name): message
524
+
525
+ Args:
526
+ text: User input text.
527
+
528
+ Returns:
529
+ Tuple of (target_name, message) if pattern matches, None otherwise.
530
+ """
531
+ # @name message
532
+ match = re.match(r"^@(\w+)\s+(.+)$", text.strip())
533
+ if match:
534
+ return match.group(1), match.group(2)
535
+
536
+ # (name): message or (name) message - same behavior as @name
537
+ match = re.match(r"^\((\w+)\):?\s*(.+)$", text.strip())
538
+ if match:
539
+ return match.group(1), match.group(2)
540
+
541
+ return None
542
+
543
+ async def _classify_intent_llm(self, text: str) -> dict:
544
+ """Use LLM to classify user intent.
545
+
546
+ Args:
547
+ text: User input text.
548
+
549
+ Returns:
550
+ Dict with 'intent' and 'args' keys.
551
+ """
552
+ if not self.llm:
553
+ return {"intent": "chat", "args": {}}
554
+
555
+ system_prompt = """Classify user intent. Return JSON only.
556
+
557
+ Commands available:
558
+ - register: Register new instance (needs: path, framework, name)
559
+ - start: Start registered instance (needs: name)
560
+ - stop: Stop instance (needs: name)
561
+ - connect: Connect to instance (needs: name)
562
+ - disconnect: Disconnect from current instance
563
+ - switch: Switch to different instance (needs: name)
564
+ - list: List instances
565
+ - status: Show status
566
+ - help: Show help
567
+ - exit: Exit commander
568
+
569
+ If user wants a command, extract arguments.
570
+ If user is chatting/asking questions, intent is "chat".
571
+
572
+ Examples:
573
+ "register my project at ~/foo as myapp using mpm" -> {"intent":"register","args":{"path":"~/foo","framework":"mpm","name":"myapp"}}
574
+ "start myapp" -> {"intent":"start","args":{"name":"myapp"}}
575
+ "stop the server" -> {"intent":"stop","args":{"name":null}}
576
+ "list instances" -> {"intent":"list","args":{}}
577
+ "hello how are you" -> {"intent":"chat","args":{}}
578
+ "what can you do" -> {"intent":"capabilities","args":{}}
579
+ "@izzie show me the code" -> {"intent":"message","args":{"target":"izzie","message":"show me the code"}}
580
+ "(myapp): what's the status" -> {"intent":"message","args":{"target":"myapp","message":"what's the status"}}
581
+
582
+ Return ONLY valid JSON."""
583
+
584
+ try:
585
+ messages = [{"role": "user", "content": f"Classify: {text}"}]
586
+ response = await self.llm.chat(messages, system=system_prompt)
587
+ return json.loads(response.strip())
588
+ except (json.JSONDecodeError, Exception): # nosec B110 - Graceful fallback
589
+ return {"intent": "chat", "args": {}}
590
+
591
+ def _handle_greeting(self) -> None:
592
+ """Handle greeting intent."""
593
+ self._print(
594
+ "Hello! I'm MPM Commander. Type '/help' for commands, or '/list' to see instances."
595
+ )
596
+
597
+ async def _handle_capabilities(self, query: str = "") -> None:
598
+ """Answer questions about capabilities, using LLM if available.
599
+
600
+ Args:
601
+ query: Optional user query about capabilities.
602
+ """
603
+ if query and self.llm:
604
+ try:
605
+ messages = [
606
+ {
607
+ "role": "user",
608
+ "content": f"Based on these capabilities:\n{self.CAPABILITIES_CONTEXT}\n\nUser asks: {query}",
609
+ }
610
+ ]
611
+ system = (
612
+ "Answer concisely about MPM Commander capabilities. "
613
+ "If asked about something not in the capabilities, say so."
112
614
  )
615
+ response = await self.llm.chat(messages, system=system)
616
+ self._print(response)
617
+ return
618
+ except Exception: # nosec B110 - Graceful fallback to static output
619
+ pass
620
+ # Fallback to static output
621
+ self._print(self.CAPABILITIES_CONTEXT)
622
+
623
+ async def _cmd_list(self, args: list[str]) -> None:
624
+ """List instances: both running and saved registrations.
625
+
626
+ Shows:
627
+ - Running instances with status (connected, ready, or connecting)
628
+ - Saved registrations that are not currently running
629
+ """
630
+ running_instances = self.instances.list_instances()
631
+ running_names = {inst.name for inst in running_instances}
632
+ saved_registrations = self._saved_registrations
633
+
634
+ # Collect all unique names
635
+ all_names = set(running_names) | set(saved_registrations.keys())
636
+
637
+ if not all_names:
638
+ self._print("No instances (running or saved).")
639
+ self._print("Use '/register <path> <framework> <name>' to create one.")
640
+ return
641
+
642
+ # Build output
643
+ self._print("Sessions:")
644
+
645
+ # Display in order: running first, then saved
646
+ for name in sorted(all_names):
647
+ inst = next((i for i in running_instances if i.name == name), None)
648
+ is_connected = inst and name == self.session.context.connected_instance
649
+
650
+ if inst:
651
+ # Running instance
113
652
  git_info = f" [{inst.git_branch}]" if inst.git_branch else ""
114
- self._print(
115
- f" {status} {inst.name} ({inst.framework}){git_info} - {inst.project_path}"
116
- )
653
+
654
+ # Determine status
655
+ if is_connected:
656
+ instance_status = "connected"
657
+ elif inst.ready:
658
+ instance_status = "ready"
659
+ else:
660
+ instance_status = "starting"
661
+
662
+ # Format with right-aligned path
663
+ line = f" {name} (running, {instance_status})"
664
+ path_display = f"{inst.project_path}{git_info}"
665
+ # Pad to align paths
666
+ padding = max(1, 40 - len(line))
667
+ self._print(f"{line}{' ' * padding}{path_display}")
668
+ else:
669
+ # Saved registration (not running)
670
+ reg = saved_registrations[name]
671
+ line = f" {name} (saved)"
672
+ # Pad to align paths
673
+ padding = max(1, 40 - len(line))
674
+ self._print(f"{line}{' ' * padding}{reg.path}")
117
675
 
118
676
  async def _cmd_start(self, args: list[str]) -> None:
119
- """Start a new instance: start <path> [--framework cc|mpm] [--name name]."""
677
+ """Start instance: start <name> OR start <path> [--framework cc|mpm] [--name name]."""
120
678
  if not args:
121
- self._print("Usage: start <path> [--framework cc|mpm] [--name name]")
679
+ self._print("Usage: start <name> (for registered instances)")
680
+ self._print(" start <path> [--framework cc|mpm] [--name name]")
122
681
  return
123
682
 
124
- # Parse arguments
683
+ # Check if first arg is a registered instance name (no path separators)
684
+ if len(args) == 1 and "/" not in args[0] and not args[0].startswith("~"):
685
+ name = args[0]
686
+ try:
687
+ instance = await self.instances.start_by_name(name)
688
+ if instance:
689
+ self._print(f"Started registered instance '{name}'")
690
+ self._print(
691
+ f" Tmux: {instance.tmux_session}:{instance.pane_target}"
692
+ )
693
+ else:
694
+ self._print(f"No registered instance named '{name}'")
695
+ self._print(
696
+ "Use 'register <path> <framework> <name>' to register first"
697
+ )
698
+ except Exception as e:
699
+ self._print(f"Error starting instance: {e}")
700
+ return
701
+
702
+ # Path-based start logic
125
703
  project_path = Path(args[0]).expanduser().resolve()
126
704
  framework = "cc" # default
127
705
  name = project_path.name # default
@@ -147,13 +725,23 @@ class CommanderREPL:
147
725
  self._print(f"Error: Path is not a directory: {project_path}")
148
726
  return
149
727
 
150
- # Start instance
728
+ # Register and start instance (creates worktree for git repos)
151
729
  try:
152
- instance = await self.instances.start_instance(
153
- name, project_path, framework
730
+ instance = await self.instances.register_instance(
731
+ str(project_path), framework, name
154
732
  )
155
733
  self._print(f"Started instance '{name}' ({framework}) at {project_path}")
156
- self._print(f"Tmux: {instance.tmux_session}:{instance.pane_target}")
734
+ self._print(f" Tmux: {instance.tmux_session}:{instance.pane_target}")
735
+
736
+ # Check if worktree was created
737
+ if self.instances._state_store:
738
+ registered = self.instances._state_store.get_instance(name)
739
+ if registered and registered.use_worktree and registered.worktree_path:
740
+ self._print(f" Worktree: {registered.worktree_path}")
741
+ self._print(f" Branch: {registered.worktree_branch}")
742
+
743
+ # Spawn background task to wait for ready (non-blocking with spinner)
744
+ self._spawn_startup_task(name, auto_connect=True, timeout=30)
157
745
  except Exception as e:
158
746
  self._print(f"Error starting instance: {e}")
159
747
 
@@ -175,17 +763,103 @@ class CommanderREPL:
175
763
  except Exception as e:
176
764
  self._print(f"Error stopping instance: {e}")
177
765
 
766
+ async def _cmd_close(self, args: list[str]) -> None:
767
+ """Close instance: merge worktree to main and end session.
768
+
769
+ Usage: /close <name> [--no-merge]
770
+ """
771
+ if not args:
772
+ self._print("Usage: /close <name> [--no-merge]")
773
+ return
774
+
775
+ name = args[0]
776
+ merge = "--no-merge" not in args
777
+
778
+ # Disconnect if we were connected
779
+ if self.session.context.connected_instance == name:
780
+ self.session.disconnect()
781
+
782
+ success, msg = await self.instances.close_instance(name, merge=merge)
783
+ if success:
784
+ self._print(f"Closed '{name}'")
785
+ if merge:
786
+ self._print(" Worktree merged to main")
787
+ else:
788
+ self._print(f"Error: {msg}")
789
+
790
+ async def _cmd_register(self, args: list[str]) -> None:
791
+ """Register and start an instance: register <path> <framework> <name>."""
792
+ if len(args) < 3:
793
+ self._print("Usage: register <path> <framework> <name>")
794
+ self._print(" framework: cc (Claude Code) or mpm")
795
+ return
796
+
797
+ path, framework, name = args[0], args[1], args[2]
798
+ path = Path(path).expanduser().resolve()
799
+
800
+ if framework not in ("cc", "mpm"):
801
+ self._print(f"Unknown framework: {framework}. Use 'cc' or 'mpm'")
802
+ return
803
+
804
+ # Validate path
805
+ if not path.exists():
806
+ self._print(f"Error: Path does not exist: {path}")
807
+ return
808
+
809
+ if not path.is_dir():
810
+ self._print(f"Error: Path is not a directory: {path}")
811
+ return
812
+
813
+ try:
814
+ instance = await self.instances.register_instance(
815
+ str(path), framework, name
816
+ )
817
+ self._print(f"Registered and started '{name}' ({framework}) at {path}")
818
+ self._print(f" Tmux: {instance.tmux_session}:{instance.pane_target}")
819
+
820
+ # Save registration for persistence
821
+ self._save_registration(name, str(path), framework)
822
+
823
+ # Spawn background task to wait for ready (non-blocking with spinner)
824
+ self._spawn_startup_task(name, auto_connect=True, timeout=30)
825
+ except Exception as e:
826
+ self._print(f"Failed to register: {e}")
827
+
178
828
  async def _cmd_connect(self, args: list[str]) -> None:
179
- """Connect to an instance: connect <name>."""
829
+ """Connect to an instance: connect <name>.
830
+
831
+ If instance is not running but has saved registration, start it first.
832
+ """
180
833
  if not args:
181
834
  self._print("Usage: connect <instance-name>")
182
835
  return
183
836
 
184
837
  name = args[0]
185
838
  inst = self.instances.get_instance(name)
839
+
186
840
  if not inst:
187
- self._print(f"Instance '{name}' not found")
188
- return
841
+ # Check if we have a saved registration
842
+ saved = self._saved_registrations.get(name)
843
+ if saved:
844
+ self._print(f"Starting '{name}' from saved config...")
845
+ try:
846
+ instance = await self.instances.register_instance(
847
+ saved.path, saved.framework, name
848
+ )
849
+ self._print(f"Started '{name}' ({saved.framework}) at {saved.path}")
850
+ self._print(
851
+ f" Tmux: {instance.tmux_session}:{instance.pane_target}"
852
+ )
853
+ # Spawn background task to wait for ready (non-blocking with spinner)
854
+ self._spawn_startup_task(name, auto_connect=True, timeout=30)
855
+ return
856
+ except Exception as e:
857
+ self._print(f"Failed to start from saved config: {e}")
858
+ return
859
+ else:
860
+ self._print(f"Instance '{name}' not found")
861
+ self._print(" Use /saved to see saved registrations")
862
+ return
189
863
 
190
864
  self.session.connect_to(name)
191
865
  self._print(f"Connected to {name}")
@@ -219,44 +893,605 @@ class CommanderREPL:
219
893
 
220
894
  self._print(f"Messages in history: {len(self.session.context.messages)}")
221
895
 
896
+ async def _cmd_saved(self, args: list[str]) -> None:
897
+ """List saved registrations."""
898
+ if not self._saved_registrations:
899
+ self._print("No saved registrations")
900
+ self._print(" Use /register to create one")
901
+ return
902
+
903
+ self._print("Saved registrations:")
904
+ for reg in self._saved_registrations.values():
905
+ running = self.instances.get_instance(reg.name) is not None
906
+ status = " (running)" if running else ""
907
+ self._print(f" {reg.name}: {reg.path} [{reg.framework}]{status}")
908
+
909
+ async def _cmd_forget(self, args: list[str]) -> None:
910
+ """Remove a saved registration: forget <name>."""
911
+ if not args:
912
+ self._print("Usage: forget <name>")
913
+ return
914
+
915
+ name = args[0]
916
+ if self._forget_registration(name):
917
+ self._print(f"Removed saved registration '{name}'")
918
+ else:
919
+ self._print(f"No saved registration named '{name}'")
920
+
222
921
  async def _cmd_help(self, args: list[str]) -> None:
223
922
  """Show help message."""
224
923
  help_text = """
225
- Commander Commands:
226
- list, ls, instances List active instances
227
- start <path> Start new instance at path
228
- --framework <cc|mpm> Specify framework (default: cc)
229
- --name <name> Specify instance name (default: dir name)
230
- stop <name> Stop an instance
231
- connect <name> Connect to an instance
232
- disconnect Disconnect from current instance
233
- status Show current session status
234
- help Show this help message
235
- exit, quit, q Exit Commander
924
+ Commander Commands (use / prefix):
925
+ /register <path> <framework> <name>
926
+ Register, start, and auto-connect (creates worktree)
927
+ /connect <name> Connect to instance (starts from saved config if needed)
928
+ /switch <name> Alias for /connect
929
+ /disconnect Disconnect from current instance
930
+ /start <name> Start a registered instance by name
931
+ /start <path> Start new instance (creates worktree for git repos)
932
+ /stop <name> Stop an instance (keeps worktree)
933
+ /close <name> [--no-merge]
934
+ Close instance: merge worktree to main and cleanup
935
+ /list, /ls List active instances
936
+ /saved List saved registrations
937
+ /forget <name> Remove a saved registration
938
+ /status Show current session status
939
+ /cleanup [--force] Clean up orphan tmux panes (--force to kill them)
940
+ /help Show this help message
941
+ /exit, /quit, /q Exit Commander
942
+
943
+ Direct Messaging (both syntaxes work the same):
944
+ @<name> <message> Send message to specific instance
945
+ (<name>) <message> Same as @name (parentheses syntax)
236
946
 
237
947
  Natural Language:
238
- When connected to an instance, any input that is not a built-in
239
- command will be sent to the connected instance as a message.
948
+ Any input without / prefix is sent to the connected instance.
949
+
950
+ Git Worktree Isolation:
951
+ When starting instances in git repos, a worktree is created on a
952
+ session-specific branch. Use /close to merge changes back to main.
240
953
 
241
954
  Examples:
242
- start ~/myproject --framework cc --name myapp
243
- connect myapp
244
- Fix the authentication bug in login.py
245
- disconnect
246
- exit
955
+ /register ~/myproject cc myapp # Register, start, and connect
956
+ /start ~/myproject # Start with auto-detected name
957
+ /start myapp # Start registered instance
958
+ /close myapp # Merge worktree to main and cleanup
959
+ /close myapp --no-merge # Cleanup without merging
960
+ /cleanup # Show orphan panes
961
+ /cleanup --force # Kill orphan panes
962
+ @myapp show me the code # Direct message to myapp
963
+ (izzie) what's the status # Same as @izzie
964
+ Fix the authentication bug # Send to connected instance
965
+ /exit
247
966
  """
248
967
  self._print(help_text)
249
968
 
250
969
  async def _cmd_exit(self, args: list[str]) -> None:
251
- """Exit the REPL."""
970
+ """Exit the REPL and stop all running instances."""
971
+ # Stop all running instances before exiting
972
+ instances_to_stop = self.instances.list_instances()
973
+ for instance in instances_to_stop:
974
+ try:
975
+ await self.instances.stop_instance(instance.name)
976
+ except Exception as e:
977
+ self._print(f"Warning: Failed to stop '{instance.name}': {e}")
978
+
252
979
  self._running = False
253
980
 
981
+ async def _cmd_oauth(self, args: list[str]) -> None:
982
+ """Handle OAuth command with subcommands.
983
+
984
+ Usage:
985
+ /mpm-oauth - Show help
986
+ /mpm-oauth list - List OAuth-capable services
987
+ /mpm-oauth setup <service> - Set up OAuth for a service
988
+ /mpm-oauth status <service> - Show token status
989
+ /mpm-oauth revoke <service> - Revoke OAuth tokens
990
+ /mpm-oauth refresh <service> - Refresh OAuth tokens
991
+ """
992
+ if not args:
993
+ await self._cmd_oauth_help()
994
+ return
995
+
996
+ subcommand = args[0].lower()
997
+ subargs = args[1:] if len(args) > 1 else []
998
+
999
+ if subcommand == "help":
1000
+ await self._cmd_oauth_help()
1001
+ elif subcommand == "list":
1002
+ await self._cmd_oauth_list()
1003
+ elif subcommand == "setup":
1004
+ if not subargs:
1005
+ self._print("Usage: /mpm-oauth setup <service>")
1006
+ return
1007
+ await self._cmd_oauth_setup(subargs[0])
1008
+ elif subcommand == "status":
1009
+ if not subargs:
1010
+ self._print("Usage: /mpm-oauth status <service>")
1011
+ return
1012
+ await self._cmd_oauth_status(subargs[0])
1013
+ elif subcommand == "revoke":
1014
+ if not subargs:
1015
+ self._print("Usage: /mpm-oauth revoke <service>")
1016
+ return
1017
+ await self._cmd_oauth_revoke(subargs[0])
1018
+ elif subcommand == "refresh":
1019
+ if not subargs:
1020
+ self._print("Usage: /mpm-oauth refresh <service>")
1021
+ return
1022
+ await self._cmd_oauth_refresh(subargs[0])
1023
+ else:
1024
+ self._print(f"Unknown subcommand: {subcommand}")
1025
+ await self._cmd_oauth_help()
1026
+
1027
+ async def _cmd_oauth_help(self) -> None:
1028
+ """Print OAuth command help."""
1029
+ help_text = """
1030
+ OAuth Commands:
1031
+ /mpm-oauth list List OAuth-capable MCP services
1032
+ /mpm-oauth setup <service> Set up OAuth authentication for a service
1033
+ /mpm-oauth status <service> Show token status for a service
1034
+ /mpm-oauth revoke <service> Revoke OAuth tokens for a service
1035
+ /mpm-oauth refresh <service> Refresh OAuth tokens for a service
1036
+ /mpm-oauth help Show this help message
1037
+
1038
+ Examples:
1039
+ /mpm-oauth list
1040
+ /mpm-oauth setup google-drive
1041
+ /mpm-oauth status google-drive
1042
+ """
1043
+ self._print(help_text)
1044
+
1045
+ async def _cmd_oauth_list(self) -> None:
1046
+ """List OAuth-capable services from MCP registry."""
1047
+ try:
1048
+ from claude_mpm.services.mcp_service_registry import MCPServiceRegistry
1049
+
1050
+ registry = MCPServiceRegistry()
1051
+ services = registry.list_oauth_services()
1052
+
1053
+ if not services:
1054
+ self._print("No OAuth-capable services found.")
1055
+ return
1056
+
1057
+ self._print("OAuth-capable services:")
1058
+ for service in services:
1059
+ self._print(f" - {service}")
1060
+ except ImportError:
1061
+ self._print("MCP Service Registry not available.")
1062
+ except Exception as e:
1063
+ self._print(f"Error listing services: {e}")
1064
+
1065
+ def _load_oauth_credentials_from_env_files(self) -> tuple[str | None, str | None]:
1066
+ """Load OAuth credentials from .env files.
1067
+
1068
+ Checks .env.local first (user overrides), then .env.
1069
+ Returns tuple of (client_id, client_secret), either may be None.
1070
+ """
1071
+ client_id = None
1072
+ client_secret = None
1073
+
1074
+ # Priority order: .env.local first (user overrides), then .env
1075
+ env_files = [".env.local", ".env"]
1076
+
1077
+ for env_file in env_files:
1078
+ env_path = Path.cwd() / env_file
1079
+ if env_path.exists():
1080
+ try:
1081
+ with open(env_path) as f:
1082
+ for line in f:
1083
+ line = line.strip()
1084
+ # Skip empty lines and comments
1085
+ if not line or line.startswith("#"):
1086
+ continue
1087
+ if "=" in line:
1088
+ key, _, value = line.partition("=")
1089
+ key = key.strip()
1090
+ value = value.strip().strip('"').strip("'")
1091
+
1092
+ if key == "GOOGLE_OAUTH_CLIENT_ID" and not client_id:
1093
+ client_id = value
1094
+ elif (
1095
+ key == "GOOGLE_OAUTH_CLIENT_SECRET"
1096
+ and not client_secret
1097
+ ):
1098
+ client_secret = value
1099
+
1100
+ # If we found both, no need to check more files
1101
+ if client_id and client_secret:
1102
+ break
1103
+ except Exception: # nosec B110 - intentionally ignore .env file read errors
1104
+ # Silently ignore read errors
1105
+ pass
1106
+
1107
+ return client_id, client_secret
1108
+
1109
+ async def _cmd_oauth_setup(self, service_name: str) -> None:
1110
+ """Set up OAuth for a service.
1111
+
1112
+ Args:
1113
+ service_name: Name of the service to authenticate.
1114
+ """
1115
+ # Priority: 1) .env files, 2) environment variables, 3) interactive prompt
1116
+ # Check .env files first
1117
+ client_id, client_secret = self._load_oauth_credentials_from_env_files()
1118
+
1119
+ # Fall back to environment variables if not found in .env files
1120
+ if not client_id:
1121
+ client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID")
1122
+ if not client_secret:
1123
+ client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET")
1124
+
1125
+ # If credentials missing, prompt for them interactively
1126
+ if not client_id or not client_secret:
1127
+ self._console.print(
1128
+ "\n[yellow]Google OAuth credentials not found.[/yellow]"
1129
+ )
1130
+ self._console.print(
1131
+ "Checked: .env.local, .env, and environment variables.\n"
1132
+ )
1133
+ self._console.print(
1134
+ "Get credentials from: https://console.cloud.google.com/apis/credentials\n"
1135
+ )
1136
+ self._console.print(
1137
+ "[dim]Tip: Add to .env.local for automatic loading:[/dim]"
1138
+ )
1139
+ self._console.print('[dim] GOOGLE_OAUTH_CLIENT_ID="your-client-id"[/dim]')
1140
+ self._console.print(
1141
+ '[dim] GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret"[/dim]\n' # pragma: allowlist secret
1142
+ )
1143
+
1144
+ try:
1145
+ client_id = pt_prompt("Enter GOOGLE_OAUTH_CLIENT_ID: ")
1146
+ if not client_id.strip():
1147
+ self._print("Error: Client ID is required")
1148
+ return
1149
+
1150
+ client_secret = pt_prompt(
1151
+ "Enter GOOGLE_OAUTH_CLIENT_SECRET: ", is_password=True
1152
+ )
1153
+ if not client_secret.strip():
1154
+ self._print("Error: Client Secret is required")
1155
+ return
1156
+
1157
+ # Set in environment for this session
1158
+ os.environ["GOOGLE_OAUTH_CLIENT_ID"] = client_id.strip()
1159
+ os.environ["GOOGLE_OAUTH_CLIENT_SECRET"] = client_secret.strip()
1160
+ self._console.print(
1161
+ "\n[green]Credentials set for this session.[/green]"
1162
+ )
1163
+
1164
+ # Ask if user wants to save credentials
1165
+ save_response = pt_prompt(
1166
+ "\nSave credentials to shell profile? (y/n): "
1167
+ )
1168
+ if save_response.strip().lower() in ("y", "yes"):
1169
+ self._console.print("\nAdd these lines to your shell profile:")
1170
+ self._console.print(
1171
+ f' export GOOGLE_OAUTH_CLIENT_ID="{client_id.strip()}"'
1172
+ )
1173
+ self._console.print(
1174
+ f' export GOOGLE_OAUTH_CLIENT_SECRET="{client_secret.strip()}"'
1175
+ )
1176
+ self._console.print("")
1177
+
1178
+ except (EOFError, KeyboardInterrupt):
1179
+ self._print("\nCredential entry cancelled.")
1180
+ return
1181
+
1182
+ try:
1183
+ from claude_mpm.auth import OAuthManager
1184
+
1185
+ manager = OAuthManager()
1186
+
1187
+ self._print(f"Setting up OAuth for '{service_name}'...")
1188
+ self._print("Opening browser for authentication...")
1189
+ self._print("Callback server listening on http://localhost:8085/callback")
1190
+
1191
+ result = await manager.authenticate(service_name)
1192
+
1193
+ if result.success:
1194
+ self._print(f"OAuth setup complete for '{service_name}'")
1195
+ self._print(f" Token expires: {result.expires_at}")
1196
+ else:
1197
+ self._print(f"OAuth setup failed: {result.error}")
1198
+ except ImportError:
1199
+ self._print("OAuth module not available.")
1200
+ except Exception as e:
1201
+ self._print(f"Error during OAuth setup: {e}")
1202
+
1203
+ async def _cmd_oauth_status(self, service_name: str) -> None:
1204
+ """Show OAuth token status for a service.
1205
+
1206
+ Args:
1207
+ service_name: Name of the service to check.
1208
+ """
1209
+ try:
1210
+ from claude_mpm.auth import OAuthManager
1211
+
1212
+ manager = OAuthManager()
1213
+ status = await manager.get_status(service_name)
1214
+
1215
+ if status is None:
1216
+ self._print(f"No OAuth tokens found for '{service_name}'")
1217
+ return
1218
+
1219
+ self._print_token_status(service_name, status, stored=True)
1220
+ except ImportError:
1221
+ self._print("OAuth module not available.")
1222
+ except Exception as e:
1223
+ self._print(f"Error checking status: {e}")
1224
+
1225
+ async def _cmd_oauth_revoke(self, service_name: str) -> None:
1226
+ """Revoke OAuth tokens for a service.
1227
+
1228
+ Args:
1229
+ service_name: Name of the service to revoke.
1230
+ """
1231
+ try:
1232
+ from claude_mpm.auth import OAuthManager
1233
+
1234
+ manager = OAuthManager()
1235
+
1236
+ self._print(f"Revoking OAuth tokens for '{service_name}'...")
1237
+ result = await manager.revoke(service_name)
1238
+
1239
+ if result.success:
1240
+ self._print(f"OAuth tokens revoked for '{service_name}'")
1241
+ else:
1242
+ self._print(f"Failed to revoke: {result.error}")
1243
+ except ImportError:
1244
+ self._print("OAuth module not available.")
1245
+ except Exception as e:
1246
+ self._print(f"Error revoking tokens: {e}")
1247
+
1248
+ async def _cmd_oauth_refresh(self, service_name: str) -> None:
1249
+ """Refresh OAuth tokens for a service.
1250
+
1251
+ Args:
1252
+ service_name: Name of the service to refresh.
1253
+ """
1254
+ try:
1255
+ from claude_mpm.auth import OAuthManager
1256
+
1257
+ manager = OAuthManager()
1258
+
1259
+ self._print(f"Refreshing OAuth tokens for '{service_name}'...")
1260
+ result = await manager.refresh(service_name)
1261
+
1262
+ if result.success:
1263
+ self._print(f"OAuth tokens refreshed for '{service_name}'")
1264
+ self._print(f" New expiry: {result.expires_at}")
1265
+ else:
1266
+ self._print(f"Failed to refresh: {result.error}")
1267
+ except ImportError:
1268
+ self._print("OAuth module not available.")
1269
+ except Exception as e:
1270
+ self._print(f"Error refreshing tokens: {e}")
1271
+
1272
+ async def _cmd_cleanup(self, args: list[str]) -> None:
1273
+ """Clean up orphan tmux panes not in tracked instances.
1274
+
1275
+ Identifies all tmux panes in the commander session and removes those
1276
+ that are not associated with any tracked instance.
1277
+
1278
+ Usage:
1279
+ /cleanup - Show orphan panes without killing
1280
+ /cleanup --force - Kill orphan panes
1281
+ """
1282
+ force_kill = "--force" in args
1283
+
1284
+ # Get all panes in the commander session
1285
+ try:
1286
+ all_panes = self.instances.orchestrator.list_panes()
1287
+ except Exception as e:
1288
+ self._print(f"Error listing panes: {e}")
1289
+ return
1290
+
1291
+ # Get tracked instance pane targets
1292
+ tracked_instances = self.instances.list_instances()
1293
+ tracked_panes = {inst.pane_target for inst in tracked_instances}
1294
+
1295
+ # Find orphan panes (panes not in any tracked instance)
1296
+ orphan_panes = []
1297
+ for pane in all_panes:
1298
+ pane_id = pane["id"]
1299
+ session_pane_target = (
1300
+ f"{self.instances.orchestrator.session_name}:{pane_id}"
1301
+ )
1302
+
1303
+ # Skip if this pane is tracked
1304
+ if session_pane_target in tracked_panes:
1305
+ continue
1306
+
1307
+ orphan_panes.append((session_pane_target, pane["path"]))
1308
+
1309
+ if not orphan_panes:
1310
+ self._print("No orphan panes found.")
1311
+ return
1312
+
1313
+ # Display orphan panes
1314
+ self._print(f"Found {len(orphan_panes)} orphan pane(s):")
1315
+ for target, path in orphan_panes:
1316
+ self._print(f" - {target} ({path})")
1317
+
1318
+ if force_kill:
1319
+ # Kill orphan panes
1320
+ killed_count = 0
1321
+ for target, path in orphan_panes:
1322
+ try:
1323
+ self.instances.orchestrator.kill_pane(target)
1324
+ killed_count += 1
1325
+ self._print(f" Killed: {target}")
1326
+ except Exception as e:
1327
+ self._print(f" Error killing {target}: {e}")
1328
+
1329
+ self._print(f"\nCleaned up {killed_count} orphan pane(s).")
1330
+ else:
1331
+ self._print("\nUse '/cleanup --force' to remove these panes.")
1332
+
1333
+ def _print_token_status(
1334
+ self, name: str, status: dict, stored: bool = False
1335
+ ) -> None:
1336
+ """Print token status information.
1337
+
1338
+ Args:
1339
+ name: Service name.
1340
+ status: Status dict with token info.
1341
+ stored: Whether tokens are stored.
1342
+ """
1343
+ self._print(f"OAuth Status for '{name}':")
1344
+ self._print(f" Stored: {'Yes' if stored else 'No'}")
1345
+
1346
+ if status.get("valid"):
1347
+ self._print(" Status: Valid")
1348
+ else:
1349
+ self._print(" Status: Invalid/Expired")
1350
+
1351
+ if status.get("expires_at"):
1352
+ self._print(f" Expires: {status['expires_at']}")
1353
+
1354
+ if status.get("scopes"):
1355
+ self._print(f" Scopes: {', '.join(status['scopes'])}")
1356
+
1357
+ # Helper methods for LLM-extracted arguments
1358
+
1359
+ async def _cmd_register_from_args(self, args: dict) -> None:
1360
+ """Handle register command from LLM-extracted args.
1361
+
1362
+ Args:
1363
+ args: Dict with optional 'path', 'framework', 'name' keys.
1364
+ """
1365
+ path = args.get("path")
1366
+ framework = args.get("framework")
1367
+ name = args.get("name")
1368
+
1369
+ if not all([path, framework, name]):
1370
+ self._print("I need the path, framework, and name to register an instance.")
1371
+ self._print("Example: 'register ~/myproject as myapp using mpm'")
1372
+ return
1373
+
1374
+ await self._cmd_register([path, framework, name])
1375
+
1376
+ async def _cmd_start_from_args(self, args: dict) -> None:
1377
+ """Handle start command from LLM-extracted args.
1378
+
1379
+ Args:
1380
+ args: Dict with optional 'name' key.
1381
+ """
1382
+ name = args.get("name")
1383
+ if not name:
1384
+ # Try to infer from connected instance or list available
1385
+ instances = self.instances.list_instances()
1386
+ if len(instances) == 1:
1387
+ name = instances[0].name
1388
+ else:
1389
+ self._print("Which instance should I start?")
1390
+ await self._cmd_list([])
1391
+ return
1392
+
1393
+ await self._cmd_start([name])
1394
+
1395
+ async def _cmd_stop_from_args(self, args: dict) -> None:
1396
+ """Handle stop command from LLM-extracted args.
1397
+
1398
+ Args:
1399
+ args: Dict with optional 'name' key.
1400
+ """
1401
+ name = args.get("name")
1402
+ if not name:
1403
+ # Try to use connected instance
1404
+ if self.session.context.is_connected:
1405
+ name = self.session.context.connected_instance
1406
+ else:
1407
+ self._print("Which instance should I stop?")
1408
+ await self._cmd_list([])
1409
+ return
1410
+
1411
+ await self._cmd_stop([name])
1412
+
1413
+ async def _cmd_connect_from_args(self, args: dict) -> None:
1414
+ """Handle connect command from LLM-extracted args.
1415
+
1416
+ Args:
1417
+ args: Dict with optional 'name' key.
1418
+ """
1419
+ name = args.get("name")
1420
+ if not name:
1421
+ instances = self.instances.list_instances()
1422
+ if len(instances) == 1:
1423
+ name = instances[0].name
1424
+ else:
1425
+ self._print("Which instance should I connect to?")
1426
+ await self._cmd_list([])
1427
+ return
1428
+
1429
+ await self._cmd_connect([name])
1430
+
1431
+ async def _cmd_message_instance(self, target: str, message: str) -> None:
1432
+ """Send message to specific instance without connecting (non-blocking).
1433
+
1434
+ Enqueues the request and returns immediately. Response will appear
1435
+ above the prompt when it arrives.
1436
+
1437
+ Args:
1438
+ target: Instance name to message.
1439
+ message: Message to send.
1440
+ """
1441
+ # Check if instance exists
1442
+ inst = self.instances.get_instance(target)
1443
+ if not inst:
1444
+ # Try to start if registered
1445
+ try:
1446
+ inst = await self.instances.start_by_name(target)
1447
+ if inst:
1448
+ # Spawn background startup task (non-blocking)
1449
+ self._spawn_startup_task(target, auto_connect=False, timeout=30)
1450
+ self._print(
1451
+ f"Starting '{target}'... message will be sent when ready"
1452
+ )
1453
+ except Exception:
1454
+ inst = None
1455
+
1456
+ if not inst:
1457
+ self._print(
1458
+ f"Instance '{target}' not found. Use /list to see instances."
1459
+ )
1460
+ return
1461
+
1462
+ # Create and enqueue request (non-blocking)
1463
+ request = PendingRequest(
1464
+ id=str(uuid.uuid4())[:8],
1465
+ target=target,
1466
+ message=message,
1467
+ )
1468
+ self._pending_requests[request.id] = request
1469
+ await self._request_queue.put(request)
1470
+
1471
+ # Return immediately - response will be handled by _process_responses
1472
+
1473
+ def _display_response(self, instance_name: str, response: str) -> None:
1474
+ """Display response from instance above prompt.
1475
+
1476
+ Args:
1477
+ instance_name: Name of the instance that responded.
1478
+ response: Response content.
1479
+ """
1480
+ # Summarize if too long
1481
+ summary = response[:100] + "..." if len(response) > 100 else response
1482
+ summary = summary.replace("\n", " ")
1483
+ print(f"\n@{instance_name}: {summary}")
1484
+
254
1485
  async def _send_to_instance(self, message: str) -> None:
255
- """Send natural language to connected instance.
1486
+ """Send natural language to connected instance (non-blocking).
1487
+
1488
+ Enqueues the request and returns immediately. Response will appear
1489
+ above the prompt when it arrives.
256
1490
 
257
1491
  Args:
258
1492
  message: User message to send.
259
1493
  """
1494
+ # Check if instance is connected and ready
260
1495
  if not self.session.context.is_connected:
261
1496
  self._print("Not connected to any instance. Use 'connect <name>' first.")
262
1497
  return
@@ -268,29 +1503,141 @@ Examples:
268
1503
  self.session.disconnect()
269
1504
  return
270
1505
 
271
- self._print(f"[Sending to {name}...]")
272
- await self.instances.send_to_instance(name, message)
1506
+ # Create and enqueue request (non-blocking)
1507
+ request = PendingRequest(
1508
+ id=str(uuid.uuid4())[:8],
1509
+ target=name,
1510
+ message=message,
1511
+ )
1512
+ self._pending_requests[request.id] = request
1513
+ await self._request_queue.put(request)
273
1514
  self.session.add_user_message(message)
274
1515
 
275
- # Wait for and display response
276
- if self.relay:
1516
+ # Return immediately - response will be handled by _process_responses
1517
+
1518
+ async def _process_responses(self) -> None:
1519
+ """Background task that processes queued requests and waits for responses."""
1520
+ while self._running:
277
1521
  try:
278
- output = await self.relay.get_latest_output(
279
- name, inst.pane_target, context=message
280
- )
281
- self._print(f"\n[Response from {name}]:\n{output}")
282
- self.session.add_assistant_message(output)
1522
+ # Get next request from queue (with timeout to allow checking _running)
1523
+ try:
1524
+ request = await asyncio.wait_for(
1525
+ self._request_queue.get(), timeout=0.5
1526
+ )
1527
+ except asyncio.TimeoutError:
1528
+ continue
1529
+
1530
+ # Update status and send to instance
1531
+ request.status = RequestStatus.SENDING
1532
+ self._render_pending_status()
1533
+
1534
+ inst = self.instances.get_instance(request.target)
1535
+ if not inst:
1536
+ request.status = RequestStatus.ERROR
1537
+ request.error = f"Instance '{request.target}' no longer exists"
1538
+ print(f"\n[{request.target}] {request.error}")
1539
+ continue
1540
+
1541
+ # Send to instance
1542
+ await self.instances.send_to_instance(request.target, request.message)
1543
+ request.status = RequestStatus.WAITING
1544
+ self._render_pending_status()
1545
+
1546
+ # Wait for response
1547
+ if self.relay:
1548
+ try:
1549
+ output = await self.relay.get_latest_output(
1550
+ request.target, inst.pane_target, context=request.message
1551
+ )
1552
+ request.status = RequestStatus.COMPLETED
1553
+ request.response = output
1554
+
1555
+ # Display response above prompt
1556
+ self._display_response(request.target, output)
1557
+ self.session.add_assistant_message(output)
1558
+ except Exception as e:
1559
+ request.status = RequestStatus.ERROR
1560
+ request.error = str(e)
1561
+ print(f"\n[{request.target}] Error: {e}")
1562
+
1563
+ # Remove from pending after a short delay
1564
+ await asyncio.sleep(0.5)
1565
+ self._pending_requests.pop(request.id, None)
1566
+
1567
+ except asyncio.CancelledError:
1568
+ break
283
1569
  except Exception as e:
284
- self._print(f"\n[Error getting response: {e}]")
1570
+ print(f"\nResponse processor error: {e}")
1571
+
1572
+ def _render_pending_status(self) -> None:
1573
+ """Render pending request status above the prompt."""
1574
+ pending = [
1575
+ r
1576
+ for r in self._pending_requests.values()
1577
+ if r.status not in (RequestStatus.COMPLETED, RequestStatus.ERROR)
1578
+ ]
1579
+ if not pending:
1580
+ return
1581
+
1582
+ # Build status line
1583
+ status_parts = []
1584
+ for req in pending:
1585
+ elapsed = req.elapsed_seconds()
1586
+ status_indicator = {
1587
+ RequestStatus.QUEUED: "...",
1588
+ RequestStatus.SENDING: ">>>",
1589
+ RequestStatus.WAITING: "...",
1590
+ RequestStatus.STARTING: "...",
1591
+ }.get(req.status, "?")
1592
+ status_parts.append(
1593
+ f"{status_indicator} [{req.target}] {req.display_message(30)} ({elapsed}s)"
1594
+ )
1595
+
1596
+ # Print above prompt (patch_stdout handles cursor positioning)
1597
+ for part in status_parts:
1598
+ print(f"\r\033[K{part}")
1599
+
1600
+ def _on_instance_event(self, event: "Event") -> None:
1601
+ """Handle instance lifecycle events with interrupt display.
1602
+
1603
+ Args:
1604
+ event: The event to handle.
1605
+ """
1606
+ if event.type == EventType.INSTANCE_STARTING:
1607
+ print(f"\n[Starting] {event.title}")
1608
+ elif event.type == EventType.INSTANCE_READY:
1609
+ metadata = event.context or {}
1610
+ instance_name = metadata.get("instance_name", "")
1611
+
1612
+ # Mark instance as ready
1613
+ if instance_name:
1614
+ self._instance_ready[instance_name] = True
1615
+
1616
+ if metadata.get("timeout"):
1617
+ print(f"\n[Warning] {event.title} (startup timeout, may still work)")
1618
+ else:
1619
+ print(f"\n[Ready] {event.title}")
1620
+
1621
+ # Show ready notification based on whether this is the connected instance
1622
+ if (
1623
+ instance_name
1624
+ and instance_name == self.session.context.connected_instance
1625
+ ):
1626
+ print(f"\n({instance_name}) ready")
1627
+ elif instance_name:
1628
+ print(f" Use @{instance_name} or /connect {instance_name}")
1629
+ elif event.type == EventType.INSTANCE_ERROR:
1630
+ print(f"\n[Error] {event.title}: {event.content}")
285
1631
 
286
1632
  def _get_prompt(self) -> str:
287
- """Get prompt string based on connection state.
1633
+ """Get prompt string.
288
1634
 
289
1635
  Returns:
290
- Prompt string for input.
1636
+ Prompt string for input, showing instance name when connected.
291
1637
  """
292
- if self.session.context.is_connected:
293
- return f"Commander ({self.session.context.connected_instance})> "
1638
+ connected = self.session.context.connected_instance
1639
+ if connected:
1640
+ return f"Commander ({connected})> "
294
1641
  return "Commander> "
295
1642
 
296
1643
  def _print(self, msg: str) -> None:
@@ -301,10 +1648,174 @@ Examples:
301
1648
  """
302
1649
  print(msg)
303
1650
 
1651
+ def _spawn_startup_task(
1652
+ self, name: str, auto_connect: bool = True, timeout: int = 30
1653
+ ) -> None:
1654
+ """Spawn a background task to wait for instance ready.
1655
+
1656
+ This returns immediately - the wait happens in the background.
1657
+ Prints status when starting and when complete.
1658
+
1659
+ Args:
1660
+ name: Instance name to wait for
1661
+ auto_connect: Whether to auto-connect when ready
1662
+ timeout: Maximum seconds to wait
1663
+ """
1664
+ # Print starting message (once)
1665
+ print(f"Waiting for '{name}' to be ready...")
1666
+
1667
+ # Spawn background task
1668
+ task = asyncio.create_task(
1669
+ self._wait_for_ready_background(name, auto_connect, timeout)
1670
+ )
1671
+ self._startup_tasks[name] = task
1672
+
1673
+ async def _wait_for_ready_background(
1674
+ self, name: str, auto_connect: bool, timeout: int
1675
+ ) -> None:
1676
+ """Background task that waits for instance ready.
1677
+
1678
+ Updates bottom toolbar with spinner animation, then prints result when done.
1679
+
1680
+ Args:
1681
+ name: Instance name to wait for
1682
+ auto_connect: Whether to auto-connect when ready
1683
+ timeout: Maximum seconds to wait
1684
+ """
1685
+ elapsed = 0.0
1686
+ interval = 0.1 # Update spinner every 100ms
1687
+ spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1688
+ frame_idx = 0
1689
+
1690
+ try:
1691
+ while elapsed < timeout:
1692
+ inst = self.instances.get_instance(name)
1693
+ if inst and inst.ready:
1694
+ # Clear toolbar and print success
1695
+ self._toolbar_status = ""
1696
+ if self.prompt_session:
1697
+ self.prompt_session.app.invalidate()
1698
+ print(f"'{name}' ready ({int(elapsed)}s)")
1699
+
1700
+ if auto_connect:
1701
+ self.session.connect_to(name)
1702
+ print(f" Connected to '{name}'")
1703
+
1704
+ # Cleanup
1705
+ self._startup_tasks.pop(name, None)
1706
+ return
1707
+
1708
+ # Update toolbar with spinner frame
1709
+ frame = spinner_frames[frame_idx % len(spinner_frames)]
1710
+ self._toolbar_status = (
1711
+ f"{frame} Waiting for '{name}'... ({int(elapsed)}s)"
1712
+ )
1713
+ if self.prompt_session:
1714
+ self.prompt_session.app.invalidate()
1715
+ frame_idx += 1
1716
+
1717
+ await asyncio.sleep(interval)
1718
+ elapsed += interval
1719
+
1720
+ # Timeout - clear toolbar and show warning
1721
+ self._toolbar_status = ""
1722
+ if self.prompt_session:
1723
+ self.prompt_session.app.invalidate()
1724
+ print(f"'{name}' startup timeout ({timeout}s) - may still work")
1725
+
1726
+ # Still auto-connect on timeout (instance may become ready later)
1727
+ if auto_connect:
1728
+ self.session.connect_to(name)
1729
+ print(f" Connected to '{name}' (may not be fully ready)")
1730
+
1731
+ # Cleanup
1732
+ self._startup_tasks.pop(name, None)
1733
+
1734
+ except asyncio.CancelledError:
1735
+ self._toolbar_status = ""
1736
+ self._startup_tasks.pop(name, None)
1737
+ except Exception as e:
1738
+ self._toolbar_status = ""
1739
+ print(f"'{name}' startup error: {e}")
1740
+ self._startup_tasks.pop(name, None)
1741
+
1742
+ async def _wait_for_ready_with_spinner(self, name: str, timeout: int = 30) -> bool:
1743
+ """Wait for instance to be ready with animated spinner (BLOCKING).
1744
+
1745
+ NOTE: This method blocks. For non-blocking, use _spawn_startup_task().
1746
+
1747
+ Shows an animated waiting indicator that updates in place.
1748
+
1749
+ Args:
1750
+ name: Instance name to wait for
1751
+ timeout: Maximum seconds to wait
1752
+
1753
+ Returns:
1754
+ True if instance became ready, False on timeout
1755
+ """
1756
+ spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
1757
+ frame_idx = 0
1758
+ elapsed = 0.0
1759
+ interval = 0.1 # Update spinner every 100ms
1760
+
1761
+ while elapsed < timeout:
1762
+ inst = self.instances.get_instance(name)
1763
+ if inst and inst.ready:
1764
+ # Clear spinner line and show success
1765
+ sys.stdout.write(f"\r\033[K'{name}' ready\n")
1766
+ sys.stdout.flush()
1767
+ return True
1768
+
1769
+ # Show spinner with elapsed time
1770
+ frame = spinner_frames[frame_idx % len(spinner_frames)]
1771
+ sys.stdout.write(
1772
+ f"\r{frame} Waiting for '{name}' to be ready... ({int(elapsed)}s)"
1773
+ )
1774
+ sys.stdout.flush()
1775
+
1776
+ await asyncio.sleep(interval)
1777
+ elapsed += interval
1778
+ frame_idx += 1
1779
+
1780
+ # Timeout - clear spinner and show warning
1781
+ sys.stdout.write(f"\r\033[K'{name}' startup timeout (may still work)\n")
1782
+ sys.stdout.flush()
1783
+ return False
1784
+
304
1785
  def _print_welcome(self) -> None:
305
1786
  """Print welcome message."""
306
1787
  print("╔══════════════════════════════════════════╗")
307
1788
  print("║ MPM Commander - Interactive Mode ║")
308
1789
  print("╚══════════════════════════════════════════╝")
309
- print("Type 'help' for commands, or natural language to chat.")
1790
+ print("Type '/help' for commands, or natural language to chat.")
310
1791
  print()
1792
+
1793
+ def _get_instance_names(self) -> list[str]:
1794
+ """Get list of instance names for autocomplete.
1795
+
1796
+ Returns:
1797
+ List of instance names (running and registered).
1798
+ """
1799
+ names: list[str] = []
1800
+
1801
+ # Running instances
1802
+ if self.instances:
1803
+ try:
1804
+ for inst in self.instances.list_instances():
1805
+ if inst.name not in names:
1806
+ names.append(inst.name)
1807
+ except Exception: # nosec B110 - Graceful fallback
1808
+ pass
1809
+
1810
+ # Registered instances from state store
1811
+ if self.instances and hasattr(self.instances, "_state_store"):
1812
+ try:
1813
+ state_store = self.instances._state_store
1814
+ if state_store:
1815
+ for name in state_store.load_instances():
1816
+ if name not in names:
1817
+ names.append(name)
1818
+ except Exception: # nosec B110 - Graceful fallback
1819
+ pass
1820
+
1821
+ return names