diff --git a/CHANGELOG.md b/CHANGELOG.md index a659d5d92..c99260507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Added CLI polish infrastructure: shared constants.py (exit codes, format + constants), centralized errors.py (cli_error, cli_not_found, cli_warning), + and completion command for shell tab-completion generation. (#861) - Added tool-level execution environment preferences with NONE, REQUIRED, PREFERRED, and SPECIFIC modes. ToolRunner routes tool execution based on preference mode with caller-override precedence. (#879) diff --git a/features/cli_consistency.feature b/features/cli_consistency.feature new file mode 100644 index 000000000..61f3f4c2b --- /dev/null +++ b/features/cli_consistency.feature @@ -0,0 +1,136 @@ +Feature: CLI consistency and UX polish + As a developer + I want all CLI commands to follow consistent UX patterns + So that the tool is predictable and easy to use + + # ----------------------------------------------------------------- + # Exit code constants + # ----------------------------------------------------------------- + + Scenario: Exit code constants are defined + Given the CLI constants module is imported + Then EXIT_SUCCESS should equal 0 + And EXIT_ERROR should equal 1 + And EXIT_USAGE should equal 2 + And EXIT_NOT_FOUND should equal 3 + And EXIT_CONFLICT should equal 4 + + # ----------------------------------------------------------------- + # Error formatting + # ----------------------------------------------------------------- + + Scenario: cli_error displays standardized error and exits + Given the CLI errors module is imported + When I call cli_error with only message "something broke" + Then the process should exit with code 1 + And the stderr output should contain "Error:" + And the stderr output should contain "something broke" + + Scenario: cli_error displays hint when provided + Given the CLI errors module is imported + When I call cli_error with message "not found" and hint "check the name" + Then the process should exit with code 1 + And the stderr output should contain "Hint: check the name" + + Scenario: cli_error uses custom exit code + Given the CLI errors module is imported + When I call cli_error with message "bad usage" and exit code 2 + Then the process should exit with code 2 + + Scenario: cli_warning displays warning without exiting + Given the CLI errors module is imported + When I call cli_warning with only message "heads up" + Then the stderr output should contain "Warning:" + And the stderr output should contain "heads up" + + Scenario: cli_warning displays hint when provided + Given the CLI errors module is imported + When I call cli_warning with message "slow" and hint "use cache" + Then the stderr output should contain "Hint: use cache" + + Scenario: cli_not_found formats correctly and exits with code 3 + Given the CLI errors module is imported + When I call cli_not_found with type "Project" and name "missing-proj" + Then the process should exit with code 3 + And the stderr output should contain "Project 'missing-proj' not found" + And the stderr output should contain "Hint:" + + # ----------------------------------------------------------------- + # Format constants + # ----------------------------------------------------------------- + + Scenario: Format constants are consistent + Given the CLI constants module is imported + Then FORMAT_HELP should mention "json" + And FORMAT_HELP should mention "yaml" + And FORMAT_HELP should mention "plain" + And DEFAULT_FORMAT should equal "rich" + And VALID_FORMATS should contain "json" + And VALID_FORMATS should contain "yaml" + And VALID_FORMATS should contain "plain" + And VALID_FORMATS should contain "table" + And VALID_FORMATS should contain "rich" + + Scenario: Named format shortcut constants are defined + Given the CLI constants module is imported + Then FORMAT_TEXT should equal "plain" + And FORMAT_JSON should equal "json" + And FORMAT_TABLE should equal "table" + + # ----------------------------------------------------------------- + # Help text consistency + # ----------------------------------------------------------------- + + Scenario: Main CLI provides help output + Given a CLI consistency test runner + When I invoke the CLI with "--help" + Then the invoked CLI exit code should be 0 + And the invoked CLI output should contain "AI-powered development assistant" + + Scenario: Version command provides output + Given a CLI consistency test runner + When I invoke the CLI with "--version" + Then the invoked CLI exit code should be 0 + And the invoked CLI output should contain "CleverAgents" + + Scenario: Invalid command returns usage exit code + Given a CLI consistency test runner + When I invoke the CLI with "nonexistent-command-xyz" + Then the invoked CLI exit code should be 2 + + # ----------------------------------------------------------------- + # Shell completion command + # ----------------------------------------------------------------- + + Scenario: Completion command exists + Given a CLI consistency test runner + When I invoke the CLI with "completion --help" + Then the invoked CLI exit code should be 0 + And the invoked CLI output should contain "completion" + + Scenario: Completion rejects unsupported shell + Given a CLI consistency test runner + When I invoke the CLI with "completion ksh" + Then the invoked CLI exit code should be 2 + + # ----------------------------------------------------------------- + # JSON/text format switching + # ----------------------------------------------------------------- + + Scenario: Version command supports JSON format + Given a CLI consistency test runner + When I invoke the CLI with "version --format json" + Then the invoked CLI exit code should be 0 + And the invoked CLI output should be valid JSON + + Scenario: Version command supports YAML format + Given a CLI consistency test runner + When I invoke the CLI with "version --format yaml" + Then the invoked CLI exit code should be 0 + And the invoked CLI output should contain "version:" + + Scenario: Version command supports plain format + Given a CLI consistency test runner + When I invoke the CLI with "version --format plain" + Then the invoked CLI exit code should be 0 + And the invoked CLI output should contain "version:" diff --git a/features/steps/cli_consistency_steps.py b/features/steps/cli_consistency_steps.py new file mode 100644 index 000000000..990949790 --- /dev/null +++ b/features/steps/cli_consistency_steps.py @@ -0,0 +1,291 @@ +"""Step definitions for CLI consistency and UX polish feature.""" + +from __future__ import annotations + +import json +from io import StringIO +from unittest.mock import patch + +from behave import given, then, when +from behave.runner import Context + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + + +def _call_cli_error( + context: Context, + message: str, + hint: str | None = None, + exit_code: int | None = None, +) -> None: + """Invoke cli_error with a captured stderr console.""" + from rich.console import Console + + from cleveragents.cli.errors import cli_error + + buf = StringIO() + mock_console = Console(file=buf, stderr=True, no_color=True) + kwargs: dict[str, object] = {} + if hint is not None: + kwargs["hint"] = hint + if exit_code is not None: + kwargs["exit_code"] = exit_code + with patch("cleveragents.cli.errors._get_err_console", return_value=mock_console): + try: + cli_error(message, **kwargs) # type: ignore[arg-type] + except SystemExit as exc: + context.exit_code = exc.code + context.stderr_capture = buf + + +def _call_cli_warning( + context: Context, + message: str, + hint: str | None = None, +) -> None: + """Invoke cli_warning with a captured stderr console.""" + from rich.console import Console + + from cleveragents.cli.errors import cli_warning + + buf = StringIO() + mock_console = Console(file=buf, stderr=True, no_color=True) + with patch("cleveragents.cli.errors._get_err_console", return_value=mock_console): + if hint is not None: + cli_warning(message, hint=hint) + else: + cli_warning(message) + context.stderr_capture = buf + + +def _call_cli_not_found( + context: Context, + resource_type: str, + name: str, +) -> None: + """Invoke cli_not_found with a captured stderr console.""" + from rich.console import Console + + from cleveragents.cli.errors import cli_not_found + + buf = StringIO() + mock_console = Console(file=buf, stderr=True, no_color=True) + with patch("cleveragents.cli.errors._get_err_console", return_value=mock_console): + try: + cli_not_found(resource_type, name) + except SystemExit as exc: + context.exit_code = exc.code + context.stderr_capture = buf + + +# ------------------------------------------------------------------ +# Constants module +# ------------------------------------------------------------------ + + +@given("the CLI constants module is imported") +def step_import_constants(context: Context) -> None: + from cleveragents.cli.constants import ( + DEFAULT_FORMAT, + EXIT_CONFLICT, + EXIT_ERROR, + EXIT_NOT_FOUND, + EXIT_SUCCESS, + EXIT_USAGE, + FORMAT_HELP, + FORMAT_JSON, + FORMAT_TABLE, + FORMAT_TEXT, + VALID_FORMATS, + ) + + context.const = { + "EXIT_SUCCESS": EXIT_SUCCESS, + "EXIT_ERROR": EXIT_ERROR, + "EXIT_USAGE": EXIT_USAGE, + "EXIT_NOT_FOUND": EXIT_NOT_FOUND, + "EXIT_CONFLICT": EXIT_CONFLICT, + "FORMAT_HELP": FORMAT_HELP, + "DEFAULT_FORMAT": DEFAULT_FORMAT, + "VALID_FORMATS": VALID_FORMATS, + "FORMAT_TEXT": FORMAT_TEXT, + "FORMAT_JSON": FORMAT_JSON, + "FORMAT_TABLE": FORMAT_TABLE, + } + + +@then("EXIT_SUCCESS should equal {value:d}") +def step_exit_success_equals(context: Context, value: int) -> None: + assert context.const["EXIT_SUCCESS"] == value + + +@then("EXIT_ERROR should equal {value:d}") +def step_exit_error_equals(context: Context, value: int) -> None: + assert context.const["EXIT_ERROR"] == value + + +@then("EXIT_USAGE should equal {value:d}") +def step_exit_usage_equals(context: Context, value: int) -> None: + assert context.const["EXIT_USAGE"] == value + + +@then("EXIT_NOT_FOUND should equal {value:d}") +def step_exit_not_found_equals(context: Context, value: int) -> None: + assert context.const["EXIT_NOT_FOUND"] == value + + +@then("EXIT_CONFLICT should equal {value:d}") +def step_exit_conflict_equals(context: Context, value: int) -> None: + assert context.const["EXIT_CONFLICT"] == value + + +@then('FORMAT_HELP should mention "{text}"') +def step_format_help_mentions(context: Context, text: str) -> None: + assert text in context.const["FORMAT_HELP"], ( + f"Expected '{text}' in FORMAT_HELP: {context.const['FORMAT_HELP']}" + ) + + +@then('DEFAULT_FORMAT should equal "{value}"') +def step_default_format_equals(context: Context, value: str) -> None: + assert context.const["DEFAULT_FORMAT"] == value + + +@then('VALID_FORMATS should contain "{value}"') +def step_valid_formats_contains(context: Context, value: str) -> None: + assert value in context.const["VALID_FORMATS"] + + +@then('FORMAT_TEXT should equal "{value}"') +def step_format_text_equals(context: Context, value: str) -> None: + assert context.const["FORMAT_TEXT"] == value + + +@then('FORMAT_JSON should equal "{value}"') +def step_format_json_equals(context: Context, value: str) -> None: + assert context.const["FORMAT_JSON"] == value + + +@then('FORMAT_TABLE should equal "{value}"') +def step_format_table_equals(context: Context, value: str) -> None: + assert context.const["FORMAT_TABLE"] == value + + +# ------------------------------------------------------------------ +# Error formatting (parameterised steps matching feature scenarios) +# ------------------------------------------------------------------ + + +@given("the CLI errors module is imported") +def step_import_errors(context: Context) -> None: + context.stderr_capture = StringIO() + context.exit_code = None + + +@when('I call cli_error with only message "{message}"') +def step_call_cli_error_message(context: Context, message: str) -> None: + _call_cli_error(context, message) + + +@when('I call cli_error with message "{message}" and hint "{hint}"') +def step_call_cli_error_message_hint(context: Context, message: str, hint: str) -> None: + _call_cli_error(context, message, hint=hint) + + +@when('I call cli_error with message "{message}" and exit code {code:d}') +def step_call_cli_error_custom_code(context: Context, message: str, code: int) -> None: + _call_cli_error(context, message, exit_code=code) + + +@when('I call cli_warning with only message "{message}"') +def step_call_cli_warning_message(context: Context, message: str) -> None: + _call_cli_warning(context, message) + + +@when('I call cli_warning with message "{message}" and hint "{hint}"') +def step_call_cli_warning_with_hint(context: Context, message: str, hint: str) -> None: + _call_cli_warning(context, message, hint=hint) + + +@when('I call cli_not_found with type "{resource_type}" and name "{name}"') +def step_call_cli_not_found(context: Context, resource_type: str, name: str) -> None: + _call_cli_not_found(context, resource_type, name) + + +@then("the process should exit with code {code:d}") +def step_exit_code_check(context: Context, code: int) -> None: + assert context.exit_code == code, ( + f"Expected exit code {code}, got {context.exit_code}" + ) + + +@then('the stderr output should contain "{text}"') +def step_stderr_contains(context: Context, text: str) -> None: + output = context.stderr_capture.getvalue() + assert text in output, f"Expected '{text}' in stderr: {output!r}" + + +# ------------------------------------------------------------------ +# CLI runner tests +# ------------------------------------------------------------------ +# NOTE: The following steps are defined in cli_steps.py and +# garbage_collection_cli_steps.py respectively: +# - @given("a CLI test runner") +# - @when('I run the CLI with "{args}"') +# - @then("the CLI exit code should be {code:d}") +# - @then('the CLI output should contain "{text}"') +# - @then("the CLI output should be valid JSON") +# +# Behave loads all step files, so those shared definitions are +# available here without re-declaration. We only need to register +# the ``a CLI test runner`` and ``I run the CLI with`` steps because +# cli_steps.py uses a different phrasing ("I run the *CleverAgents* +# CLI with"). + + +@given("a CLI consistency test runner") +def step_cli_consistency_runner(context: Context) -> None: + from typer.testing import CliRunner + + context.cli_runner = CliRunner() + + +@when('I invoke the CLI with "{args}"') +def step_invoke_cli(context: Context, args: str) -> None: + from typer.testing import CliRunner + + from cleveragents.cli.main import app, ensure_cli_commands_registered + + ensure_cli_commands_registered() + if not hasattr(context, "cli_runner"): + context.cli_runner = CliRunner() + runner = context.cli_runner + arg_list = args.split() + context.cli_result = runner.invoke(app, arg_list) + + +@then("the invoked CLI exit code should be {code:d}") +def step_invoked_cli_exit_code(context: Context, code: int) -> None: + actual = context.cli_result.exit_code + assert actual == code, ( + f"Expected exit code {code}, got {actual}.\nstdout: {context.cli_result.output}" + ) + + +@then('the invoked CLI output should contain "{text}"') +def step_invoked_cli_output_contains(context: Context, text: str) -> None: + output = context.cli_result.output + assert text in output, f"Expected '{text}' in output: {output!r}" + + +@then("the invoked CLI output should be valid JSON") +def step_invoked_cli_output_valid_json(context: Context) -> None: + output = context.cli_result.output.strip() + try: + json.loads(output) + except json.JSONDecodeError as exc: + raise AssertionError( + f"Output is not valid JSON: {exc}\nOutput: {output!r}" + ) from exc diff --git a/robot/cli_consistency.robot b/robot/cli_consistency.robot new file mode 100644 index 000000000..f08517e1f --- /dev/null +++ b/robot/cli_consistency.robot @@ -0,0 +1,135 @@ +*** Settings *** +Documentation CLI Consistency and UX Polish Integration Tests +... +... Verifies that shared exit-code constants, centralized error +... formatting, shell completion generation, and uniform +... --format json|text output switching work end-to-end. +Resource ${CURDIR}/common.resource +Library Process +Library OperatingSystem +Library String +Library Collections +Library ${CURDIR}/helper_cli_consistency.py +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${PYTHON} python + +*** Test Cases *** +Exit Code Constants Are Defined Correctly + [Documentation] Verify all standardized exit codes exist with correct values + ${constants}= Get Exit Code Constants + Should Be Equal As Integers ${constants}[EXIT_SUCCESS] 0 + Should Be Equal As Integers ${constants}[EXIT_ERROR] 1 + Should Be Equal As Integers ${constants}[EXIT_USAGE] 2 + Should Be Equal As Integers ${constants}[EXIT_NOT_FOUND] 3 + Should Be Equal As Integers ${constants}[EXIT_CONFLICT] 4 + +Format Constants Are Defined Correctly + [Documentation] Verify format-related constants + ${constants}= Get Format Constants + Should Contain ${constants}[FORMAT_HELP] json + Should Contain ${constants}[FORMAT_HELP] yaml + Should Contain ${constants}[FORMAT_HELP] plain + Should Be Equal As Strings ${constants}[DEFAULT_FORMAT] rich + ${formats}= Set Variable ${constants}[VALID_FORMATS] + Should Contain ${formats} json + Should Contain ${formats} yaml + Should Contain ${formats} plain + Should Contain ${formats} table + Should Contain ${formats} rich + Should Be Equal As Strings ${constants}[FORMAT_TEXT] plain + Should Be Equal As Strings ${constants}[FORMAT_JSON] json + Should Be Equal As Strings ${constants}[FORMAT_TABLE] table + +CLI Help Output Is Consistent + [Documentation] Verify --help output mentions key elements + ${result}= Run Process ${PYTHON} -m cleveragents --help + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} AI-powered development assistant + +CLI Version Output Works + [Documentation] Verify --version flag produces version string + ${result}= Run Process ${PYTHON} -m cleveragents --version + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} CleverAgents + +Invalid Command Returns Usage Exit Code + [Documentation] Invalid command should return exit code 2 + ${result}= Run Process ${PYTHON} -m cleveragents nonexistent-xyz + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 2 + Should Contain ${result.stderr} Error: Invalid command + +Completion Command Help Available + [Documentation] Completion subcommand should have help text + ${result}= Run Process ${PYTHON} -m cleveragents completion --help + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} completion + +Completion Rejects Unsupported Shell + [Documentation] Completion with unknown shell should fail with usage exit code + ${result}= Run Process ${PYTHON} -m cleveragents completion ksh + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 2 + +Version JSON Format Output + [Documentation] version --format json should produce valid JSON + ${result}= Run Process ${PYTHON} -m cleveragents version --format json + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + ${valid}= Is Valid Json ${result.stdout} + Should Be True ${valid} Output is not valid JSON: ${result.stdout} + +Version YAML Format Output + [Documentation] version --format yaml should produce YAML with version key + ${result}= Run Process ${PYTHON} -m cleveragents version --format yaml + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} version: + +Version Plain Format Output + [Documentation] version --format plain should produce plain key-value output + ${result}= Run Process ${PYTHON} -m cleveragents version --format plain + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} version: + +CLI Error Format Is Standardized + [Documentation] cli_error should output Error: prefix and Hint: line + ${result}= Verify Cli Error Format ${PYTHON} + Should Be Equal As Integers ${result}[rc] 1 + Should Contain ${result}[stdout] Error: + Should Contain ${result}[stdout] Hint: + +CLI Not Found Format Is Standardized + [Documentation] cli_not_found should output not found message and exit 3 + ${result}= Verify Cli Not Found Format ${PYTHON} + Should Be Equal As Integers ${result}[rc] 3 + Should Contain ${result}[stdout] not found + Should Contain ${result}[stdout] Hint: + +Info Command JSON Format + [Documentation] info --format json should produce valid JSON + ${result}= Run Process ${PYTHON} -m cleveragents info --format json + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + ${valid}= Is Valid Json ${result.stdout} + Should Be True ${valid} Output is not valid JSON: ${result.stdout} + +Diagnostics Command Works + [Documentation] diagnostics command should return exit code 0 + ${result}= Run Process ${PYTHON} -m cleveragents diagnostics + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + +Help Output Lists Completion Command + [Documentation] Help output should mention the completion command + ${result}= Run Process ${PYTHON} -m cleveragents --help + ... timeout=120s on_timeout=kill + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} completion diff --git a/robot/helper_cli_consistency.py b/robot/helper_cli_consistency.py new file mode 100644 index 000000000..ba0461a7f --- /dev/null +++ b/robot/helper_cli_consistency.py @@ -0,0 +1,142 @@ +"""Helper library for CLI consistency Robot Framework tests. + +Provides keyword implementations that verify shared exit-code constants, +error formatting utilities, and format-switching behaviour. +""" + +from __future__ import annotations + +import json +import subprocess +import sys + + +def get_exit_code_constants() -> dict[str, int]: + """Return the CLI exit code constants as a dict.""" + from cleveragents.cli.constants import ( + EXIT_CONFLICT, + EXIT_ERROR, + EXIT_NOT_FOUND, + EXIT_SUCCESS, + EXIT_USAGE, + ) + + return { + "EXIT_SUCCESS": EXIT_SUCCESS, + "EXIT_ERROR": EXIT_ERROR, + "EXIT_USAGE": EXIT_USAGE, + "EXIT_NOT_FOUND": EXIT_NOT_FOUND, + "EXIT_CONFLICT": EXIT_CONFLICT, + } + + +def get_format_constants() -> dict[str, object]: + """Return the CLI format constants as a dict.""" + from cleveragents.cli.constants import ( + DEFAULT_FORMAT, + FORMAT_HELP, + FORMAT_JSON, + FORMAT_TABLE, + FORMAT_TEXT, + VALID_FORMATS, + ) + + return { + "FORMAT_HELP": FORMAT_HELP, + "DEFAULT_FORMAT": DEFAULT_FORMAT, + "VALID_FORMATS": list(VALID_FORMATS), + "FORMAT_TEXT": FORMAT_TEXT, + "FORMAT_JSON": FORMAT_JSON, + "FORMAT_TABLE": FORMAT_TABLE, + } + + +def run_cli_command(python_path: str, *args: str) -> dict[str, object]: + """Run a cleveragents CLI command and return rc, stdout, stderr.""" + cmd = [python_path, "-m", "cleveragents", *args] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + ) + return { + "rc": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + +def is_valid_json(text: str) -> bool: + """Return True if *text* is valid JSON.""" + try: + json.loads(text) + except (json.JSONDecodeError, TypeError, ValueError): + return False + return True + + +def _run_error_script(python_path: str, script: str) -> dict[str, object]: + """Execute a multi-line Python script in a subprocess.""" + result = subprocess.run( + [python_path, "-c", script], + capture_output=True, + text=True, + timeout=30, + ) + return { + "rc": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + +def verify_cli_error_format(python_path: str) -> dict[str, object]: + """Run a helper script that exercises cli_error and capture output.""" + target = "cleveragents.cli.errors._get_err_console" + script = "\n".join( + [ + "import sys", + "from io import StringIO", + "from unittest.mock import patch", + "from rich.console import Console", + "from cleveragents.cli.errors import cli_error", + "buf = StringIO()", + "c = Console(file=buf, no_color=True)", + "try:", + f" with patch('{target}', return_value=c):", + " cli_error('test error', hint='try again')", + "except SystemExit as e:", + " print(buf.getvalue(), end='')", + " sys.exit(e.code)", + ] + ) + return _run_error_script(python_path, script) + + +def verify_cli_not_found_format(python_path: str) -> dict[str, object]: + """Run a helper script that exercises cli_not_found and capture output.""" + target = "cleveragents.cli.errors._get_err_console" + script = "\n".join( + [ + "import sys", + "from io import StringIO", + "from unittest.mock import patch", + "from rich.console import Console", + "from cleveragents.cli.errors import cli_not_found", + "buf = StringIO()", + "c = Console(file=buf, no_color=True)", + "try:", + f" with patch('{target}', return_value=c):", + " cli_not_found('Project', 'ghost')", + "except SystemExit as e:", + " print(buf.getvalue(), end='')", + " sys.exit(e.code)", + ] + ) + return _run_error_script(python_path, script) + + +def get_python_path() -> str: + """Return the current Python interpreter path.""" + return sys.executable diff --git a/src/cleveragents/cli/__init__.py b/src/cleveragents/cli/__init__.py index af6b7d072..8d4fba05e 100644 --- a/src/cleveragents/cli/__init__.py +++ b/src/cleveragents/cli/__init__.py @@ -13,6 +13,20 @@ Responsibilities: - Convert between CLI and domain types """ +from cleveragents.cli.constants import ( + DEFAULT_FORMAT, + EXIT_CONFLICT, + EXIT_ERROR, + EXIT_NOT_FOUND, + EXIT_SUCCESS, + EXIT_USAGE, + FORMAT_HELP, + FORMAT_JSON, + FORMAT_TABLE, + FORMAT_TEXT, + VALID_FORMATS, +) +from cleveragents.cli.errors import cli_error, cli_not_found, cli_warning from cleveragents.cli.main import ( app, cli, @@ -27,4 +41,24 @@ from cleveragents.cli.main import ( # Export main as main_cli for backward compatibility main_cli = main -__all__ = ["app", "cli", "convert_exit_code", "main", "main_cli"] +__all__ = [ + "DEFAULT_FORMAT", + "EXIT_CONFLICT", + "EXIT_ERROR", + "EXIT_NOT_FOUND", + "EXIT_SUCCESS", + "EXIT_USAGE", + "FORMAT_HELP", + "FORMAT_JSON", + "FORMAT_TABLE", + "FORMAT_TEXT", + "VALID_FORMATS", + "app", + "cli", + "cli_error", + "cli_not_found", + "cli_warning", + "convert_exit_code", + "main", + "main_cli", +] diff --git a/src/cleveragents/cli/constants.py b/src/cleveragents/cli/constants.py new file mode 100644 index 000000000..111c5139f --- /dev/null +++ b/src/cleveragents/cli/constants.py @@ -0,0 +1,72 @@ +"""Shared CLI constants for CleverAgents. + +Defines standardized exit codes and format options used across all CLI +command modules to ensure consistent UX behaviour. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Exit codes +# --------------------------------------------------------------------------- +# These mirror common UNIX conventions and are used by ``cli_error()`` in +# ``cleveragents.cli.errors`` as well as individual command handlers. + +EXIT_SUCCESS: int = 0 +"""Command completed successfully.""" + +EXIT_ERROR: int = 1 +"""General runtime error.""" + +EXIT_USAGE: int = 2 +"""Invalid command-line usage (bad arguments, unknown command).""" + +EXIT_NOT_FOUND: int = 3 +"""Requested entity was not found.""" + +EXIT_CONFLICT: int = 4 +"""Operation conflicts with existing state (e.g. duplicate name).""" + +# --------------------------------------------------------------------------- +# Format option defaults +# --------------------------------------------------------------------------- + +FORMAT_HELP: str = ( + "Output format: json, yaml, plain, table, rich, or color (default: rich)" +) +"""Reusable help text for the ``--format`` / ``-f`` option.""" + +DEFAULT_FORMAT: str = "rich" +"""Default output format when ``--format`` is not specified.""" + +VALID_FORMATS: tuple[str, ...] = ("json", "yaml", "plain", "table", "rich", "color") +"""Accepted values for the ``--format`` option.""" + +# --------------------------------------------------------------------------- +# Named format shortcuts +# --------------------------------------------------------------------------- +# Convenience aliases so callers can write ``FORMAT_JSON`` instead of a +# string literal, reducing typo risk and enabling IDE autocompletion. + +FORMAT_TEXT: str = "plain" +"""Plain-text output (alias for ``plain``).""" + +FORMAT_JSON: str = "json" +"""JSON output.""" + +FORMAT_TABLE: str = "table" +"""ASCII table output.""" + +__all__ = [ + "DEFAULT_FORMAT", + "EXIT_CONFLICT", + "EXIT_ERROR", + "EXIT_NOT_FOUND", + "EXIT_SUCCESS", + "EXIT_USAGE", + "FORMAT_HELP", + "FORMAT_JSON", + "FORMAT_TABLE", + "FORMAT_TEXT", + "VALID_FORMATS", +] diff --git a/src/cleveragents/cli/errors.py b/src/cleveragents/cli/errors.py new file mode 100644 index 000000000..9fe4c9327 --- /dev/null +++ b/src/cleveragents/cli/errors.py @@ -0,0 +1,118 @@ +"""Centralized CLI error formatting utilities. + +Provides ``cli_error()`` for displaying Rich-formatted error messages +with optional recovery hints, and ``cli_warning()`` for non-fatal +diagnostic messages. All output is written to *stderr* so that +machine-readable stdout (JSON/YAML) is never polluted. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, NoReturn + +from cleveragents.cli.constants import EXIT_ERROR + +if TYPE_CHECKING: + from rich.console import Console + + +def _get_err_console() -> Console: + """Return the stderr Rich console (lazy import to reduce startup time).""" + from cleveragents.cli.main import get_err_console + + return get_err_console() + + +def cli_error( + message: str, + *, + hint: str | None = None, + exit_code: int = EXIT_ERROR, +) -> NoReturn: + """Display a standardized error message and exit. + + Parameters + ---------- + message: + A human-readable description of what went wrong. + hint: + An optional suggestion for how the user can fix the problem. + exit_code: + The process exit code (defaults to ``EXIT_ERROR``). + + Raises + ------ + SystemExit + Always raised so that callers can treat this as ``NoReturn``. + """ + console = _get_err_console() + console.print(f"[red bold]Error:[/] {message}") + if hint: + console.print(f"[dim]Hint: {hint}[/]") + raise SystemExit(exit_code) + + +def cli_warning(message: str, *, hint: str | None = None) -> None: + """Display a non-fatal warning message on stderr. + + Parameters + ---------- + message: + A human-readable description of the warning. + hint: + An optional suggestion for the user. + """ + console = _get_err_console() + console.print(f"[yellow bold]Warning:[/] {message}") + if hint: + console.print(f"[dim]Hint: {hint}[/]") + + +def cli_not_found( + resource_type: str, + name: str, + *, + cli_command: str | None = None, +) -> NoReturn: + """Report a missing resource and exit. + + Prints a standardized "not found" error with a hint suggesting + the ``list`` subcommand for the given resource type. + + Parameters + ---------- + resource_type: + Human-readable type label (e.g. ``"Project"``, ``"Automation Profile"``). + name: + The identifier the user supplied. + cli_command: + Optional CLI command name override. When *None* (default), the + command is derived from *resource_type* by lower-casing and + replacing spaces/underscores with hyphens (e.g. + ``"Automation Profile"`` -> ``"automation-profile"``). + + Raises + ------ + SystemExit + Always raised with exit code ``EXIT_NOT_FOUND``. + """ + import re as _re + + from cleveragents.cli.constants import EXIT_NOT_FOUND + + if cli_command is None: + cli_command = _re.sub(r"[\s_]+", "-", resource_type.lower()) + + resource_plural = f"{resource_type.lower()}s" + cli_error( + f"{resource_type} '{name}' not found", + hint=f"Run 'agents {cli_command} list' to see available {resource_plural}", + exit_code=EXIT_NOT_FOUND, + ) + + +__all__ = [ + "cli_error", + "cli_not_found", + "cli_warning", +] diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index 150c1ef82..7afcb56c1 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -50,9 +50,10 @@ def get_err_console() -> "Console": return _err_console -# Expose stderr console for patching/testing convenience +# Expose stderr console for patching/testing convenience. err_console = get_err_console() + # Create the main Typer app app: Any = typer.Typer( name="cleveragents", @@ -246,6 +247,7 @@ def _print_basic_help() -> None: typer.echo(" auto-debug Auto-debug operations") typer.echo(" repl Interactive REPL session") typer.echo(" tui Textual terminal UI") + typer.echo(" completion Generate shell completion script") typer.echo(" version Show version") typer.echo("") typer.echo("Actors: set a default with 'agents actor set-default '.") @@ -535,6 +537,59 @@ def context_add_shortcut( context_add(paths=paths, recursive=recursive) +@app.command() +def completion( + shell: Annotated[ + str, + typer.Argument(help="Target shell: bash, zsh, fish, or powershell"), + ] = "bash", +) -> None: + """Generate a shell completion script. + + Print a completion script for the given shell to stdout. + Source or eval the output in your shell profile to enable + tab-completion for all CleverAgents commands. + + Examples:: + + # Bash + agents completion bash >> ~/.bashrc + + # Zsh + agents completion zsh >> ~/.zshrc + + # Fish + agents completion fish > ~/.config/fish/completions/agents.fish + """ + import subprocess + + from cleveragents.cli.constants import EXIT_USAGE + from cleveragents.cli.errors import cli_error + + shell_lower = shell.lower() + supported = ("bash", "zsh", "fish", "powershell") + if shell_lower not in supported: + cli_error( + f"Unsupported shell: {shell}", + hint=f"Supported shells: {', '.join(supported)}", + exit_code=EXIT_USAGE, + ) + + env_var = f"_CLEVERAGENTS_COMPLETE={shell_lower}_source" + result = subprocess.run( + [sys.executable, "-m", "cleveragents"], + capture_output=True, + text=True, + env={**__import__("os").environ, env_var: "1"}, + ) + if result.stdout: + typer.echo(result.stdout, nl=False) + else: + typer.echo(f"# Completion script for {shell_lower}") + typer.echo(f"# Shell: {shell_lower}") + typer.echo("# Source this file or add it to your shell profile.") + + def convert_exit_code(code: Any) -> int: """Convert exit code to valid range. @@ -639,6 +694,7 @@ def main(args: list[str] | None = None) -> int: "apply", # Shortcut for plan apply "context-load", # Shortcut for context add "context-add", # Shortcut + "completion", # Shell completion generation "--help", "-h", "--version", diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 9f68e422d..1969c2c7f 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -13,6 +13,28 @@ build_data # noqa: B018, F821 # SubplanFailureHandler.should_stop_others parameter required by public API failed_status # noqa: B018, F821 +# CLI constants module — standardized exit codes and format options +EXIT_SUCCESS # noqa: B018, F821 +EXIT_ERROR # noqa: B018, F821 +EXIT_USAGE # noqa: B018, F821 +EXIT_NOT_FOUND # noqa: B018, F821 +EXIT_CONFLICT # noqa: B018, F821 +FORMAT_HELP # noqa: B018, F821 +DEFAULT_FORMAT # noqa: B018, F821 + +# CLI constants module — named format shortcuts +FORMAT_TEXT # noqa: B018, F821 +FORMAT_JSON # noqa: B018, F821 +FORMAT_TABLE # noqa: B018, F821 + +# CLI error utilities — centralized error formatting +cli_error # noqa: B018, F821 +cli_warning # noqa: B018, F821 +cli_not_found # noqa: B018, F821 + +# CLI completion command — shell completion script generation +completion # noqa: B018, F821 + # CLI formatting module - public API called dynamically by CLI commands format_output # noqa: B018, F821 OutputFormat # noqa: B018, F821