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 all 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 @@ -279,8 +283,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 @@ -325,6 +330,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_with_loop(loop: asyncio.AbstractEventLoop) -> None:
"""Runs the agent in CLI mode."""
args = parse_arguments()
Expand All @@ -339,6 +361,19 @@ async def main_with_loop(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 @@ -408,6 +443,7 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
current_dir,
task_str,
session_name=args.name,
skip_banner=banner_shown,
)

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

provider = None
# Set default provider - use the first available provider from the list
provider = provider_list[0] if provider_list else 'openai'
model = None
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(f'\n<grey>Default provider: </grey><green>{provider}</green>')
)
change_provider = (
cli_confirm(
'Do you want to use a different provider?',
[f'Use {provider}', 'Select another provider'],
)
== 1
)

if change_provider:
# Define a validator function that prints an error message
def provider_validator(x):
is_valid = x in organized_models
if not is_valid:
print_formatted_text(
HTML('<grey>Invalid provider selected: {}</grey>'.format(x))
)
return is_valid

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

# Make sure the provider exists in organized_models
if provider not in organized_models:
# If the provider doesn't exist, use the first available provider
provider = (
next(iter(organized_models.keys())) if organized_models else 'openai'
)

provider_models = organized_models[provider]['models']
if provider == 'openai':
provider_models = [
Expand All @@ -183,14 +213,45 @@ 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}',
# Set default model to the first model in the list
default_model = provider_models[0] if provider_models else 'gpt-4'

# Show the default model but allow changing it
print_formatted_text(
HTML(f'\n<grey>Default model: </grey><green>{default_model}</green>')
)
change_model = (
cli_confirm(
'Do you want to use a different model?',
[f'Use {default_model}', 'Select another model'],
)
== 1
)

if change_model:
model_completer = FuzzyWordCompleter(provider_models)

# Define a validator function that prints an error message
def model_validator(x):
is_valid = x in provider_models
if not is_valid:
print_formatted_text(
HTML(
f'<grey>Invalid model selected for provider {provider}: {x}</grey>'
)
)
return is_valid

model = await get_validated_input(
session,
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
completer=model_completer,
validator=model_validator,
error_message=f'Invalid model selected for provider {provider}',
)
else:
# Use the default model
model = default_model

api_key = await get_validated_input(
session,
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 @@ -64,7 +64,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')
file_store_web_hook_url: str | None = Field(default=None)
file_store_web_hook_headers: dict | None = Field(default=None)
save_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
9 changes: 8 additions & 1 deletion tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,13 @@ async def test_main_without_task(

# Check that run_session was called with expected arguments
mock_run_session.assert_called_once_with(
loop, mock_config, mock_settings_store, '/test/dir', None, session_name=None
loop,
mock_config,
mock_settings_store,
'/test/dir',
None,
session_name=None,
skip_banner=False,
)


Expand Down Expand Up @@ -571,6 +577,7 @@ async def test_main_with_session_name_passes_name_to_run_session(
'/test/dir',
None,
session_name=test_session_name,
skip_banner=False,
)


Expand Down
18 changes: 12 additions & 6 deletions tests/unit/test_cli_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,24 +178,29 @@ async def test_modify_llm_settings_basic_success(
)
mock_session.return_value = session_instance

# Mock user confirmation
mock_confirm.return_value = 0 # User selects "Yes, proceed"
# Mock cli_confirm to select the second option (change provider/model) for the first two calls
# and then select the first option (save settings) for the last call
mock_confirm.side_effect = [1, 1, 0]

# Call the function
await modify_llm_settings_basic(app_config, settings_store)

# Verify LLM config was updated
app_config.set_llm_config.assert_called_once()
args, kwargs = app_config.set_llm_config.call_args
assert args[0].model == 'openai/gpt-4'
# The model name might be different based on the default model in the list
# Just check that it starts with 'openai/'
assert args[0].model.startswith('openai/')
assert args[0].api_key.get_secret_value() == 'new-api-key'
assert args[0].base_url is None

# Verify settings were saved
settings_store.store.assert_called_once()
args, kwargs = settings_store.store.call_args
settings = args[0]
assert settings.llm_model == 'openai/gpt-4'
# The model name might be different based on the default model in the list
# Just check that it starts with openai/
assert settings.llm_model.startswith('openai/')
assert settings.llm_api_key.get_secret_value() == 'new-api-key'
assert settings.llm_base_url is None

Expand Down Expand Up @@ -272,8 +277,9 @@ async def test_modify_llm_settings_basic_invalid_input(
)
mock_session.return_value = session_instance

# Mock user confirmation to save settings
mock_confirm.return_value = 0 # "Yes, proceed"
# Mock cli_confirm to select the second option (change provider/model) for the first two calls
# and then select the first option (save settings) for the last call
mock_confirm.side_effect = [1, 1, 0]

# Call the function
await modify_llm_settings_basic(app_config, settings_store)
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()
Loading