diff --git a/openhands/cli/main.py b/openhands/cli/main.py
index 0433d022a9..43a0e39cf1 100644
--- a/openhands/cli/main.py
+++ b/openhands/cli/main.py
@@ -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('🚀 Welcome to OpenHands CLI!'))
+ print_formatted_text('')
+
+ # Check if aliases already exist
+ if aliases_exist_in_shell_config():
+ print_formatted_text(
+ HTML(
+ 'We detected existing OpenHands aliases in your shell configuration.'
+ )
+ )
+ print_formatted_text('')
+ print_formatted_text(
+ HTML(
+ ' • openhands → uvx --python 3.12 --from openhands-ai openhands'
+ )
+ )
+ print_formatted_text(
+ HTML(
+ ' • oh → uvx --python 3.12 --from openhands-ai openhands'
+ )
+ )
+ print_formatted_text('')
+ print_formatted_text(
+ HTML('✅ Aliases are already configured.')
+ )
+ return # Exit early since aliases already exist
+ else:
+ # No existing aliases, show the normal setup flow
+ print_formatted_text(
+ HTML('Would you like to set up convenient shell aliases?')
+ )
+ print_formatted_text('')
+ print_formatted_text(
+ HTML(
+ 'This will add the following aliases to your shell profile:'
+ )
+ )
+ print_formatted_text(
+ HTML(
+ ' • openhands → uvx --python 3.12 --from openhands-ai openhands'
+ )
+ )
+ print_formatted_text(
+ HTML(
+ ' • oh → uvx --python 3.12 --from openhands-ai openhands'
+ )
+ )
+ print_formatted_text('')
+ print_formatted_text(
+ HTML(
+ '⚠️ Note: This requires uv to be installed first.'
+ )
+ )
+ print_formatted_text(
+ HTML(
+ ' Installation guide: https://docs.astral.sh/uv/getting-started/installation'
+ )
+ )
+ 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('✅ Aliases added successfully!')
+ )
+
+ # 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'Run {reload_cmd} (or restart your terminal) to use the new aliases.'
+ )
+ )
+ else:
+ print_formatted_text('')
+ print_formatted_text(
+ HTML(
+ '❌ Failed to add aliases. You can set them up manually later.'
+ )
+ )
+ else: # User chose "No"
+ print_formatted_text('')
+ print_formatted_text(
+ HTML(
+ 'Skipped alias setup. You can run this setup again anytime.'
+ )
+ )
+
+ 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
diff --git a/openhands/cli/shell_config.py b/openhands/cli/shell_config.py
new file mode 100644
index 0000000000..06ae0de021
--- /dev/null
+++ b/openhands/cli/shell_config.py
@@ -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()
diff --git a/poetry.lock b/poetry.lock
index 5d18fafc45..d47f1e76d4 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -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"
diff --git a/pyproject.toml b/pyproject.toml
index b382fed160..a4c29e4273 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 47662702d0..7c8db05f18 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -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
diff --git a/tests/unit/test_cli_alias_setup.py b/tests/unit/test_cli_alias_setup.py
new file mode 100644
index 0000000000..d2c4eb3bbb
--- /dev/null
+++ b/tests/unit/test_cli_alias_setup.py
@@ -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