diff --git a/mini_agent/cli.py b/mini_agent/cli.py index f060c9c2..52528eba 100644 --- a/mini_agent/cli.py +++ b/mini_agent/cli.py @@ -483,11 +483,43 @@ async def _quiet_cleanup(): pass -async def run_agent(workspace_dir: Path, task: str = None): +def resolve_workspace_dir(cli_workspace: str | None, config: Config) -> Path: + """Resolve the effective workspace directory. + + Precedence (highest first): + 1. ``--workspace`` CLI argument + 2. ``workspace_dir`` from config.yaml (``config.agent.workspace_dir``) + 3. ``Path.cwd()`` (current working directory) + + Relative paths are resolved against the current working directory so the + behaviour matches what users see when invoking the CLI from a project root + (e.g. ``./workspace`` -> ``/workspace``). + + Args: + cli_workspace: Raw ``--workspace`` argument value (may be ``None``). + config: Loaded :class:`Config` instance. + + Returns: + Absolute :class:`Path` to the workspace directory. + """ + if cli_workspace: + return Path(cli_workspace).expanduser().resolve() + + config_workspace = config.agent.workspace_dir + if config_workspace: + return Path(config_workspace).expanduser().resolve() + + return Path.cwd() + + +async def run_agent(cli_workspace: str | None, task: str = None): """Run Agent in interactive or non-interactive mode. Args: - workspace_dir: Workspace directory path + cli_workspace: Raw ``--workspace`` CLI argument (``None`` if not + provided). The effective workspace directory is resolved after + loading the configuration so ``workspace_dir`` from ``config.yaml`` + is honoured when no CLI argument is given. task: If provided, execute this task and exit (non-interactive mode) """ session_start = datetime.now() @@ -535,6 +567,10 @@ async def run_agent(workspace_dir: Path, task: str = None): print(f"{Colors.RED}❌ Error: Failed to load configuration file: {e}{Colors.RESET}") return + # 1.5 Resolve workspace directory honouring CLI > config.yaml > cwd precedence + workspace_dir = resolve_workspace_dir(cli_workspace, config) + workspace_dir.mkdir(parents=True, exist_ok=True) + # 2. Initialize LLM client from mini_agent.retry import RetryConfig as RetryConfigBase @@ -854,19 +890,10 @@ def main(): show_log_directory(open_file_manager=True) return - # Determine workspace directory - # Expand ~ to user home directory for portability - if args.workspace: - workspace_dir = Path(args.workspace).expanduser().absolute() - else: - # Use current working directory - workspace_dir = Path.cwd() - - # Ensure workspace directory exists - workspace_dir.mkdir(parents=True, exist_ok=True) - - # Run the agent (config always loaded from package directory) - asyncio.run(run_agent(workspace_dir, task=args.task)) + # Workspace directory is resolved inside run_agent() after the config has + # been loaded, so the precedence ``--workspace`` > ``workspace_dir`` in + # config.yaml > ``Path.cwd()`` is honoured uniformly (see #90). + asyncio.run(run_agent(args.workspace, task=args.task)) if __name__ == "__main__": diff --git a/tests/test_cli_workspace.py b/tests/test_cli_workspace.py new file mode 100644 index 00000000..d3bbe412 --- /dev/null +++ b/tests/test_cli_workspace.py @@ -0,0 +1,88 @@ +"""Tests for CLI workspace directory resolution. + +Regression coverage for #90: ``workspace_dir`` configured in ``config.yaml`` +must be honoured when no ``--workspace`` CLI argument is provided. +""" + +import os +from pathlib import Path + +import pytest + +from mini_agent.cli import resolve_workspace_dir +from mini_agent.config import AgentConfig, Config, LLMConfig, ToolsConfig + + +def _make_config(workspace_dir: str) -> Config: + """Build a minimal :class:`Config` for tests with a custom workspace.""" + return Config( + llm=LLMConfig(api_key="test-key"), + agent=AgentConfig(workspace_dir=workspace_dir), + tools=ToolsConfig(), + ) + + +def test_resolve_workspace_prefers_cli_argument(tmp_path: Path) -> None: + """When ``--workspace`` is provided it wins over ``config.workspace_dir``.""" + cli_dir = tmp_path / "from-cli" + config_dir = tmp_path / "from-config" + config = _make_config(str(config_dir)) + + resolved = resolve_workspace_dir(str(cli_dir), config) + + assert resolved == cli_dir.resolve() + + +def test_resolve_workspace_falls_back_to_config(tmp_path: Path) -> None: + """Without a CLI argument the value from ``config.yaml`` is used.""" + config_dir = tmp_path / "from-config" + config = _make_config(str(config_dir)) + + resolved = resolve_workspace_dir(None, config) + + assert resolved == config_dir.resolve() + + +def test_resolve_workspace_relative_config_resolves_against_cwd( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Relative ``workspace_dir`` from config resolves against the current cwd. + + Reproduces the scenario from issue #90 where users set + ``workspace_dir: "./workspace"`` and expect ``/workspace`` to be used. + """ + monkeypatch.chdir(tmp_path) + config = _make_config("./workspace") + + resolved = resolve_workspace_dir(None, config) + + assert resolved == (tmp_path / "workspace").resolve() + + +def test_resolve_workspace_falls_back_to_cwd_when_config_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty ``workspace_dir`` in config falls back to the current cwd.""" + monkeypatch.chdir(tmp_path) + config = _make_config("") + + resolved = resolve_workspace_dir(None, config) + + assert resolved == tmp_path + + +def test_resolve_workspace_expands_user_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``~`` in ``workspace_dir`` is expanded to the user home directory.""" + fake_home = tmp_path / "home" / "user" + fake_home.mkdir(parents=True) + monkeypatch.setenv("HOME", str(fake_home)) + # On Windows ``Path.expanduser`` honours USERPROFILE, set both to be safe. + monkeypatch.setenv("USERPROFILE", str(fake_home)) + + config = _make_config("~/my-workspace") + + resolved = resolve_workspace_dir(None, config) + + assert resolved == (fake_home / "my-workspace").resolve()