mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Add CLI alias setup for first-time users (#9542)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
bfe0aa08b6
commit
4aaa2ccd39
@ -14,8 +14,14 @@ from openhands.cli.commands import (
|
||||
handle_commands,
|
||||
)
|
||||
from openhands.cli.settings import modify_llm_settings_basic
|
||||
from openhands.cli.shell_config import (
|
||||
ShellConfigManager,
|
||||
add_aliases_to_shell_config,
|
||||
aliases_exist_in_shell_config,
|
||||
)
|
||||
from openhands.cli.tui import (
|
||||
UsageMetrics,
|
||||
cli_confirm,
|
||||
display_agent_running_message,
|
||||
display_banner,
|
||||
display_event,
|
||||
@ -361,6 +367,115 @@ async def run_setup_flow(config: OpenHandsConfig, settings_store: FileSettingsSt
|
||||
await modify_llm_settings_basic(config, settings_store)
|
||||
|
||||
|
||||
def run_alias_setup_flow(config: OpenHandsConfig) -> None:
|
||||
"""Run the alias setup flow to configure shell aliases.
|
||||
|
||||
Prompts the user to set up aliases for 'openhands' and 'oh' commands.
|
||||
Handles existing aliases by offering to keep or remove them.
|
||||
"""
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>🚀 Welcome to OpenHands CLI!</gold>'))
|
||||
print_formatted_text('')
|
||||
|
||||
# Check if aliases already exist
|
||||
if aliases_exist_in_shell_config():
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>We detected existing OpenHands aliases in your shell configuration.</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✅ Aliases are already configured.</ansigreen>')
|
||||
)
|
||||
return # Exit early since aliases already exist
|
||||
else:
|
||||
# No existing aliases, show the normal setup flow
|
||||
print_formatted_text(
|
||||
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>This will add the following aliases to your shell profile:</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
# Use cli_confirm to get user choice
|
||||
choice = cli_confirm(
|
||||
config,
|
||||
'Set up shell aliases?',
|
||||
['Yes, set up aliases', 'No, skip this step'],
|
||||
)
|
||||
|
||||
if choice == 0: # User chose "Yes"
|
||||
success = add_aliases_to_shell_config()
|
||||
if success:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
|
||||
)
|
||||
|
||||
# Get the appropriate reload command using the shell config manager
|
||||
shell_manager = ShellConfigManager()
|
||||
reload_cmd = shell_manager.get_reload_command()
|
||||
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
|
||||
)
|
||||
)
|
||||
else:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
|
||||
)
|
||||
)
|
||||
else: # User chose "No"
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
|
||||
)
|
||||
)
|
||||
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Runs the agent in CLI mode."""
|
||||
args = parse_arguments()
|
||||
@ -452,6 +567,17 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
# This ensures Jupyter plugin is disabled for CLI runtime
|
||||
finalize_config(config)
|
||||
|
||||
# Check if we should show the alias setup flow
|
||||
# Only show it if aliases don't exist in the shell configuration
|
||||
# and we're in an interactive environment (not during tests or CI)
|
||||
if not aliases_exist_in_shell_config() and sys.stdin.isatty():
|
||||
# Clear the terminal if we haven't shown a banner yet
|
||||
if not banner_shown:
|
||||
clear()
|
||||
|
||||
run_alias_setup_flow(config)
|
||||
banner_shown = True
|
||||
|
||||
# TODO: Set working directory from config or use current working directory?
|
||||
current_dir = config.workspace_base
|
||||
|
||||
|
||||
279
openhands/cli/shell_config.py
Normal file
279
openhands/cli/shell_config.py
Normal file
@ -0,0 +1,279 @@
|
||||
"""Shell configuration management for OpenHands CLI aliases.
|
||||
|
||||
This module provides a simplified, more maintainable approach to managing
|
||||
shell aliases across different shell types and platforms.
|
||||
"""
|
||||
|
||||
import platform
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
try:
|
||||
import shellingham
|
||||
except ImportError:
|
||||
shellingham = None
|
||||
|
||||
|
||||
class ShellConfigManager:
|
||||
"""Manages shell configuration files and aliases across different shells."""
|
||||
|
||||
# Shell configuration templates
|
||||
ALIAS_TEMPLATES = {
|
||||
'bash': Template("""
|
||||
# OpenHands CLI aliases
|
||||
alias openhands="{{ command }}"
|
||||
alias oh="{{ command }}"
|
||||
"""),
|
||||
'zsh': Template("""
|
||||
# OpenHands CLI aliases
|
||||
alias openhands="{{ command }}"
|
||||
alias oh="{{ command }}"
|
||||
"""),
|
||||
'fish': Template("""
|
||||
# OpenHands CLI aliases
|
||||
alias openhands="{{ command }}"
|
||||
alias oh="{{ command }}"
|
||||
"""),
|
||||
'powershell': Template("""
|
||||
# OpenHands CLI aliases
|
||||
function openhands { {{ command }} $args }
|
||||
function oh { {{ command }} $args }
|
||||
"""),
|
||||
}
|
||||
|
||||
# Shell configuration file patterns
|
||||
SHELL_CONFIG_PATTERNS = {
|
||||
'bash': ['.bashrc', '.bash_profile'],
|
||||
'zsh': ['.zshrc'],
|
||||
'fish': ['.config/fish/config.fish'],
|
||||
'csh': ['.cshrc'],
|
||||
'tcsh': ['.tcshrc'],
|
||||
'ksh': ['.kshrc'],
|
||||
'powershell': [
|
||||
'Documents/PowerShell/Microsoft.PowerShell_profile.ps1',
|
||||
'Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1',
|
||||
'.config/powershell/Microsoft.PowerShell_profile.ps1',
|
||||
],
|
||||
}
|
||||
|
||||
# Regex patterns for detecting existing aliases
|
||||
ALIAS_PATTERNS = {
|
||||
'bash': [
|
||||
r'^\s*alias\s+openhands\s*=',
|
||||
r'^\s*alias\s+oh\s*=',
|
||||
],
|
||||
'zsh': [
|
||||
r'^\s*alias\s+openhands\s*=',
|
||||
r'^\s*alias\s+oh\s*=',
|
||||
],
|
||||
'fish': [
|
||||
r'^\s*alias\s+openhands\s*=',
|
||||
r'^\s*alias\s+oh\s*=',
|
||||
],
|
||||
'powershell': [
|
||||
r'^\s*function\s+openhands\s*\{',
|
||||
r'^\s*function\s+oh\s*\{',
|
||||
],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, command: str = 'uvx --python 3.12 --from openhands-ai openhands'
|
||||
):
|
||||
"""Initialize the shell config manager.
|
||||
|
||||
Args:
|
||||
command: The command that aliases should point to.
|
||||
"""
|
||||
self.command = command
|
||||
self.is_windows = platform.system() == 'Windows'
|
||||
|
||||
def detect_shell(self) -> Optional[str]:
|
||||
"""Detect the current shell using shellingham.
|
||||
|
||||
Returns:
|
||||
Shell name if detected, None otherwise.
|
||||
"""
|
||||
if not shellingham:
|
||||
return None
|
||||
|
||||
try:
|
||||
shell_name, _ = shellingham.detect_shell()
|
||||
return shell_name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_shell_config_path(self, shell: Optional[str] = None) -> Path:
|
||||
"""Get the path to the shell configuration file.
|
||||
|
||||
Args:
|
||||
shell: Shell name. If None, will attempt to detect.
|
||||
|
||||
Returns:
|
||||
Path to the shell configuration file.
|
||||
"""
|
||||
if shell is None:
|
||||
shell = self.detect_shell()
|
||||
|
||||
home = Path.home()
|
||||
|
||||
# Try to find existing config file for the detected shell
|
||||
if shell and shell in self.SHELL_CONFIG_PATTERNS:
|
||||
for config_file in self.SHELL_CONFIG_PATTERNS[shell]:
|
||||
config_path = home / config_file
|
||||
if config_path.exists():
|
||||
return config_path
|
||||
|
||||
# If no existing file found, return the first option
|
||||
return home / self.SHELL_CONFIG_PATTERNS[shell][0]
|
||||
|
||||
# Fallback logic
|
||||
if self.is_windows:
|
||||
# Windows fallback to PowerShell
|
||||
ps_profile = (
|
||||
home / 'Documents' / 'PowerShell' / 'Microsoft.PowerShell_profile.ps1'
|
||||
)
|
||||
return ps_profile
|
||||
else:
|
||||
# Unix fallback to bash
|
||||
bashrc = home / '.bashrc'
|
||||
if bashrc.exists():
|
||||
return bashrc
|
||||
return home / '.bash_profile'
|
||||
|
||||
def get_shell_type_from_path(self, config_path: Path) -> str:
|
||||
"""Determine shell type from configuration file path.
|
||||
|
||||
Args:
|
||||
config_path: Path to the shell configuration file.
|
||||
|
||||
Returns:
|
||||
Shell type name.
|
||||
"""
|
||||
path_str = str(config_path).lower()
|
||||
|
||||
if 'powershell' in path_str:
|
||||
return 'powershell'
|
||||
elif '.zshrc' in path_str:
|
||||
return 'zsh'
|
||||
elif 'fish' in path_str:
|
||||
return 'fish'
|
||||
elif '.bashrc' in path_str or '.bash_profile' in path_str:
|
||||
return 'bash'
|
||||
else:
|
||||
return 'bash' # Default fallback
|
||||
|
||||
def aliases_exist(self, config_path: Optional[Path] = None) -> bool:
|
||||
"""Check if OpenHands aliases already exist in the shell config.
|
||||
|
||||
Args:
|
||||
config_path: Path to check. If None, will detect automatically.
|
||||
|
||||
Returns:
|
||||
True if aliases exist, False otherwise.
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = self.get_shell_config_path()
|
||||
|
||||
if not config_path.exists():
|
||||
return False
|
||||
|
||||
shell_type = self.get_shell_type_from_path(config_path)
|
||||
patterns = self.ALIAS_PATTERNS.get(shell_type, self.ALIAS_PATTERNS['bash'])
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, content, re.MULTILINE):
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def add_aliases(self, config_path: Optional[Path] = None) -> bool:
|
||||
"""Add OpenHands aliases to the shell configuration.
|
||||
|
||||
Args:
|
||||
config_path: Path to modify. If None, will detect automatically.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = self.get_shell_config_path()
|
||||
|
||||
# Check if aliases already exist
|
||||
if self.aliases_exist(config_path):
|
||||
return True
|
||||
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get the appropriate template
|
||||
shell_type = self.get_shell_type_from_path(config_path)
|
||||
template = self.ALIAS_TEMPLATES.get(
|
||||
shell_type, self.ALIAS_TEMPLATES['bash']
|
||||
)
|
||||
|
||||
# Render the aliases
|
||||
aliases_content = template.render(command=self.command)
|
||||
|
||||
# Append to the config file
|
||||
with open(config_path, 'a', encoding='utf-8') as f:
|
||||
f.write(aliases_content)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f'Error adding aliases: {e}')
|
||||
return False
|
||||
|
||||
def get_reload_command(self, config_path: Optional[Path] = None) -> str:
|
||||
"""Get the command to reload the shell configuration.
|
||||
|
||||
Args:
|
||||
config_path: Path to the config file. If None, will detect automatically.
|
||||
|
||||
Returns:
|
||||
Command to reload the shell configuration.
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = self.get_shell_config_path()
|
||||
|
||||
shell_type = self.get_shell_type_from_path(config_path)
|
||||
|
||||
if shell_type == 'zsh':
|
||||
return 'source ~/.zshrc'
|
||||
elif shell_type == 'fish':
|
||||
return 'source ~/.config/fish/config.fish'
|
||||
elif shell_type == 'powershell':
|
||||
return '. $PROFILE'
|
||||
else: # bash and others
|
||||
if '.bash_profile' in str(config_path):
|
||||
return 'source ~/.bash_profile'
|
||||
else:
|
||||
return 'source ~/.bashrc'
|
||||
|
||||
|
||||
# Convenience functions that use the ShellConfigManager
|
||||
def add_aliases_to_shell_config() -> bool:
|
||||
"""Add OpenHands aliases to the shell configuration."""
|
||||
manager = ShellConfigManager()
|
||||
return manager.add_aliases()
|
||||
|
||||
|
||||
def aliases_exist_in_shell_config() -> bool:
|
||||
"""Check if OpenHands aliases exist in the shell configuration."""
|
||||
manager = ShellConfigManager()
|
||||
return manager.aliases_exist()
|
||||
|
||||
|
||||
def get_shell_config_path() -> Path:
|
||||
"""Get the path to the shell configuration file."""
|
||||
manager = ShellConfigManager()
|
||||
return manager.get_shell_config_path()
|
||||
4
poetry.lock
generated
4
poetry.lock
generated
@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aioboto3"
|
||||
@ -11790,4 +11790,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "3457e02f9b9fdb18c342bf0e361bf6ad59955aa402aee19227b0aa34d352bf68"
|
||||
content-hash = "8af146b6bbc131f9f4d8d6c5098865cc4f4aadaeb0158b97665b86295a246d5c"
|
||||
|
||||
@ -72,6 +72,7 @@ anyio = "4.9.0"
|
||||
pythonnet = "*"
|
||||
fastmcp = "^2.5.2"
|
||||
python-frontmatter = "^1.1.0"
|
||||
shellingham = "^1.5.4"
|
||||
# TODO: Should these go into the runtime group?
|
||||
ipywidgets = "^8.1.5"
|
||||
qtconsole = "^5.6.1"
|
||||
|
||||
@ -334,7 +334,9 @@ async def test_run_session_with_initial_action(
|
||||
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
|
||||
@patch('openhands.cli.main.NoOpCondenserConfig')
|
||||
@patch('openhands.cli.main.finalize_config')
|
||||
@patch('openhands.cli.main.aliases_exist_in_shell_config')
|
||||
async def test_main_without_task(
|
||||
mock_aliases_exist,
|
||||
mock_finalize_config,
|
||||
mock_noop_condenser,
|
||||
mock_llm_condenser,
|
||||
@ -348,6 +350,9 @@ async def test_main_without_task(
|
||||
"""Test main function without a task."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Mock alias setup functions to prevent the alias setup flow
|
||||
mock_aliases_exist.return_value = True
|
||||
|
||||
# Mock arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.agent_cls = None
|
||||
@ -420,7 +425,9 @@ async def test_main_without_task(
|
||||
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
|
||||
@patch('openhands.cli.main.NoOpCondenserConfig')
|
||||
@patch('openhands.cli.main.finalize_config')
|
||||
@patch('openhands.cli.main.aliases_exist_in_shell_config')
|
||||
async def test_main_with_task(
|
||||
mock_aliases_exist,
|
||||
mock_finalize_config,
|
||||
mock_noop_condenser,
|
||||
mock_llm_condenser,
|
||||
@ -434,6 +441,9 @@ async def test_main_with_task(
|
||||
"""Test main function with a task."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Mock alias setup functions to prevent the alias setup flow
|
||||
mock_aliases_exist.return_value = True
|
||||
|
||||
# Mock arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.agent_cls = 'custom-agent'
|
||||
@ -517,7 +527,9 @@ async def test_main_with_task(
|
||||
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
|
||||
@patch('openhands.cli.main.NoOpCondenserConfig')
|
||||
@patch('openhands.cli.main.finalize_config')
|
||||
@patch('openhands.cli.main.aliases_exist_in_shell_config')
|
||||
async def test_main_with_session_name_passes_name_to_run_session(
|
||||
mock_aliases_exist,
|
||||
mock_finalize_config,
|
||||
mock_noop_condenser,
|
||||
mock_llm_condenser,
|
||||
@ -532,6 +544,9 @@ async def test_main_with_session_name_passes_name_to_run_session(
|
||||
loop = asyncio.get_running_loop()
|
||||
test_session_name = 'my_named_session'
|
||||
|
||||
# Mock alias setup functions to prevent the alias setup flow
|
||||
mock_aliases_exist.return_value = True
|
||||
|
||||
# Mock arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.agent_cls = None
|
||||
@ -703,7 +718,9 @@ async def test_run_session_with_name_attempts_state_restore(
|
||||
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
|
||||
@patch('openhands.cli.main.NoOpCondenserConfig')
|
||||
@patch('openhands.cli.main.finalize_config')
|
||||
@patch('openhands.cli.main.aliases_exist_in_shell_config')
|
||||
async def test_main_security_check_fails(
|
||||
mock_aliases_exist,
|
||||
mock_finalize_config,
|
||||
mock_noop_condenser,
|
||||
mock_llm_condenser,
|
||||
@ -717,6 +734,9 @@ async def test_main_security_check_fails(
|
||||
"""Test main function when security check fails."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Mock alias setup functions to prevent the alias setup flow
|
||||
mock_aliases_exist.return_value = True
|
||||
|
||||
# Mock arguments
|
||||
mock_args = MagicMock()
|
||||
mock_parse_args.return_value = mock_args
|
||||
@ -764,7 +784,9 @@ async def test_main_security_check_fails(
|
||||
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
|
||||
@patch('openhands.cli.main.NoOpCondenserConfig')
|
||||
@patch('openhands.cli.main.finalize_config')
|
||||
@patch('openhands.cli.main.aliases_exist_in_shell_config')
|
||||
async def test_config_loading_order(
|
||||
mock_aliases_exist,
|
||||
mock_finalize_config,
|
||||
mock_noop_condenser,
|
||||
mock_llm_condenser,
|
||||
@ -784,6 +806,9 @@ async def test_config_loading_order(
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Mock alias setup functions to prevent the alias setup flow
|
||||
mock_aliases_exist.return_value = True
|
||||
|
||||
# Mock arguments with specific agent but no LLM config
|
||||
mock_args = MagicMock()
|
||||
mock_args.agent_cls = 'cmd-line-agent' # This should override settings
|
||||
@ -869,9 +894,11 @@ async def test_config_loading_order(
|
||||
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
|
||||
@patch('openhands.cli.main.NoOpCondenserConfig')
|
||||
@patch('openhands.cli.main.finalize_config')
|
||||
@patch('openhands.cli.main.aliases_exist_in_shell_config')
|
||||
@patch('builtins.open', new_callable=MagicMock)
|
||||
async def test_main_with_file_option(
|
||||
mock_open,
|
||||
mock_aliases_exist,
|
||||
mock_finalize_config,
|
||||
mock_noop_condenser,
|
||||
mock_llm_condenser,
|
||||
@ -885,6 +912,9 @@ async def test_main_with_file_option(
|
||||
"""Test main function with a file option."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Mock alias setup functions to prevent the alias setup flow
|
||||
mock_aliases_exist.return_value = True
|
||||
|
||||
# Mock arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.agent_cls = None
|
||||
|
||||
246
tests/unit/test_cli_alias_setup.py
Normal file
246
tests/unit/test_cli_alias_setup.py
Normal file
@ -0,0 +1,246 @@
|
||||
"""Unit tests for CLI alias setup functionality."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands.cli.shell_config import (
|
||||
ShellConfigManager,
|
||||
add_aliases_to_shell_config,
|
||||
aliases_exist_in_shell_config,
|
||||
get_shell_config_path,
|
||||
)
|
||||
|
||||
|
||||
def test_get_shell_config_path_no_files_fallback():
|
||||
"""Test shell config path fallback when no shell detection and no config files exist."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mock shellingham to raise an exception (detection failure)
|
||||
with patch(
|
||||
'shellingham.detect_shell',
|
||||
side_effect=Exception('Shell detection failed'),
|
||||
):
|
||||
profile_path = get_shell_config_path()
|
||||
assert profile_path.name == '.bash_profile'
|
||||
|
||||
|
||||
def test_get_shell_config_path_bash_fallback():
|
||||
"""Test shell config path fallback to bash when it exists."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Create .bashrc
|
||||
bashrc = Path(temp_dir) / '.bashrc'
|
||||
bashrc.touch()
|
||||
|
||||
# Mock shellingham to raise an exception (detection failure)
|
||||
with patch(
|
||||
'shellingham.detect_shell',
|
||||
side_effect=Exception('Shell detection failed'),
|
||||
):
|
||||
profile_path = get_shell_config_path()
|
||||
assert profile_path.name == '.bashrc'
|
||||
|
||||
|
||||
def test_get_shell_config_path_with_bash_detection():
|
||||
"""Test shell config path when bash is detected."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Create .bashrc
|
||||
bashrc = Path(temp_dir) / '.bashrc'
|
||||
bashrc.touch()
|
||||
|
||||
# Mock shellingham to return bash
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
profile_path = get_shell_config_path()
|
||||
assert profile_path.name == '.bashrc'
|
||||
|
||||
|
||||
def test_get_shell_config_path_with_zsh_detection():
|
||||
"""Test shell config path when zsh is detected."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Create .zshrc
|
||||
zshrc = Path(temp_dir) / '.zshrc'
|
||||
zshrc.touch()
|
||||
|
||||
# Mock shellingham to return zsh
|
||||
with patch('shellingham.detect_shell', return_value=('zsh', 'zsh')):
|
||||
profile_path = get_shell_config_path()
|
||||
assert profile_path.name == '.zshrc'
|
||||
|
||||
|
||||
def test_get_shell_config_path_with_fish_detection():
|
||||
"""Test shell config path when fish is detected."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Create fish config directory and file
|
||||
fish_config_dir = Path(temp_dir) / '.config' / 'fish'
|
||||
fish_config_dir.mkdir(parents=True)
|
||||
fish_config = fish_config_dir / 'config.fish'
|
||||
fish_config.touch()
|
||||
|
||||
# Mock shellingham to return fish
|
||||
with patch('shellingham.detect_shell', return_value=('fish', 'fish')):
|
||||
profile_path = get_shell_config_path()
|
||||
assert profile_path.name == 'config.fish'
|
||||
assert 'fish' in str(profile_path)
|
||||
|
||||
|
||||
def test_add_aliases_to_shell_config_bash():
|
||||
"""Test adding aliases to bash config."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mock shellingham to return bash
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
# Add aliases
|
||||
success = add_aliases_to_shell_config()
|
||||
assert success is True
|
||||
|
||||
# Get the actual path that was used
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
profile_path = get_shell_config_path()
|
||||
|
||||
# Check that the aliases were added
|
||||
with open(profile_path, 'r') as f:
|
||||
content = f.read()
|
||||
assert 'alias openhands=' in content
|
||||
assert 'alias oh=' in content
|
||||
assert 'uvx --python 3.12 --from openhands-ai openhands' in content
|
||||
|
||||
|
||||
def test_add_aliases_to_shell_config_zsh():
|
||||
"""Test adding aliases to zsh config."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mock shellingham to return zsh
|
||||
with patch('shellingham.detect_shell', return_value=('zsh', 'zsh')):
|
||||
# Add aliases
|
||||
success = add_aliases_to_shell_config()
|
||||
assert success is True
|
||||
|
||||
# Check that the aliases were added to .zshrc
|
||||
profile_path = Path(temp_dir) / '.zshrc'
|
||||
with open(profile_path, 'r') as f:
|
||||
content = f.read()
|
||||
assert 'alias openhands=' in content
|
||||
assert 'alias oh=' in content
|
||||
assert 'uvx --python 3.12 --from openhands-ai openhands' in content
|
||||
|
||||
|
||||
def test_add_aliases_handles_existing_aliases():
|
||||
"""Test that adding aliases handles existing aliases correctly."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mock shellingham to return bash
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
# Add aliases first time
|
||||
success = add_aliases_to_shell_config()
|
||||
assert success is True
|
||||
|
||||
# Try adding again - should detect existing aliases
|
||||
success = add_aliases_to_shell_config()
|
||||
assert success is True
|
||||
|
||||
# Get the actual path that was used
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
profile_path = get_shell_config_path()
|
||||
|
||||
# Check that aliases weren't duplicated
|
||||
with open(profile_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Count occurrences of the alias
|
||||
openhands_count = content.count('alias openhands=')
|
||||
oh_count = content.count('alias oh=')
|
||||
assert openhands_count == 1
|
||||
assert oh_count == 1
|
||||
|
||||
|
||||
def test_aliases_exist_in_shell_config_no_file():
|
||||
"""Test alias detection when no shell config exists."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mock shellingham to return bash
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
assert aliases_exist_in_shell_config() is False
|
||||
|
||||
|
||||
def test_aliases_exist_in_shell_config_no_aliases():
|
||||
"""Test alias detection when shell config exists but has no aliases."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mock shellingham to return bash
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
# Create bash profile with other content
|
||||
profile_path = get_shell_config_path()
|
||||
with open(profile_path, 'w') as f:
|
||||
f.write('export PATH=$PATH:/usr/local/bin\n')
|
||||
|
||||
assert aliases_exist_in_shell_config() is False
|
||||
|
||||
|
||||
def test_aliases_exist_in_shell_config_with_aliases():
|
||||
"""Test alias detection when aliases exist."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Mock shellingham to return bash
|
||||
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
|
||||
# Add aliases first
|
||||
add_aliases_to_shell_config()
|
||||
|
||||
assert aliases_exist_in_shell_config() is True
|
||||
|
||||
|
||||
def test_shell_config_manager_basic_functionality():
|
||||
"""Test basic ShellConfigManager functionality."""
|
||||
manager = ShellConfigManager()
|
||||
|
||||
# Test command customization
|
||||
custom_manager = ShellConfigManager(command='custom-command')
|
||||
assert custom_manager.command == 'custom-command'
|
||||
|
||||
# Test shell type detection from path
|
||||
assert manager.get_shell_type_from_path(Path('/home/user/.bashrc')) == 'bash'
|
||||
assert manager.get_shell_type_from_path(Path('/home/user/.zshrc')) == 'zsh'
|
||||
assert (
|
||||
manager.get_shell_type_from_path(Path('/home/user/.config/fish/config.fish'))
|
||||
== 'fish'
|
||||
)
|
||||
|
||||
|
||||
def test_shell_config_manager_reload_commands():
|
||||
"""Test reload command generation."""
|
||||
manager = ShellConfigManager()
|
||||
|
||||
# Test different shell reload commands
|
||||
assert 'source ~/.zshrc' in manager.get_reload_command(Path('/home/user/.zshrc'))
|
||||
assert 'source ~/.bashrc' in manager.get_reload_command(Path('/home/user/.bashrc'))
|
||||
assert 'source ~/.bash_profile' in manager.get_reload_command(
|
||||
Path('/home/user/.bash_profile')
|
||||
)
|
||||
assert 'source ~/.config/fish/config.fish' in manager.get_reload_command(
|
||||
Path('/home/user/.config/fish/config.fish')
|
||||
)
|
||||
|
||||
|
||||
def test_shell_config_manager_template_rendering():
|
||||
"""Test that templates are properly rendered."""
|
||||
manager = ShellConfigManager(command='test-command')
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
|
||||
# Create a bash config file
|
||||
bashrc = Path(temp_dir) / '.bashrc'
|
||||
bashrc.touch()
|
||||
|
||||
# Mock shell detection
|
||||
with patch.object(manager, 'detect_shell', return_value='bash'):
|
||||
success = manager.add_aliases()
|
||||
assert success is True
|
||||
|
||||
# Check that the custom command was used
|
||||
with open(bashrc, 'r') as f:
|
||||
content = f.read()
|
||||
assert 'test-command' in content
|
||||
assert 'alias openhands="test-command"' in content
|
||||
assert 'alias oh="test-command"' in content
|
||||
Loading…
x
Reference in New Issue
Block a user