claude-mpm 5.4.41__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.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/CLAUDE_MPM_OUTPUT_STYLE.md +66 -241
- claude_mpm/agents/CLAUDE_MPM_RESEARCH_OUTPUT_STYLE.md +413 -0
- claude_mpm/agents/CLAUDE_MPM_TEACHER_OUTPUT_STYLE.md +109 -1925
- claude_mpm/agents/PM_INSTRUCTIONS.md +161 -298
- claude_mpm/agents/WORKFLOW.md +2 -0
- claude_mpm/agents/templates/circuit-breakers.md +26 -17
- claude_mpm/auth/__init__.py +35 -0
- claude_mpm/auth/callback_server.py +328 -0
- claude_mpm/auth/models.py +104 -0
- claude_mpm/auth/oauth_manager.py +266 -0
- claude_mpm/auth/providers/__init__.py +12 -0
- claude_mpm/auth/providers/base.py +165 -0
- claude_mpm/auth/providers/google.py +261 -0
- claude_mpm/auth/token_storage.py +252 -0
- claude_mpm/cli/__init__.py +5 -1
- claude_mpm/cli/commands/agents.py +2 -4
- claude_mpm/cli/commands/agents_reconcile.py +197 -0
- claude_mpm/cli/commands/autotodos.py +566 -0
- claude_mpm/cli/commands/commander.py +216 -0
- claude_mpm/cli/commands/configure.py +620 -21
- claude_mpm/cli/commands/configure_agent_display.py +3 -1
- claude_mpm/cli/commands/hook_errors.py +60 -60
- claude_mpm/cli/commands/mcp.py +29 -17
- claude_mpm/cli/commands/mcp_command_router.py +39 -0
- claude_mpm/cli/commands/mcp_service_commands.py +304 -0
- claude_mpm/cli/commands/monitor.py +2 -2
- claude_mpm/cli/commands/mpm_init/core.py +15 -8
- claude_mpm/cli/commands/oauth.py +481 -0
- claude_mpm/cli/commands/profile.py +9 -10
- claude_mpm/cli/commands/run.py +35 -3
- claude_mpm/cli/commands/skill_source.py +51 -2
- claude_mpm/cli/commands/skills.py +182 -32
- claude_mpm/cli/executor.py +129 -16
- claude_mpm/cli/helpers.py +1 -1
- claude_mpm/cli/interactive/__init__.py +10 -0
- claude_mpm/cli/interactive/agent_wizard.py +30 -50
- claude_mpm/cli/interactive/questionary_styles.py +65 -0
- claude_mpm/cli/interactive/skill_selector.py +481 -0
- claude_mpm/cli/parsers/base_parser.py +89 -1
- claude_mpm/cli/parsers/commander_parser.py +116 -0
- claude_mpm/cli/parsers/mcp_parser.py +79 -0
- claude_mpm/cli/parsers/oauth_parser.py +165 -0
- claude_mpm/cli/parsers/profile_parser.py +0 -1
- claude_mpm/cli/parsers/run_parser.py +10 -0
- claude_mpm/cli/parsers/skill_source_parser.py +4 -0
- claude_mpm/cli/parsers/skills_parser.py +2 -3
- claude_mpm/cli/startup.py +662 -524
- claude_mpm/cli/startup_display.py +76 -7
- claude_mpm/cli/startup_logging.py +2 -2
- claude_mpm/cli/utils.py +7 -3
- claude_mpm/commander/__init__.py +78 -0
- claude_mpm/commander/adapters/__init__.py +60 -0
- claude_mpm/commander/adapters/auggie.py +260 -0
- claude_mpm/commander/adapters/base.py +288 -0
- claude_mpm/commander/adapters/claude_code.py +392 -0
- claude_mpm/commander/adapters/codex.py +237 -0
- claude_mpm/commander/adapters/communication.py +366 -0
- claude_mpm/commander/adapters/example_usage.py +310 -0
- claude_mpm/commander/adapters/mpm.py +389 -0
- claude_mpm/commander/adapters/registry.py +204 -0
- claude_mpm/commander/api/__init__.py +16 -0
- claude_mpm/commander/api/app.py +121 -0
- claude_mpm/commander/api/errors.py +133 -0
- claude_mpm/commander/api/routes/__init__.py +8 -0
- claude_mpm/commander/api/routes/events.py +184 -0
- claude_mpm/commander/api/routes/inbox.py +171 -0
- claude_mpm/commander/api/routes/messages.py +148 -0
- claude_mpm/commander/api/routes/projects.py +271 -0
- claude_mpm/commander/api/routes/sessions.py +226 -0
- claude_mpm/commander/api/routes/work.py +296 -0
- claude_mpm/commander/api/schemas.py +186 -0
- claude_mpm/commander/chat/__init__.py +7 -0
- claude_mpm/commander/chat/cli.py +149 -0
- claude_mpm/commander/chat/commands.py +122 -0
- claude_mpm/commander/chat/repl.py +1821 -0
- claude_mpm/commander/config.py +51 -0
- claude_mpm/commander/config_loader.py +115 -0
- claude_mpm/commander/core/__init__.py +10 -0
- claude_mpm/commander/core/block_manager.py +325 -0
- claude_mpm/commander/core/response_manager.py +323 -0
- claude_mpm/commander/daemon.py +603 -0
- claude_mpm/commander/env_loader.py +59 -0
- claude_mpm/commander/events/__init__.py +26 -0
- claude_mpm/commander/events/manager.py +392 -0
- claude_mpm/commander/frameworks/__init__.py +12 -0
- claude_mpm/commander/frameworks/base.py +233 -0
- claude_mpm/commander/frameworks/claude_code.py +58 -0
- claude_mpm/commander/frameworks/mpm.py +57 -0
- claude_mpm/commander/git/__init__.py +5 -0
- claude_mpm/commander/git/worktree_manager.py +212 -0
- claude_mpm/commander/inbox/__init__.py +16 -0
- claude_mpm/commander/inbox/dedup.py +128 -0
- claude_mpm/commander/inbox/inbox.py +224 -0
- claude_mpm/commander/inbox/models.py +70 -0
- claude_mpm/commander/instance_manager.py +865 -0
- claude_mpm/commander/llm/__init__.py +6 -0
- claude_mpm/commander/llm/openrouter_client.py +167 -0
- claude_mpm/commander/llm/summarizer.py +70 -0
- claude_mpm/commander/memory/__init__.py +45 -0
- claude_mpm/commander/memory/compression.py +347 -0
- claude_mpm/commander/memory/embeddings.py +230 -0
- claude_mpm/commander/memory/entities.py +310 -0
- claude_mpm/commander/memory/example_usage.py +290 -0
- claude_mpm/commander/memory/integration.py +325 -0
- claude_mpm/commander/memory/search.py +381 -0
- claude_mpm/commander/memory/store.py +657 -0
- claude_mpm/commander/models/__init__.py +18 -0
- claude_mpm/commander/models/events.py +127 -0
- claude_mpm/commander/models/project.py +162 -0
- claude_mpm/commander/models/work.py +214 -0
- claude_mpm/commander/parsing/__init__.py +20 -0
- claude_mpm/commander/parsing/extractor.py +132 -0
- claude_mpm/commander/parsing/output_parser.py +270 -0
- claude_mpm/commander/parsing/patterns.py +100 -0
- claude_mpm/commander/persistence/__init__.py +11 -0
- claude_mpm/commander/persistence/event_store.py +274 -0
- claude_mpm/commander/persistence/state_store.py +403 -0
- claude_mpm/commander/persistence/work_store.py +164 -0
- claude_mpm/commander/polling/__init__.py +13 -0
- claude_mpm/commander/polling/event_detector.py +104 -0
- claude_mpm/commander/polling/output_buffer.py +49 -0
- claude_mpm/commander/polling/output_poller.py +153 -0
- claude_mpm/commander/project_session.py +268 -0
- claude_mpm/commander/proxy/__init__.py +12 -0
- claude_mpm/commander/proxy/formatter.py +89 -0
- claude_mpm/commander/proxy/output_handler.py +191 -0
- claude_mpm/commander/proxy/relay.py +155 -0
- claude_mpm/commander/registry.py +410 -0
- claude_mpm/commander/runtime/__init__.py +10 -0
- claude_mpm/commander/runtime/executor.py +191 -0
- claude_mpm/commander/runtime/monitor.py +346 -0
- claude_mpm/commander/session/__init__.py +6 -0
- claude_mpm/commander/session/context.py +81 -0
- claude_mpm/commander/session/manager.py +59 -0
- claude_mpm/commander/tmux_orchestrator.py +362 -0
- claude_mpm/commander/web/__init__.py +1 -0
- claude_mpm/commander/work/__init__.py +30 -0
- claude_mpm/commander/work/executor.py +207 -0
- claude_mpm/commander/work/queue.py +405 -0
- claude_mpm/commander/workflow/__init__.py +27 -0
- claude_mpm/commander/workflow/event_handler.py +241 -0
- claude_mpm/commander/workflow/notifier.py +146 -0
- claude_mpm/commands/mpm-config.md +8 -0
- claude_mpm/commands/mpm-doctor.md +8 -0
- claude_mpm/commands/mpm-help.md +8 -0
- claude_mpm/commands/mpm-init.md +8 -0
- claude_mpm/commands/mpm-monitor.md +8 -0
- claude_mpm/commands/mpm-organize.md +8 -0
- claude_mpm/commands/mpm-postmortem.md +8 -0
- claude_mpm/commands/mpm-session-resume.md +9 -1
- claude_mpm/commands/mpm-status.md +8 -0
- claude_mpm/commands/mpm-ticket-view.md +8 -0
- claude_mpm/commands/mpm-version.md +8 -0
- claude_mpm/commands/mpm.md +8 -0
- claude_mpm/config/agent_presets.py +8 -7
- claude_mpm/config/skill_sources.py +16 -0
- claude_mpm/constants.py +6 -0
- claude_mpm/core/claude_runner.py +154 -2
- claude_mpm/core/config.py +35 -22
- claude_mpm/core/config_constants.py +74 -9
- claude_mpm/core/constants.py +56 -12
- claude_mpm/core/hook_manager.py +53 -4
- claude_mpm/core/interactive_session.py +12 -11
- claude_mpm/core/logger.py +26 -9
- claude_mpm/core/logging_utils.py +39 -13
- claude_mpm/core/network_config.py +148 -0
- claude_mpm/core/oneshot_session.py +7 -6
- claude_mpm/core/optimized_startup.py +3 -1
- claude_mpm/core/output_style_manager.py +66 -18
- claude_mpm/core/shared/config_loader.py +3 -1
- claude_mpm/core/socketio_pool.py +47 -15
- claude_mpm/core/unified_config.py +54 -8
- claude_mpm/core/unified_paths.py +95 -90
- claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.C33zOoyM.css +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.CW1J-YuA.css +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/1WZnGYqX.js +24 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/67pF3qNn.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/6RxdMKe4.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/8cZrfX0h.js +60 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/9a6T2nm-.js +7 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/B443AUzu.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/B8AwtY2H.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BF15LAsF.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BQaXIfA_.js +331 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BRcwIQNr.js +4 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{uj46x2Wr.js → BSNlmTZj.js} +1 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BV6nKitt.js +43 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BViJ8lZt.js +128 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BcQ-Q0FE.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Bpyvgze_.js +30 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BzTRqg-z.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C0Fr8dve.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C3rbW_a-.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C8WYN38h.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/C9I8FlXH.js +61 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIQcWgO2.js +36 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIctN7YN.js +7 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CKrS_JZW.js +145 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CR6P9C4A.js +89 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CRRR9MD_.js +2 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CRcR2DqT.js +334 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CSXtMOf0.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CT-sbxSk.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CWm6DJsp.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CmKTTxBW.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CpqQ1Kzn.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Cu_Erd72.js +261 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/D2nGpDRe.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/D9iCMida.js +267 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/D9ykgMoY.js +10 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DL2Ldur1.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DPfltzjH.js +165 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{N4qtv3Hx.js → DR8nis88.js} +2 -2
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DUliQN2b.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DVp1hx9R.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DXlhR01x.js +122 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/D_lyTybS.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DngoTTgh.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DqkmHtDC.js +220 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DsDh8EYs.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DypDmXgd.js +139 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Gi6I4Gst.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/IPYC-LnN.js +162 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/JTLiF7dt.js +24 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/JpevfAFt.js +68 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/{DjhvlsAc.js → NqQ1dWOy.js} +1 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/R8CEIRAd.js +2 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/Zxy7qc-l.js +64 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/q9Hm6zAU.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/qtd3IeO4.js +15 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/ulBFON_C.js +65 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/wQVh1CoA.js +10 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.Dr7t0z2J.js +2 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.BGhZHUS3.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/{0.CAGBuiOw.js → 0.RgBboRvH.js} +1 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.DG-KkbDf.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.D_jnf-x6.js +1 -0
- claude_mpm/dashboard/static/svelte-build/_app/version.json +1 -1
- claude_mpm/dashboard/static/svelte-build/index.html +11 -11
- claude_mpm/dashboard-svelte/node_modules/katex/src/fonts/generate_fonts.py +58 -0
- claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/extract_tfms.py +114 -0
- claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/extract_ttfs.py +122 -0
- claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/format_json.py +28 -0
- claude_mpm/dashboard-svelte/node_modules/katex/src/metrics/parse_tfm.py +211 -0
- claude_mpm/experimental/cli_enhancements.py +2 -1
- claude_mpm/hooks/claude_hooks/INTEGRATION_EXAMPLE.md +243 -0
- claude_mpm/hooks/claude_hooks/README_AUTO_PAUSE.md +403 -0
- claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/auto_pause_handler.py +485 -0
- claude_mpm/hooks/claude_hooks/event_handlers.py +466 -136
- claude_mpm/hooks/claude_hooks/hook_handler.py +204 -104
- claude_mpm/hooks/claude_hooks/hook_wrapper.sh +6 -11
- claude_mpm/hooks/claude_hooks/installer.py +291 -59
- claude_mpm/hooks/claude_hooks/memory_integration.py +52 -32
- claude_mpm/hooks/claude_hooks/response_tracking.py +43 -60
- claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/container.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/protocols.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/connection_manager.py +41 -26
- claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +38 -105
- claude_mpm/hooks/claude_hooks/services/container.py +326 -0
- claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
- claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
- claude_mpm/hooks/claude_hooks/services/subagent_processor.py +75 -77
- claude_mpm/hooks/kuzu_memory_hook.py +5 -5
- claude_mpm/hooks/session_resume_hook.py +89 -1
- claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
- claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
- claude_mpm/init.py +224 -4
- claude_mpm/mcp/__init__.py +9 -0
- claude_mpm/mcp/google_workspace_server.py +610 -0
- claude_mpm/scripts/claude-hook-handler.sh +46 -19
- claude_mpm/services/agents/agent_recommendation_service.py +8 -8
- claude_mpm/services/agents/agent_selection_service.py +2 -2
- claude_mpm/services/agents/cache_git_manager.py +1 -1
- claude_mpm/services/agents/deployment/agent_discovery_service.py +3 -1
- claude_mpm/services/agents/deployment/agent_format_converter.py +25 -13
- claude_mpm/services/agents/deployment/agent_template_builder.py +37 -17
- claude_mpm/services/agents/deployment/async_agent_deployment.py +31 -27
- claude_mpm/services/agents/deployment/deployment_reconciler.py +577 -0
- claude_mpm/services/agents/deployment/local_template_deployment.py +3 -1
- claude_mpm/services/agents/deployment/multi_source_deployment_service.py +36 -8
- claude_mpm/services/agents/deployment/remote_agent_discovery_service.py +50 -26
- claude_mpm/services/agents/deployment/startup_reconciliation.py +138 -0
- claude_mpm/services/agents/git_source_manager.py +21 -2
- claude_mpm/services/agents/loading/framework_agent_loader.py +75 -2
- claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
- claude_mpm/services/agents/sources/git_source_sync_service.py +116 -5
- claude_mpm/services/agents/startup_sync.py +5 -2
- claude_mpm/services/cli/__init__.py +3 -0
- claude_mpm/services/cli/incremental_pause_manager.py +561 -0
- claude_mpm/services/cli/session_resume_helper.py +10 -2
- claude_mpm/services/command_deployment_service.py +44 -26
- claude_mpm/services/delegation_detector.py +175 -0
- claude_mpm/services/diagnostics/checks/agent_sources_check.py +30 -0
- claude_mpm/services/diagnostics/checks/configuration_check.py +24 -0
- claude_mpm/services/diagnostics/checks/installation_check.py +22 -0
- claude_mpm/services/diagnostics/checks/mcp_services_check.py +23 -0
- claude_mpm/services/diagnostics/doctor_reporter.py +31 -1
- claude_mpm/services/diagnostics/models.py +14 -1
- claude_mpm/services/event_log.py +325 -0
- claude_mpm/services/hook_installer_service.py +77 -8
- claude_mpm/services/infrastructure/__init__.py +4 -0
- claude_mpm/services/infrastructure/context_usage_tracker.py +291 -0
- claude_mpm/services/infrastructure/resume_log_generator.py +24 -5
- claude_mpm/services/mcp_config_manager.py +99 -19
- claude_mpm/services/mcp_service_registry.py +294 -0
- claude_mpm/services/monitor/daemon_manager.py +15 -4
- claude_mpm/services/monitor/management/lifecycle.py +8 -3
- claude_mpm/services/monitor/server.py +111 -16
- claude_mpm/services/pm_skills_deployer.py +302 -94
- claude_mpm/services/profile_manager.py +10 -4
- claude_mpm/services/skills/git_skill_source_manager.py +192 -29
- claude_mpm/services/skills/selective_skill_deployer.py +211 -46
- claude_mpm/services/skills/skill_discovery_service.py +74 -4
- claude_mpm/services/skills_deployer.py +192 -70
- claude_mpm/services/socketio/handlers/hook.py +14 -7
- claude_mpm/services/socketio/server/main.py +12 -4
- claude_mpm/skills/__init__.py +2 -1
- claude_mpm/skills/bundled/collaboration/brainstorming/SKILL.md +79 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/SKILL.md +178 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/agent-prompts.md +577 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/coordination-patterns.md +467 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/examples.md +537 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/troubleshooting.md +730 -0
- claude_mpm/skills/bundled/collaboration/git-worktrees.md +317 -0
- claude_mpm/skills/bundled/collaboration/requesting-code-review/SKILL.md +112 -0
- claude_mpm/skills/bundled/collaboration/requesting-code-review/references/code-reviewer-template.md +146 -0
- claude_mpm/skills/bundled/collaboration/requesting-code-review/references/review-examples.md +412 -0
- claude_mpm/skills/bundled/collaboration/stacked-prs.md +251 -0
- claude_mpm/skills/bundled/collaboration/writing-plans/SKILL.md +81 -0
- claude_mpm/skills/bundled/collaboration/writing-plans/references/best-practices.md +362 -0
- claude_mpm/skills/bundled/collaboration/writing-plans/references/plan-structure-templates.md +312 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/SKILL.md +152 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/advanced-techniques.md +668 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/examples.md +587 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/integration.md +438 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/tracing-techniques.md +391 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/CREATION-LOG.md +119 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/SKILL.md +148 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/anti-patterns.md +483 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/examples.md +452 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/troubleshooting.md +449 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/workflow.md +411 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-academic.md +14 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-pressure-1.md +58 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-pressure-2.md +68 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-pressure-3.md +69 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/SKILL.md +131 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/gate-function.md +325 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/integration-and-workflows.md +490 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/red-flags-and-failures.md +425 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/verification-patterns.md +499 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/INTEGRATION.md +611 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/README.md +596 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/SKILL.md +260 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/examples/nextjs-env-structure.md +315 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/frameworks.md +436 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/security.md +433 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/synchronization.md +452 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/troubleshooting.md +404 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/validation.md +420 -0
- claude_mpm/skills/bundled/main/artifacts-builder/SKILL.md +86 -0
- claude_mpm/skills/bundled/main/internal-comms/SKILL.md +43 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/3p-updates.md +47 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/company-newsletter.md +65 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/faq-answers.md +30 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/general-comms.md +16 -0
- claude_mpm/skills/bundled/main/mcp-builder/SKILL.md +160 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/design_principles.md +412 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/evaluation.md +602 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/mcp_best_practices.md +915 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/node_mcp_server.md +916 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/python_mcp_server.md +752 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/workflow.md +1237 -0
- claude_mpm/skills/bundled/main/skill-creator/SKILL.md +189 -0
- claude_mpm/skills/bundled/main/skill-creator/references/best-practices.md +500 -0
- claude_mpm/skills/bundled/main/skill-creator/references/creation-workflow.md +464 -0
- claude_mpm/skills/bundled/main/skill-creator/references/examples.md +619 -0
- claude_mpm/skills/bundled/main/skill-creator/references/progressive-disclosure.md +437 -0
- claude_mpm/skills/bundled/main/skill-creator/references/skill-structure.md +231 -0
- claude_mpm/skills/bundled/php/espocrm-development/SKILL.md +170 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/architecture.md +602 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/common-tasks.md +821 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/development-workflow.md +742 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/frontend-customization.md +726 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/hooks-and-services.md +764 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/testing-debugging.md +831 -0
- claude_mpm/skills/bundled/pm/mpm/SKILL.md +38 -0
- claude_mpm/skills/bundled/pm/mpm-agent-update-workflow/SKILL.md +75 -0
- claude_mpm/skills/bundled/pm/mpm-bug-reporting/SKILL.md +248 -0
- claude_mpm/skills/bundled/pm/mpm-circuit-breaker-enforcement/SKILL.md +476 -0
- claude_mpm/skills/bundled/pm/mpm-config/SKILL.md +29 -0
- claude_mpm/skills/bundled/pm/mpm-delegation-patterns/SKILL.md +167 -0
- claude_mpm/skills/bundled/pm/mpm-doctor/SKILL.md +53 -0
- claude_mpm/skills/bundled/pm/mpm-git-file-tracking/SKILL.md +113 -0
- claude_mpm/skills/bundled/pm/mpm-help/SKILL.md +35 -0
- claude_mpm/skills/bundled/pm/mpm-init/SKILL.md +125 -0
- claude_mpm/skills/bundled/pm/mpm-monitor/SKILL.md +32 -0
- claude_mpm/skills/bundled/pm/mpm-organize/SKILL.md +121 -0
- claude_mpm/skills/bundled/pm/mpm-postmortem/SKILL.md +22 -0
- claude_mpm/skills/bundled/pm/mpm-pr-workflow/SKILL.md +124 -0
- claude_mpm/skills/bundled/pm/mpm-session-management/SKILL.md +312 -0
- claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
- claude_mpm/skills/bundled/pm/mpm-session-resume/SKILL.md +31 -0
- claude_mpm/skills/bundled/pm/mpm-status/SKILL.md +37 -0
- claude_mpm/skills/bundled/pm/mpm-teaching-mode/SKILL.md +657 -0
- claude_mpm/skills/bundled/pm/mpm-ticket-view/SKILL.md +110 -0
- claude_mpm/skills/bundled/pm/mpm-ticketing-integration/SKILL.md +154 -0
- claude_mpm/skills/bundled/pm/mpm-tool-usage-guide/SKILL.md +386 -0
- claude_mpm/skills/bundled/pm/mpm-verification-protocols/SKILL.md +198 -0
- claude_mpm/skills/bundled/pm/mpm-version/SKILL.md +21 -0
- claude_mpm/skills/bundled/react/flexlayout-react.md +742 -0
- claude_mpm/skills/bundled/rust/desktop-applications/SKILL.md +226 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/architecture-patterns.md +901 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/native-gui-frameworks.md +901 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/platform-integration.md +775 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/state-management.md +937 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/tauri-framework.md +770 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/testing-deployment.md +961 -0
- claude_mpm/skills/bundled/security-scanning.md +112 -0
- claude_mpm/skills/bundled/tauri/tauri-async-patterns.md +495 -0
- claude_mpm/skills/bundled/tauri/tauri-build-deploy.md +599 -0
- claude_mpm/skills/bundled/tauri/tauri-command-patterns.md +535 -0
- claude_mpm/skills/bundled/tauri/tauri-error-handling.md +613 -0
- claude_mpm/skills/bundled/tauri/tauri-event-system.md +648 -0
- claude_mpm/skills/bundled/tauri/tauri-file-system.md +673 -0
- claude_mpm/skills/bundled/tauri/tauri-frontend-integration.md +767 -0
- claude_mpm/skills/bundled/tauri/tauri-performance.md +669 -0
- claude_mpm/skills/bundled/tauri/tauri-state-management.md +573 -0
- claude_mpm/skills/bundled/tauri/tauri-testing.md +384 -0
- claude_mpm/skills/bundled/tauri/tauri-window-management.md +628 -0
- claude_mpm/skills/bundled/testing/condition-based-waiting/SKILL.md +119 -0
- claude_mpm/skills/bundled/testing/condition-based-waiting/references/patterns-and-implementation.md +253 -0
- claude_mpm/skills/bundled/testing/test-driven-development/SKILL.md +145 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/anti-patterns.md +543 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/examples.md +741 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/integration.md +470 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/philosophy.md +458 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/workflow.md +639 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/SKILL.md +458 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/examples/example-inspection-report.md +411 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/references/assertion-quality.md +317 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/references/inspection-checklist.md +270 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/references/red-flags.md +436 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/SKILL.md +140 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/completeness-anti-patterns.md +572 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/core-anti-patterns.md +411 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/detection-guide.md +569 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/tdd-connection.md +695 -0
- claude_mpm/skills/bundled/testing/webapp-testing/SKILL.md +184 -0
- claude_mpm/skills/bundled/testing/webapp-testing/decision-tree.md +459 -0
- claude_mpm/skills/bundled/testing/webapp-testing/playwright-patterns.md +479 -0
- claude_mpm/skills/bundled/testing/webapp-testing/reconnaissance-pattern.md +687 -0
- claude_mpm/skills/bundled/testing/webapp-testing/server-management.md +758 -0
- claude_mpm/skills/bundled/testing/webapp-testing/troubleshooting.md +868 -0
- claude_mpm/skills/registry.py +295 -90
- claude_mpm/skills/skill_manager.py +29 -23
- claude_mpm/templates/.pre-commit-config.yaml +112 -0
- claude_mpm/utils/agent_dependency_loader.py +103 -4
- claude_mpm/utils/robust_installer.py +45 -24
- claude_mpm-5.6.72.dist-info/METADATA +416 -0
- {claude_mpm-5.4.41.dist-info → claude_mpm-5.6.72.dist-info}/RECORD +477 -159
- {claude_mpm-5.4.41.dist-info → claude_mpm-5.6.72.dist-info}/WHEEL +1 -1
- {claude_mpm-5.4.41.dist-info → claude_mpm-5.6.72.dist-info}/entry_points.txt +2 -0
- claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/0.B_FtCwCQ.css +0 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/assets/2.Cl_eSA4x.css +0 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/BgChzWQ1.js +0 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CIXEwuWe.js +0 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/CWc5urbQ.js +0 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/chunks/DMkZpdF2.js +0 -2
- claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/app.DTL5mJO-.js +0 -2
- claude_mpm/dashboard/static/svelte-build/_app/immutable/entry/start.DzuEhzqh.js +0 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/1.DFLC8jdE.js +0 -1
- claude_mpm/dashboard/static/svelte-build/_app/immutable/nodes/2.DPvEihJJ.js +0 -10
- claude_mpm/hooks/claude_hooks/__pycache__/installer.cpython-311.pyc +0 -0
- claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager.cpython-311.pyc +0 -0
- claude_mpm-5.4.41.dist-info/METADATA +0 -998
- {claude_mpm-5.4.41.dist-info → claude_mpm-5.6.72.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-5.4.41.dist-info → claude_mpm-5.6.72.dist-info}/licenses/LICENSE-FAQ.md +0 -0
- {claude_mpm-5.4.41.dist-info → claude_mpm-5.6.72.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1821 @@
|
|
|
1
|
+
"""Commander chat REPL interface."""
|
|
2
|
+
|
|
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
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Optional
|
|
14
|
+
|
|
15
|
+
from prompt_toolkit import PromptSession, prompt as pt_prompt
|
|
16
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
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
|
+
|
|
94
|
+
|
|
95
|
+
from claude_mpm.commander.instance_manager import InstanceManager
|
|
96
|
+
from claude_mpm.commander.llm.openrouter_client import OpenRouterClient
|
|
97
|
+
from claude_mpm.commander.models.events import EventType
|
|
98
|
+
from claude_mpm.commander.proxy.relay import OutputRelay
|
|
99
|
+
from claude_mpm.commander.session.manager import SessionManager
|
|
100
|
+
|
|
101
|
+
from .commands import Command, CommandParser, CommandType
|
|
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
|
+
|
|
204
|
+
|
|
205
|
+
class CommanderREPL:
|
|
206
|
+
"""Interactive REPL for Commander mode."""
|
|
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
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
instance_manager: InstanceManager,
|
|
250
|
+
session_manager: SessionManager,
|
|
251
|
+
output_relay: Optional[OutputRelay] = None,
|
|
252
|
+
llm_client: Optional[OpenRouterClient] = None,
|
|
253
|
+
event_manager: Optional["EventManager"] = None,
|
|
254
|
+
):
|
|
255
|
+
"""Initialize REPL.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
instance_manager: Manages Claude instances.
|
|
259
|
+
session_manager: Manages chat session state.
|
|
260
|
+
output_relay: Optional relay for instance output.
|
|
261
|
+
llm_client: Optional OpenRouter client for chat.
|
|
262
|
+
event_manager: Optional event manager for notifications.
|
|
263
|
+
"""
|
|
264
|
+
self.instances = instance_manager
|
|
265
|
+
self.session = session_manager
|
|
266
|
+
self.relay = output_relay
|
|
267
|
+
self.llm = llm_client
|
|
268
|
+
self.event_manager = event_manager
|
|
269
|
+
self.parser = CommandParser()
|
|
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
|
|
297
|
+
|
|
298
|
+
async def run(self) -> None:
|
|
299
|
+
"""Start the REPL loop."""
|
|
300
|
+
self._running = True
|
|
301
|
+
self._print_welcome()
|
|
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
|
+
|
|
319
|
+
# Setup history file
|
|
320
|
+
history_path = Path.home() / ".claude-mpm" / "commander_history"
|
|
321
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
|
|
323
|
+
# Create completer for slash commands and instance names
|
|
324
|
+
completer = CommandCompleter(self._get_instance_names)
|
|
325
|
+
|
|
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()
|
|
354
|
+
try:
|
|
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}")
|
|
366
|
+
|
|
367
|
+
self._print("\nGoodbye!")
|
|
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
|
+
|
|
413
|
+
async def _handle_input(self, input_text: str) -> None:
|
|
414
|
+
"""Handle user input - command or natural language.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
input_text: User input string.
|
|
418
|
+
"""
|
|
419
|
+
if not input_text:
|
|
420
|
+
return
|
|
421
|
+
|
|
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
|
|
430
|
+
command = self.parser.parse(input_text)
|
|
431
|
+
if command:
|
|
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)
|
|
471
|
+
else:
|
|
472
|
+
# Default to chat - send to connected instance
|
|
473
|
+
await self._send_to_instance(input_text)
|
|
474
|
+
|
|
475
|
+
async def _execute_command(self, cmd: Command) -> None:
|
|
476
|
+
"""Execute a built-in command.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
cmd: Parsed command.
|
|
480
|
+
"""
|
|
481
|
+
handlers = {
|
|
482
|
+
CommandType.LIST: self._cmd_list,
|
|
483
|
+
CommandType.START: self._cmd_start,
|
|
484
|
+
CommandType.STOP: self._cmd_stop,
|
|
485
|
+
CommandType.CLOSE: self._cmd_close,
|
|
486
|
+
CommandType.REGISTER: self._cmd_register,
|
|
487
|
+
CommandType.CONNECT: self._cmd_connect,
|
|
488
|
+
CommandType.DISCONNECT: self._cmd_disconnect,
|
|
489
|
+
CommandType.SAVED: self._cmd_saved,
|
|
490
|
+
CommandType.FORGET: self._cmd_forget,
|
|
491
|
+
CommandType.STATUS: self._cmd_status,
|
|
492
|
+
CommandType.HELP: self._cmd_help,
|
|
493
|
+
CommandType.EXIT: self._cmd_exit,
|
|
494
|
+
CommandType.MPM_OAUTH: self._cmd_oauth,
|
|
495
|
+
CommandType.CLEANUP: self._cmd_cleanup,
|
|
496
|
+
}
|
|
497
|
+
handler = handlers.get(cmd.type)
|
|
498
|
+
if handler:
|
|
499
|
+
await handler(cmd.args)
|
|
500
|
+
|
|
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."
|
|
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
|
|
652
|
+
git_info = f" [{inst.git_branch}]" if inst.git_branch else ""
|
|
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}")
|
|
675
|
+
|
|
676
|
+
async def _cmd_start(self, args: list[str]) -> None:
|
|
677
|
+
"""Start instance: start <name> OR start <path> [--framework cc|mpm] [--name name]."""
|
|
678
|
+
if not args:
|
|
679
|
+
self._print("Usage: start <name> (for registered instances)")
|
|
680
|
+
self._print(" start <path> [--framework cc|mpm] [--name name]")
|
|
681
|
+
return
|
|
682
|
+
|
|
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
|
|
703
|
+
project_path = Path(args[0]).expanduser().resolve()
|
|
704
|
+
framework = "cc" # default
|
|
705
|
+
name = project_path.name # default
|
|
706
|
+
|
|
707
|
+
# Parse optional flags
|
|
708
|
+
i = 1
|
|
709
|
+
while i < len(args):
|
|
710
|
+
if args[i] == "--framework" and i + 1 < len(args):
|
|
711
|
+
framework = args[i + 1]
|
|
712
|
+
i += 2
|
|
713
|
+
elif args[i] == "--name" and i + 1 < len(args):
|
|
714
|
+
name = args[i + 1]
|
|
715
|
+
i += 2
|
|
716
|
+
else:
|
|
717
|
+
i += 1
|
|
718
|
+
|
|
719
|
+
# Validate path
|
|
720
|
+
if not project_path.exists():
|
|
721
|
+
self._print(f"Error: Path does not exist: {project_path}")
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
if not project_path.is_dir():
|
|
725
|
+
self._print(f"Error: Path is not a directory: {project_path}")
|
|
726
|
+
return
|
|
727
|
+
|
|
728
|
+
# Register and start instance (creates worktree for git repos)
|
|
729
|
+
try:
|
|
730
|
+
instance = await self.instances.register_instance(
|
|
731
|
+
str(project_path), framework, name
|
|
732
|
+
)
|
|
733
|
+
self._print(f"Started instance '{name}' ({framework}) at {project_path}")
|
|
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)
|
|
745
|
+
except Exception as e:
|
|
746
|
+
self._print(f"Error starting instance: {e}")
|
|
747
|
+
|
|
748
|
+
async def _cmd_stop(self, args: list[str]) -> None:
|
|
749
|
+
"""Stop an instance: stop <name>."""
|
|
750
|
+
if not args:
|
|
751
|
+
self._print("Usage: stop <instance-name>")
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
name = args[0]
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
await self.instances.stop_instance(name)
|
|
758
|
+
self._print(f"Stopped instance '{name}'")
|
|
759
|
+
|
|
760
|
+
# Disconnect if we were connected
|
|
761
|
+
if self.session.context.connected_instance == name:
|
|
762
|
+
self.session.disconnect()
|
|
763
|
+
except Exception as e:
|
|
764
|
+
self._print(f"Error stopping instance: {e}")
|
|
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
|
+
|
|
828
|
+
async def _cmd_connect(self, args: list[str]) -> None:
|
|
829
|
+
"""Connect to an instance: connect <name>.
|
|
830
|
+
|
|
831
|
+
If instance is not running but has saved registration, start it first.
|
|
832
|
+
"""
|
|
833
|
+
if not args:
|
|
834
|
+
self._print("Usage: connect <instance-name>")
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
name = args[0]
|
|
838
|
+
inst = self.instances.get_instance(name)
|
|
839
|
+
|
|
840
|
+
if not inst:
|
|
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
|
|
863
|
+
|
|
864
|
+
self.session.connect_to(name)
|
|
865
|
+
self._print(f"Connected to {name}")
|
|
866
|
+
|
|
867
|
+
async def _cmd_disconnect(self, args: list[str]) -> None:
|
|
868
|
+
"""Disconnect from current instance."""
|
|
869
|
+
if not self.session.context.is_connected:
|
|
870
|
+
self._print("Not connected to any instance")
|
|
871
|
+
return
|
|
872
|
+
|
|
873
|
+
name = self.session.context.connected_instance
|
|
874
|
+
self.session.disconnect()
|
|
875
|
+
self._print(f"Disconnected from {name}")
|
|
876
|
+
|
|
877
|
+
async def _cmd_status(self, args: list[str]) -> None:
|
|
878
|
+
"""Show status of current session."""
|
|
879
|
+
if self.session.context.is_connected:
|
|
880
|
+
name = self.session.context.connected_instance
|
|
881
|
+
inst = self.instances.get_instance(name)
|
|
882
|
+
if inst:
|
|
883
|
+
self._print(f"Connected to: {name}")
|
|
884
|
+
self._print(f" Framework: {inst.framework}")
|
|
885
|
+
self._print(f" Project: {inst.project_path}")
|
|
886
|
+
if inst.git_branch:
|
|
887
|
+
self._print(f" Git: {inst.git_branch} ({inst.git_status})")
|
|
888
|
+
self._print(f" Tmux: {inst.tmux_session}:{inst.pane_target}")
|
|
889
|
+
else:
|
|
890
|
+
self._print(f"Connected to: {name} (instance no longer exists)")
|
|
891
|
+
else:
|
|
892
|
+
self._print("Not connected to any instance")
|
|
893
|
+
|
|
894
|
+
self._print(f"Messages in history: {len(self.session.context.messages)}")
|
|
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
|
+
|
|
921
|
+
async def _cmd_help(self, args: list[str]) -> None:
|
|
922
|
+
"""Show help message."""
|
|
923
|
+
help_text = """
|
|
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)
|
|
946
|
+
|
|
947
|
+
Natural Language:
|
|
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.
|
|
953
|
+
|
|
954
|
+
Examples:
|
|
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
|
|
966
|
+
"""
|
|
967
|
+
self._print(help_text)
|
|
968
|
+
|
|
969
|
+
async def _cmd_exit(self, args: list[str]) -> None:
|
|
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
|
+
|
|
979
|
+
self._running = False
|
|
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
|
+
|
|
1485
|
+
async def _send_to_instance(self, message: str) -> None:
|
|
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.
|
|
1490
|
+
|
|
1491
|
+
Args:
|
|
1492
|
+
message: User message to send.
|
|
1493
|
+
"""
|
|
1494
|
+
# Check if instance is connected and ready
|
|
1495
|
+
if not self.session.context.is_connected:
|
|
1496
|
+
self._print("Not connected to any instance. Use 'connect <name>' first.")
|
|
1497
|
+
return
|
|
1498
|
+
|
|
1499
|
+
name = self.session.context.connected_instance
|
|
1500
|
+
inst = self.instances.get_instance(name)
|
|
1501
|
+
if not inst:
|
|
1502
|
+
self._print(f"Instance '{name}' no longer exists")
|
|
1503
|
+
self.session.disconnect()
|
|
1504
|
+
return
|
|
1505
|
+
|
|
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)
|
|
1514
|
+
self.session.add_user_message(message)
|
|
1515
|
+
|
|
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:
|
|
1521
|
+
try:
|
|
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
|
|
1569
|
+
except Exception as 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}")
|
|
1631
|
+
|
|
1632
|
+
def _get_prompt(self) -> str:
|
|
1633
|
+
"""Get prompt string.
|
|
1634
|
+
|
|
1635
|
+
Returns:
|
|
1636
|
+
Prompt string for input, showing instance name when connected.
|
|
1637
|
+
"""
|
|
1638
|
+
connected = self.session.context.connected_instance
|
|
1639
|
+
if connected:
|
|
1640
|
+
return f"Commander ({connected})> "
|
|
1641
|
+
return "Commander> "
|
|
1642
|
+
|
|
1643
|
+
def _print(self, msg: str) -> None:
|
|
1644
|
+
"""Print message to console.
|
|
1645
|
+
|
|
1646
|
+
Args:
|
|
1647
|
+
msg: Message to print.
|
|
1648
|
+
"""
|
|
1649
|
+
print(msg)
|
|
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
|
+
|
|
1785
|
+
def _print_welcome(self) -> None:
|
|
1786
|
+
"""Print welcome message."""
|
|
1787
|
+
print("╔══════════════════════════════════════════╗")
|
|
1788
|
+
print("║ MPM Commander - Interactive Mode ║")
|
|
1789
|
+
print("╚══════════════════════════════════════════╝")
|
|
1790
|
+
print("Type '/help' for commands, or natural language to chat.")
|
|
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
|