diff --git a/openhands-cli/openhands_cli/argparsers/main_parser.py b/openhands-cli/openhands_cli/argparsers/main_parser.py
new file mode 100644
index 0000000000..6f28d1e637
--- /dev/null
+++ b/openhands-cli/openhands_cli/argparsers/main_parser.py
@@ -0,0 +1,56 @@
+"""Main argument parser for OpenHands CLI."""
+
+import argparse
+
+
+def create_main_parser() -> argparse.ArgumentParser:
+ """Create the main argument parser with CLI as default and serve as subcommand.
+
+ Returns:
+ The configured argument parser
+ """
+ parser = argparse.ArgumentParser(
+ description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+By default, OpenHands runs in CLI mode (terminal interface).
+Use 'serve' subcommand to launch the GUI server instead.
+
+Examples:
+ openhands # Start CLI mode
+ openhands --resume conversation-id # Resume a conversation in CLI mode
+ openhands serve # Launch GUI server
+ openhands serve --gpu # Launch GUI server with GPU support
+"""
+ )
+
+ # CLI arguments at top level (default mode)
+ parser.add_argument(
+ '--resume',
+ type=str,
+ help='Conversation ID to resume'
+ )
+
+ # Only serve as subcommand
+ subparsers = parser.add_subparsers(
+ dest='command',
+ help='Additional commands'
+ )
+
+ # Add serve subcommand
+ serve_parser = subparsers.add_parser(
+ 'serve',
+ help='Launch the OpenHands GUI server using Docker (web interface)'
+ )
+ serve_parser.add_argument(
+ '--mount-cwd',
+ action='store_true',
+ help='Mount the current working directory in the Docker container'
+ )
+ serve_parser.add_argument(
+ '--gpu',
+ action='store_true',
+ help='Enable GPU support in the Docker container'
+ )
+
+ return parser
\ No newline at end of file
diff --git a/openhands-cli/openhands_cli/argparsers/serve_parser.py b/openhands-cli/openhands_cli/argparsers/serve_parser.py
new file mode 100644
index 0000000000..dea9912548
--- /dev/null
+++ b/openhands-cli/openhands_cli/argparsers/serve_parser.py
@@ -0,0 +1,31 @@
+"""Argument parser for serve subcommand."""
+
+import argparse
+
+
+def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
+ """Add serve subcommand parser.
+
+ Args:
+ subparsers: The subparsers object to add the serve parser to
+
+ Returns:
+ The serve argument parser
+ """
+ serve_parser = subparsers.add_parser(
+ 'serve',
+ help='Launch the OpenHands GUI server using Docker (web interface)'
+ )
+ serve_parser.add_argument(
+ '--mount-cwd',
+ help='Mount the current working directory into the GUI server container',
+ action='store_true',
+ default=False,
+ )
+ serve_parser.add_argument(
+ '--gpu',
+ help='Enable GPU support by mounting all GPUs into the Docker container via nvidia-docker',
+ action='store_true',
+ default=False,
+ )
+ return serve_parser
\ No newline at end of file
diff --git a/openhands-cli/openhands_cli/gui_launcher.py b/openhands-cli/openhands_cli/gui_launcher.py
new file mode 100644
index 0000000000..49cb5579c4
--- /dev/null
+++ b/openhands-cli/openhands_cli/gui_launcher.py
@@ -0,0 +1,229 @@
+"""GUI launcher for OpenHands CLI."""
+
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+from prompt_toolkit import print_formatted_text
+from prompt_toolkit.formatted_text import HTML
+from openhands_cli.locations import PERSISTENCE_DIR
+
+
+def _format_docker_command_for_logging(cmd: list[str]) -> str:
+ """Format a Docker command for logging with grey color.
+
+ Args:
+ cmd (list[str]): The Docker command as a list of strings
+
+ Returns:
+ str: The formatted command string in grey HTML color
+ """
+ cmd_str = ' '.join(cmd)
+ return f'Running Docker command: {cmd_str}'
+
+
+def check_docker_requirements() -> bool:
+ """Check if Docker is installed and running.
+
+ Returns:
+ bool: True if Docker is available and running, False otherwise.
+ """
+ # Check if Docker is installed
+ if not shutil.which('docker'):
+ print_formatted_text(
+ HTML('❌ Docker is not installed or not in PATH.')
+ )
+ print_formatted_text(
+ HTML(
+ 'Please install Docker first: https://docs.docker.com/get-docker/'
+ )
+ )
+ return False
+
+ # Check if Docker daemon is running
+ try:
+ result = subprocess.run(
+ ['docker', 'info'], capture_output=True, text=True, timeout=10
+ )
+ if result.returncode != 0:
+ print_formatted_text(
+ HTML('❌ Docker daemon is not running.')
+ )
+ print_formatted_text(
+ HTML('Please start Docker and try again.')
+ )
+ return False
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
+ print_formatted_text(
+ HTML('❌ Failed to check Docker status.')
+ )
+ print_formatted_text(HTML(f'Error: {e}'))
+ return False
+
+ return True
+
+
+def ensure_config_dir_exists() -> Path:
+ """Ensure the OpenHands configuration directory exists and return its path."""
+ path = Path(PERSISTENCE_DIR)
+ path.mkdir(exist_ok=True, parents=True)
+ return path
+
+
+def get_openhands_version() -> str:
+ """Get the OpenHands version for Docker images.
+
+ Returns:
+ str: The version string to use for Docker images
+ """
+ # For now, use 'latest' as the default version
+ # In the future, this could be read from a version file or environment variable
+ return os.environ.get('OPENHANDS_VERSION', 'latest')
+
+
+def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
+ """Launch the OpenHands GUI server using Docker.
+
+ Args:
+ mount_cwd: If True, mount the current working directory into the container.
+ gpu: If True, enable GPU support by mounting all GPUs into the container via nvidia-docker.
+ """
+ print_formatted_text(
+ HTML('🚀 Launching OpenHands GUI server...')
+ )
+ print_formatted_text('')
+
+ # Check Docker requirements
+ if not check_docker_requirements():
+ sys.exit(1)
+
+ # Ensure config directory exists
+ config_dir = ensure_config_dir_exists()
+
+ # Get the current version for the Docker image
+ version = get_openhands_version()
+ runtime_image = f'docker.all-hands.dev/all-hands-ai/runtime:{version}-nikolaik'
+ app_image = f'docker.all-hands.dev/all-hands-ai/openhands:{version}'
+
+ print_formatted_text(HTML('Pulling required Docker images...'))
+
+ # Pull the runtime image first
+ pull_cmd = ['docker', 'pull', runtime_image]
+ print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
+ try:
+ subprocess.run(
+ pull_cmd,
+ check=True,
+ timeout=300, # 5 minutes timeout
+ )
+ except subprocess.CalledProcessError:
+ print_formatted_text(
+ HTML('❌ Failed to pull runtime image.')
+ )
+ sys.exit(1)
+ except subprocess.TimeoutExpired:
+ print_formatted_text(
+ HTML('❌ Timeout while pulling runtime image.')
+ )
+ sys.exit(1)
+
+ print_formatted_text('')
+ print_formatted_text(
+ HTML('✅ Starting OpenHands GUI server...')
+ )
+ print_formatted_text(
+ HTML('The server will be available at: http://localhost:3000')
+ )
+ print_formatted_text(HTML('Press Ctrl+C to stop the server.'))
+ print_formatted_text('')
+
+ # Build the Docker command
+ docker_cmd = [
+ 'docker',
+ 'run',
+ '-it',
+ '--rm',
+ '--pull=always',
+ '-e',
+ f'SANDBOX_RUNTIME_CONTAINER_IMAGE={runtime_image}',
+ '-e',
+ 'LOG_ALL_EVENTS=true',
+ '-v',
+ '/var/run/docker.sock:/var/run/docker.sock',
+ '-v',
+ f'{config_dir}:/.openhands',
+ ]
+
+ # Add GPU support if requested
+ if gpu:
+ print_formatted_text(
+ HTML('🖥️ Enabling GPU support via nvidia-docker...')
+ )
+ # Add the --gpus all flag to enable all GPUs
+ docker_cmd.insert(2, '--gpus')
+ docker_cmd.insert(3, 'all')
+ # Add environment variable to pass GPU support to sandbox containers
+ docker_cmd.extend(
+ [
+ '-e',
+ 'SANDBOX_ENABLE_GPU=true',
+ ]
+ )
+
+ # Add current working directory mount if requested
+ if mount_cwd:
+ cwd = Path.cwd()
+ # Following the documentation at https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem
+ docker_cmd.extend(
+ [
+ '-e',
+ f'SANDBOX_VOLUMES={cwd}:/workspace:rw',
+ ]
+ )
+
+ # Set user ID for Unix-like systems only
+ if os.name != 'nt': # Not Windows
+ try:
+ user_id = subprocess.check_output(['id', '-u'], text=True).strip()
+ docker_cmd.extend(['-e', f'SANDBOX_USER_ID={user_id}'])
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ # If 'id' command fails or doesn't exist, skip setting user ID
+ pass
+ # Print the folder that will be mounted to inform the user
+ print_formatted_text(
+ HTML(
+ f'📂 Mounting current directory: {cwd} to /workspace'
+ )
+ )
+
+ docker_cmd.extend(
+ [
+ '-p',
+ '3000:3000',
+ '--add-host',
+ 'host.docker.internal:host-gateway',
+ '--name',
+ 'openhands-app',
+ app_image,
+ ]
+ )
+
+ try:
+ # Log and run the Docker command
+ print_formatted_text(HTML(_format_docker_command_for_logging(docker_cmd)))
+ subprocess.run(docker_cmd, check=True)
+ except subprocess.CalledProcessError as e:
+ print_formatted_text('')
+ print_formatted_text(
+ HTML('❌ Failed to start OpenHands GUI server.')
+ )
+ print_formatted_text(HTML(f'Error: {e}'))
+ sys.exit(1)
+ except KeyboardInterrupt:
+ print_formatted_text('')
+ print_formatted_text(
+ HTML('✓ OpenHands GUI server stopped successfully.')
+ )
+ sys.exit(0)
diff --git a/openhands-cli/openhands_cli/simple_main.py b/openhands-cli/openhands_cli/simple_main.py
index a31b3ac6ad..343d37a4d3 100644
--- a/openhands-cli/openhands_cli/simple_main.py
+++ b/openhands-cli/openhands_cli/simple_main.py
@@ -4,9 +4,9 @@ Simple main entry point for OpenHands CLI.
This is a simplified version that demonstrates the TUI functionality.
"""
-import argparse
import logging
import os
+import sys
import warnings
debug_env = os.getenv('DEBUG', 'false').lower()
@@ -17,7 +17,7 @@ if debug_env != '1' and debug_env != 'true':
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
-from openhands_cli.agent_chat import run_cli_entry
+from openhands_cli.argparsers.main_parser import create_main_parser
def main() -> None:
@@ -27,35 +27,28 @@ def main() -> None:
ImportError: If agent chat dependencies are missing
Exception: On other error conditions
"""
- parser = argparse.ArgumentParser(
- description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent'
- )
- parser.add_argument(
- '--resume',
- type=str,
- help='Conversation ID to use for the session. If not provided, a random UUID will be generated.',
- )
-
+ parser = create_main_parser()
args = parser.parse_args()
try:
- # Start agent chat
- run_cli_entry(resume_conversation_id=args.resume)
+ if args.command == 'serve':
+ # Import gui_launcher only when needed
+ from openhands_cli.gui_launcher import launch_gui_server
- except ImportError as e:
- print_formatted_text(
- HTML(f'Error: Agent chat requires additional dependencies: {e}')
- )
- print_formatted_text(
- HTML('Please ensure the agent SDK is properly installed.')
- )
- raise
+ launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
+ else:
+ # Default CLI behavior - no subcommand needed
+ # Import agent_chat only when needed
+ from openhands_cli.agent_chat import run_cli_entry
+
+ # Start agent chat
+ run_cli_entry(resume_conversation_id=args.resume)
except KeyboardInterrupt:
print_formatted_text(HTML('\nGoodbye! 👋'))
except EOFError:
print_formatted_text(HTML('\nGoodbye! 👋'))
except Exception as e:
- print_formatted_text(HTML(f'Error starting agent chat: {e}'))
+ print_formatted_text(HTML(f'Error: {e}'))
import traceback
traceback.print_exc()
diff --git a/openhands-cli/tests/test_gui_launcher.py b/openhands-cli/tests/test_gui_launcher.py
new file mode 100644
index 0000000000..ce8abadaa4
--- /dev/null
+++ b/openhands-cli/tests/test_gui_launcher.py
@@ -0,0 +1,201 @@
+"""Tests for GUI launcher functionality."""
+
+import os
+import subprocess
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from openhands_cli.gui_launcher import (
+ _format_docker_command_for_logging,
+ check_docker_requirements,
+ get_openhands_version,
+ launch_gui_server,
+)
+
+
+class TestFormatDockerCommand:
+ """Test the Docker command formatting function."""
+
+ @pytest.mark.parametrize(
+ "cmd,expected",
+ [
+ (
+ ['docker', 'run', 'hello-world'],
+ 'Running Docker command: docker run hello-world',
+ ),
+ (
+ ['docker', 'run', '-it', '--rm', '-p', '3000:3000', 'openhands:latest'],
+ 'Running Docker command: docker run -it --rm -p 3000:3000 openhands:latest',
+ ),
+ ([], 'Running Docker command: '),
+ ],
+ )
+ def test_format_docker_command(self, cmd, expected):
+ """Test formatting Docker commands."""
+ result = _format_docker_command_for_logging(cmd)
+ assert result == expected
+
+
+class TestCheckDockerRequirements:
+ """Test Docker requirements checking."""
+
+ @pytest.mark.parametrize(
+ "which_return,run_side_effect,expected_result,expected_print_count",
+ [
+ # Docker not installed
+ (None, None, False, 2),
+ # Docker daemon not running
+ ('/usr/bin/docker', MagicMock(returncode=1), False, 2),
+ # Docker timeout
+ ('/usr/bin/docker', subprocess.TimeoutExpired('docker info', 10), False, 2),
+ # Docker available
+ ('/usr/bin/docker', MagicMock(returncode=0), True, 0),
+ ],
+ )
+ @patch('shutil.which')
+ @patch('subprocess.run')
+ def test_docker_requirements(
+ self, mock_run, mock_which, which_return, run_side_effect, expected_result, expected_print_count
+ ):
+ """Test Docker requirements checking scenarios."""
+ mock_which.return_value = which_return
+ if run_side_effect is not None:
+ if isinstance(run_side_effect, Exception):
+ mock_run.side_effect = run_side_effect
+ else:
+ mock_run.return_value = run_side_effect
+
+ with patch('openhands_cli.gui_launcher.print_formatted_text') as mock_print:
+ result = check_docker_requirements()
+
+ assert result is expected_result
+ assert mock_print.call_count == expected_print_count
+
+
+class TestGetOpenHandsVersion:
+ """Test version retrieval."""
+
+ @pytest.mark.parametrize(
+ "env_value,expected",
+ [
+ (None, 'latest'), # No environment variable set
+ ('1.2.3', '1.2.3'), # Environment variable set
+ ],
+ )
+ def test_version_retrieval(self, env_value, expected):
+ """Test version retrieval from environment."""
+ if env_value:
+ os.environ['OPENHANDS_VERSION'] = env_value
+ result = get_openhands_version()
+ assert result == expected
+
+
+class TestLaunchGuiServer:
+ """Test GUI server launching."""
+
+ @patch('openhands_cli.gui_launcher.check_docker_requirements')
+ @patch('openhands_cli.gui_launcher.print_formatted_text')
+ def test_launch_gui_server_docker_not_available(self, mock_print, mock_check_docker):
+ """Test that launch_gui_server exits when Docker is not available."""
+ mock_check_docker.return_value = False
+
+ with pytest.raises(SystemExit) as exc_info:
+ launch_gui_server()
+
+ assert exc_info.value.code == 1
+
+ @pytest.mark.parametrize(
+ "pull_side_effect,run_side_effect,expected_exit_code,mount_cwd,gpu",
+ [
+ # Docker pull failure
+ (subprocess.CalledProcessError(1, 'docker pull'), None, 1, False, False),
+ # Docker pull timeout
+ (subprocess.TimeoutExpired('docker pull', 300), None, 1, False, False),
+ # Docker run failure
+ (MagicMock(returncode=0), subprocess.CalledProcessError(1, 'docker run'), 1, False, False),
+ # KeyboardInterrupt during run
+ (MagicMock(returncode=0), KeyboardInterrupt(), 0, False, False),
+ # Success with mount_cwd
+ (MagicMock(returncode=0), MagicMock(returncode=0), None, True, False),
+ # Success with GPU
+ (MagicMock(returncode=0), MagicMock(returncode=0), None, False, True),
+ ],
+ )
+ @patch('openhands_cli.gui_launcher.check_docker_requirements')
+ @patch('openhands_cli.gui_launcher.ensure_config_dir_exists')
+ @patch('openhands_cli.gui_launcher.get_openhands_version')
+ @patch('subprocess.run')
+ @patch('subprocess.check_output')
+ @patch('pathlib.Path.cwd')
+ @patch('openhands_cli.gui_launcher.print_formatted_text')
+ def test_launch_gui_server_scenarios(
+ self,
+ mock_print,
+ mock_cwd,
+ mock_check_output,
+ mock_run,
+ mock_version,
+ mock_config_dir,
+ mock_check_docker,
+ pull_side_effect,
+ run_side_effect,
+ expected_exit_code,
+ mount_cwd,
+ gpu,
+ ):
+ """Test various GUI server launch scenarios."""
+ # Setup mocks
+ mock_check_docker.return_value = True
+ mock_config_dir.return_value = Path('/home/user/.openhands')
+ mock_version.return_value = 'latest'
+ mock_check_output.return_value = '1000\n'
+ mock_cwd.return_value = Path('/current/dir')
+
+ # Configure subprocess.run side effects
+ side_effects = []
+ if pull_side_effect is not None:
+ if isinstance(pull_side_effect, Exception):
+ side_effects.append(pull_side_effect)
+ else:
+ side_effects.append(pull_side_effect)
+
+ if run_side_effect is not None:
+ if isinstance(run_side_effect, Exception):
+ side_effects.append(run_side_effect)
+ else:
+ side_effects.append(run_side_effect)
+
+ mock_run.side_effect = side_effects
+
+ # Test the function
+ if expected_exit_code is not None:
+ with pytest.raises(SystemExit) as exc_info:
+ launch_gui_server(mount_cwd=mount_cwd, gpu=gpu)
+ assert exc_info.value.code == expected_exit_code
+ else:
+ # Should not raise SystemExit for successful cases
+ launch_gui_server(mount_cwd=mount_cwd, gpu=gpu)
+
+ # Verify subprocess.run was called correctly
+ assert mock_run.call_count == 2 # Pull and run commands
+
+ # Check pull command
+ pull_call = mock_run.call_args_list[0]
+ pull_cmd = pull_call[0][0]
+ assert pull_cmd[0:3] == ['docker', 'pull', 'docker.all-hands.dev/all-hands-ai/runtime:latest-nikolaik']
+
+ # Check run command
+ run_call = mock_run.call_args_list[1]
+ run_cmd = run_call[0][0]
+ assert run_cmd[0:2] == ['docker', 'run']
+
+ if mount_cwd:
+ assert 'SANDBOX_VOLUMES=/current/dir:/workspace:rw' in ' '.join(run_cmd)
+ assert 'SANDBOX_USER_ID=1000' in ' '.join(run_cmd)
+
+ if gpu:
+ assert '--gpus' in run_cmd
+ assert 'all' in run_cmd
+ assert 'SANDBOX_ENABLE_GPU=true' in ' '.join(run_cmd)
diff --git a/openhands-cli/tests/test_main.py b/openhands-cli/tests/test_main.py
index 27ce870738..2e2a4a47ca 100644
--- a/openhands-cli/tests/test_main.py
+++ b/openhands-cli/tests/test_main.py
@@ -1,15 +1,19 @@
"""Tests for main entry point functionality."""
+import sys
+from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from openhands_cli import simple_main
+from openhands_cli.simple_main import main
+
class TestMainEntryPoint:
"""Test the main entry point behavior."""
- @patch('openhands_cli.simple_main.run_cli_entry')
+ @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands'])
def test_main_starts_agent_chat_directly(
self, mock_run_agent_chat: MagicMock
@@ -24,7 +28,7 @@ class TestMainEntryPoint:
# Should call run_cli_entry with no resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)
- @patch('openhands_cli.simple_main.run_cli_entry')
+ @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands'])
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles ImportError gracefully."""
@@ -36,7 +40,7 @@ class TestMainEntryPoint:
assert str(exc_info.value) == 'Missing dependency'
- @patch('openhands_cli.simple_main.run_cli_entry')
+ @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands'])
def test_main_handles_keyboard_interrupt(
self, mock_run_agent_chat: MagicMock
@@ -48,7 +52,7 @@ class TestMainEntryPoint:
# Should complete without raising an exception (graceful exit)
simple_main.main()
- @patch('openhands_cli.simple_main.run_cli_entry')
+ @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands'])
def test_main_handles_eof_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles EOFError gracefully."""
@@ -58,7 +62,7 @@ class TestMainEntryPoint:
# Should complete without raising an exception (graceful exit)
simple_main.main()
- @patch('openhands_cli.simple_main.run_cli_entry')
+ @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands'])
def test_main_handles_general_exception(
self, mock_run_agent_chat: MagicMock
@@ -72,7 +76,7 @@ class TestMainEntryPoint:
assert str(exc_info.value) == 'Unexpected error'
- @patch('openhands_cli.simple_main.run_cli_entry')
+ @patch('openhands_cli.agent_chat.run_cli_entry')
@patch('sys.argv', ['openhands', '--resume', 'test-conversation-id'])
def test_main_with_resume_argument(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() passes resume conversation ID when provided."""
@@ -86,3 +90,65 @@ class TestMainEntryPoint:
mock_run_agent_chat.assert_called_once_with(
resume_conversation_id='test-conversation-id'
)
+
+
+
+
+@pytest.mark.parametrize(
+ "argv,expected_kwargs",
+ [
+ (['openhands'], {"resume_conversation_id": None}),
+ (['openhands', '--resume', 'test-id'], {"resume_conversation_id": 'test-id'}),
+ ],
+)
+def test_main_cli_calls_run_cli_entry(monkeypatch, argv, expected_kwargs):
+ # Patch sys.argv since main() takes no params
+ monkeypatch.setattr(sys, "argv", argv, raising=False)
+
+ called = {}
+ fake_agent_chat = SimpleNamespace(
+ run_cli_entry=lambda **kw: called.setdefault("kwargs", kw)
+ )
+ # Provide the symbol that main() will import
+ monkeypatch.setitem(sys.modules, "openhands_cli.agent_chat", fake_agent_chat)
+
+ # Execute (no SystemExit expected on success)
+ main()
+ assert called["kwargs"] == expected_kwargs
+
+
+@pytest.mark.parametrize(
+ "argv,expected_kwargs",
+ [
+ (['openhands', 'serve'], {"mount_cwd": False, "gpu": False}),
+ (['openhands', 'serve', '--mount-cwd'], {"mount_cwd": True, "gpu": False}),
+ (['openhands', 'serve', '--gpu'], {"mount_cwd": False, "gpu": True}),
+ (['openhands', 'serve', '--mount-cwd', '--gpu'], {"mount_cwd": True, "gpu": True}),
+ ],
+)
+def test_main_serve_calls_launch_gui_server(monkeypatch, argv, expected_kwargs):
+ monkeypatch.setattr(sys, "argv", argv, raising=False)
+
+ called = {}
+ fake_gui = SimpleNamespace(
+ launch_gui_server=lambda **kw: called.setdefault("kwargs", kw)
+ )
+ monkeypatch.setitem(sys.modules, "openhands_cli.gui_launcher", fake_gui)
+
+ main()
+ assert called["kwargs"] == expected_kwargs
+
+
+@pytest.mark.parametrize(
+ "argv,expected_exit_code",
+ [
+ (['openhands', 'invalid-command'], 2), # argparse error
+ (['openhands', '--help'], 0), # top-level help
+ (['openhands', 'serve', '--help'], 0), # subcommand help
+ ],
+)
+def test_help_and_invalid(monkeypatch, argv, expected_exit_code):
+ monkeypatch.setattr(sys, "argv", argv, raising=False)
+ with pytest.raises(SystemExit) as exc:
+ main()
+ assert exc.value.code == expected_exit_code