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