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