| """Subcommand implementations for the openra-rl CLI.""" |
|
|
| import shutil |
| import subprocess |
| import sys |
| import webbrowser |
| from pathlib import Path |
| from typing import Optional |
|
|
| from openra_env.cli.console import dim, error, header, info, step, success, warn |
| from openra_env.cli import docker_manager as docker |
| from openra_env.cli.wizard import ( |
| CONFIG_PATH, |
| has_saved_config, |
| load_saved_config, |
| merge_cli_into_config, |
| run_wizard, |
| ) |
|
|
|
|
| def cmd_play( |
| provider: Optional[str] = None, |
| model: Optional[str] = None, |
| api_key: Optional[str] = None, |
| difficulty: str = "normal", |
| verbose: bool = False, |
| port: int = 8000, |
| server_url: Optional[str] = None, |
| local: bool = False, |
| image_version: Optional[str] = None, |
| ) -> None: |
| """Run the LLM agent against the game server.""" |
| use_docker = server_url is None and not local |
|
|
| |
| if use_docker and not docker.check_docker(): |
| sys.exit(1) |
|
|
| |
| if use_docker and image_version is None: |
| versions = docker.list_local_versions() |
| |
| concrete = [v for v in versions if v != "latest"] |
| if len(concrete) > 1: |
| info(f"Multiple engine versions available: {', '.join(concrete)}") |
| try: |
| choice = input(f" Version to use [{concrete[0]}]: ").strip() |
| except (EOFError, KeyboardInterrupt): |
| choice = "" |
| if choice: |
| image_version = choice |
| else: |
| image_version = concrete[0] |
|
|
| |
| has_cli_overrides = any([provider, model, api_key]) |
|
|
| if has_cli_overrides: |
| config = load_saved_config() or {} |
| config = merge_cli_into_config(config, provider=provider, model=model, api_key=api_key) |
| elif has_saved_config(): |
| config = load_saved_config() or {} |
| else: |
| config = run_wizard() |
|
|
| |
| llm_cfg = config.get("llm", {}) |
| base_url = llm_cfg.get("base_url", "") |
| is_local_llm = any(h in base_url for h in ("localhost", "127.0.0.1", "0.0.0.0")) |
| if not llm_cfg.get("api_key") and not is_local_llm: |
| error("No API key configured. Run `openra-rl config` or pass --api-key.") |
| sys.exit(1) |
| if not llm_cfg.get("model"): |
| error("No model configured. Run `openra-rl config` or pass --model.") |
| sys.exit(1) |
|
|
| |
| actual_url = server_url or f"http://localhost:{port}" |
| we_started_server = False |
| local_server_proc = None |
|
|
| if local: |
| |
| header("Starting local server...") |
| local_server_proc = subprocess.Popen( |
| [sys.executable, "-m", "openra_env.server.app"], |
| stdout=sys.stdout, |
| stderr=sys.stderr, |
| ) |
| we_started_server = True |
| |
| import time |
| import urllib.request |
| import urllib.error |
| step(f"Waiting for local server on port {port}...") |
| start = time.time() |
| while time.time() - start < 60: |
| try: |
| req = urllib.request.urlopen(f"{actual_url}/health", timeout=3) |
| if req.status == 200: |
| success("Local server is ready!") |
| break |
| except (urllib.error.URLError, OSError): |
| pass |
| time.sleep(2) |
| else: |
| error("Local server did not become ready within 60s.") |
| local_server_proc.terminate() |
| sys.exit(1) |
| elif use_docker: |
| if docker.is_running(): |
| info(f"Server already running on port {port}.") |
| else: |
| if not docker.start_server(port=port, difficulty=difficulty, version=image_version): |
| sys.exit(1) |
| we_started_server = True |
| if not docker.wait_for_health(port=port): |
| sys.exit(1) |
|
|
| |
| header("Starting LLM agent...") |
| provider_name = config.get("provider", "custom") |
| info(f"Model: {llm_cfg.get('model', '?')} via {provider_name}") |
| print() |
|
|
| try: |
| _run_llm_agent(config, actual_url, verbose) |
| except KeyboardInterrupt: |
| print("\nInterrupted.") |
| except ConnectionRefusedError: |
| error(f"Could not connect to {actual_url}.") |
| info("Try: openra-rl server start") |
| info("Check: openra-rl doctor") |
| except Exception as e: |
| error(f"Agent error: {e}") |
| info("Run with --verbose for full details, or check: openra-rl doctor") |
|
|
| |
| if use_docker and docker.is_running(): |
| new_replays = docker.copy_replays() |
| if new_replays: |
| print() |
| for f in new_replays: |
| success(f"Replay saved: {docker.LOCAL_REPLAY_DIR / f}") |
| info("Watch with: openra-rl replay watch") |
|
|
| |
| if we_started_server: |
| print() |
| if local_server_proc: |
| try: |
| answer = input(" Stop local server? [Y/n] ").strip().lower() |
| except (EOFError, KeyboardInterrupt): |
| answer = "y" |
| if answer in ("", "y", "yes"): |
| local_server_proc.terminate() |
| local_server_proc.wait(timeout=10) |
| success("Local server stopped.") |
| elif use_docker: |
| try: |
| answer = input(" Stop game server? [Y/n] ").strip().lower() |
| except (EOFError, KeyboardInterrupt): |
| answer = "y" |
| if answer in ("", "y", "yes"): |
| docker.stop_server() |
|
|
|
|
| def _run_llm_agent(config: dict, server_url: str, verbose: bool) -> None: |
| """Import and run the LLM agent with the given config.""" |
| import asyncio |
|
|
| from openra_env.config import load_config |
|
|
| |
| cli_overrides: dict = {} |
| llm_cfg = config.get("llm", {}) |
| if llm_cfg: |
| cli_overrides["llm"] = llm_cfg |
| cli_overrides.setdefault("agent", {})["server_url"] = server_url |
| if verbose: |
| cli_overrides.setdefault("agent", {})["verbose"] = True |
|
|
| app_config = load_config(cli_overrides=cli_overrides) |
|
|
| from openra_env.agent import run_agent |
| asyncio.run(run_agent(app_config, verbose)) |
|
|
|
|
| def cmd_config() -> None: |
| """Re-run the setup wizard.""" |
| run_wizard() |
|
|
|
|
| def cmd_server_start(port: int = 8000, difficulty: str = "normal", detach: bool = True) -> None: |
| """Start the game server.""" |
| if not docker.check_docker(): |
| sys.exit(1) |
| if not docker.start_server(port=port, difficulty=difficulty, detach=detach): |
| sys.exit(1) |
| if detach: |
| docker.wait_for_health(port=port) |
|
|
|
|
| def cmd_server_stop() -> None: |
| """Stop the game server.""" |
| docker.stop_server() |
|
|
|
|
| def cmd_server_status() -> None: |
| """Show game server status.""" |
| status = docker.server_status() |
| if status: |
| success(f"Server is running: {status['status']}") |
| if status.get("ports"): |
| dim(f" Ports: {status['ports']}") |
| else: |
| info("Server is not running.") |
|
|
|
|
| def cmd_server_logs(follow: bool = False) -> None: |
| """Show game server logs.""" |
| docker.get_logs(follow=follow) |
|
|
|
|
| def cmd_doctor() -> None: |
| """Check system prerequisites.""" |
| header("OpenRA-RL Doctor") |
| ok = True |
|
|
| |
| if shutil.which("docker"): |
| success("Docker CLI: installed") |
| from openra_env.cli.docker_manager import _run |
| result = _run(["docker", "info"]) |
| if result.returncode == 0: |
| success("Docker daemon: running") |
| else: |
| warn("Docker daemon: not running") |
| ok = False |
| else: |
| error("Docker CLI: not found") |
| dim(" Install from https://docs.docker.com/get-docker/") |
| ok = False |
|
|
| |
| if docker.image_exists(): |
| success(f"Game image: available ({docker.IMAGE})") |
| else: |
| warn("Game image: not pulled yet (will be pulled on first `openra-rl play`)") |
|
|
| |
| if docker.is_running(): |
| success("Game server: running") |
| else: |
| dim("Game server: not running") |
|
|
| |
| py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" |
| if sys.version_info >= (3, 10): |
| success(f"Python: {py_version}") |
| else: |
| error(f"Python: {py_version} (requires 3.10+)") |
| ok = False |
|
|
| |
| if has_saved_config(): |
| cfg = load_saved_config() or {} |
| provider = cfg.get("provider", "unknown") |
| model = cfg.get("llm", {}).get("model", "unknown") |
| success(f"Config: {CONFIG_PATH}") |
| dim(f" Provider: {provider}, Model: {model}") |
| else: |
| dim("Config: not yet configured (run `openra-rl play` or `openra-rl config`)") |
|
|
| print() |
| if ok: |
| success("All checks passed!") |
| else: |
| warn("Some checks failed. Fix the issues above and try again.") |
|
|
|
|
| def cmd_version() -> None: |
| """Print version.""" |
| try: |
| from importlib.metadata import version |
| v = version("openra-rl") |
| except Exception: |
| v = "dev" |
| print(f"openra-rl {v}") |
|
|
|
|
| def cmd_mcp_server(server_url: Optional[str] = None, port: int = 8000) -> None: |
| """Start the MCP stdio server.""" |
| from openra_env.mcp_server import main as mcp_main |
| mcp_main(server_url=server_url or f"http://localhost:{port}") |
|
|
|
|
| |
|
|
|
|
| def cmd_replay_watch( |
| file: Optional[str] = None, |
| port: int = 6080, |
| resolution: Optional[str] = None, |
| render_mode: Optional[str] = None, |
| vnc_quality: Optional[int] = None, |
| vnc_compression: Optional[int] = None, |
| cpu_cores: Optional[int] = None, |
| ) -> None: |
| """Watch a replay in the browser via VNC-in-Docker.""" |
| if not docker.check_docker(): |
| sys.exit(1) |
|
|
| try: |
| viewer_settings = docker.load_replay_viewer_settings( |
| resolution=resolution, |
| render_mode=render_mode, |
| vnc_quality=vnc_quality, |
| vnc_compression=vnc_compression, |
| cpu_cores=cpu_cores, |
| ) |
| except ValueError as exc: |
| error(f"Invalid replay viewer setting: {exc}") |
| sys.exit(1) |
|
|
| replay_path = file |
|
|
| if replay_path is None: |
| |
| local_replays = sorted(docker.LOCAL_REPLAY_DIR.glob("*.orarep")) |
| if local_replays: |
| replay_path = str(local_replays[-1]) |
| info(f"Latest local replay: {local_replays[-1].name}") |
| elif docker.is_running(): |
| |
| replay_path = docker.get_latest_replay() |
| if replay_path: |
| info(f"Latest container replay: {Path(replay_path).name}") |
| if replay_path is None: |
| error("No replays found. Play a game first with: openra-rl play") |
| sys.exit(1) |
|
|
| header("Starting replay viewer...") |
| info( |
| f"Settings: {viewer_settings.width}x{viewer_settings.height}, " |
| f"render={viewer_settings.render_mode}, " |
| f"vnc q/c={viewer_settings.vnc_quality}/{viewer_settings.vnc_compression}" |
| ) |
|
|
| if not docker.start_replay_viewer(replay_path, port=port, settings=viewer_settings): |
| sys.exit(1) |
|
|
| import time |
| import urllib.error |
| import urllib.request |
|
|
| url = ( |
| f"http://localhost:{port}/vnc.html?autoconnect=1&resize=scale" |
| f"&quality={viewer_settings.vnc_quality}" |
| f"&compression={viewer_settings.vnc_compression}" |
| ) |
| step("Waiting for viewer to be ready...") |
|
|
| ready = False |
| start_time = time.time() |
| timeout = 30 |
| while time.time() - start_time < timeout: |
| if not docker.is_replay_viewer_running(): |
| error("Replay viewer exited before it became ready.") |
| logs = docker.get_replay_viewer_logs() |
| if logs: |
| print() |
| info("Replay viewer logs:") |
| print(logs) |
| sys.exit(1) |
| try: |
| req = urllib.request.urlopen(f"http://localhost:{port}/vnc.html", timeout=2) |
| if 200 <= req.status < 500: |
| ready = True |
| break |
| except (urllib.error.URLError, OSError): |
| pass |
| time.sleep(1) |
|
|
| if not ready: |
| error(f"Viewer did not become ready within {timeout}s.") |
| logs = docker.get_replay_viewer_logs() |
| if logs: |
| print() |
| info("Replay viewer logs:") |
| print(logs) |
| sys.exit(1) |
|
|
| info(f"Opening {url}") |
| webbrowser.open(url) |
| print() |
| info("Tip: press F12 in the viewer for maximum replay speed.") |
| info("Tip: tune with --resolution, --render, --vnc-quality, --vnc-compression.") |
| info("Press Ctrl+C to stop the replay viewer") |
| print() |
|
|
| try: |
| |
| while docker.is_replay_viewer_running(): |
| time.sleep(2) |
| info("Replay viewer has stopped.") |
| except KeyboardInterrupt: |
| print() |
| docker.stop_replay_viewer() |
|
|
|
|
| def cmd_replay_list() -> None: |
| """List available replays from Docker and local.""" |
| header("Game Replays") |
|
|
| |
| if docker.is_running(): |
| docker_replays = docker.list_replays() |
| if docker_replays: |
| info(f"In Docker container ({len(docker_replays)}):") |
| for r in docker_replays: |
| dim(f" {Path(r).name}") |
| else: |
| dim(" No replays in Docker container.") |
| else: |
| dim(" Docker server not running — cannot list container replays.") |
|
|
| |
| print() |
| local_dir = docker.LOCAL_REPLAY_DIR |
| if local_dir.exists(): |
| local_replays = sorted(local_dir.glob("*.orarep")) |
| if local_replays: |
| info(f"Local ({len(local_replays)}) — {local_dir}:") |
| for r in local_replays: |
| dim(f" {r.name}") |
| else: |
| dim(f" No local replays in {local_dir}") |
| else: |
| dim(f" No local replay directory ({local_dir})") |
|
|
|
|
| def cmd_replay_copy() -> None: |
| """Copy replays from Docker container to local directory.""" |
| if not docker.check_docker(): |
| sys.exit(1) |
|
|
| if not docker.is_running(): |
| error("Game server is not running. Start it first or use: openra-rl server start") |
| sys.exit(1) |
|
|
| header("Copying replays from Docker...") |
| new_files = docker.copy_replays() |
| if new_files: |
| for f in new_files: |
| success(f" Copied: {f}") |
| success(f"Copied {len(new_files)} new replay(s) to {docker.LOCAL_REPLAY_DIR}") |
| else: |
| info(f"No new replays to copy. Replays are in {docker.LOCAL_REPLAY_DIR}") |
|
|
|
|
| def cmd_replay_stop() -> None: |
| """Stop the replay viewer.""" |
| docker.stop_replay_viewer() |
|
|