diff --git a/CHANGELOG.md b/CHANGELOG.md index 89d67e2c3..50d4b7d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `sandbox_root=.cleveragents/sandbox/`, so LLM file output (`FILE:` blocks) is written to disk during the execute phase. (#4222) +- **CLI global format flag parsing** (#6879): Promotes global CLI options before + Typer parsing so `agents --format json` and `-f` shorthand work from + the documented position. Adds regression coverage for version, info, and + diagnostics commands. + - **Robot Framework TDD Listener Guards** (#5436): Added three guard conditions to the `tdd_expected_fail_listener` `end_test()` function to prevent blindly inverting ALL test failures to passes, which was masking infrastructure errors and causing flaky CI behavior. diff --git a/features/cli_global_format_flag.feature b/features/cli_global_format_flag.feature index eddf75327..dec0df4d8 100644 --- a/features/cli_global_format_flag.feature +++ b/features/cli_global_format_flag.feature @@ -44,6 +44,25 @@ Feature: Global --format flag propagation to subcommands Then the global format command should succeed And the global format output should be valid JSON + # ----------------------------------------------------------------------- + # Regression: global --format accepted after the subcommand name + # ----------------------------------------------------------------------- + + Scenario: Global --format flag works after the info subcommand name + When I invoke the CLI with global args "info --format json" + Then the global format command should succeed + And the global format output should be valid JSON + + Scenario: Global --format flag works after the version subcommand name + When I invoke the CLI with global args "version --format json" + Then the global format command should succeed + And the global format output should be valid JSON + + Scenario: Global --format flag works after the diagnostics subcommand name + When I invoke the CLI with global args "diagnostics --format json" + Then the global format command should succeed + And the global format output should be valid JSON + # ----------------------------------------------------------------------- # Subtask 3: version, info, diagnostics read format from global ctx.obj # ----------------------------------------------------------------------- diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index 684691996..80d9b51e1 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -7,7 +7,9 @@ import sys from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any +import click import typer +from typer.core import TyperGroup from cleveragents import __version__ from cleveragents.cli.formatting import OutputFormat @@ -20,6 +22,81 @@ _console: "Console | None" = None _err_console: "Console | None" = None _subcommands_registered = False +_GLOBAL_VALUE_OPTIONS = frozenset({"--format"}) +_GLOBAL_VALUE_SHORT_OPTIONS = frozenset({"-f"}) +_GLOBAL_VALUE_EQ_PREFIXES = ("--format=",) +_GLOBAL_FLAG_OPTIONS = frozenset({"--show-secrets", "--version", "-V"}) + + +def _split_short_value_option(option: str) -> tuple[str, str] | None: + for short in _GLOBAL_VALUE_SHORT_OPTIONS: + if option.startswith(short) and len(option) > len(short): + return short, option[len(short) :] + return None + + +def _promote_global_options(args: list[str]) -> list[str]: + """Move global root options before subcommands for Typer parsing.""" + + if not args: + return args + + promoted: list[str] = [] + remaining: list[str] = [] + i = 0 + length = len(args) + + while i < length: + arg = args[i] + + if arg == "--": + remaining.extend(args[i:]) + break + + if arg in _GLOBAL_FLAG_OPTIONS: + promoted.append(arg) + i += 1 + continue + + if arg in _GLOBAL_VALUE_OPTIONS: + if i + 1 < length: + promoted.extend([arg, args[i + 1]]) + i += 2 + else: + remaining.append(arg) + i += 1 + continue + + eq_prefix = next( + (prefix for prefix in _GLOBAL_VALUE_EQ_PREFIXES if arg.startswith(prefix)), + None, + ) + if eq_prefix is not None and len(arg) > len(eq_prefix): + promoted.extend([eq_prefix.rstrip("="), arg[len(eq_prefix) :]]) + i += 1 + continue + + split_short = _split_short_value_option(arg) + if split_short is not None: + short_opt, value = split_short + promoted.extend([short_opt, value]) + i += 1 + continue + + remaining.append(arg) + i += 1 + + return promoted + remaining + + +class CleverAgentsTyperGroup(TyperGroup): + """Typer group that promotes global options before subcommand parsing.""" + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + promoted_args = _promote_global_options(list(args)) + return super().parse_args(ctx, promoted_args) + + __all__ = [ "_print_basic_help", "app", @@ -68,6 +145,7 @@ app: Any = typer.Typer( pretty_exceptions_enable=True, pretty_exceptions_show_locals=False, rich_markup_mode="rich", + cls=CleverAgentsTyperGroup, ) @@ -700,6 +778,9 @@ def main(args: list[str] | None = None) -> int: if not args: args = ["--help"] + # Global CLI options are promoted by CleverAgentsTyperGroup.parse_args, + # so we avoid reprocessing here to prevent double handling. + # Fast paths for lightweight flags to avoid heavy imports when using the # real Typer app. If the app is monkeypatched (e.g., tests), fall back to # normal execution so patched call paths are exercised.