diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb1ffd94..a0734842d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,15 @@ - **Plan Rollback Command** (#8557): Implemented `agents plan rollback []` for checkpoint-based plan state restoration in Epic #8493. The command restores a plan's sandbox to the state captured at a given checkpoint, discarding all decisions made after that checkpoint. The checkpoint can be specified as an optional positional second argument or via the `--to-checkpoint` named option. Supports `--yes/-y` flag to skip confirmation prompts and `--format/-f` for output format selection (rich/plain/json/yaml). Included with comprehensive BDD test coverage (>= 97%) and spec-aligned output formatting showing rollback summary, changes reverted, impact analysis, and post-rollback state panels. ### Fixed +- **Global CLI options ``--data-dir``, ``--config-path``, and ``-v`` now work correctly** + (#6785): These spec-required flags were absent from ``main_callback()`` in + ``src/cleveragents/cli/main.py``, causing any invocation with these flags to crash + with ``NoSuchOption: No such option``. All three options are now implemented per + ADR-021 §Global CLI Flags and ADR-024 §Resolution Chain. ``--data-dir`` sets + ``CLEVERAGENTS_DATA_DIR`` before any ``Settings``-reading code runs, ``--config-path`` + sets ``CLEVERAGENTS_CONFIG_PATH`` for ``ConfigService`` to pick up, and ``-v`` + (repeatable count) maps to the appropriate ``structlog`` log level. + - **Actor configuration validation incorrectly requires top-level provider field** (#4300): Actor configuration in V3 is now obtained from the nested configuration parameter, according to the specification. diff --git a/features/cli_global_options.feature b/features/cli_global_options.feature new file mode 100644 index 000000000..a68627c47 --- /dev/null +++ b/features/cli_global_options.feature @@ -0,0 +1,128 @@ +Feature: CLI global options --data-dir, --config-path, and -v + + Background: + Given global options test env is clean + + # ----------------------------------------------------------------------- + # --data-dir option + # ----------------------------------------------------------------------- + + @tdd_issue @tdd_issue_6785 + Scenario: --data-dir option is accepted with an existing directory + Given a temp data dir is prepared + When I run the global options CLI with data-dir flag and "version" + Then the global options CLI should succeed + + Scenario: --data-dir option overrides CLEVERAGENTS_DATA_DIR for the invocation + Given a temp data dir is prepared + When I run the global options CLI with data-dir flag and "version" + Then the CLEVERAGENTS_DATA_DIR env var should match the temp data dir + + Scenario: --data-dir with a path that is an existing file fails with a clear error + Given a temp file path is prepared + When I run the global options CLI with data-dir as temp file and "version" + Then the global options CLI should fail + And the global options output should contain "--data-dir" + + Scenario: --data-dir stores value in ctx.obj for subcommands + Given a temp data dir is prepared + When I run the global options CLI with data-dir flag and "version" + Then the CLEVERAGENTS_DATA_DIR env var should match the temp data dir + + # ----------------------------------------------------------------------- + # --config-path option + # ----------------------------------------------------------------------- + + @tdd_issue @tdd_issue_6785 + Scenario: --config-path option is accepted with an existing file + Given a temp config file is prepared + When I run the global options CLI with config-path flag and "version" + Then the global options CLI should succeed + + Scenario: --config-path overrides CLEVERAGENTS_CONFIG_PATH for the invocation + Given a temp config file is prepared + When I run the global options CLI with config-path flag and "version" + Then the CLEVERAGENTS_CONFIG_PATH env var should match the temp config file + + Scenario: --config-path with a non-existent file fails with a clear error + When I run the global options CLI with invalid config-path and "version" + Then the global options CLI should fail + And the global options output should contain "--config-path" + + Scenario: --config-path stores value in ctx.obj for subcommands + Given a temp config file is prepared + When I run the global options CLI with config-path flag and "version" + Then the CLEVERAGENTS_CONFIG_PATH env var should match the temp config file + + # ----------------------------------------------------------------------- + # Combining --data-dir and --config-path + # ----------------------------------------------------------------------- + + Scenario: --data-dir and --config-path can be combined + Given a temp data dir is prepared + And a temp config file is prepared + When I run the global options CLI with both path flags and "version" + Then the global options CLI should succeed + And the CLEVERAGENTS_DATA_DIR env var should match the temp data dir + And the CLEVERAGENTS_CONFIG_PATH env var should match the temp config file + + # ----------------------------------------------------------------------- + # -v verbosity option + # ----------------------------------------------------------------------- + + Scenario: No -v flag results in CRITICAL (silent) log level + When I call main_callback with verbosity 0 + Then the global options log level should be "CRITICAL" + + Scenario: Single -v flag results in ERROR log level + When I call main_callback with verbosity 1 + Then the global options log level should be "ERROR" + + Scenario: -vv results in WARNING log level + When I call main_callback with verbosity 2 + Then the global options log level should be "WARNING" + + Scenario: -vvv results in INFO log level + When I call main_callback with verbosity 3 + Then the global options log level should be "INFO" + + Scenario: -vvvv results in DEBUG log level + When I call main_callback with verbosity 4 + Then the global options log level should be "DEBUG" + + Scenario: -vvvvv or more results in DEBUG log level + When I call main_callback with verbosity 5 + Then the global options log level should be "DEBUG" + + @tdd_issue @tdd_issue_6785 + Scenario: -v flag is accepted by the CLI without crashing + When I run the global options CLI with args "-v version" + Then the global options CLI should succeed + + Scenario: -vvv flags are accepted by the CLI without crashing + When I run the global options CLI with args "-v -v -v version" + Then the global options CLI should succeed + + # ----------------------------------------------------------------------- + # --help shows all three options + # ----------------------------------------------------------------------- + + Scenario: --help output includes --data-dir option + When I run the global options CLI with args "--help" + Then the global options output should contain "--data-dir" + + Scenario: --help output includes --config-path option + When I run the global options CLI with args "--help" + Then the global options output should contain "--config-path" + + Scenario: -v flag appears in --help output + When I run the global options CLI with args "--help" + Then the global options output should contain "-v" + + # ----------------------------------------------------------------------- + # -v verbosity stored in ctx.obj + # ----------------------------------------------------------------------- + + Scenario: verbose count is stored in ctx.obj + When I call main_callback with verbosity 3 + Then the global options ctx obj "verbose" should be 3 diff --git a/features/steps/cli_global_options_steps.py b/features/steps/cli_global_options_steps.py new file mode 100644 index 000000000..d6bb3306c --- /dev/null +++ b/features/steps/cli_global_options_steps.py @@ -0,0 +1,346 @@ +"""Step definitions for CLI global options: --data-dir, --config-path, -v. + +Tests for issue #6785 — spec-required global options that were missing from +main_callback() and caused fatal "No such option" crashes. + +All step patterns use the prefix "global options" to avoid any ambiguity +with the many generic CLI step definitions in other step files. +""" + +from __future__ import annotations + +import os +import re +import shutil +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.formatting import OutputFormat +from cleveragents.cli.main import app + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Regex to strip ANSI escape sequences (CSI sequences and OSC sequences). +# Used to make output assertions environment-agnostic — Rich may emit ANSI +# colour/style codes when the CI environment does not set TERM=dumb. +_ANSI_ESCAPE_RE = re.compile( + r"\x1b(?:" + r"\[[0-9;]*[a-zA-Z]" # CSI sequences (SGR, cursor, etc.) + r"|" + r"\][^\x07]*\x07" # OSC sequences (hyperlink, title, etc.) + r")" +) + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape sequences from *text*.""" + return _ANSI_ESCAPE_RE.sub("", text) + + +def _invoke(args: list[str]) -> Any: + """Invoke the Typer app with the given args and return the result.""" + runner = CliRunner() + return runner.invoke(app, args, catch_exceptions=True) + + +def _combined_output(result: Any) -> str: + """Return combined output from a CliRunner result, with ANSI codes stripped. + + In non-TTY environments (CI), Typer/Rich may split output between + stdout and stderr. Check both explicitly so that ``--help`` output + assertions work regardless of the terminal detection. + """ + stdout = _strip_ansi((getattr(result, "stdout", "") or "").strip()) + stderr = _strip_ansi((getattr(result, "stderr", "") or "").strip()) + combined = ( + (stdout + "\n" + stderr).strip() if (stdout and stderr) else (stdout or stderr) + ) + return combined or _strip_ansi((result.output or "").strip()) + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given("global options test env is clean") +def step_global_opts_env_clean(context: Context) -> None: + """Ensure test-specific env vars are not polluted from previous tests. + + Registers a cleanup handler that restores the original env var values after + the scenario ends. The global ``after_scenario`` hook in + ``features/environment.py`` runs all ``context._cleanup_handlers``. + """ + orig_data_dir = os.environ.get("CLEVERAGENTS_DATA_DIR") + orig_config_path = os.environ.get("CLEVERAGENTS_CONFIG_PATH") + # Clean up any leftover from previous scenarios + for var in ("CLEVERAGENTS_DATA_DIR", "CLEVERAGENTS_CONFIG_PATH"): + os.environ.pop(var, None) + + # Register restoration handler so that env vars set by main_callback() + # during CLI invocation do not pollute subsequent scenarios. + def _restore() -> None: + for key, val in [ + ("CLEVERAGENTS_DATA_DIR", orig_data_dir), + ("CLEVERAGENTS_CONFIG_PATH", orig_config_path), + ]: + if val is not None: + os.environ[key] = val + else: + os.environ.pop(key, None) + + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(_restore) + + +@given("a temp data dir is prepared") +def step_temp_data_dir_prepared(context: Context) -> None: + """Create a real temporary directory for --data-dir testing. + + Registers a cleanup handler so the directory is removed after the scenario. + """ + tmp_dir = tempfile.mkdtemp(prefix="ca_test_data_dir_") + context.temp_data_dir = tmp_dir + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(lambda: shutil.rmtree(tmp_dir, ignore_errors=True)) + + +@given("a temp config file is prepared") +def step_temp_config_file_prepared(context: Context) -> None: + """Create a real temporary TOML config file for --config-path testing. + + Registers a cleanup handler so the file is removed after the scenario. + """ + with tempfile.NamedTemporaryFile( + suffix=".toml", prefix="ca_test_config_", delete=False + ) as tmp_file: + tmp_file.write(b"# test config\n") + tmp_path = tmp_file.name + context.temp_config_file = tmp_path + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(lambda: os.unlink(tmp_path)) + + +@given("a temp file path is prepared") +def step_temp_file_path_prepared(context: Context) -> None: + """Create a temporary FILE (not directory) to trigger --data-dir validation. + + Registers a cleanup handler so the file is removed after the scenario. + """ + with tempfile.NamedTemporaryFile(prefix="ca_test_file_", delete=False) as tmp_file: + tmp_path = tmp_file.name + context.temp_file_path = tmp_path + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(lambda: os.unlink(tmp_path)) + + +# --------------------------------------------------------------------------- +# When steps - CLI invocation +# --------------------------------------------------------------------------- + + +@when('I run the global options CLI with data-dir flag and "{subcommand}"') +def step_run_global_opts_data_dir(context: Context, subcommand: str) -> None: + """Run the CLI with --data-dir pointing at the temp directory.""" + args = ["--data-dir", context.temp_data_dir, subcommand] + result = _invoke(args) + context.global_opts_result = result + context.global_opts_exit_code = result.exit_code + context.global_opts_output = _combined_output(result) + + +@when('I run the global options CLI with config-path flag and "{subcommand}"') +def step_run_global_opts_config_path(context: Context, subcommand: str) -> None: + """Run the CLI with --config-path pointing at the temp config file.""" + args = ["--config-path", context.temp_config_file, subcommand] + result = _invoke(args) + context.global_opts_result = result + context.global_opts_exit_code = result.exit_code + context.global_opts_output = _combined_output(result) + + +@when('I run the global options CLI with both path flags and "{subcommand}"') +def step_run_global_opts_both(context: Context, subcommand: str) -> None: + """Run the CLI with both --data-dir and --config-path.""" + args = [ + "--data-dir", + context.temp_data_dir, + "--config-path", + context.temp_config_file, + subcommand, + ] + result = _invoke(args) + context.global_opts_result = result + context.global_opts_exit_code = result.exit_code + context.global_opts_output = _combined_output(result) + + +@when('I run the global options CLI with data-dir as temp file and "{subcommand}"') +def step_run_global_opts_data_dir_file(context: Context, subcommand: str) -> None: + """Run CLI with --data-dir pointing to a FILE (triggers validation error).""" + args = ["--data-dir", context.temp_file_path, subcommand] + result = _invoke(args) + context.global_opts_result = result + context.global_opts_exit_code = result.exit_code + context.global_opts_output = _combined_output(result) + + +@when('I run the global options CLI with invalid config-path and "{subcommand}"') +def step_run_global_opts_invalid_config_path(context: Context, subcommand: str) -> None: + """Run CLI with --config-path pointing to a non-existent file.""" + args = ["--config-path", "/tmp/no_such_file_xyz_99999.toml", subcommand] + result = _invoke(args) + context.global_opts_result = result + context.global_opts_exit_code = result.exit_code + context.global_opts_output = _combined_output(result) + + +@when('I run the global options CLI with args "{args}"') +def step_run_global_opts_with_args(context: Context, args: str) -> None: + """Run the CLI with space-separated args string.""" + result = _invoke(args.split()) + context.global_opts_result = result + context.global_opts_exit_code = result.exit_code + context.global_opts_output = _combined_output(result) + + +# --------------------------------------------------------------------------- +# When steps - direct callback testing for verbosity +# --------------------------------------------------------------------------- + + +@when("I call main_callback with verbosity {count:d}") +def step_call_main_callback_verbosity(context: Context, count: int) -> None: + """Invoke main_callback directly, capturing the log level configured.""" + from cleveragents.cli.main import main_callback + + context.global_opts_captured_log_level: str | None = None + context.global_opts_captured_ctx_obj: dict[str, Any] = {} + + def _capture_log_level( + *, env: str = "development", log_level: str = "INFO" + ) -> None: + context.global_opts_captured_log_level = log_level + + mock_ctx = MagicMock() + mock_ctx.obj = {} + + with patch( + "cleveragents.config.logging.configure_structlog", + side_effect=_capture_log_level, + ): + main_callback( + ctx=mock_ctx, + version=None, # type: ignore[arg-type] + show_secrets=False, + fmt=OutputFormat.RICH, + data_dir=None, + config_path=None, + verbose=count, + ) + + context.global_opts_captured_ctx_obj = mock_ctx.obj + + +# --------------------------------------------------------------------------- +# Then steps - exit codes +# --------------------------------------------------------------------------- + + +@then("the global options CLI should succeed") +def step_global_opts_cli_succeed(context: Context) -> None: + actual = context.global_opts_exit_code + output = context.global_opts_output + assert actual == 0, f"Expected exit code 0, got {actual}.\nOutput:\n{output}" + + +@then("the global options CLI should fail") +def step_global_opts_cli_fail(context: Context) -> None: + actual = context.global_opts_exit_code + assert actual != 0, ( + f"Expected non-zero exit code, got {actual}.\nOutput:\n{context.global_opts_output}" + ) + + +# --------------------------------------------------------------------------- +# Then steps - env var assertions +# --------------------------------------------------------------------------- + + +@then("the CLEVERAGENTS_DATA_DIR env var should match the temp data dir") +def step_env_data_dir_matches(context: Context) -> None: + """Verify that --data-dir set CLEVERAGENTS_DATA_DIR to the temp directory.""" + env_val = os.environ.get("CLEVERAGENTS_DATA_DIR") + expected = str(Path(context.temp_data_dir).resolve()) + assert env_val is not None, ( + "CLEVERAGENTS_DATA_DIR was not set after --data-dir invocation" + ) + assert str(Path(env_val).resolve()) == expected, ( + f"CLEVERAGENTS_DATA_DIR={env_val!r} does not match expected {expected!r}" + ) + + +@then("the CLEVERAGENTS_CONFIG_PATH env var should match the temp config file") +def step_env_config_path_matches(context: Context) -> None: + """Verify that --config-path set CLEVERAGENTS_CONFIG_PATH to the temp file.""" + env_val = os.environ.get("CLEVERAGENTS_CONFIG_PATH") + expected = str(Path(context.temp_config_file).resolve()) + assert env_val is not None, ( + "CLEVERAGENTS_CONFIG_PATH was not set after --config-path invocation" + ) + assert str(Path(env_val).resolve()) == expected, ( + f"CLEVERAGENTS_CONFIG_PATH={env_val!r} does not match expected {expected!r}" + ) + + +# --------------------------------------------------------------------------- +# Then steps - ctx.obj assertions +# --------------------------------------------------------------------------- + + +@then('the global options ctx obj "{key}" should be {value:d}') +def step_global_opts_ctx_obj_int(context: Context, key: str, value: int) -> None: + """Verify ctx.obj[key] has the expected integer value.""" + obj = context.global_opts_captured_ctx_obj + assert key in obj, f"ctx.obj missing key '{key}'. ctx.obj={obj}" + actual = obj[key] + assert actual == value, f"ctx.obj['{key}']={actual!r}, expected {value!r}" + + +# --------------------------------------------------------------------------- +# Then steps - log level assertions +# --------------------------------------------------------------------------- + + +@then('the global options log level should be "{level}"') +def step_global_opts_log_level(context: Context, level: str) -> None: + """Verify that configure_structlog was called with the expected log level.""" + actual = context.global_opts_captured_log_level + assert actual is not None, "configure_structlog was not called" + assert actual.upper() == level.upper(), ( + f"Expected log level {level!r}, got {actual!r}" + ) + + +# --------------------------------------------------------------------------- +# Then steps - output assertions +# --------------------------------------------------------------------------- + + +@then('the global options output should contain "{text}"') +def step_global_opts_output_contains(context: Context, text: str) -> None: + output = context.global_opts_output + assert text in output, f"Expected '{text}' in output:\n{output}" diff --git a/robot/cli_global_options.robot b/robot/cli_global_options.robot new file mode 100644 index 000000000..20950f2df --- /dev/null +++ b/robot/cli_global_options.robot @@ -0,0 +1,79 @@ +*** Settings *** +Documentation Integration tests for CLI global options --data-dir, --config-path, and -v. +... +... Verifies that the spec-required global options are properly accepted +... and propagated end-to-end (issue #6785). +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_cli_global_options.py + +*** Test Cases *** +Data Dir Option Accepted With Valid Directory + [Documentation] Verify --data-dir is accepted without error for an existing directory. + ${result}= Run Process ${PYTHON} ${HELPER} data-dir-accepted cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-global-options-data-dir-accepted-ok + +Config Path Option Accepted With Valid File + [Documentation] Verify --config-path is accepted without error for an existing file. + ${result}= Run Process ${PYTHON} ${HELPER} config-path-accepted cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-global-options-config-path-accepted-ok + +Verbosity Flag Accepted + [Documentation] Verify -v flag is accepted without crashing. + ${result}= Run Process ${PYTHON} ${HELPER} verbosity-accepted cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-global-options-verbosity-accepted-ok + +Data Dir And Config Path Combined + [Documentation] Verify --data-dir and --config-path can be combined end-to-end. + ${result}= Run Process ${PYTHON} ${HELPER} combined-options cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-global-options-combined-ok + +Data Dir Overrides Settings Data Dir + [Documentation] Verify --data-dir properly overrides CLEVERAGENTS_DATA_DIR. + ${result}= Run Process ${PYTHON} ${HELPER} data-dir-override cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-global-options-data-dir-override-ok + +Config Path Overrides Config Service Path + [Documentation] Verify --config-path properly overrides the config service path. + ${result}= Run Process ${PYTHON} ${HELPER} config-path-override cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-global-options-config-path-override-ok + +Verbosity Sets Log Level Correctly + [Documentation] Verify -v count correctly maps to log levels. + ${result}= Run Process ${PYTHON} ${HELPER} verbosity-levels cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-global-options-verbosity-levels-ok + +Help Shows All Three Options + [Documentation] Verify --help output includes --data-dir, --config-path, and -v. + ${result}= Run Process ${PYTHON} -m cleveragents --help + ... timeout=60s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} --data-dir + Should Contain ${result.stdout} --config-path + Should Contain ${result.stdout} -v diff --git a/robot/helper_cli_global_options.py b/robot/helper_cli_global_options.py new file mode 100644 index 000000000..80092190b --- /dev/null +++ b/robot/helper_cli_global_options.py @@ -0,0 +1,204 @@ +"""Helper script for cli_global_options.robot integration tests. + +Each sub-command verifies one end-to-end behaviour of the new global CLI +options (--data-dir, --config-path, -v) introduced by issue #6785. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _invoke(args: list[str]) -> tuple[int, str]: + """Invoke the Typer app and return (exit_code, combined_output).""" + from typer.testing import CliRunner + + from cleveragents.cli.main import app + + runner = CliRunner() + result = runner.invoke(app, args, catch_exceptions=True) + output = result.output or "" + return result.exit_code, output + + +# --------------------------------------------------------------------------- +# Test cases (sub-commands) +# --------------------------------------------------------------------------- + + +def test_data_dir_accepted() -> None: + with tempfile.TemporaryDirectory() as tmp: + code, output = _invoke(["--data-dir", tmp, "version"]) + assert code == 0, f"Expected exit 0, got {code}. Output: {output}" + print("cli-global-options-data-dir-accepted-ok") + + +def test_config_path_accepted() -> None: + with tempfile.NamedTemporaryFile(suffix=".toml", delete=False) as tmp: + tmp.write(b"# test config\n") + tmp_path = tmp.name + try: + code, output = _invoke(["--config-path", tmp_path, "version"]) + assert code == 0, f"Expected exit 0, got {code}. Output: {output}" + print("cli-global-options-config-path-accepted-ok") + finally: + os.unlink(tmp_path) + + +def test_verbosity_accepted() -> None: + code, output = _invoke(["-v", "version"]) + assert code == 0, f"Expected exit 0, got {code}. Output: {output}" + print("cli-global-options-verbosity-accepted-ok") + + +def test_combined_options() -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + with tempfile.NamedTemporaryFile(suffix=".toml", delete=False) as tmp_cfg: + tmp_cfg.write(b"# test\n") + tmp_cfg_path = tmp_cfg.name + try: + code, output = _invoke( + [ + "--data-dir", + tmp_dir, + "--config-path", + tmp_cfg_path, + "version", + ] + ) + assert code == 0, f"Expected exit 0, got {code}. Output: {output}" + print("cli-global-options-combined-ok") + finally: + os.unlink(tmp_cfg_path) + + +def test_data_dir_override() -> None: + """Verify --data-dir sets CLEVERAGENTS_DATA_DIR env var during invocation.""" + with tempfile.TemporaryDirectory() as tmp: + code, output = _invoke(["--data-dir", tmp, "version"]) + assert code == 0, f"Expected exit 0, got {code}. Output: {output}" + + # Verify the env var was set to (or derived from) the tmp dir + env_val = os.environ.get("CLEVERAGENTS_DATA_DIR") + if env_val: + resolved_env = str(Path(env_val).resolve()) + resolved_tmp = str(Path(tmp).resolve()) + assert resolved_env == resolved_tmp, ( + f"CLEVERAGENTS_DATA_DIR={env_val!r} != {tmp!r}" + ) + + print("cli-global-options-data-dir-override-ok") + + +def test_config_path_override() -> None: + """Verify --config-path sets CLEVERAGENTS_CONFIG_PATH env var during invocation.""" + with tempfile.NamedTemporaryFile(suffix=".toml", delete=False) as tmp: + tmp.write(b"# test\n") + tmp_path = tmp.name + try: + code, output = _invoke(["--config-path", tmp_path, "version"]) + assert code == 0, f"Expected exit 0, got {code}. Output: {output}" + env_val = os.environ.get("CLEVERAGENTS_CONFIG_PATH") + if env_val: + assert str(Path(env_val).resolve()) == str(Path(tmp_path).resolve()), ( + f"CLEVERAGENTS_CONFIG_PATH={env_val!r} != {tmp_path!r}" + ) + print("cli-global-options-config-path-override-ok") + finally: + os.unlink(tmp_path) + + +def test_verbosity_levels() -> None: + """Verify the verbosity count → log level mapping.""" + from unittest.mock import MagicMock + + from cleveragents.cli.formatting import OutputFormat + from cleveragents.cli.main import main_callback + + expected_levels = { + 0: "CRITICAL", + 1: "ERROR", + 2: "WARNING", + 3: "INFO", + 4: "DEBUG", + 5: "DEBUG", + } + + for count, expected in expected_levels.items(): + captured_levels: list[str] = [] + + def _make_capture(levels: list[str]): # type: ignore[no-untyped-def] + def _capture(*, env: str = "development", log_level: str = "INFO") -> None: + levels.append(log_level) + + return _capture + + mock_ctx = MagicMock() + mock_ctx.obj = {} + + with patch( + "cleveragents.config.logging.configure_structlog", + side_effect=_make_capture(captured_levels), + ): + main_callback( + ctx=mock_ctx, + version=None, # type: ignore[arg-type] + show_secrets=False, + fmt=OutputFormat.RICH, + data_dir=None, + config_path=None, + verbose=count, + ) + + assert captured_levels, f"configure_structlog not called for count={count}" + actual = captured_levels[-1].upper() + assert actual == expected, ( + f"verbose={count}: expected log_level={expected!r}, got {actual!r}" + ) + + print("cli-global-options-verbosity-levels-ok") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +_COMMANDS: dict[str, object] = { + "data-dir-accepted": test_data_dir_accepted, + "config-path-accepted": test_config_path_accepted, + "verbosity-accepted": test_verbosity_accepted, + "combined-options": test_combined_options, + "data-dir-override": test_data_dir_override, + "config-path-override": test_config_path_override, + "verbosity-levels": test_verbosity_levels, +} + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: helper_cli_global_options.py ", file=sys.stderr) + sys.exit(1) + + cmd = sys.argv[1] + fn = _COMMANDS.get(cmd) + if fn is None: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + + try: + fn() # type: ignore[call-arg] + except Exception as exc: + print(f"FAILED: {exc}", file=sys.stderr) + import traceback + + traceback.print_exc(file=sys.stderr) + sys.exit(1) diff --git a/src/cleveragents/application/services/config_service.py b/src/cleveragents/application/services/config_service.py index 7048f5508..92fa0316f 100644 --- a/src/cleveragents/application/services/config_service.py +++ b/src/cleveragents/application/services/config_service.py @@ -1183,6 +1183,14 @@ class ConfigService: project_root: Path | _AutoDiscover | None = _AUTO_DISCOVER, ) -> None: self._config_dir: Path = config_dir or _DEFAULT_CONFIG_DIR + # Honour CLEVERAGENTS_CONFIG_PATH env var when no explicit path is given. + # This allows the --config-path CLI flag (which sets the env var in + # main_callback) to propagate into all ConfigService instances created + # during subcommand execution (ADR-024 §Resolution Chain). + if config_path is None: + _env_config_path = os.environ.get("CLEVERAGENTS_CONFIG_PATH", "").strip() + if _env_config_path: + config_path = Path(_env_config_path) self._config_path: Path = config_path or (self._config_dir / "config.toml") self._event_bus = event_bus # ``_AUTO_DISCOVER`` means "auto-discover"; ``None`` means "no project root". diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index 4fbd6d0c2..e2ec7d8be 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -3,6 +3,7 @@ Based on ADR-009: CLI Framework using Typer. """ +import os import sys from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any @@ -249,6 +250,13 @@ def _print_basic_help() -> None: """Print a lightweight help message without heavy imports.""" typer.echo("CleverAgents - AI-powered development assistant (actor-first)") typer.echo("Usage: cleveragents [OPTIONS] COMMAND [ARGS]...") + typer.echo("\nGlobal options:") + typer.echo(" --data-dir PATH Override global data directory") + typer.echo(" --config-path PATH Override global configuration file path") + typer.echo(" -v Increase log verbosity (repeatable)") + typer.echo(" --format -f FMT Output format: rich/color/table/plain/json/yaml") + typer.echo(" --version Show version and exit") + typer.echo(" --help -h Show this message and exit") typer.echo("\nCommon commands:") typer.echo(" project Project management") typer.echo(" actor context Actor context management") @@ -272,6 +280,21 @@ def _print_basic_help() -> None: ) +# --------------------------------------------------------------------------- +# Verbosity (-v) → log level mapping (ADR-021 §Global CLI Flags) +# --------------------------------------------------------------------------- +# Mapping: 0 = silent (CRITICAL), 1 = ERROR, 2 = WARNING, 3 = INFO, +# 4 = DEBUG, 5+ = DEBUG (TRACE not available in Python stdlib). +_VERBOSITY_LOG_LEVELS: tuple[str, ...] = ( + "CRITICAL", # 0 — no -v flag (silent) + "ERROR", # 1 — -v + "WARNING", # 2 — -vv + "INFO", # 3 — -vvv + "DEBUG", # 4 — -vvvv + "DEBUG", # 5+ — -vvvvv (TRACE mapped to DEBUG) +) + + def version_callback(value: bool) -> None: """Handle --version flag.""" if value: @@ -316,20 +339,98 @@ def main_callback( ), ), ] = OutputFormat.RICH, + data_dir: Annotated[ + Path | None, + typer.Option( + "--data-dir", + help=( + "Override the global data directory for this invocation " + "(database, caches, sessions, logs). " + "Overrides CLEVERAGENTS_DATA_DIR and core.data-dir config key." + ), + metavar="PATH", + ), + ] = None, + config_path: Annotated[ + Path | None, + typer.Option( + "--config-path", + help=( + "Override the global configuration file path for this invocation. " + "Overrides CLEVERAGENTS_CONFIG_PATH and the default config location." + ), + metavar="PATH", + ), + ] = None, + verbose: Annotated[ + int, + typer.Option( + "-v", + count=True, + help=( + "Increase log verbosity (repeatable). " + "No flag = silent; -v = ERROR; -vv = WARN; " + "-vvv = INFO; -vvvv = DEBUG; -vvvvv = TRACE." + ), + ), + ] = 0, ) -> None: """CleverAgents - AI-powered development assistant.""" - # Suppress debug-level logs on stdout for ALL commands so machine-readable - # output formats (json, yaml, plain) receive clean stdout. Commands that - # need verbose logging can override this after parsing --log-level flags. from cleveragents.config.logging import configure_structlog - configure_structlog(log_level="WARNING") + # ----------------------------------------------------------------------- + # Validate and wire --data-dir + # ----------------------------------------------------------------------- + if data_dir is not None: + resolved_data_dir = data_dir.resolve() + if resolved_data_dir.exists() and not resolved_data_dir.is_dir(): + get_err_console().print( + f"[red]Error: --data-dir '{data_dir}' exists but is not" + " a directory.[/red]" + ) + raise typer.Exit(1) + # Wire: set env var so Settings and other components pick it up. + # Settings.__new__ reads CLEVERAGENTS_DATA_DIR on first access, so + # setting the env var before any Settings-reading code runs is the + # correct (and non-destructive) way to propagate the CLI override. + os.environ["CLEVERAGENTS_DATA_DIR"] = str(resolved_data_dir) + + # ----------------------------------------------------------------------- + # Validate and wire --config-path + # ----------------------------------------------------------------------- + if config_path is not None: + resolved_config_path = config_path.resolve() + if not resolved_config_path.exists(): + get_err_console().print( + f"[red]Error: --config-path '{config_path}' does not exist.[/red]" + ) + raise typer.Exit(1) + if not resolved_config_path.is_file(): + get_err_console().print( + f"[red]Error: --config-path '{config_path}' is not a file.[/red]" + ) + raise typer.Exit(1) + # Wire: set env var so ConfigService instances created later use this path + os.environ["CLEVERAGENTS_CONFIG_PATH"] = str(resolved_config_path) + + # ----------------------------------------------------------------------- + # Configure log verbosity from -v count (ADR-021 §Global CLI Flags) + # ----------------------------------------------------------------------- + log_level = _VERBOSITY_LOG_LEVELS[min(verbose, len(_VERBOSITY_LOG_LEVELS) - 1)] + configure_structlog(log_level=log_level) + _register_subcommands() - # Store the selected output format in the Typer context so all subcommands - # can read it via ctx.obj["format"] without needing their own --format flag. + # ----------------------------------------------------------------------- + # Store all global options in ctx.obj for subcommand access + # ----------------------------------------------------------------------- ctx.ensure_object(dict) ctx.obj["format"] = fmt.value + ctx.obj["data_dir"] = str(data_dir.resolve()) if data_dir is not None else None + ctx.obj["config_path"] = ( + str(config_path.resolve()) if config_path is not None else None + ) + ctx.obj["verbose"] = verbose @app.command() @@ -760,8 +861,13 @@ def main(args: list[str] | None = None) -> int: return 130 except Exception as e: from cleveragents.core.error_handling import classify_error, wrap_unexpected + from cleveragents.shared.redaction import redact_value err_console = get_err_console() + # Always print the original exception type/message to stderr so that + # actionable details (e.g. "No such option: --flag") remain visible + # even when the log level is set to CRITICAL (structlog suppressed). + err_console.print(f"[dim]{type(e).__name__}: {redact_value(str(e))}[/dim]") safe = wrap_unexpected(e) info = classify_error(safe) err_console.print(