From 02c75fb4bb91584ea12417c2f6cad0953e5e897b Mon Sep 17 00:00:00 2001 From: khyari hamza Date: Tue, 17 Feb 2026 13:13:13 +0000 Subject: [PATCH] feat(cli): enhance version/info/diagnostics with --format and --check Add structured data builders and Rich renderers for version, info, and diagnostics commands. Support --format rich/plain/json/yaml output parity and --check flag for diagnostics (exits non-zero on errors). Includes Behave scenarios, Robot smoke tests, ASV benchmarks, and updates existing tests to match the new Rich panel output format. --- benchmarks/cli_core_bench.py | 95 +++++ features/cli.feature | 10 +- features/cli_core.feature | 124 ++++++ features/steps/cli_core_steps.py | 237 +++++++++++ features/steps/cli_coverage_steps.py | 8 +- robot/cli.robot | 10 +- robot/cli_core.robot | 108 +++++ src/cleveragents/cli/commands/system.py | 508 ++++++++++++++++++++++++ src/cleveragents/cli/main.py | 109 ++--- 9 files changed, 1141 insertions(+), 68 deletions(-) create mode 100644 benchmarks/cli_core_bench.py create mode 100644 features/cli_core.feature create mode 100644 features/steps/cli_core_steps.py create mode 100644 robot/cli_core.robot create mode 100644 src/cleveragents/cli/commands/system.py diff --git a/benchmarks/cli_core_bench.py b/benchmarks/cli_core_bench.py new file mode 100644 index 000000000..6a389024f --- /dev/null +++ b/benchmarks/cli_core_bench.py @@ -0,0 +1,95 @@ +"""ASV benchmarks for enhanced CLI core system commands. + +Measures in-process execution time for version, info, and diagnostics +commands across all supported output formats (rich, plain, json, yaml). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +try: + from cleveragents.cli.commands.system import ( + build_diagnostics_data, + build_info_data, + build_version_data, + ) + from cleveragents.cli.formatting import format_output +except ModuleNotFoundError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + from cleveragents.cli.commands.system import ( + build_diagnostics_data, + build_info_data, + build_version_data, + ) + from cleveragents.cli.formatting import format_output + + +class VersionSuite: + """Benchmark version data assembly and formatting.""" + + def time_build_version_data(self) -> None: + """Measure build_version_data() latency.""" + build_version_data() + + def time_version_json(self) -> None: + """Measure version + JSON format.""" + data = build_version_data() + format_output(data, "json") + + def time_version_yaml(self) -> None: + """Measure version + YAML format.""" + data = build_version_data() + format_output(data, "yaml") + + def time_version_plain(self) -> None: + """Measure version + plain format.""" + data = build_version_data() + format_output(data, "plain") + + +class InfoSuite: + """Benchmark info data assembly and formatting.""" + + def time_build_info_data(self) -> None: + """Measure build_info_data() latency.""" + build_info_data() + + def time_info_json(self) -> None: + """Measure info + JSON format.""" + data = build_info_data() + format_output(data, "json") + + def time_info_yaml(self) -> None: + """Measure info + YAML format.""" + data = build_info_data() + format_output(data, "yaml") + + def time_info_plain(self) -> None: + """Measure info + plain format.""" + data = build_info_data() + format_output(data, "plain") + + +class DiagnosticsSuite: + """Benchmark diagnostics data assembly and formatting.""" + + def time_build_diagnostics_data(self) -> None: + """Measure build_diagnostics_data() latency.""" + build_diagnostics_data() + + def time_diagnostics_json(self) -> None: + """Measure diagnostics + JSON format.""" + data = build_diagnostics_data() + format_output(data, "json") + + def time_diagnostics_yaml(self) -> None: + """Measure diagnostics + YAML format.""" + data = build_diagnostics_data() + format_output(data, "yaml") + + def time_diagnostics_plain(self) -> None: + """Measure diagnostics + plain format.""" + data = build_diagnostics_data() + format_output(data, "plain") diff --git a/features/cli.feature b/features/cli.feature index 855289e02..4fc12b594 100644 --- a/features/cli.feature +++ b/features/cli.feature @@ -10,15 +10,13 @@ Feature: CleverAgents CLI metadata Scenario: Display diagnostics When I run the CleverAgents CLI with "diagnostics" - Then the output should contain "CleverAgents Diagnostics" - And the output should contain "Version:" - And the output should contain "Python:" - And the output should contain "Platform:" + Then the output should contain "Checks" + And the output should contain "Summary" Scenario: Display info When I run the CleverAgents CLI with "info" - Then the output should contain "CleverAgents Information" - And the output should contain "Version: 1.0.0" + Then the output should contain "Environment" + And the output should contain "Runtime" Scenario Outline: Display help for command groups When I run the CleverAgents CLI with " --help" diff --git a/features/cli_core.feature b/features/cli_core.feature new file mode 100644 index 000000000..62a0d971f --- /dev/null +++ b/features/cli_core.feature @@ -0,0 +1,124 @@ +Feature: Core system commands (version, info, diagnostics) + As a user of CleverAgents + I want version, info, and diagnostics commands + So that I can inspect the system state and troubleshoot issues + + # -- version command -- + + Scenario: Version command shows version string + When I run the system version command + Then the system version output should contain "1.0.0" + And the system version output should contain "CleverAgents" + + Scenario: Version command with plain format + When I run the system version command with format "plain" + Then the system version output should contain "version: 1.0.0" + + Scenario: Version command with json format + When I run the system version command with format "json" + Then the system version json output should have key "version" with value "1.0.0" + And the system version json output should have key "schema" with value "v3" + And the system version json output should have key "channel" with value "stable" + + Scenario: Version command with yaml format + When I run the system version command with format "yaml" + Then the system version output should contain "version: 1.0.0" + And the system version output should contain "schema: v3" + + Scenario: Version command includes python version + When I run the system version command with format "json" + Then the system version json output should have key "python" + + Scenario: Version command includes build metadata + When I run the system version command with format "json" + Then the system version json output should have key "build_date" + And the system version json output should have key "commit" + And the system version json output should have key "platform" + + Scenario: Version command includes dependencies + When I run the system version command with format "json" + Then the system version json output should have nested key "dependencies" + + # -- info command -- + + Scenario: Info command shows environment details + When I run the system info command + Then the system info output should contain "Environment" + And the system info output should contain "Runtime" + + Scenario: Info command with json format + When I run the system info command with format "json" + Then the system info json output should have key "version" with value "1.0.0" + And the system info json output should have key "data_dir" + And the system info json output should have key "database" + And the system info json output should have key "server_mode" with value "disabled" + + Scenario: Info command with plain format + When I run the system info command with format "plain" + Then the system info output should contain "version: 1.0.0" + And the system info output should contain "server_mode: disabled" + + Scenario: Info command shows provider count + When I run the system info command with format "json" + Then the system info json output should have key "providers_configured" + + Scenario: Info command shows automation level + When I run the system info command with format "json" + Then the system info json output should have key "automation" + + Scenario: Info command shows storage info + When I run the system info command with format "json" + Then the system info json output should have nested key "storage" + + # -- diagnostics command -- + + Scenario: Diagnostics command runs checks + When I run the system diagnostics command + Then the system diagnostics output should contain "Checks" + And the system diagnostics output should contain "Summary" + + Scenario: Diagnostics command with json format + When I run the system diagnostics command with format "json" + Then the system diagnostics json output should have key "checks" + And the system diagnostics json output should have key "summary" + And the system diagnostics json output should have key "recommendations" + + Scenario: Diagnostics json summary has counts + When I run the system diagnostics command with format "json" + Then the system diagnostics json summary should have key "total" + And the system diagnostics json summary should have key "warnings" + And the system diagnostics json summary should have key "errors" + And the system diagnostics json summary should have key "duration_s" + + Scenario: Diagnostics checks include config file + When I run the system diagnostics command with format "json" + Then the system diagnostics checks should include "Config file" + + Scenario: Diagnostics checks include database + When I run the system diagnostics command with format "json" + Then the system diagnostics checks should include "Database" + + Scenario: Diagnostics checks include git + When I run the system diagnostics command with format "json" + Then the system diagnostics checks should include "Git" + + Scenario: Diagnostics checks include disk space + When I run the system diagnostics command with format "json" + Then the system diagnostics checks should include "Disk space" + + Scenario: Diagnostics checks include file permissions + When I run the system diagnostics command with format "json" + Then the system diagnostics checks should include "File permissions" + + Scenario: Diagnostics --check exits zero when no errors + When I run the system diagnostics command with check flag + Then the system diagnostics exit code should be 0 + + Scenario: Diagnostics with plain format + When I run the system diagnostics command with format "plain" + Then the system diagnostics output should contain "checks:" + And the system diagnostics output should contain "summary:" + + Scenario: Diagnostics recommendations are a list + When I run the system diagnostics command with format "json" + Then the system diagnostics json recommendations should be a list diff --git a/features/steps/cli_core_steps.py b/features/steps/cli_core_steps.py new file mode 100644 index 000000000..ca31fbf38 --- /dev/null +++ b/features/steps/cli_core_steps.py @@ -0,0 +1,237 @@ +"""Step definitions for core system commands (version, info, diagnostics). + +Covers features/cli_core.feature — the enhanced CLI0.core commands that +support ``--format`` (rich/plain/json/yaml) and ``--check`` for diagnostics. +""" + +from __future__ import annotations + +from typing import Any + +from behave import then, when +from behave.runner import Context + +from cleveragents.cli.commands.system import ( + build_diagnostics_data, + build_info_data, + build_version_data, +) +from cleveragents.cli.formatting import format_output + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _format_data(data: dict[str, Any], fmt: str) -> str: + """Format *data* and return the rendered string.""" + if fmt == "rich": + from io import StringIO + from unittest.mock import patch + + from rich.console import Console + + buf = StringIO() + console = Console(file=buf, width=200, no_color=True) + + with patch("cleveragents.cli.main.get_console", return_value=console): + if data.get("checks") is not None: + from cleveragents.cli.commands.system import render_diagnostics_rich + + render_diagnostics_rich(data) + elif "data_dir" in data: + from cleveragents.cli.commands.system import render_info_rich + + render_info_rich(data) + else: + from cleveragents.cli.commands.system import render_version_rich + + render_version_rich(data) + + return buf.getvalue() + return format_output(data, fmt) + + +# --------------------------------------------------------------------------- +# VERSION — When steps +# --------------------------------------------------------------------------- + + +@when("I run the system version command") +def step_run_system_version(context: Context) -> None: + """Run version with default (rich) format.""" + data = build_version_data() + context.sys_version_data = data + context.sys_version_output = _format_data(data, "rich") + + +@when('I run the system version command with format "{fmt}"') +def step_run_system_version_fmt(context: Context, fmt: str) -> None: + """Run version with a specific output format.""" + data = build_version_data() + context.sys_version_data = data + context.sys_version_output = _format_data(data, fmt) + + +# --------------------------------------------------------------------------- +# VERSION — Then steps +# --------------------------------------------------------------------------- + + +@then('the system version output should contain "{text}"') +def step_system_version_output_contains(context: Context, text: str) -> None: + output = context.sys_version_output + assert text in output, f"Expected '{text}' in version output:\n{output}" + + +@then('the system version json output should have key "{key}" with value "{value}"') +def step_system_version_json_key_value(context: Context, key: str, value: str) -> None: + data = context.sys_version_data + assert key in data, f"Key '{key}' not in version data: {list(data.keys())}" + assert str(data[key]) == value, f"Expected {key}={value!r}, got {data[key]!r}" + + +@then('the system version json output should have key "{key}"') +def step_system_version_json_key(context: Context, key: str) -> None: + data = context.sys_version_data + assert key in data, f"Key '{key}' not in version data: {list(data.keys())}" + + +@then('the system version json output should have nested key "{key}"') +def step_system_version_json_nested_key(context: Context, key: str) -> None: + data = context.sys_version_data + assert key in data, f"Key '{key}' not in version data: {list(data.keys())}" + assert isinstance(data[key], dict), ( + f"Expected '{key}' to be a dict, got {type(data[key])}" + ) + + +# --------------------------------------------------------------------------- +# INFO — When steps +# --------------------------------------------------------------------------- + + +@when("I run the system info command") +def step_run_system_info(context: Context) -> None: + """Run info with default (rich) format.""" + data = build_info_data() + context.sys_info_data = data + context.sys_info_output = _format_data(data, "rich") + + +@when('I run the system info command with format "{fmt}"') +def step_run_system_info_fmt(context: Context, fmt: str) -> None: + """Run info with a specific output format.""" + data = build_info_data() + context.sys_info_data = data + context.sys_info_output = _format_data(data, fmt) + + +# --------------------------------------------------------------------------- +# INFO — Then steps +# --------------------------------------------------------------------------- + + +@then('the system info output should contain "{text}"') +def step_system_info_output_contains(context: Context, text: str) -> None: + output = context.sys_info_output + assert text in output, f"Expected '{text}' in info output:\n{output}" + + +@then('the system info json output should have key "{key}" with value "{value}"') +def step_system_info_json_key_value(context: Context, key: str, value: str) -> None: + data = context.sys_info_data + assert key in data, f"Key '{key}' not in info data: {list(data.keys())}" + assert str(data[key]) == value, f"Expected {key}={value!r}, got {data[key]!r}" + + +@then('the system info json output should have key "{key}"') +def step_system_info_json_key(context: Context, key: str) -> None: + data = context.sys_info_data + assert key in data, f"Key '{key}' not in info data: {list(data.keys())}" + + +@then('the system info json output should have nested key "{key}"') +def step_system_info_json_nested_key(context: Context, key: str) -> None: + data = context.sys_info_data + assert key in data, f"Key '{key}' not in info data: {list(data.keys())}" + assert isinstance(data[key], dict), ( + f"Expected '{key}' to be a dict, got {type(data[key])}" + ) + + +# --------------------------------------------------------------------------- +# DIAGNOSTICS — When steps +# --------------------------------------------------------------------------- + + +@when("I run the system diagnostics command") +def step_run_system_diagnostics(context: Context) -> None: + """Run diagnostics with default (rich) format.""" + data = build_diagnostics_data() + context.sys_diag_data = data + context.sys_diag_output = _format_data(data, "rich") + + +@when('I run the system diagnostics command with format "{fmt}"') +def step_run_system_diagnostics_fmt(context: Context, fmt: str) -> None: + """Run diagnostics with a specific output format.""" + data = build_diagnostics_data() + context.sys_diag_data = data + context.sys_diag_output = _format_data(data, fmt) + + +@when("I run the system diagnostics command with check flag") +def step_run_system_diagnostics_check(context: Context) -> None: + """Run diagnostics with --check flag.""" + data = build_diagnostics_data() + context.sys_diag_data = data + context.sys_diag_output = _format_data(data, "rich") + # Compute exit code: 1 if errors, 0 otherwise + context.sys_diag_exit_code = 1 if data["has_errors"] else 0 + + +# --------------------------------------------------------------------------- +# DIAGNOSTICS — Then steps +# --------------------------------------------------------------------------- + + +@then('the system diagnostics output should contain "{text}"') +def step_system_diag_output_contains(context: Context, text: str) -> None: + output = context.sys_diag_output + assert text in output, f"Expected '{text}' in diagnostics output:\n{output}" + + +@then('the system diagnostics json output should have key "{key}"') +def step_system_diag_json_key(context: Context, key: str) -> None: + data = context.sys_diag_data + assert key in data, f"Key '{key}' not in diagnostics data: {list(data.keys())}" + + +@then('the system diagnostics json summary should have key "{key}"') +def step_system_diag_json_summary_key(context: Context, key: str) -> None: + summary = context.sys_diag_data.get("summary", {}) + assert key in summary, ( + f"Key '{key}' not in diagnostics summary: {list(summary.keys())}" + ) + + +@then('the system diagnostics checks should include "{name}"') +def step_system_diag_checks_include(context: Context, name: str) -> None: + checks = context.sys_diag_data.get("checks", []) + names = [c["name"] for c in checks] + assert name in names, f"Check '{name}' not in diagnostics checks: {names}" + + +@then("the system diagnostics exit code should be {code:d}") +def step_system_diag_exit_code(context: Context, code: int) -> None: + actual = getattr(context, "sys_diag_exit_code", 0) + assert actual == code, f"Expected exit code {code}, got {actual}" + + +@then("the system diagnostics json recommendations should be a list") +def step_system_diag_recommendations_list(context: Context) -> None: + recs = context.sys_diag_data.get("recommendations") + assert isinstance(recs, list), ( + f"Expected recommendations to be a list, got {type(recs)}" + ) diff --git a/features/steps/cli_coverage_steps.py b/features/steps/cli_coverage_steps.py index fce3c228b..10a7081ed 100644 --- a/features/steps/cli_coverage_steps.py +++ b/features/steps/cli_coverage_steps.py @@ -184,15 +184,11 @@ def step_test_cli_commands(context): # Test info result = runner.invoke(app, ["info"]) - context.results.append( - (result.exit_code, "CleverAgents Information" in result.stdout) - ) + context.results.append((result.exit_code, "Environment" in result.stdout)) # Test diagnostics result = runner.invoke(app, ["diagnostics"]) - context.results.append( - (result.exit_code, "CleverAgents Diagnostics" in result.stdout) - ) + context.results.append((result.exit_code, "Checks" in result.stdout)) # Test init - use temp directory to avoid conflicts import os diff --git a/robot/cli.robot b/robot/cli.robot index 5dbe04502..5f270aa8e 100644 --- a/robot/cli.robot +++ b/robot/cli.robot @@ -46,17 +46,15 @@ CLI Diagnostics Command [Documentation] Test diagnostics command functionality ${result}= Run Process ${PYTHON} -m cleveragents diagnostics Should Be Equal As Integers ${result.rc} 0 - Should Contain ${result.stdout} CleverAgents Diagnostics - Should Contain ${result.stdout} Version: - Should Contain ${result.stdout} Python: - Should Contain ${result.stdout} Platform: + Should Contain ${result.stdout} Checks + Should Contain ${result.stdout} Summary CLI Info Command [Documentation] Test info command functionality ${result}= Run Process ${PYTHON} -m cleveragents info Should Be Equal As Integers ${result.rc} 0 - Should Contain ${result.stdout} CleverAgents Information - Should Contain ${result.stdout} Version: + Should Contain ${result.stdout} Environment + Should Contain ${result.stdout} Runtime Invalid Command Error Handling [Documentation] Verify proper error handling for invalid commands diff --git a/robot/cli_core.robot b/robot/cli_core.robot new file mode 100644 index 000000000..744451945 --- /dev/null +++ b/robot/cli_core.robot @@ -0,0 +1,108 @@ +*** Settings *** +Documentation Integration tests for enhanced version, info, diagnostics commands +Resource ${CURDIR}/common.resource +Library Process +Library OperatingSystem +Library String +Library Collections +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${PYTHON} python + +*** Test Cases *** +Version Command Default Rich Format + [Documentation] Version command with default (rich) format shows version string + ${result}= Run Process ${PYTHON} -m cleveragents version timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} 1.0.0 + Should Contain ${result.stdout} CleverAgents + +Version Command JSON Format + [Documentation] Version command with --format json returns valid JSON + ${result}= Run Process ${PYTHON} -m cleveragents version --format json timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} "version": "1.0.0" + Should Contain ${result.stdout} "schema": "v3" + Should Contain ${result.stdout} "channel": "stable" + Should Contain ${result.stdout} "python" + Should Contain ${result.stdout} "dependencies" + Should Contain ${result.stdout} "build_date" + Should Contain ${result.stdout} "commit" + +Version Command Plain Format + [Documentation] Version command with --format plain returns key-value pairs + ${result}= Run Process ${PYTHON} -m cleveragents version --format plain timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} version: 1.0.0 + Should Contain ${result.stdout} schema: v3 + +Version Command YAML Format + [Documentation] Version command with --format yaml returns YAML + ${result}= Run Process ${PYTHON} -m cleveragents version --format yaml timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} version: 1.0.0 + Should Contain ${result.stdout} schema: v3 + +Info Command Default Rich Format + [Documentation] Info command with default (rich) format shows environment details + ${result}= Run Process ${PYTHON} -m cleveragents info timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} Environment + Should Contain ${result.stdout} Runtime + +Info Command JSON Format + [Documentation] Info command with --format json returns structured data + ${result}= Run Process ${PYTHON} -m cleveragents info --format json timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} "version": "1.0.0" + Should Contain ${result.stdout} "data_dir" + Should Contain ${result.stdout} "database" + Should Contain ${result.stdout} "server_mode": "disabled" + +Info Command Plain Format + [Documentation] Info command with --format plain returns key-value pairs + ${result}= Run Process ${PYTHON} -m cleveragents info --format plain timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} version: 1.0.0 + Should Contain ${result.stdout} server_mode: disabled + +Diagnostics Command Default Rich Format + [Documentation] Diagnostics command with default (rich) format runs checks + ${result}= Run Process ${PYTHON} -m cleveragents diagnostics timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} Checks + Should Contain ${result.stdout} Summary + +Diagnostics Command JSON Format + [Documentation] Diagnostics command with --format json returns structured data + ${result}= Run Process ${PYTHON} -m cleveragents diagnostics --format json timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} "checks" + Should Contain ${result.stdout} "summary" + Should Contain ${result.stdout} "recommendations" + Should Contain ${result.stdout} "total" + Should Contain ${result.stdout} "warnings" + Should Contain ${result.stdout} "errors" + +Diagnostics Command Plain Format + [Documentation] Diagnostics command with --format plain returns key-value pairs + ${result}= Run Process ${PYTHON} -m cleveragents diagnostics --format plain timeout=30s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} checks: + Should Contain ${result.stdout} summary: + +Diagnostics Command Check Flag No Errors + [Documentation] Diagnostics --check exits zero when no errors + ${result}= Run Process ${PYTHON} -m cleveragents diagnostics --check timeout=30s + Should Be Equal As Integers ${result.rc} 0 + +Diagnostics Command Performance + [Documentation] Diagnostics command completes within acceptable time + ${start}= Get Time epoch + ${result}= Run Process ${PYTHON} -m cleveragents diagnostics --format json timeout=30s + ${end}= Get Time epoch + ${duration}= Evaluate ${end} - ${start} + Should Be True ${duration} < 30 Diagnostics took too long: ${duration}s + Should Be Equal As Integers ${result.rc} 0 diff --git a/src/cleveragents/cli/commands/system.py b/src/cleveragents/cli/commands/system.py new file mode 100644 index 000000000..552abf720 --- /dev/null +++ b/src/cleveragents/cli/commands/system.py @@ -0,0 +1,508 @@ +"""Core system commands: version, info, diagnostics. + +Implements the CLI0.core group per specification. Each command supports +``--format`` for output parity (rich/plain/json/yaml) and ``diagnostics`` +additionally supports ``--check`` to exit non-zero when any check fails. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +import time +from datetime import UTC, datetime +from enum import StrEnum +from pathlib import Path +from typing import Any + +from cleveragents import __version__ + +# --------------------------------------------------------------------------- +# Diagnostic check status +# --------------------------------------------------------------------------- + + +class CheckStatus(StrEnum): + """Status of a single diagnostic check.""" + + OK = "ok" + WARN = "warn" + ERROR = "error" + + +# --------------------------------------------------------------------------- +# Data builders (format-agnostic) +# --------------------------------------------------------------------------- + + +def _git_sha() -> str: + """Return the short git SHA of HEAD, or 'unknown'.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + pass + return "unknown" + + +def _dep_version(package: str) -> str: + """Return the installed version of *package*, or 'not installed'.""" + try: + from importlib.metadata import version as pkg_version + + return pkg_version(package) + except Exception: + return "not installed" + + +def build_version_data() -> dict[str, Any]: + """Assemble structured data for the ``version`` command.""" + return { + "version": __version__, + "channel": "stable", + "python": platform.python_version(), + "build_date": datetime.now(tz=UTC).strftime("%Y-%m-%d"), + "commit": _git_sha(), + "schema": "v3", + "platform": f"{sys.platform}-{platform.machine()}", + "dependencies": { + "langgraph": _dep_version("langgraph"), + "langchain-core": _dep_version("langchain-core"), + "pydantic": _dep_version("pydantic"), + "typer": _dep_version("typer"), + }, + } + + +def build_info_data() -> dict[str, Any]: + """Assemble structured data for the ``info`` command.""" + from cleveragents.config.settings import get_settings + + settings = get_settings() + + configured_providers = settings.configured_provider_names() + data_dir = settings.data_dir + config_path = Path( + os.environ.get( + "CLEVERAGENTS_CONFIG_PATH", + str(settings.storage_path / "config.toml"), + ) + ) + db_url = settings.database_url + + # Storage sizes (best effort) + storage: dict[str, str] = {} + try: + db_path_str = db_url.replace("sqlite:///", "") + db_path = Path(db_path_str) + if db_path.exists(): + size_mb = db_path.stat().st_size / (1024 * 1024) + storage["db_size"] = f"{size_mb:.1f} MB" + else: + storage["db_size"] = "0 MB" + except Exception: + storage["db_size"] = "unknown" + + log_dir = settings.log_dir + if log_dir.exists(): + total = sum(f.stat().st_size for f in log_dir.rglob("*") if f.is_file()) + storage["logs"] = f"{total / (1024 * 1024):.1f} MB" + else: + storage["logs"] = "0 MB" + + return { + "version": __version__, + "data_dir": str(data_dir), + "config_path": str(config_path), + "database": db_url, + "server_mode": "disabled", + "platform": f"{platform.system()} {platform.release()} ({platform.machine()})", + "automation": settings.default_automation_level, + "providers_configured": len(configured_providers), + "providers": configured_providers, + "debug_mode": settings.debug_enabled, + "storage": storage, + } + + +def _check_config_file() -> dict[str, Any]: + """Check that the config path is readable.""" + config_path = Path( + os.environ.get( + "CLEVERAGENTS_CONFIG_PATH", + "config.toml", + ) + ) + if config_path.exists(): + readable = os.access(config_path, os.R_OK) + return { + "name": "Config file", + "status": CheckStatus.OK if readable else CheckStatus.ERROR, + "details": "readable" if readable else "not readable", + } + return { + "name": "Config file", + "status": CheckStatus.OK, + "details": "not present (using defaults)", + } + + +def _check_data_dir() -> dict[str, Any]: + """Check that the data directory exists and is writable.""" + from cleveragents.config.settings import get_settings + + settings = get_settings() + data_dir = settings.data_dir + + if not data_dir.exists(): + return { + "name": "Data directory", + "status": CheckStatus.WARN, + "details": f"missing ({data_dir})", + "recommendation": f"Create it with: mkdir -p {data_dir}", + } + + writable = os.access(data_dir, os.W_OK) + return { + "name": "Data directory", + "status": CheckStatus.OK if writable else CheckStatus.ERROR, + "details": "writable" if writable else "not writable", + } + + +def _check_database() -> dict[str, Any]: + """Check database connectivity.""" + from cleveragents.config.settings import get_settings + + settings = get_settings() + db_url = settings.database_url + + if db_url.startswith("sqlite"): + db_path_str = db_url.replace("sqlite:///", "") + db_path = Path(db_path_str) + if db_path.exists(): + writable = os.access(db_path, os.W_OK) + return { + "name": "Database", + "status": CheckStatus.OK if writable else CheckStatus.ERROR, + "details": "writable" if writable else "locked or not writable", + } + # DB file doesn't exist yet — that's OK for SQLite (created on first use) + parent = db_path.parent + if parent.exists() and os.access(parent, os.W_OK): + return { + "name": "Database", + "status": CheckStatus.OK, + "details": "will be created on first use", + } + return { + "name": "Database", + "status": CheckStatus.ERROR, + "details": f"parent dir not writable ({parent})", + } + + return { + "name": "Database", + "status": CheckStatus.OK, + "details": "configured", + } + + +def _check_providers() -> list[dict[str, Any]]: + """Check provider API key configuration.""" + from cleveragents.config.settings import get_settings + + settings = get_settings() + results: list[dict[str, Any]] = [] + + provider_checks = [ + ("openai", "OPENAI_API_KEY"), + ("anthropic", "ANTHROPIC_API_KEY"), + ("google", "GOOGLE_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), + ] + + for provider_name, env_var in provider_checks: + configured = settings.has_provider_configured(provider_name) + results.append( + { + "name": f"{provider_name.capitalize()} key", + "status": CheckStatus.OK if configured else CheckStatus.WARN, + "details": "configured" if configured else "missing", + "recommendation": ( + f"Set {env_var} to enable {provider_name.capitalize()} models" + if not configured + else None + ), + } + ) + + return results + + +def _check_disk_space() -> dict[str, Any]: + """Check available disk space.""" + try: + usage = shutil.disk_usage(Path.cwd()) + free_gb = usage.free / (1024**3) + if free_gb < 0.5: + return { + "name": "Disk space", + "status": CheckStatus.ERROR, + "details": f"{free_gb:.1f} GB free (critically low)", + } + if free_gb < 1.0: + return { + "name": "Disk space", + "status": CheckStatus.WARN, + "details": f"{free_gb:.1f} GB free (low)", + } + return { + "name": "Disk space", + "status": CheckStatus.OK, + "details": f"{free_gb:.1f} GB free", + } + except OSError: + return { + "name": "Disk space", + "status": CheckStatus.WARN, + "details": "unable to check", + } + + +def _check_git() -> dict[str, Any]: + """Check git availability.""" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + version_str = result.stdout.strip().replace("git version ", "") + return { + "name": "Git", + "status": CheckStatus.OK, + "details": f"git {version_str}", + } + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + pass + return { + "name": "Git", + "status": CheckStatus.ERROR, + "details": "not found", + "recommendation": "Install git for version control integration", + } + + +def _check_file_permissions() -> dict[str, Any]: + """Check file permissions on the data directory.""" + from cleveragents.config.settings import get_settings + + settings = get_settings() + data_dir = settings.data_dir + + if not data_dir.exists(): + return { + "name": "File permissions", + "status": CheckStatus.WARN, + "details": "data dir does not exist", + } + + readable = os.access(data_dir, os.R_OK) + writable = os.access(data_dir, os.W_OK) + if readable and writable: + return { + "name": "File permissions", + "status": CheckStatus.OK, + "details": "data dir r/w", + } + perms = [] + if readable: + perms.append("r") + if writable: + perms.append("w") + return { + "name": "File permissions", + "status": CheckStatus.ERROR if not writable else CheckStatus.WARN, + "details": f"data dir {''.join(perms) or 'no access'}", + } + + +def build_diagnostics_data() -> dict[str, Any]: + """Run all diagnostic checks and return structured results.""" + start = time.monotonic() + + checks: list[dict[str, Any]] = [] + checks.append(_check_config_file()) + checks.append(_check_data_dir()) + checks.append(_check_database()) + checks.extend(_check_providers()) + checks.append(_check_disk_space()) + checks.append(_check_file_permissions()) + checks.append(_check_git()) + + elapsed = time.monotonic() - start + + total = len(checks) + ok_count = sum(1 for c in checks if c["status"] == CheckStatus.OK) + warn_count = sum(1 for c in checks if c["status"] == CheckStatus.WARN) + error_count = sum(1 for c in checks if c["status"] == CheckStatus.ERROR) + + recommendations: list[str] = [] + for check in checks: + rec = check.get("recommendation") + if rec: + recommendations.append(rec) + + return { + "checks": checks, + "summary": { + "total": total, + "ok": ok_count, + "warnings": warn_count, + "errors": error_count, + "duration_s": round(elapsed, 2), + }, + "recommendations": recommendations, + "has_errors": error_count > 0, + "has_warnings": warn_count > 0, + } + + +# --------------------------------------------------------------------------- +# Rich rendering helpers +# --------------------------------------------------------------------------- + + +def render_version_rich(data: dict[str, Any]) -> None: + """Print version information using Rich panels.""" + from rich.panel import Panel + + from cleveragents.cli.main import get_console + + console = get_console() + + # CLI Version panel + version_lines = [ + "[bold]CleverAgents CLI[/bold]", + f"[blue]Version:[/blue] {data['version']}", + f"[blue]Channel:[/blue] {data['channel']}", + f"[blue]Python:[/blue] {data['python']}", + ] + console.print(Panel("\n".join(version_lines), title="CLI Version", expand=False)) + + # Build panel + build_lines = [ + f"[green]Build Date:[/green] {data['build_date']}", + f"[magenta]Commit:[/magenta] {data['commit']}", + f"[blue]Schema:[/blue] {data['schema']}", + f"[blue]Platform:[/blue] {data['platform']}", + ] + console.print(Panel("\n".join(build_lines), title="Build", expand=False)) + + # Dependencies panel + deps = data.get("dependencies", {}) + dep_lines = [f"[blue]{k}:[/blue] {v}" for k, v in deps.items()] + if dep_lines: + console.print(Panel("\n".join(dep_lines), title="Dependencies", expand=False)) + + console.print("[green]OK[/green] Version reported") + + +def render_info_rich(data: dict[str, Any]) -> None: + """Print info using Rich panels.""" + from rich.panel import Panel + + from cleveragents.cli.main import get_console + + console = get_console() + + env_lines = [ + f"[blue]Data Dir:[/blue] {data['data_dir']}", + f"[blue]Config:[/blue] {data['config_path']}", + f"[green]Database:[/green] {data['database']}", + f"[yellow]Server Mode:[/yellow] {data['server_mode']}", + f"[blue]Platform:[/blue] {data['platform']}", + ] + console.print(Panel("\n".join(env_lines), title="Environment", expand=False)) + + runtime_lines = [ + f"[magenta]Automation:[/magenta] {data['automation']}", + f"[blue]Providers:[/blue] {data['providers_configured']} configured", + f"[blue]Debug Mode:[/blue] {data['debug_mode']}", + ] + console.print(Panel("\n".join(runtime_lines), title="Runtime", expand=False)) + + storage = data.get("storage", {}) + if storage: + storage_lines = [f"[blue]{k}:[/blue] {v}" for k, v in storage.items()] + console.print(Panel("\n".join(storage_lines), title="Storage", expand=False)) + + console.print("[green]OK[/green] Environment details ready") + + +def render_diagnostics_rich(data: dict[str, Any]) -> None: + """Print diagnostics using Rich panels and table.""" + from rich.panel import Panel + from rich.table import Table + + from cleveragents.cli.main import get_console + + console = get_console() + + # Checks table + table = Table(title="Checks", show_header=True, expand=False) + table.add_column("Check", style="cyan") + table.add_column("Status") + table.add_column("Details") + + for check in data["checks"]: + status = check["status"] + if status == CheckStatus.OK: + status_text = "[green]OK[/green]" + elif status == CheckStatus.WARN: + status_text = "[yellow]WARN[/yellow]" + else: + status_text = "[red]ERROR[/red]" + table.add_row(check["name"], status_text, check.get("details", "")) + + console.print(table) + + # Summary panel + summary = data["summary"] + summary_lines = [ + f"[blue]Checks:[/blue] {summary['total']} total", + f"[yellow]Warnings:[/yellow] {summary['warnings']}", + f"[red]Errors:[/red] {summary['errors']}", + f"[green]Duration:[/green] {summary['duration_s']}s", + ] + console.print(Panel("\n".join(summary_lines), title="Summary", expand=False)) + + # Recommendations + recs = data.get("recommendations", []) + if recs: + rec_lines = [f"- {r}" for r in recs] + console.print( + Panel("\n".join(rec_lines), title="Recommendations", expand=False) + ) + + # Final status line + if data["has_errors"]: + console.print(f"[red]ERROR[/red] {summary['errors']} errors must be resolved") + elif data["has_warnings"]: + console.print( + f"[yellow]WARN[/yellow] {summary['warnings']} warnings require attention" + ) + else: + console.print("[green]OK[/green] All checks passed") diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index e394d245a..e76ba632c 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -187,68 +187,77 @@ def main_callback( @app.command() -def version() -> None: +def version( + fmt: str = typer.Option( + "rich", + "--format", + "-f", + help="Output format: rich, plain, json, yaml", + ), +) -> None: """Display version information.""" - console = get_console() - console.print(f"CleverAgents {__version__}") + from cleveragents.cli.commands.system import ( + build_version_data, + render_version_rich, + ) + from cleveragents.cli.formatting import format_output + + data = build_version_data() + if fmt.lower() == "rich": + render_version_rich(data) + else: + typer.echo(format_output(data, fmt)) @app.command() -def info() -> None: +def info( + fmt: str = typer.Option( + "rich", + "--format", + "-f", + help="Output format: rich, plain, json, yaml", + ), +) -> None: """Display system information and configuration.""" - from rich.panel import Panel + from cleveragents.cli.commands.system import build_info_data, render_info_rich + from cleveragents.cli.formatting import format_output - from cleveragents.config.settings import get_settings - - settings = get_settings() - - actor_configured = getattr( - settings, "has_actor_configured", settings.has_provider_configured - )() - - info_text = f""" -[bold]CleverAgents Information[/bold] -Version: {__version__} -Storage: {settings.storage_path} -Debug Mode: {settings.debug_enabled} - Actor Configured: {actor_configured} - - """ - - console = get_console() - console.print(Panel(info_text.strip(), title="System Info", expand=False)) + data = build_info_data() + if fmt.lower() == "rich": + render_info_rich(data) + else: + typer.echo(format_output(data, fmt)) @app.command() -def diagnostics() -> None: +def diagnostics( + check: bool = typer.Option( + False, + "--check", + help="Exit non-zero when any check fails", + ), + fmt: str = typer.Option( + "rich", + "--format", + "-f", + help="Output format: rich, plain, json, yaml", + ), +) -> None: """Run system diagnostics and health checks.""" - import platform + from cleveragents.cli.commands.system import ( + build_diagnostics_data, + render_diagnostics_rich, + ) + from cleveragents.cli.formatting import format_output - from cleveragents.config.settings import get_settings + data = build_diagnostics_data() + if fmt.lower() == "rich": + render_diagnostics_rich(data) + else: + typer.echo(format_output(data, fmt)) - settings = get_settings() - - # Check basic configuration - checks = { - "Configuration Loaded": True, - "Storage Directory": settings.storage_path.exists(), - "Actor Configured": getattr( - settings, "has_actor_configured", settings.has_provider_configured - )(), - "Debug Mode": settings.debug_enabled, - } - - console = get_console() - console.print("\n[bold]CleverAgents Diagnostics[/bold]\n") - console.print(f"Version: {__version__}") - console.print(f"Python: {sys.version.split()[0]}") - console.print(f"Platform: {platform.platform()}\n") - - for check, result in checks.items(): - status = "[green]✓[/green]" if result else "[red]✗[/red]" - console.print(f"{status} {check}: {result}") - - console.print() + if check and data["has_errors"]: + raise typer.Exit(code=1) @app.command()