CLI(V1): GUI Launcher (#11257)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Rohit Malhotra 2025-10-07 11:23:58 -04:00 committed by GitHub
parent 80dc2efaab
commit 23d325cb16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 604 additions and 28 deletions

View File

@ -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

View File

@ -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

View File

@ -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'<grey>Running Docker command: {cmd_str}</grey>'
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('<ansired>❌ Docker is not installed or not in PATH.</ansired>')
)
print_formatted_text(
HTML(
'<grey>Please install Docker first: https://docs.docker.com/get-docker/</grey>'
)
)
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('<ansired>❌ Docker daemon is not running.</ansired>')
)
print_formatted_text(
HTML('<grey>Please start Docker and try again.</grey>')
)
return False
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
print_formatted_text(
HTML('<ansired>❌ Failed to check Docker status.</ansired>')
)
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
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('<ansiblue>🚀 Launching OpenHands GUI server...</ansiblue>')
)
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('<grey>Pulling required Docker images...</grey>'))
# 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('<ansired>❌ Failed to pull runtime image.</ansired>')
)
sys.exit(1)
except subprocess.TimeoutExpired:
print_formatted_text(
HTML('<ansired>❌ Timeout while pulling runtime image.</ansired>')
)
sys.exit(1)
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Starting OpenHands GUI server...</ansigreen>')
)
print_formatted_text(
HTML('<grey>The server will be available at: http://localhost:3000</grey>')
)
print_formatted_text(HTML('<grey>Press Ctrl+C to stop the server.</grey>'))
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('<ansigreen>🖥️ Enabling GPU support via nvidia-docker...</ansigreen>')
)
# 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'<ansigreen>📂 Mounting current directory:</ansigreen> <ansiyellow>{cwd}</ansiyellow> <ansigreen>to</ansigreen> <ansiyellow>/workspace</ansiyellow>'
)
)
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('<ansired>❌ Failed to start OpenHands GUI server.</ansired>')
)
print_formatted_text(HTML(f'<grey>Error: {e}</grey>'))
sys.exit(1)
except KeyboardInterrupt:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✓ OpenHands GUI server stopped successfully.</ansigreen>')
)
sys.exit(0)

View File

@ -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'<red>Error: Agent chat requires additional dependencies: {e}</red>')
)
print_formatted_text(
HTML('<yellow>Please ensure the agent SDK is properly installed.</yellow>')
)
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('\n<yellow>Goodbye! 👋</yellow>'))
except EOFError:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
except Exception as e:
print_formatted_text(HTML(f'<red>Error starting agent chat: {e}</red>'))
print_formatted_text(HTML(f'<red>Error: {e}</red>'))
import traceback
traceback.print_exc()

View File

@ -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'],
'<grey>Running Docker command: docker run hello-world</grey>',
),
(
['docker', 'run', '-it', '--rm', '-p', '3000:3000', 'openhands:latest'],
'<grey>Running Docker command: docker run -it --rm -p 3000:3000 openhands:latest</grey>',
),
([], '<grey>Running Docker command: </grey>'),
],
)
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)

View File

@ -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