from __future__ import annotations import argparse from datetime import datetime, timezone import json import os from pathlib import Path import signal import subprocess import sys import time from typing import Any from urllib.error import URLError from urllib.request import urlopen ROOT = Path(__file__).resolve().parents[1] DEFAULT_RUNTIME_DIR = ROOT / ".local_stack" DEFAULT_DATA_DIR = ROOT / ".data_local" DEFAULT_ENV_FILES = [ ROOT / ".env.production", ROOT / ".env.local", ROOT / ".env", ] def _env_int(name: str, default: int, env: dict[str, str] | None = None) -> int: source = env or os.environ raw = str(source.get(name, "")).strip() if not raw: return default try: value = int(raw) except ValueError as exc: raise RuntimeError(f"{name} must be an integer") from exc if value <= 0: raise RuntimeError(f"{name} must be positive") return value def utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() def load_env_file(path: Path) -> dict[str, str]: if not path.exists(): return {} result: dict[str, str] = {} for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip() if not key: continue if value and value[0] == value[-1] and value[0] in {'"', "'"}: value = value[1:-1] result[key] = value return result def load_stack_env(extra_env_files: list[Path] | None = None) -> dict[str, str]: env = os.environ.copy() for path in DEFAULT_ENV_FILES: for key, value in load_env_file(path).items(): env.setdefault(key, value) for path in extra_env_files or []: for key, value in load_env_file(path).items(): env[key] = value return env def build_service_specs(env: dict[str, str] | None = None) -> list[dict[str, Any]]: gateway_port = _env_int("LOCAL_STACK_GATEWAY_PORT", 8080, env) return [ {"name": "auth", "port": 8001, "module": "services.auth_service.app:app"}, {"name": "audit", "port": 8002, "module": "services.audit_service.app:app"}, {"name": "customer", "port": 8003, "module": "services.customer_service.app:app"}, {"name": "interaction", "port": 8004, "module": "services.interaction_service.app:app"}, {"name": "routing", "port": 8005, "module": "services.routing_service.app:app"}, {"name": "voice", "port": 8006, "module": "services.voice_adapter_service.app:app"}, {"name": "recording", "port": 8013, "module": "services.recording_service.app:app"}, {"name": "ivr", "port": 8014, "module": "services.ivr_service.app:app"}, {"name": "event-bus", "port": 8015, "module": "services.event_bus_service.app:app"}, {"name": "asterisk-bridge", "port": 8016, "module": "services.asterisk_bridge_service.app:app"}, {"name": "telegram", "port": 8007, "module": "services.telegram_adapter_service.app:app"}, {"name": "whatsapp", "port": 8019, "module": "services.whatsapp_adapter_service.app:app"}, {"name": "ai", "port": 8017, "module": "services.ai_orchestrator_service.app:app"}, {"name": "streaming-asr-sidecar", "port": 8021, "module": "services.streaming_asr_sidecar_service.app:app"}, {"name": "ai-voice-runtime", "port": 8018, "module": "services.ai_voice_runtime_service.app:app"}, {"name": "webchat", "port": 8011, "module": "services.webchat_adapter_service.app:app"}, {"name": "email", "port": 8012, "module": "services.email_adapter_service.app:app"}, {"name": "kb", "port": 8008, "module": "services.kb_service.app:app"}, {"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"}, {"name": "supervisor", "port": 8010, "module": "services.supervisor_service.app:app"}, {"name": "sales", "port": 8020, "module": "services.sales_service.app:app"}, {"name": "gateway", "port": gateway_port, "module": "gateway.app:app"}, ] def build_gateway_env(env: dict[str, str] | None = None) -> dict[str, str]: ports = {spec["name"]: int(spec["port"]) for spec in build_service_specs(env)} base = "http://127.0.0.1" return { "AUTH_SERVICE_URL": f"{base}:{ports['auth']}", "AUDIT_SERVICE_URL": f"{base}:{ports['audit']}", "CUSTOMER_SERVICE_URL": f"{base}:{ports['customer']}", "INTERACTION_SERVICE_URL": f"{base}:{ports['interaction']}", "ROUTING_SERVICE_URL": f"{base}:{ports['routing']}", "VOICE_ADAPTER_SERVICE_URL": f"{base}:{ports['voice']}", "RECORDING_SERVICE_URL": f"{base}:{ports['recording']}", "IVR_SERVICE_URL": f"{base}:{ports['ivr']}", "EVENT_BUS_SERVICE_URL": f"{base}:{ports['event-bus']}", "ASTERISK_BRIDGE_SERVICE_URL": f"{base}:{ports['asterisk-bridge']}", "TELEGRAM_ADAPTER_SERVICE_URL": f"{base}:{ports['telegram']}", "WHATSAPP_ADAPTER_SERVICE_URL": f"{base}:{ports['whatsapp']}", "AI_ORCHESTRATOR_SERVICE_URL": f"{base}:{ports['ai']}", "AI_VOICE_RUNTIME_SERVICE_URL": f"{base}:{ports['ai-voice-runtime']}", "WEBCHAT_ADAPTER_SERVICE_URL": f"{base}:{ports['webchat']}", "EMAIL_ADAPTER_SERVICE_URL": f"{base}:{ports['email']}", "KB_SERVICE_URL": f"{base}:{ports['kb']}", "REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}", "SUPERVISOR_SERVICE_URL": f"{base}:{ports['supervisor']}", "SALES_SERVICE_URL": f"{base}:{ports['sales']}", } def service_bind_host(spec_name: str, env: dict[str, str]) -> str: if spec_name == "gateway": return env.get("LOCAL_STACK_GATEWAY_HOST", "127.0.0.1").strip() or "127.0.0.1" return env.get("LOCAL_STACK_SERVICE_HOST", "127.0.0.1").strip() or "127.0.0.1" def service_health_host(spec_name: str, env: dict[str, str]) -> str: if spec_name == "gateway": configured = env.get("LOCAL_STACK_GATEWAY_HEALTH_HOST", "").strip() if configured: return configured bind_host = service_bind_host(spec_name, env) if bind_host in {"0.0.0.0", "::"}: return "127.0.0.1" return bind_host def public_base_url(env: dict[str, str] | None = None) -> str: current_env = env or {} default_url = "http://127.0.0.1:8080" return current_env.get("LOCAL_STACK_PUBLIC_BASE_URL", default_url).strip() or default_url def build_manifest_payload( runtime_dir: Path, data_dir: Path, services: list[dict[str, Any]], env: dict[str, str] | None = None, ) -> dict[str, Any]: return { "generated_at": utc_now_iso(), "root": str(ROOT), "runtime_dir": str(runtime_dir), "data_dir": str(data_dir), "base_url": public_base_url(env), "services": services, } def manifest_path(runtime_dir: Path) -> Path: return runtime_dir / "manifest.json" def load_manifest(runtime_dir: Path) -> dict[str, Any] | None: path = manifest_path(runtime_dir) if not path.exists(): return None return json.loads(path.read_text(encoding="utf-8")) def save_manifest(runtime_dir: Path, payload: dict[str, Any]) -> None: runtime_dir.mkdir(parents=True, exist_ok=True) manifest_path(runtime_dir).write_text(json.dumps(payload, indent=2), encoding="utf-8") def remove_manifest(runtime_dir: Path) -> None: path = manifest_path(runtime_dir) if path.exists(): path.unlink() def is_pid_running(pid: int) -> bool: if pid <= 0: return False if os.name == "nt": result = subprocess.run( ["tasklist", "/FI", f"PID eq {pid}"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False, ) if result.returncode != 0: return False return str(pid) in (result.stdout or "") try: os.kill(pid, 0) except (OSError, SystemError): return False return True def resolve_listener_pid(port: int) -> int | None: if os.name == "nt": result = subprocess.run( ["netstat", "-ano", "-p", "tcp"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False, ) if result.returncode != 0: return None markers = {f"127.0.0.1:{port}", f"0.0.0.0:{port}", f"[::]:{port}"} for raw_line in (result.stdout or "").splitlines(): line = raw_line.strip() if "LISTENING" not in line: continue parts = line.split() if len(parts) < 5: continue local_address = parts[1] state = parts[3] pid_raw = parts[4] if local_address not in markers or state != "LISTENING": continue try: pid = int(pid_raw) except ValueError: continue if pid > 0: return pid return None return None def wait_for_health(host: str, port: int, retries: int = 80, delay: float = 0.25) -> None: url = f"http://{host}:{port}/health" for _ in range(retries): try: with urlopen(url, timeout=1.5) as response: # noqa: S310 - local health probe if response.status == 200: return except (OSError, URLError): pass time.sleep(delay) raise RuntimeError(f"Service did not become healthy: {url}") def create_log_paths(runtime_dir: Path, name: str) -> tuple[Path, Path]: log_dir = runtime_dir / "logs" log_dir.mkdir(parents=True, exist_ok=True) return log_dir / f"{name}.stdout.log", log_dir / f"{name}.stderr.log" def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_env: dict[str, str]) -> dict[str, Any]: env = base_env.copy() env["CC_DATA_DIR"] = str(data_dir) env["EVENT_BUS_ENABLED"] = env.get("EVENT_BUS_ENABLED", "0") env["EVENT_BUS_URL"] = env.get("EVENT_BUS_URL", "amqp://guest:guest@127.0.0.1:5672/") env["EVENT_BUS_EXCHANGE"] = env.get("EVENT_BUS_EXCHANGE", "mvpcc.domain.events") env["EVENT_BUS_DISPATCH_BATCH_SIZE"] = env.get("EVENT_BUS_DISPATCH_BATCH_SIZE", "50") env["EVENT_BUS_MAX_ATTEMPTS"] = env.get("EVENT_BUS_MAX_ATTEMPTS", "5") env["EVENT_BUS_POLL_INTERVAL_SECONDS"] = env.get("EVENT_BUS_POLL_INTERVAL_SECONDS", "2") env["EVENT_BUS_CONSUMER_ENABLED"] = env.get("EVENT_BUS_CONSUMER_ENABLED", "1") env["EVENT_BUS_AUDIT_QUEUE"] = env.get("EVENT_BUS_AUDIT_QUEUE", "mvpcc.audit.events") env["EVENT_BUS_REPORTING_QUEUE"] = env.get("EVENT_BUS_REPORTING_QUEUE", "mvpcc.reporting.events") env["ASTERISK_BRIDGE_ENABLED"] = env.get("ASTERISK_BRIDGE_ENABLED", "0") env["ASTERISK_AMI_EVENT_PREFIX"] = env.get("ASTERISK_AMI_EVENT_PREFIX", "MVPCC") env["ASTERISK_QUEUE_MAP_JSON"] = env.get("ASTERISK_QUEUE_MAP_JSON", "{}") env["ASTERISK_CALLCONTROL_ENABLED"] = env.get("ASTERISK_CALLCONTROL_ENABLED", "0") env["ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS"] = env.get( "ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS", "10", ) env["ASTERISK_WEBRTC_ENABLED"] = env.get("ASTERISK_WEBRTC_ENABLED", "0") env["ASTERISK_WEBRTC_WS_URL"] = env.get("ASTERISK_WEBRTC_WS_URL", "") env["ASTERISK_WEBRTC_ICE_SERVERS_JSON"] = env.get("ASTERISK_WEBRTC_ICE_SERVERS_JSON", "[]") env["ASTERISK_OPERATOR_EXTENSION_MAP_JSON"] = env.get("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", "{}") env["ASTERISK_BROWSER_SIP_MAP_JSON"] = env.get("ASTERISK_BROWSER_SIP_MAP_JSON", "{}") env["ASTERISK_TRANSFER_TARGET_MAP_JSON"] = env.get("ASTERISK_TRANSFER_TARGET_MAP_JSON", "{}") env["AI_PROVIDER"] = env.get("AI_PROVIDER", "stub") env["AI_API_BASE"] = env.get("AI_API_BASE", "") env["AI_API_KEY"] = env.get("AI_API_KEY", "") env["AI_MODEL"] = env.get("AI_MODEL", "stub-telegram-assistant") env["AI_TIMEOUT_SECONDS"] = env.get("AI_TIMEOUT_SECONDS", "20") env["AI_TELEGRAM_ENABLED"] = env.get("AI_TELEGRAM_ENABLED", "0") env["AI_TELEGRAM_ALWAYS_REPLY"] = env.get("AI_TELEGRAM_ALWAYS_REPLY", "0") env["AI_TELEGRAM_MAX_CONTEXT_MESSAGES"] = env.get("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", "12") env["AI_TELEGRAM_MAX_KB_RESULTS"] = env.get("AI_TELEGRAM_MAX_KB_RESULTS", "3") env["AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get( "AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD", "0.65", ) env["AI_WHATSAPP_ENABLED"] = env.get("AI_WHATSAPP_ENABLED", "0") env["AI_WHATSAPP_ALWAYS_REPLY"] = env.get("AI_WHATSAPP_ALWAYS_REPLY", "0") env["AI_WHATSAPP_MAX_CONTEXT_MESSAGES"] = env.get("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", "12") env["AI_WHATSAPP_MAX_KB_RESULTS"] = env.get("AI_WHATSAPP_MAX_KB_RESULTS", "3") env["AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get( "AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD", "0.65", ) env["AI_VOICE_RUNTIME_SERVICE_URL"] = env.get("AI_VOICE_RUNTIME_SERVICE_URL", "http://127.0.0.1:8018") env["AI_VOICE_ENABLED"] = env.get("AI_VOICE_ENABLED", "0") env["AI_VOICE_QUEUE_CONFIG_JSON"] = env.get("AI_VOICE_QUEUE_CONFIG_JSON", "{}") env["AI_VOICE_ASR_PROVIDER"] = env.get("AI_VOICE_ASR_PROVIDER", "yandex") env["AI_VOICE_TTS_PROVIDER"] = env.get("AI_VOICE_TTS_PROVIDER", "yandex") env["AI_VOICE_POLICY_MODE"] = env.get("AI_VOICE_POLICY_MODE", "v2_fast_conversational") env["AI_VOICE_V2_ENABLED"] = env.get("AI_VOICE_V2_ENABLED", "1") env["AI_VOICE_V2_QUEUE_CODES"] = env.get("AI_VOICE_V2_QUEUE_CODES", "voice_lab_ai") env["AI_VOICE_V2_ACK_MODE"] = env.get("AI_VOICE_V2_ACK_MODE", "immediate_short") env["AI_VOICE_V2_DUPLEX_ENABLED"] = env.get("AI_VOICE_V2_DUPLEX_ENABLED", "1") env["AI_VOICE_V2_STREAMING_ASR_BACKEND"] = env.get("AI_VOICE_V2_STREAMING_ASR_BACKEND", "yandex_speechkit") env["AI_VOICE_V2_STREAMING_ASR_BASE_URL"] = env.get("AI_VOICE_V2_STREAMING_ASR_BASE_URL", "http://127.0.0.1:8021") env["AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS"] = env.get("AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS", "4") env["AI_VOICE_V2_STREAMING_ASR_PARTIAL_POLL_ENABLED"] = env.get("AI_VOICE_V2_STREAMING_ASR_PARTIAL_POLL_ENABLED", "1") env["AI_VOICE_V2_STREAMING_ASR_MODEL"] = env.get("AI_VOICE_V2_STREAMING_ASR_MODEL", "base") env["AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE"] = env.get("AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE", "int8") env["AI_VOICE_V2_STREAMING_ASR_DEVICE"] = env.get("AI_VOICE_V2_STREAMING_ASR_DEVICE", "cpu") env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"] = env.get("AI_VOICE_V2_STREAMING_ASR_CACHE_DIR", str(ROOT / ".models" / "faster_whisper")) env["AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES"] = env.get("AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES", "ru") env["AI_VOICE_V2_PREBAKED_ACK_ENABLED"] = env.get("AI_VOICE_V2_PREBAKED_ACK_ENABLED", "1") env["AI_VOICE_V2_PREBAKED_ACK_DIR"] = env.get("AI_VOICE_V2_PREBAKED_ACK_DIR", str(DATA_DIR / "voice_v2_ack_bank")) env["AI_VOICE_V2_STREAMING_TTS"] = env.get("AI_VOICE_V2_STREAMING_TTS", "1") env["AI_VOICE_V2_PARTIAL_ASR"] = env.get("AI_VOICE_V2_PARTIAL_ASR", "1") env["AI_VOICE_V2_EMOTIVE_ACK_ENABLED"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_ENABLED", "1") env["AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY", "1") env["AI_VOICE_MAX_CONTEXT_SEGMENTS"] = env.get("AI_VOICE_MAX_CONTEXT_SEGMENTS", "12") env["AI_VOICE_HANDOFF_TIMEOUT_SECONDS"] = env.get("AI_VOICE_HANDOFF_TIMEOUT_SECONDS", "8") env["AI_VOICE_VAD_TRAILING_SILENCE_MS"] = env.get("AI_VOICE_VAD_TRAILING_SILENCE_MS", "400") env["AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS"] = env.get( "AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS", "svc:ai-voice-runtime", ) env.update(build_gateway_env(env)) env["ASTERISK_CALLCONTROL_CLAIM_CONTEXT"] = env.get("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim") env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get( "ASTERISK_CALLCONTROL_TRANSFER_CONTEXT", "mvpcc-transfer", ) if spec["name"] == "recording": env["CC_RECORDINGS_DIR"] = str((data_dir / "recordings").resolve()) env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400") if spec["name"] == "streaming-asr-sidecar": Path(env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"]).mkdir(parents=True, exist_ok=True) stdout_path, stderr_path = create_log_paths(runtime_dir, str(spec["name"])) creationflags = 0 if os.name == "nt": creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) cmd = [ sys.executable, "-m", "uvicorn", str(spec["module"]), "--host", service_bind_host(str(spec["name"]), env), "--port", str(spec["port"]), ] with stdout_path.open("ab") as stdout_file, stderr_path.open("ab") as stderr_file: proc = subprocess.Popen( cmd, cwd=str(ROOT), env=env, stdin=subprocess.DEVNULL, stdout=stdout_file, stderr=stderr_file, creationflags=creationflags, ) time.sleep(0.15) if proc.poll() is not None: raise RuntimeError(f"{spec['name']} failed to start; check {stderr_path}") return { "name": spec["name"], "module": spec["module"], "port": spec["port"], "pid": proc.pid, "stdout_log": str(stdout_path), "stderr_log": str(stderr_path), } def terminate_pid(pid: int) -> None: if not is_pid_running(pid): return try: os.kill(pid, signal.SIGTERM) except OSError: return for _ in range(20): if not is_pid_running(pid): return time.sleep(0.2) if os.name == "nt": subprocess.run( ["taskkill", "/PID", str(pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) return try: os.kill(pid, signal.SIGKILL) except OSError: pass def running_services(manifest: dict[str, Any] | None) -> list[dict[str, Any]]: if not manifest or not isinstance(manifest, dict): return [] active: list[dict[str, Any]] = [] for svc in manifest.get("services", []): if not isinstance(svc, dict): continue try: pid = int(svc.get("pid", 0)) except Exception: # noqa: BLE001 continue if is_pid_running(pid): active.append(svc) return active def start_stack(runtime_dir: Path, data_dir: Path, force_restart: bool, env: dict[str, str]) -> int: service_specs = build_service_specs(env) current = load_manifest(runtime_dir) active = running_services(current) if active and not force_restart: print("Local stack is already running.") print(f"Base URL: {current.get('base_url', public_base_url(env))}") print(f"Logs: {runtime_dir / 'logs'}") print("Use `python scripts\\local_stack.py stop` first or rerun with --force-restart.") return 0 if active and force_restart: stop_stack(runtime_dir) if current and not active: remove_manifest(runtime_dir) runtime_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True) launched: list[dict[str, Any]] = [] try: for spec in service_specs: meta = spawn_service(spec, runtime_dir, data_dir, env) wait_for_health(service_health_host(str(spec["name"]), env), int(meta["port"])) if not is_pid_running(int(meta["pid"])): listener_pid = resolve_listener_pid(int(meta["port"])) if listener_pid and is_pid_running(listener_pid): meta["pid"] = listener_pid else: raise RuntimeError(f"{meta['name']} exited before becoming ready; check {meta['stderr_log']}") launched.append(meta) print(f"[ok] {meta['name']} on {meta['port']} (pid {meta['pid']})") except Exception as exc: print(f"[fail] {exc}") print(f"Check logs: {runtime_dir / 'logs'}") print("If ports are already busy, stop old local processes and rerun.") for meta in reversed(launched): terminate_pid(int(meta["pid"])) return 1 save_manifest(runtime_dir, build_manifest_payload(runtime_dir, data_dir, launched, env)) print("") print("Local stack is ready.") print(f"UI: {public_base_url(env)}/") print(f"Logs: {runtime_dir / 'logs'}") print("Stop: python scripts\\local_stack.py stop") return 0 def stop_stack(runtime_dir: Path) -> int: current = load_manifest(runtime_dir) if not current: print("Local stack is not running (no manifest found).") return 0 services = current.get("services", []) if isinstance(current, dict) else [] for meta in reversed(services): if not isinstance(meta, dict): continue name = str(meta.get("name", "service")) try: pid = int(meta.get("pid", 0)) except Exception: # noqa: BLE001 print(f"[skip] {name} (pid invalid)") continue if is_pid_running(pid): terminate_pid(pid) print(f"[stopped] {name} (pid {pid})") else: print(f"[skip] {name} (pid {pid}) already stopped") remove_manifest(runtime_dir) print("Local stack is stopped.") return 0 def status_stack(runtime_dir: Path, env: dict[str, str]) -> int: current = load_manifest(runtime_dir) if not current: print("Local stack is not running.") return 0 print(f"Base URL: {current.get('base_url', public_base_url(env))}") print(f"Data dir: {current.get('data_dir', str(DEFAULT_DATA_DIR))}") print(f"Logs: {runtime_dir / 'logs'}") print("") services = current.get("services", []) if isinstance(current, dict) else [] for meta in services: if not isinstance(meta, dict): continue try: pid = int(meta.get("pid", 0)) except Exception: # noqa: BLE001 pid = 0 label = "running" if is_pid_running(pid) else "stopped" print(f"- {meta.get('name')}: {label} on {meta.get('port')} (pid {pid})") return 0 def main() -> int: parser = argparse.ArgumentParser(description="Manage the local MVP stack without opening extra consoles") parser.add_argument("command", choices=["start", "stop", "status"], help="Action to perform") parser.add_argument( "--runtime-dir", default=str(DEFAULT_RUNTIME_DIR), help="Directory for manifest and logs", ) parser.add_argument( "--data-dir", default=None, help="Directory for local runtime data and recordings", ) parser.add_argument( "--env-file", action="append", default=[], help="Additional env file to apply after the default stack env files", ) parser.add_argument( "--force-restart", action="store_true", help="Stop the existing managed stack before starting a new one", ) args = parser.parse_args() extra_env_files: list[Path] = [] for raw_path in args.env_file: path = Path(raw_path) if not path.is_absolute(): path = (ROOT / path).resolve() extra_env_files.append(path) env = load_stack_env(extra_env_files) runtime_dir = Path(args.runtime_dir) if not runtime_dir.is_absolute(): runtime_dir = (ROOT / runtime_dir).resolve() raw_data_dir = args.data_dir or env.get("CC_DATA_DIR") or str(DEFAULT_DATA_DIR) data_dir = Path(raw_data_dir) if not data_dir.is_absolute(): data_dir = (ROOT / data_dir).resolve() if args.command == "start": return start_stack(runtime_dir, data_dir, args.force_restart, env) if args.command == "stop": return stop_stack(runtime_dir) return status_stack(runtime_dir, env) if __name__ == "__main__": raise SystemExit(main())