Skip to content

Add automatic setup flow in CLI mode when settings are not found #8775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions openhands/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
import os
import sys

from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.shortcuts import clear

import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands.cli.commands import (
check_folder_security_agreement,
handle_commands,
)
from openhands.cli.settings import modify_llm_settings_basic
from openhands.cli.tui import (
UsageMetrics,
display_agent_running_message,
Expand Down Expand Up @@ -109,6 +112,7 @@ async def run_session(
task_content: str | None = None,
conversation_instructions: str | None = None,
session_name: str | None = None,
skip_banner: bool = False,
) -> bool:
reload_microagents = False
new_session_requested = False
Expand Down Expand Up @@ -281,8 +285,9 @@ def on_event(event: Event) -> None:
# Clear the terminal
clear()

# Show OpenHands banner and session ID
display_banner(session_id=sid)
# Show OpenHands banner and session ID if not skipped
if not skip_banner:
display_banner(session_id=sid)

welcome_message = 'What do you want to build?' # from the application
initial_message = '' # from the user
Expand Down Expand Up @@ -327,6 +332,23 @@ def on_event(event: Event) -> None:
return new_session_requested


async def run_setup_flow(config: OpenHandsConfig, settings_store: FileSettingsStore):
"""Run the setup flow to configure initial settings.

Returns:
bool: True if settings were successfully configured, False otherwise.
"""
# Display the banner with ASCII art first
display_banner(session_id='setup')

print_formatted_text(
HTML('<grey>No settings found. Starting initial setup...</grey>\n')
)

# Use the existing settings modification function for basic setup
await modify_llm_settings_basic(config, settings_store)


async def main(loop: asyncio.AbstractEventLoop) -> None:
"""Runs the agent in CLI mode."""
args = parse_arguments()
Expand All @@ -341,6 +363,19 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
settings_store = await FileSettingsStore.get_instance(config=config, user_id=None)
settings = await settings_store.load()

# Track if we've shown the banner during setup
banner_shown = False

# If settings don't exist, automatically enter the setup flow
if not settings:
# Clear the terminal before showing the banner
clear()

await run_setup_flow(config, settings_store)
banner_shown = True

settings = await settings_store.load()

# Use settings from settings store if available and override with command line arguments
if settings:
if args.agent_cls:
Expand Down Expand Up @@ -410,6 +445,7 @@ async def main(loop: asyncio.AbstractEventLoop) -> None:
current_dir,
task_str,
session_name=args.name,
skip_banner=banner_shown, # Skip banner if already shown during setup
)

