diff --git a/ra_aid/tools/programmer.py b/ra_aid/tools/programmer.py index 4f62f92..ad68689 100644 --- a/ra_aid/tools/programmer.py +++ b/ra_aid/tools/programmer.py @@ -1,4 +1,6 @@ import os +import sys +from pathlib import Path from typing import Dict, List, Union from langchain_core.tools import tool @@ -16,6 +18,30 @@ console = Console() logger = get_logger(__name__) +def get_aider_executable() -> str: + """Get the path to the aider executable in the same bin/Scripts directory as Python. + + Returns: + str: Full path to aider executable + """ + # Get directory containing Python executable + bin_dir = Path(sys.executable).parent + + # Check for platform-specific executable name + if sys.platform == "win32": + aider_exe = bin_dir / "aider.exe" + else: + aider_exe = bin_dir / "aider" + + if not aider_exe.exists(): + raise RuntimeError(f"Could not find aider executable at {aider_exe}") + + if not os.access(aider_exe, os.X_OK): + raise RuntimeError(f"Aider executable at {aider_exe} is not executable") + + return str(aider_exe) + + def _truncate_for_log(text: str, max_length: int = 300) -> str: """Truncate text for logging, adding [truncated] if necessary.""" if len(text) <= max_length: @@ -59,8 +85,9 @@ def run_programming_task( ) # Build command + aider_exe = get_aider_executable() command = [ - "aider", + aider_exe, "--yes-always", "--no-auto-commits", "--dark-mode", @@ -163,5 +190,7 @@ def parse_aider_flags(aider_flags: str) -> List[str]: return [f"--{flag.lstrip('-')}" for flag in flags if flag.strip()] + + # Export the functions -__all__ = ["run_programming_task"] +__all__ = ["run_programming_task", "get_aider_executable"] diff --git a/tests/ra_aid/test_programmer.py b/tests/ra_aid/test_programmer.py index 583951d..994f65b 100644 --- a/tests/ra_aid/test_programmer.py +++ b/tests/ra_aid/test_programmer.py @@ -1,6 +1,7 @@ import pytest +from pathlib import Path -from ra_aid.tools.programmer import parse_aider_flags, run_programming_task +from ra_aid.tools.programmer import parse_aider_flags, run_programming_task, get_aider_executable # Test cases for parse_aider_flags function test_cases = [ @@ -62,3 +63,48 @@ def test_aider_config_flag(mocker): assert "--config" in args config_index = args.index("--config") assert args[config_index + 1] == "/path/to/config.yml" + + +def test_get_aider_executable(mocker): + """Test the get_aider_executable function.""" + mock_sys = mocker.patch("ra_aid.tools.programmer.sys") + mock_path = mocker.patch("ra_aid.tools.programmer.Path") + mock_os = mocker.patch("ra_aid.tools.programmer.os") + + # Mock sys.executable and platform + mock_sys.executable = "/path/to/venv/bin/python" + mock_sys.platform = "linux" + + # Mock Path().parent and exists() + mock_path_instance = mocker.MagicMock() + mock_path.return_value = mock_path_instance + mock_parent = mocker.MagicMock() + mock_path_instance.parent = mock_parent + mock_aider = mocker.MagicMock() + mock_parent.__truediv__.return_value = mock_aider + mock_aider.exists.return_value = True + + # Mock os.access to return True + mock_os.access.return_value = True + mock_os.X_OK = 1 # Mock the execute permission constant + + # Test happy path on Linux + aider_path = get_aider_executable() + assert aider_path == str(mock_aider) + mock_parent.__truediv__.assert_called_with("aider") + + # Test Windows path + mock_sys.platform = "win32" + aider_path = get_aider_executable() + mock_parent.__truediv__.assert_called_with("aider.exe") + + # Test executable not found + mock_aider.exists.return_value = False + with pytest.raises(RuntimeError, match="Could not find aider executable"): + get_aider_executable() + + # Test not executable + mock_aider.exists.return_value = True + mock_os.access.return_value = False + with pytest.raises(RuntimeError, match="is not executable"): + get_aider_executable()