#!/usr/bin/env python3 """ Build script for OpenHands CLI using PyInstaller. This script packages the OpenHands CLI into a standalone executable binary using PyInstaller with the custom spec file. """ import argparse import os import select import shutil import subprocess import sys import time from pathlib import Path from openhands_cli.utils import get_default_cli_agent, get_llm_metadata from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR from openhands.sdk import LLM # ================================================= # SECTION: Build Binary # ================================================= def clean_build_directories() -> None: """Clean up previous build artifacts.""" print('๐Ÿงน Cleaning up previous build artifacts...') build_dirs = ['build', 'dist', '__pycache__'] for dir_name in build_dirs: if os.path.exists(dir_name): print(f' Removing {dir_name}/') shutil.rmtree(dir_name) # Clean up .pyc files for root, _dirs, files in os.walk('.'): for file in files: if file.endswith('.pyc'): os.remove(os.path.join(root, file)) print('โœ… Cleanup complete!') def check_pyinstaller() -> bool: """Check if PyInstaller is available.""" try: subprocess.run( ['uv', 'run', 'pyinstaller', '--version'], check=True, capture_output=True ) return True except (subprocess.CalledProcessError, FileNotFoundError): print( 'โŒ PyInstaller is not available. Use --install-pyinstaller flag or install manually with:' ) print(' uv add --dev pyinstaller') return False def build_executable( spec_file: str = 'openhands.spec', clean: bool = True, ) -> bool: """Build the executable using PyInstaller.""" if clean: clean_build_directories() # Check if PyInstaller is available (installation is handled by build.sh) if not check_pyinstaller(): return False print(f'๐Ÿ”จ Building executable using {spec_file}...') try: # Run PyInstaller with uv cmd = ['uv', 'run', 'pyinstaller', spec_file, '--clean'] print(f'Running: {" ".join(cmd)}') subprocess.run(cmd, check=True, capture_output=True, text=True) print('โœ… Build completed successfully!') # Check if the executable was created dist_dir = Path('dist') if dist_dir.exists(): executables = list(dist_dir.glob('*')) if executables: print('๐Ÿ“ Executable(s) created in dist/:') for exe in executables: size = exe.stat().st_size / (1024 * 1024) # Size in MB print(f' - {exe.name} ({size:.1f} MB)') else: print('โš ๏ธ No executables found in dist/ directory') return True except subprocess.CalledProcessError as e: print(f'โŒ Build failed: {e}') if e.stdout: print('STDOUT:', e.stdout) if e.stderr: print('STDERR:', e.stderr) return False # ================================================= # SECTION: Test and profile binary # ================================================= WELCOME_MARKERS = ['welcome', 'openhands cli', 'type /help', 'available commands', '>'] def _is_welcome(line: str) -> bool: s = line.strip().lower() return any(marker in s for marker in WELCOME_MARKERS) def test_executable(dummy_agent) -> bool: """Test the built executable, measuring boot time and total test time.""" print('๐Ÿงช Testing the built executable...') spec_path = os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH) specs_path = Path(os.path.expanduser(spec_path)) if specs_path.exists(): print(f'โš ๏ธ Using existing settings at {specs_path}') else: print(f'๐Ÿ’พ Creating dummy settings at {specs_path}') specs_path.parent.mkdir(parents=True, exist_ok=True) specs_path.write_text(dummy_agent.model_dump_json()) exe_path = Path('dist/openhands') if not exe_path.exists(): exe_path = Path('dist/openhands.exe') if not exe_path.exists(): print('โŒ Executable not found!') return False try: if os.name != 'nt': os.chmod(exe_path, 0o755) boot_start = time.time() proc = subprocess.Popen( [str(exe_path)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, env={**os.environ}, ) # --- Wait for welcome --- deadline = boot_start + 60 saw_welcome = False captured = [] while time.time() < deadline: if proc.poll() is not None: break rlist, _, _ = select.select([proc.stdout], [], [], 0.2) if not rlist: continue line = proc.stdout.readline() if not line: continue captured.append(line) if _is_welcome(line): saw_welcome = True break if not saw_welcome: print('โŒ Did not detect welcome prompt') try: proc.kill() except Exception: pass return False boot_end = time.time() print(f'โฑ๏ธ Boot to welcome: {boot_end - boot_start:.2f} seconds') # --- Run /help then /exit --- if proc.stdin is None: print('โŒ stdin unavailable') proc.kill() return False proc.stdin.write('/help\n/exit\n') proc.stdin.flush() out, _ = proc.communicate(timeout=60) total_end = time.time() full_output = ''.join(captured) + (out or '') print(f'โฑ๏ธ End-to-end test time: {total_end - boot_start:.2f} seconds') if 'available commands' in full_output.lower(): print('โœ… Executable starts, welcome detected, and /help works') return True else: print('โŒ /help output not found') print('Output preview:', full_output[-500:]) return False except subprocess.TimeoutExpired: print('โŒ Executable test timed out') try: proc.kill() except Exception: pass return False except Exception as e: print(f'โŒ Error testing executable: {e}') try: proc.kill() except Exception: pass return False # ================================================= # SECTION: Main # ================================================= def main() -> int: """Main function.""" parser = argparse.ArgumentParser(description='Build OpenHands CLI executable') parser.add_argument( '--spec', default='openhands.spec', help='PyInstaller spec file to use' ) parser.add_argument( '--no-clean', action='store_true', help='Skip cleaning build directories' ) parser.add_argument( '--no-test', action='store_true', help='Skip testing the built executable' ) parser.add_argument( '--install-pyinstaller', action='store_true', help='Install PyInstaller using uv before building', ) parser.add_argument( '--no-build', action='store_true', help='Skip testing the built executable' ) args = parser.parse_args() print('๐Ÿš€ OpenHands CLI Build Script') print('=' * 40) # Check if spec file exists if not os.path.exists(args.spec): print(f"โŒ Spec file '{args.spec}' not found!") return 1 # Build the executable if not args.no_build and not build_executable(args.spec, clean=not args.no_clean): return 1 # Test the executable if not args.no_test: dummy_agent = get_default_cli_agent( llm=LLM( model='dummy-model', api_key='dummy-key', litellm_extra_body={"metadata": get_llm_metadata(model_name='dummy-model', llm_type='openhands')}, ) ) if not test_executable(dummy_agent): print('โŒ Executable test failed, build process failed') return 1 print('\n๐ŸŽ‰ Build process completed!') print("๐Ÿ“ Check the 'dist/' directory for your executable") return 0 if __name__ == '__main__': try: sys.exit(main()) except Exception as e: print(e) print('โŒ Executable test failed') sys.exit(1)