# If a new session was requested, run it
Expand Down
73 changes: 57 additions & 16 deletions openhands/cli/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,34 @@ async def modify_llm_settings_basic(
provider_completer = FuzzyWordCompleter(provider_list)
session = PromptSession(key_bindings=kb_cancel())

provider = None
model = None
# Set default provider to anthropic
provider = 'anthropic'
# Set default model to claude-sonnet-4-20250514
model = 'claude-sonnet-4-20250514'
api_key = None

try:
provider = await get_validated_input(
session,
'(Step 1/3) Select LLM Provider (TAB for options, CTRL-c to cancel): ',
completer=provider_completer,
validator=lambda x: x in organized_models,
error_message='Invalid provider selected',
# Show the default provider but allow changing it
print_formatted_text(
HTML('\n<grey>Default provider: </grey><green>anthropic</green>')
)
change_provider = (
cli_confirm(
'Do you want to use a different provider?',
['Use anthropic', 'Select another provider'],
)
== 1
)

if change_provider:
provider = await get_validated_input(
session,
'(Step 1/3) Select LLM Provider (TAB for options, CTRL-c to cancel): ',
completer=provider_completer,
validator=lambda x: x in organized_models,
error_message='Invalid provider selected',
)

provider_models = organized_models[provider]['models']
if provider == 'openai':
provider_models = [
Expand All @@ -183,14 +198,40 @@ async def modify_llm_settings_basic(
]
provider_models = VERIFIED_ANTHROPIC_MODELS + provider_models

model_completer = FuzzyWordCompleter(provider_models)
model = await get_validated_input(
session,
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
completer=model_completer,
validator=lambda x: x in provider_models,
error_message=f'Invalid model selected for provider {provider}',
)
# Show the default model but allow changing it
if provider == 'anthropic':
print_formatted_text(
HTML(
'\n<grey>Default model: </grey><green>claude-sonnet-4-20250514</green>'
)
)
change_model = (
cli_confirm(
'Do you want to use a different model?',
['Use claude-sonnet-4-20250514', 'Select another model'],
)
== 1
)

if change_model:
model_completer = FuzzyWordCompleter(provider_models)
model = await get_validated_input(
session,
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
completer=model_completer,
validator=lambda x: x in provider_models,
error_message=f'Invalid model selected for provider {provider}',
)
else:
# For other providers, always prompt for model selection
model_completer = FuzzyWordCompleter(provider_models)
model = await get_validated_input(
session,
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
completer=model_completer,
validator=lambda x: x in provider_models,
error_message=f'Invalid model selected for provider {provider}',
)

api_key = await get_validated_input(
session,
Expand Down
36 changes: 28 additions & 8 deletions openhands/cli/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# CLI Settings are handled separately in cli_settings.py

import asyncio
import shutil
import sys
import threading
import time
Expand Down Expand Up @@ -132,15 +133,34 @@ def display_initialization_animation(text: str, is_loaded: asyncio.Event) -> Non


def display_banner(session_id: str) -> None:
width, _ = shutil.get_terminal_size()
cards = """
🙌🙌🙌🙌 🙌🙌🙌🙌 🙌🙌🙌🙌 🙌 🙌|🙌 🙌 🙌🙌🙌🙌 🙌 🙌 🙌🙌🙌 🙌🙌🙌
🙌 🙌 🙌 🙌 🙌 🙌🙌 🙌|🙌 🙌 🙌 🙌 🙌🙌 🙌 🙌 🙌 🙌
🙌 🙌 🙌🙌🙌🙌 🙌🙌🙌🙌 🙌 🙌 🙌|🙌🙌🙌🙌 🙌🙌🙌🙌 🙌 🙌 🙌 🙌 🙌 🙌🙌
🙌 🙌 🙌 🙌 🙌 🙌🙌|🙌 🙌 🙌 🙌 🙌 🙌🙌 🙌 🙌 🙌
🙌🙌🙌🙌 🙌 🙌🙌🙌🙌 🙌 🙌|🙌 🙌 🙌 🙌 🙌 🙌 🙌🙌🙌 🙌🙌🙌
"""
if width < 90:
card_lines = cards.split('\n')
cards = ''
for i, line in enumerate(card_lines):
if '|' not in line:
cards += line + '\n'
continue
first_half = line.split('|')[0]
cards += first_half + '\n'
for i, line in enumerate(card_lines):
if '|' not in line:
cards += line + '\n'
continue
second_half = line.split('|')[1]
cards += second_half + '\n'
else:
cards = cards.replace('|', ' ')

print_formatted_text(
HTML(r"""<gold>
___ _ _ _
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|_|
</gold>"""),
HTML(cards),
style=DEFAULT_STYLE,
)

Expand Down
2 changes: 1 addition & 1 deletion openhands/core/config/openhands_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class OpenHandsConfig(BaseModel):
extended: ExtendedConfig = Field(default_factory=lambda: ExtendedConfig({}))
runtime: str = Field(default='docker')
file_store: str = Field(default='local')
file_store_path: str = Field(default='/tmp/openhands_file_store')
file_store_path: str = Field(default='~/.openhands/file_store')
save_trajectory_path: str | None = Field(default=None)
save_screenshots_in_trajectory: bool = Field(default=False)
replay_trajectory_path: str | None = Field(default=None)
Expand Down
2 changes: 2 additions & 0 deletions openhands/storage/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class LocalFileStore(FileStore):
root: str

def __init__(self, root: str):
if root.startswith('~'):
root = os.path.expanduser(root)
self.root = root
os.makedirs(self.root, exist_ok=True)

Expand Down
90 changes: 90 additions & 0 deletions tests/unit/test_cli_setup_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock, patch

from openhands.cli.main import run_setup_flow
from openhands.core.config import OpenHandsConfig
from openhands.storage.settings.file_settings_store import FileSettingsStore


class TestCLISetupFlow(unittest.TestCase):
"""Test the CLI setup flow."""

@patch('openhands.cli.settings.modify_llm_settings_basic')
@patch('openhands.cli.main.print_formatted_text')
async def test_run_setup_flow(self, mock_print, mock_modify_settings):
"""Test that the setup flow calls the modify_llm_settings_basic function."""
# Setup
config = MagicMock(spec=OpenHandsConfig)
settings_store = MagicMock(spec=FileSettingsStore)
mock_modify_settings.return_value = None

# Mock settings_store.load to return a settings object
settings = MagicMock()
settings_store.load = AsyncMock(return_value=settings)

# Execute
result = await run_setup_flow(config, settings_store)

# Verify
mock_modify_settings.assert_called_once_with(config, settings_store)
# Verify that print_formatted_text was called at least twice (for welcome message and instructions)
self.assertGreaterEqual(mock_print.call_count, 2)
# Verify that the function returns True when settings are found
self.assertTrue(result)

@patch('openhands.cli.main.print_formatted_text')
@patch('openhands.cli.main.run_setup_flow')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.setup_config_from_args')
@patch('openhands.cli.main.parse_arguments')
async def test_main_calls_setup_flow_when_no_settings(
self,
mock_parse_args,
mock_setup_config,
mock_get_instance,
mock_run_setup_flow,
mock_print,
):
"""Test that main calls run_setup_flow when no settings are found and exits."""
# Setup
mock_args = MagicMock()
mock_config = MagicMock(spec=OpenHandsConfig)
mock_settings_store = AsyncMock(spec=FileSettingsStore)

# Settings load returns None (no settings)
mock_settings_store.load = AsyncMock(return_value=None)

mock_parse_args.return_value = mock_args
mock_setup_config.return_value = mock_config
mock_get_instance.return_value = mock_settings_store

# Mock run_setup_flow to return True (settings configured successfully)
mock_run_setup_flow.return_value = True

# Import here to avoid circular imports during patching
from openhands.cli.main import main

# Execute
loop = asyncio.get_event_loop()
await main(loop)

# Verify
mock_run_setup_flow.assert_called_once_with(mock_config, mock_settings_store)
# Verify that load was called once (before setup)
self.assertEqual(mock_settings_store.load.call_count, 1)
# Verify that print_formatted_text was called for success messages
self.assertGreaterEqual(mock_print.call_count, 2)


def run_async_test(coro):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()


if __name__ == '__main__':
unittest.main()