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/benchmarks/project_repository_bench.py b/benchmarks/project_repository_bench.py index 65d4d79f1..23552dbb2 100644 --- a/benchmarks/project_repository_bench.py +++ b/benchmarks/project_repository_bench.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import sessionmaker from cleveragents.domain.models.core.project import NamespacedProject from cleveragents.infrastructure.database.models import ( Base, + NamespacedProjectModel, ResourceModel, ResourceTypeModel, ) @@ -96,8 +97,13 @@ class LinkRepositoryCRUD: session.commit() # Project - project_repo = NamespacedProjectRepository(session_factory=self.sf) - project_repo.create(NamespacedProject(name="link-bench", namespace="bench")) + proj = NamespacedProjectModel() + proj.namespaced_name = "bench/link-bench" # type: ignore[assignment] + proj.namespace = "bench" # type: ignore[assignment] + proj.tags_json = "[]" # type: ignore[assignment] + proj.created_at = _now_iso() # type: ignore[assignment] + proj.updated_at = _now_iso() # type: ignore[assignment] + session.add(proj) session.commit() # Resources diff --git a/benchmarks/resource_dag_bench.py b/benchmarks/resource_dag_bench.py index ec47baed9..81ed1b4f1 100644 --- a/benchmarks/resource_dag_bench.py +++ b/benchmarks/resource_dag_bench.py @@ -32,7 +32,7 @@ def _bench_ulid() -> str: _BENCH_CTR += 1 n = _BENCH_CTR suffix = "" - for _ in range(8): + for _ in range(7): suffix = _CB32[n % 32] + suffix n //= 32 return f"01HDAGBNCH0AQDYTR4B{suffix:>7s}"[:26] @@ -40,6 +40,7 @@ def _bench_ulid() -> str: def _seed_types( rt_repo: Any, + session_factory: Any, parent_name: str = "bench/dag-parent", child_name: str = "bench/dag-child", ) -> None: @@ -94,12 +95,16 @@ def _seed_types( built_in=False, ) rt_repo.create(parent_spec) - rt_repo.create(child_spec) + if child_name != parent_name: + rt_repo.create(child_spec) + # Commit so types survive across session boundaries (StaticPool). + session_factory().commit() def _make_resource( res_repo: Any, type_name: str, + session_factory: Any, ) -> str: """Create a resource and return its ID.""" from cleveragents.domain.models.core.resource import ( @@ -124,6 +129,8 @@ def _make_resource( updated_at=datetime.now(tz=UTC), ) res_repo.create(res) + # Commit so the resource survives across session boundaries. + session_factory().commit() return rid @@ -139,12 +146,12 @@ class TimeLinkChild: self.factory = _setup_db() self.rt_repo = ResourceTypeRepository(self.factory) self.res_repo = ResourceRepository(self.factory) - _seed_types(self.rt_repo) - self.parent_id = _make_resource(self.res_repo, "bench/dag-parent") + _seed_types(self.rt_repo, self.factory) + self.parent_id = _make_resource(self.res_repo, "bench/dag-parent", self.factory) self._child_ctr = 0 def time_link_child(self) -> None: - child_id = _make_resource(self.res_repo, "bench/dag-child") + child_id = _make_resource(self.res_repo, "bench/dag-child", self.factory) self.res_repo.link_child(self.parent_id, child_id) @@ -160,10 +167,11 @@ class TimeUnlinkChild: self.factory = _setup_db() self.rt_repo = ResourceTypeRepository(self.factory) self.res_repo = ResourceRepository(self.factory) - _seed_types(self.rt_repo) - self.parent_id = _make_resource(self.res_repo, "bench/dag-parent") - self.child_id = _make_resource(self.res_repo, "bench/dag-child") + _seed_types(self.rt_repo, self.factory) + self.parent_id = _make_resource(self.res_repo, "bench/dag-parent", self.factory) + self.child_id = _make_resource(self.res_repo, "bench/dag-child", self.factory) self.res_repo.link_child(self.parent_id, self.child_id) + self.factory().commit() def time_unlink_child(self) -> None: self.res_repo.unlink_child(self.parent_id, self.child_id) @@ -183,8 +191,8 @@ class TimeAutoDiscoverChildren: self.factory = _setup_db() self.rt_repo = ResourceTypeRepository(self.factory) self.res_repo = ResourceRepository(self.factory) - _seed_types(self.rt_repo) - self.parent_id = _make_resource(self.res_repo, "bench/dag-parent") + _seed_types(self.rt_repo, self.factory) + self.parent_id = _make_resource(self.res_repo, "bench/dag-parent", self.factory) def time_auto_discover_children(self) -> None: self.res_repo.auto_discover_children(self.parent_id) @@ -207,6 +215,7 @@ class TimeCycleDetection: self.res_repo = ResourceRepository(self.factory) _seed_types( self.rt_repo, + self.factory, "bench/chain-type", "bench/chain-type", ) @@ -214,7 +223,7 @@ class TimeCycleDetection: # Build a chain: r0 -> r1 -> ... -> rN self.chain_ids: list[str] = [] for _ in range(chain_depth + 1): - rid = _make_resource(self.res_repo, "bench/chain-type") + rid = _make_resource(self.res_repo, "bench/chain-type", self.factory) self.chain_ids.append(rid) for i in range(chain_depth): @@ -222,6 +231,7 @@ class TimeCycleDetection: self.chain_ids[i], self.chain_ids[i + 1], ) + self.factory().commit() def time_cycle_detection(self, chain_depth: int) -> None: """Attempt to close the cycle (should raise).""" diff --git a/docs/reference/cli_system_commands.md b/docs/reference/cli_system_commands.md new file mode 100644 index 000000000..4b0a2aeed --- /dev/null +++ b/docs/reference/cli_system_commands.md @@ -0,0 +1,268 @@ +# Core System Commands + +The CLI exposes three system-level commands for inspecting the running +environment: `version`, `info`, and `diagnostics`. All three support a +`--format` flag (`rich`, `plain`, `json`, `yaml`) so output can be consumed +by humans or parsed by scripts. + +--- + +## `agents version` + +Display version, build metadata, and dependency versions. + +### Usage + +``` +agents version [--format rich|plain|json|yaml] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--format`, `-f` | `rich` | Output format | + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `version` | string | Semantic version (`1.0.0`) | +| `channel` | string | Release channel (`stable`) | +| `python` | string | Python runtime version | +| `build_date` | string | ISO date of build (`YYYY-MM-DD`) | +| `commit` | string | Short git SHA of HEAD | +| `schema` | string | Data schema version (`v3`) | +| `platform` | string | OS and architecture | +| `dependencies` | object | Key dependency versions (langgraph, langchain-core, pydantic, typer) | + +### Sample output — rich (default) + +``` +╭──────── CLI Version ────────╮ +│ CleverAgents CLI │ +│ Version: 1.0.0 │ +│ Channel: stable │ +│ Python: 3.13.2 │ +╰──────────────────────────────╯ +╭──────── Build ──────────────╮ +│ Build Date: 2026-02-17 │ +│ Commit: 6de85ca │ +│ Schema: v3 │ +│ Platform: linux-x86_64 │ +╰──────────────────────────────╯ +╭──────── Dependencies ───────╮ +│ langgraph: 0.3.34 │ +│ langchain-core: 0.3.41 │ +│ pydantic: 2.11.1 │ +│ typer: 0.15.2 │ +╰──────────────────────────────╯ +OK Version reported +``` + +### Sample output — json + +```json +{ + "version": "1.0.0", + "channel": "stable", + "python": "3.13.2", + "build_date": "2026-02-17", + "commit": "6de85ca", + "schema": "v3", + "platform": "linux-x86_64", + "dependencies": { + "langgraph": "0.3.34", + "langchain-core": "0.3.41", + "pydantic": "2.11.1", + "typer": "0.15.2" + } +} +``` + +### Sample output — plain + +``` +version: 1.0.0 +channel: stable +python: 3.13.2 +build_date: 2026-02-17 +commit: 6de85ca +schema: v3 +platform: linux-x86_64 +dependencies: + langgraph: 0.3.34 + langchain-core: 0.3.41 + pydantic: 2.11.1 + typer: 0.15.2 +``` + +--- + +## `agents info` + +Display environment details, runtime configuration, and storage sizes. + +### Usage + +``` +agents info [--format rich|plain|json|yaml] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--format`, `-f` | `rich` | Output format | + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `version` | string | CleverAgents version | +| `data_dir` | string | Absolute path to data directory | +| `config_path` | string | Absolute path to config file | +| `database` | string | Database URL | +| `server_mode` | string | Server mode (`disabled` in local-only) | +| `platform` | string | OS, release, and architecture | +| `automation` | string | Default automation level | +| `providers_configured` | int | Number of configured LLM providers | +| `providers` | list | Names of configured providers | +| `debug_mode` | bool | Whether debug mode is enabled | +| `storage` | object | Storage sizes (`db_size`, `logs`) | + +### Sample output — rich (default) + +``` +╭──────── Environment ────────╮ +│ Data Dir: /home/user/... │ +│ Config: config.toml │ +│ Database: sqlite:///... │ +│ Server Mode: disabled │ +│ Platform: Linux 6.x (x86) │ +╰──────────────────────────────╯ +╭──────── Runtime ────────────╮ +│ Automation: suggest │ +│ Providers: 2 configured │ +│ Debug Mode: False │ +╰──────────────────────────────╯ +╭──────── Storage ────────────╮ +│ db_size: 0.1 MB │ +│ logs: 0.0 MB │ +╰──────────────────────────────╯ +OK Environment details ready +``` + +### Sample output — json + +```json +{ + "version": "1.0.0", + "data_dir": "/home/user/.local/share/cleveragents", + "config_path": "/home/user/.config/cleveragents/config.toml", + "database": "sqlite:///data/db.sqlite", + "server_mode": "disabled", + "platform": "Linux 6.1.0 (x86_64)", + "automation": "suggest", + "providers_configured": 2, + "providers": ["openai", "anthropic"], + "debug_mode": false, + "storage": { + "db_size": "0.1 MB", + "logs": "0.0 MB" + } +} +``` + +--- + +## `agents diagnostics` + +Run health checks and report system status. + +### Usage + +``` +agents diagnostics [--format rich|plain|json|yaml] [--check] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--format`, `-f` | `rich` | Output format | +| `--check` | `false` | Exit with code 1 if any check has status `error` | + +The `--check` flag is intended for CI and scripting: pipe diagnostics into +a gate that blocks deployment when critical checks fail. + +### Output schema (json/yaml) + +```json +{ + "checks": [ + {"name": "Config file", "status": "ok", "details": "..."}, + {"name": "Database", "status": "ok", "details": "..."} + ], + "summary": { + "total": 11, + "ok": 9, + "warnings": 2, + "errors": 0, + "duration_s": 0.03 + }, + "recommendations": ["Set OPENAI_API_KEY to enable OpenAI models"], + "has_errors": false, + "has_warnings": true +} +``` + +### Check statuses + +| Status | Meaning | +|--------|---------| +| `ok` | Check passed | +| `warn` | Non-critical issue detected | +| `error` | Critical issue — must be resolved | + +### Sample output — rich (default) + +``` + Checks +┏━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ +┃ Check ┃ Status┃ Details ┃ +┡━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ +│ Config file │ OK │ not present (defaults)│ +│ Data directory │ OK │ writable │ +│ Database │ OK │ writable │ +│ Openai key │ OK │ configured │ +│ Anthropic key │ WARN │ missing │ +│ Google key │ WARN │ missing │ +│ Openrouter key │ WARN │ missing │ +│ Disk space │ OK │ 42.1 GB free │ +│ File permissions │ OK │ data dir r/w │ +│ Git │ OK │ git 2.43.0 │ +└──────────────────┴───────┴──────────────────────┘ +╭──────── Summary ────────────╮ +│ Checks: 10 total │ +│ Warnings: 3 │ +│ Errors: 0 │ +│ Duration: 0.03s │ +╰──────────────────────────────╯ +╭──────── Recommendations ────╮ +│ - Set ANTHROPIC_API_KEY ... │ +│ - Set GOOGLE_API_KEY ... │ +│ - Set OPENROUTER_API_KEY ...│ +╰──────────────────────────────╯ +OK All checks passed +``` + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | All checks passed (or warnings only) | +| `1` | At least one `error` check (only with `--check`) | + +See [Diagnostics Checks](diagnostics_checks.md) for the full check list +and remediation steps. diff --git a/docs/reference/diagnostics_checks.md b/docs/reference/diagnostics_checks.md new file mode 100644 index 000000000..f8417feeb --- /dev/null +++ b/docs/reference/diagnostics_checks.md @@ -0,0 +1,123 @@ +# Diagnostics Check List + +`agents diagnostics` runs the checks listed below. Each check returns one +of three statuses: **ok**, **warn**, or **error**. When the `--check` flag +is passed the command exits with code 1 if any check reports `error`. + +--- + +## Checks + +### Config file + +Verifies that the configuration file (default `config.toml` or the path set +via `CLEVERAGENTS_CONFIG_PATH`) exists and is readable. + +| Status | Condition | Remediation | +|--------|-----------|-------------| +| ok | File exists and is readable, or file is absent (defaults are used) | None required | +| error | File exists but is not readable | Fix file permissions: `chmod 644 ` | + +### Data directory + +Verifies that the data directory (from `Settings.data_dir`) exists and is +writable. + +| Status | Condition | Remediation | +|--------|-----------|-------------| +| ok | Directory exists and is writable | None required | +| warn | Directory does not exist | Create it: `mkdir -p ` | +| error | Directory exists but is not writable | Fix permissions: `chmod 755 ` or change ownership | + +### Database + +Verifies that the SQLite database file (or its parent directory for +first-run) is accessible and writable. + +| Status | Condition | Remediation | +|--------|-----------|-------------| +| ok | DB file is writable, or parent dir is writable (first run) | None required | +| ok | Non-SQLite database URL is configured | None required | +| error | DB file exists but is locked or not writable | Close other processes using the DB, or fix permissions | +| error | Parent directory is not writable | Fix parent directory permissions | + +### Provider API keys + +Checks whether each supported LLM provider has its API key configured. +One sub-check is emitted per provider. + +| Provider | Environment variable | Status when missing | +|----------|---------------------|---------------------| +| OpenAI | `OPENAI_API_KEY` | warn | +| Anthropic | `ANTHROPIC_API_KEY` | warn | +| Google | `GOOGLE_API_KEY` | warn | +| OpenRouter | `OPENROUTER_API_KEY` | warn | + +| Status | Condition | Remediation | +|--------|-----------|-------------| +| ok | API key is configured | None required | +| warn | API key is not set | Set the environment variable to enable the provider | + +Missing provider keys are warnings, not errors, because the system can +operate with any single provider configured. + +### Disk space + +Checks available disk space on the volume containing the current working +directory. + +| Status | Condition | Remediation | +|--------|-----------|-------------| +| ok | >= 1.0 GB free | None required | +| warn | 0.5 -- 1.0 GB free | Free disk space or move data directory to a larger volume | +| error | < 0.5 GB free | Immediately free disk space to avoid data corruption | + +### File permissions + +Verifies read and write access to the data directory. + +| Status | Condition | Remediation | +|--------|-----------|-------------| +| ok | Data directory is readable and writable | None required | +| warn | Data directory does not exist | Create it (see Data directory check) | +| warn | Data directory is readable but not writable | Fix write permissions: `chmod u+w ` | +| error | Data directory is not writable | Fix permissions or change ownership | + +### Git + +Checks that the `git` binary is available on `PATH`. + +| Status | Condition | Remediation | +|--------|-----------|-------------| +| ok | `git --version` succeeds | None required | +| error | `git` is not found or times out | Install git for version control integration | + +--- + +## Summary counts + +The `summary` object in JSON/YAML output includes: + +| Field | Type | Description | +|-------|------|-------------| +| `total` | int | Total number of individual checks run | +| `ok` | int | Checks that passed | +| `warnings` | int | Checks with non-critical issues | +| `errors` | int | Checks with critical issues | +| `duration_s` | float | Wall-clock seconds for the full diagnostic run | + +## Recommendations + +When a check returns `warn` or `error` and has an actionable fix, the +recommendation is collected into the `recommendations` list in the output. +Recommendations are ordered by check execution order. + +## Using `--check` in CI + +```bash +# Fail the pipeline if any critical check errors +agents diagnostics --check --format json +``` + +Exit code `0` means all checks passed (warnings are tolerated). +Exit code `1` means at least one check reported `error`. 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/implementation_plan.md b/implementation_plan.md index 79446cbfc..8bd383575 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -5171,28 +5171,28 @@ By Day 30, the system must be able to (all in LOCAL MODE, server connectivity is ### Section 13: Additional CLI Commands & UX [Days 10-14] **Parallel Group CLI0: Core System Commands [Hamza]** -- [ ] **COMMIT (Owner: Hamza | Group: CLI0.core | Branch: feature/m4-cli-core | Planned: Day 10 | Expected: Day 12) - Commit message: "feat(cli): add version/info/diagnostics"** - - [ ] Git [Hamza]: `git checkout master` - - [ ] Git [Hamza]: `git pull origin master` - - [ ] Git [Hamza]: `git checkout -b feature/m4-cli-core` - - [ ] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit) - - [ ] Code [Hamza]: Implement `version`, `info`, and `diagnostics` commands with rich/plain/json/yaml output parity. - - [ ] Code [Hamza]: Add diagnostics checks for config file, database, providers, and filesystem permissions per spec. - - [ ] Code [Hamza]: Include build metadata in `version` (semver, git sha, build date) and expose in JSON/YAML outputs. - - [ ] Code [Hamza]: Add `diagnostics --check` to exit non-zero when any check fails; report total pass/fail counts. - - [ ] Code [Hamza]: Add `diagnostics --format json/yaml` output schema with explicit check names and statuses. - - [ ] Code [Hamza]: Add detection for missing data dir and invalid config path with actionable error messages. - - [ ] Docs [Hamza]: Update CLI reference with core system commands and sample outputs. - - [ ] Docs [Hamza]: Add diagnostics check list and expected remediation steps. - - [ ] Tests (Behave) [Hamza]: Add `features/cli_core.feature` scenarios for each command output. - - [ ] Tests (Robot) [Hamza]: Add core command smoke tests. - - [ ] Tests (ASV) [Hamza]: Add `benchmarks/cli_core_bench.py` for command runtime baseline. - - [ ] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%. - - [ ] Quality [Hamza]: Run `nox` (all default sessions, including benchmark). - - [ ] Git [Hamza]: `git add .` (only after nox passes) - - [ ] Git [Hamza]: `git commit -m "feat(cli): add version/info/diagnostics"` - - [ ] Git [Hamza]: `git push -u origin feature/m4-cli-core` - - [ ] Forgejo PR [Hamza]: Open PR from `feature/m4-cli-core` to `master` with description "Add core CLI system commands and diagnostics with tests.". +- [x] **COMMIT (Owner: Hamza | Group: CLI0.core | Branch: feature/m4-cli-core | Planned: Day 10 | Expected: Day 12) - Commit message: "feat(cli): add version/info/diagnostics"** + - [x] Git [Hamza]: `git checkout master` + - [x] Git [Hamza]: `git pull origin master` + - [x] Git [Hamza]: `git checkout -b feature/m4-cli-core` + - [x] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit) + - [x] Code [Hamza]: Implement `version`, `info`, and `diagnostics` commands with rich/plain/json/yaml output parity. + - [x] Code [Hamza]: Add diagnostics checks for config file, database, providers, and filesystem permissions per spec. + - [x] Code [Hamza]: Include build metadata in `version` (semver, git sha, build date) and expose in JSON/YAML outputs. + - [x] Code [Hamza]: Add `diagnostics --check` to exit non-zero when any check fails; report total pass/fail counts. + - [x] Code [Hamza]: Add `diagnostics --format json/yaml` output schema with explicit check names and statuses. + - [x] Code [Hamza]: Add detection for missing data dir and invalid config path with actionable error messages. + - [x] Docs [Hamza]: Update CLI reference with core system commands and sample outputs. + - [x] Docs [Hamza]: Add diagnostics check list and expected remediation steps. + - [x] Tests (Behave) [Hamza]: Add `features/cli_core.feature` scenarios for each command output. + - [x] Tests (Robot) [Hamza]: Add core command smoke tests. + - [x] Tests (ASV) [Hamza]: Add `benchmarks/cli_core_bench.py` for command runtime baseline. + - [x] Quality [Hamza]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%. + - [x] Quality [Hamza]: Run `nox` (all default sessions, including benchmark). + - [x] Git [Hamza]: `git add .` (only after nox passes) + - [x] Git [Hamza]: `git commit -m "feat(cli): add version/info/diagnostics"` + - [x] Git [Hamza]: `git push -u origin feature/m4-cli-core` + - [x] Forgejo PR [Hamza]: Open PR from `feature/m4-cli-core` to `master` with description "Add core CLI system commands and diagnostics with tests.". - [ ] Git [Hamza]: `git checkout master` - [ ] Git [Hamza]: `git branch -d feature/m4-cli-core` diff --git a/robot/actor_configuration.robot b/robot/actor_configuration.robot index bb1235dd6..8fa6b50bf 100644 --- a/robot/actor_configuration.robot +++ b/robot/actor_configuration.robot @@ -31,7 +31,7 @@ V2 Actor Config Produces Provider And Graph Descriptor ... ${SPACE*4}publications: ... ${SPACE*6}- __output__ Create File ${config} ${content} - ${result}= Run Process ${PYTHON} robot/helper_actor_config.py ${config} stdout=PIPE stderr=PIPE + ${result}= Run Process ${PYTHON} robot/helper_actor_config.py ${config} Should Be Equal As Integers ${result.rc} 0 ${payload}= Evaluate __import__('json').loads('''${result.stdout.strip()}''') Should Be Equal ${payload['provider']} openai diff --git a/robot/changeset_capture.robot b/robot/changeset_capture.robot index ddb1c9496..ccb808124 100644 --- a/robot/changeset_capture.robot +++ b/robot/changeset_capture.robot @@ -13,7 +13,6 @@ File Write Produces ChangeSet Entry ... verify the resulting ChangeSet contains the entry. ${result}= Run Process ${PYTHON} -c ... ${CHANGESET_SCRIPT} - ... stdout=PIPE stderr=PIPE Log ${result.stdout} Log ${result.stderr} Should Be Equal As Integers ${result.rc} 0 @@ -24,7 +23,6 @@ ChangeSet Summary Counts Match [Documentation] Verify summary counts after multiple operations. ${result}= Run Process ${PYTHON} -c ... ${SUMMARY_SCRIPT} - ... stdout=PIPE stderr=PIPE Log ${result.stdout} Log ${result.stderr} Should Be Equal As Integers ${result.rc} 0 @@ -36,7 +34,6 @@ InMemoryChangeSetStore Round Trip [Documentation] Verify start/record/get via InMemoryChangeSetStore. ${result}= Run Process ${PYTHON} -c ... ${STORE_SCRIPT} - ... stdout=PIPE stderr=PIPE Log ${result.stdout} Log ${result.stderr} Should Be Equal As Integers ${result.rc} 0 diff --git a/robot/ci_nox_validation.robot b/robot/ci_nox_validation.robot index d4918d5b2..4017f4ff2 100644 --- a/robot/ci_nox_validation.robot +++ b/robot/ci_nox_validation.robot @@ -7,7 +7,7 @@ Library OperatingSystem Nox Lists All Required Sessions [Documentation] Verify that nox can list sessions and the required CI sessions are present [Tags] ci quality slow - ${result}= Run Process nox --list stdout=PIPE stderr=PIPE + ${result}= Run Process nox --list Should Be Equal As Integers ${result.rc} 0 msg=nox --list failed with: ${result.stderr} # Verify required sessions exist in nox output Should Contain ${result.stdout} lint @@ -28,5 +28,5 @@ CI Workflow File Exists Nox Lint Session Runs Successfully [Documentation] Verify that nox lint session can be invoked [Tags] ci quality slow - ${result}= Run Process nox -s lint stdout=PIPE stderr=PIPE timeout=120s + ${result}= Run Process nox -s lint timeout=120s Should Be Equal As Integers ${result.rc} 0 msg=nox -s lint failed: ${result.stderr} 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..a28880132 --- /dev/null +++ b/robot/cli_core.robot @@ -0,0 +1,110 @@ +*** 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 Returns Valid Exit Code + [Documentation] Diagnostics --check exits 0 (no errors) or 1 (has errors) without crashing + ${result}= Run Process ${PYTHON} -m cleveragents diagnostics --check --format json timeout=30s + Should Be True ${result.rc} == 0 or ${result.rc} == 1 Unexpected exit code: ${result.rc} + Should Contain ${result.stdout} "checks" + Should Contain ${result.stdout} "has_errors" + +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/robot/plan_lifecycle_persistence.robot b/robot/plan_lifecycle_persistence.robot index ff52376d7..3ac4f8703 100644 --- a/robot/plan_lifecycle_persistence.robot +++ b/robot/plan_lifecycle_persistence.robot @@ -11,7 +11,7 @@ ${PYTHON} python Plan Lifecycle Persistence Via Helper Script [Documentation] Create action + plan, verify persistence through transitions ${result}= Run Process ${PYTHON} ${CURDIR}/helper_plan_lifecycle_persistence.py - ... stdout=STDOUT stderr=STDOUT timeout=30s + ... stderr=STDOUT timeout=30s Log ${result.stdout} Should Contain ${result.stdout} PASS: plan_lifecycle_persistence smoke test Should Be Equal As Integers ${result.rc} 0 diff --git a/robot/plan_repository.robot b/robot/plan_repository.robot index 1ed0fc846..f6ea572a3 100644 --- a/robot/plan_repository.robot +++ b/robot/plan_repository.robot @@ -11,7 +11,7 @@ ${PYTHON} python Plan Repository CRUD Via Helper Script [Documentation] Create, retrieve, list, count, and delete a plan via LifecyclePlanRepository ${result}= Run Process ${PYTHON} ${CURDIR}/helper_plan_repository.py - ... stdout=STDOUT stderr=STDOUT timeout=30s + ... stderr=STDOUT timeout=30s Log ${result.stdout} Should Contain ${result.stdout} PASS: plan_repository smoke test Should Be Equal As Integers ${result.rc} 0 diff --git a/robot/uow_lifecycle.robot b/robot/uow_lifecycle.robot index 08e02ec59..57acd1690 100644 --- a/robot/uow_lifecycle.robot +++ b/robot/uow_lifecycle.robot @@ -11,7 +11,7 @@ ${PYTHON} python UoW Lifecycle Action And Plan Via Helper Script [Documentation] Create action + plan via UoW, verify retrieval in new session ${result}= Run Process ${PYTHON} ${CURDIR}/helper_uow_lifecycle.py - ... stdout=STDOUT stderr=STDOUT timeout=30s + ... stderr=STDOUT timeout=30s Log ${result.stdout} Should Contain ${result.stdout} PASS: uow_lifecycle smoke test 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()