From 156e3ffde9651ab7e6e43a3d5c9f81e9c1a27299 Mon Sep 17 00:00:00 2001 From: "Jeff (CTO)" Date: Sat, 14 Feb 2026 15:22:47 +0000 Subject: [PATCH] feat(cli): stabilize action/plan output formats --- benchmarks/cli_format_bench.py | 126 ++++++ docs/reference/action_cli.md | 71 +++- docs/reference/plan_cli.md | 114 ++++-- features/cli_output_formats.feature | 113 ++++++ features/steps/cli_output_formats_steps.py | 431 +++++++++++++++++++++ implementation_plan.md | 46 ++- robot/cli_formats.robot | 35 ++ robot/helper_cli_formats.py | 149 +++++++ src/cleveragents/cli/commands/action.py | 94 ++++- src/cleveragents/cli/commands/plan.py | 198 ++++++++-- src/cleveragents/cli/formatting.py | 166 ++++++++ vulture_whitelist.py | 15 + 12 files changed, 1452 insertions(+), 106 deletions(-) create mode 100644 benchmarks/cli_format_bench.py create mode 100644 features/cli_output_formats.feature create mode 100644 features/steps/cli_output_formats_steps.py create mode 100644 robot/cli_formats.robot create mode 100644 robot/helper_cli_formats.py create mode 100644 src/cleveragents/cli/formatting.py diff --git a/benchmarks/cli_format_bench.py b/benchmarks/cli_format_bench.py new file mode 100644 index 000000000..246f3d66b --- /dev/null +++ b/benchmarks/cli_format_bench.py @@ -0,0 +1,126 @@ +"""ASV benchmarks for CLI --format rendering throughput. + +Measures the overhead of json, yaml, plain, table, and rich formats +for both action and plan CLI commands. +""" + +from __future__ import annotations + +import importlib +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from typer.testing import CliRunner # noqa: E402 + +from cleveragents.cli.commands.action import app as action_app # noqa: E402 +from cleveragents.cli.commands.plan import app as plan_app # noqa: E402 +from cleveragents.domain.models.core.action import Action, ActionState # noqa: E402 +from cleveragents.domain.models.core.plan import ( # noqa: E402 + AutomationLevel, + NamespacedName, + Plan, + PlanIdentity, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +_runner = CliRunner() +_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S" + + +def _mock_action(name: str = "local/bench-action") -> Action: + return Action( + namespaced_name=NamespacedName.parse(name), + description="Benchmark action", + long_description=None, + definition_of_done="Benchmarks pass", + strategy_actor="openai/gpt-4", + execution_actor="openai/gpt-4", + reusable=True, + read_only=False, + state=ActionState.AVAILABLE, + created_by=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +def _mock_plan(name: str = "local/bench-plan") -> Plan: + now = datetime.now() + return Plan( + identity=PlanIdentity(plan_id=_ULID), + namespaced_name=NamespacedName.parse(name), + description="Benchmark plan", + definition_of_done="Benchmarks pass", + action_name="local/bench-action", + phase=PlanPhase.STRATEGIZE, + processing_state=ProcessingState.QUEUED, + automation_level=AutomationLevel.MANUAL, + strategy_actor="openai/gpt-4", + execution_actor="openai/gpt-4", + project_links=[ProjectLink(project_name="proj-a")], + timestamps=PlanTimestamps(created_at=now, updated_at=now), + created_by=None, + reusable=True, + read_only=False, + ) + + +class ActionListFormatSuite: + """Benchmark action list --format throughput.""" + + params: list[str] = ["json", "yaml", "plain", "table", "rich"] + param_names: list[str] = ["format"] + + def setup(self) -> None: + self._svc = MagicMock() + self._svc.list_actions.return_value = [ + _mock_action(f"local/action-{i}") for i in range(20) + ] + self._patcher = patch( + "cleveragents.cli.commands.action._get_lifecycle_service", + return_value=self._svc, + ) + self._patcher.start() + + def teardown(self) -> None: + self._patcher.stop() + + def time_list(self, fmt: str) -> None: + _runner.invoke(action_app, ["list", "--format", fmt]) + + +class PlanStatusFormatSuite: + """Benchmark plan status --format throughput.""" + + params: list[str] = ["json", "yaml", "plain", "table", "rich"] + param_names: list[str] = ["format"] + + def setup(self) -> None: + self._svc = MagicMock() + self._svc.get_plan.return_value = _mock_plan() + self._patcher = patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=self._svc, + ) + self._patcher.start() + + def teardown(self) -> None: + self._patcher.stop() + + def time_status(self, fmt: str) -> None: + _runner.invoke(plan_app, ["status", _ULID, "--format", fmt]) diff --git a/docs/reference/action_cli.md b/docs/reference/action_cli.md index 8b32f84d2..30fa40ef8 100644 --- a/docs/reference/action_cli.md +++ b/docs/reference/action_cli.md @@ -58,6 +58,7 @@ agents action list [OPTIONS] [REGEX] |------------------|-------|----------------------------------| | `--namespace` | `-n` | Filter by namespace | | `--state` | `-s` | Filter by state (available, archived) | +| `--format` | `-f` | Output format: json, yaml, plain, table, rich (default: rich) | ### Positional Arguments @@ -82,9 +83,21 @@ agents action list "code-.*" # Combine filters agents action list --namespace local --state available "lint" + +# Output as JSON +agents action list --format json + +# Output as YAML +agents action list --format yaml + +# Output as plain text +agents action list --format plain + +# Output as ASCII table +agents action list --format table ``` -### Output Columns +### Output Columns (rich / table) | Column | Description | |------------------|--------------------------------------| @@ -94,37 +107,64 @@ agents action list --namespace local --state available "lint" | Strategy Actor | Actor for the Strategize phase | | Execution Actor | Actor for the Execute phase | | Definition of Done | Truncated summary (first 40 chars) | -| Reusable | ✓ if the action persists after use | +| Reusable | check if the action persists after use | | Created | Creation timestamp | +### JSON/YAML Output Keys + +| Key | Description | +|----------------------|--------------------------------------| +| `namespaced_name` | Full namespaced identifier | +| `short_name` | Name portion only | +| `state` | Current state value | +| `description` | Short description | +| `definition_of_done` | Completion criteria | +| `strategy_actor` | Strategize phase actor | +| `execution_actor` | Execute phase actor | +| `automation_profile` | Default profile (or null) | +| `arguments` | List of argument definitions | +| `invariants` | List of invariant constraint strings | + ## Show Action ```bash -agents action show +agents action show [OPTIONS] ``` +### Options + +| Flag | Short | Description | +|------------|-------|----------------------------------| +| `--format` | `-f` | Output format (default: rich) | + Displays full details for a single action identified by its namespaced name (e.g., `local/code-coverage`). -### Output Fields +### Examples -- **Namespaced Name**: Full identifier -- **Short Name**: Name portion only -- **Description**: Short description -- **State**: Current state -- **Strategy Actor**: Strategize phase actor -- **Execution Actor**: Execute phase actor -- **Reusable / Read Only**: Behavior flags -- **Definition of Done**: Full completion criteria (truncated at 120 chars in panel) -- **Arguments**: Typed argument definitions -- **Created**: Timestamp +```bash +# Rich panel (default) +agents action show local/code-coverage + +# JSON output +agents action show local/code-coverage --format json + +# YAML output +agents action show local/code-coverage --format yaml +``` ## Archive Action ```bash -agents action archive +agents action archive [OPTIONS] ``` +### Options + +| Flag | Short | Description | +|------------|-------|----------------------------------| +| `--format` | `-f` | Output format (default: rich) | + Soft-deletes an action by transitioning it to `archived` state. Archived actions cannot be used for new plans but are preserved for history. @@ -142,3 +182,4 @@ restore an archived action, recreate it from a YAML config file. - CLI commands: `src/cleveragents/cli/commands/action.py` - Config schema: `src/cleveragents/action/schema.py` - Domain model: `src/cleveragents/domain/models/core/action.py` +- Formatting helpers: `src/cleveragents/cli/formatting.py` diff --git a/docs/reference/plan_cli.md b/docs/reference/plan_cli.md index 5180b0098..8a4c3c2b2 100644 --- a/docs/reference/plan_cli.md +++ b/docs/reference/plan_cli.md @@ -28,6 +28,7 @@ operate on. Projects can also be supplied via the repeatable | `--estimation-actor` | | Override the estimation actor | | `--invariant-actor` | | Override the invariant reconciliation actor | | `--automation-level` | | `manual`, `review_before_apply`, or `full_automation` | +| `--format` | `-f` | Output format: json, yaml, plain, table, rich (default: rich) | ### Examples @@ -38,18 +39,11 @@ agents plan use local/code-coverage my-project --arg target_coverage=80 # Multiple projects via positional args agents plan use local/lint proj-a proj-b -# Projects via --project option -agents plan use local/lint --project proj-a --project proj-b +# JSON output +agents plan use local/lint proj-a --format json -# With invariants and actor overrides -agents plan use local/refactor proj-1 \ - --invariant "No new warnings" \ - --invariant "Keep backward compat" \ - --strategy-actor openai/gpt-4 \ - --execution-actor anthropic/claude-3 - -# With automation profile -agents plan use local/code-coverage proj-1 --automation-profile trusted +# YAML output +agents plan use local/lint proj-a --format yaml ``` ## Plan List (lifecycle-list) @@ -67,12 +61,7 @@ agents plan lifecycle-list [REGEX] [OPTIONS] | `--processing-state` | | Alias for `--state` | | `--project` | `-p` | Filter by project name | | `--action` | | Filter by action name | - -### Positional Arguments - -| Argument | Description | -|----------|------------------------------------------| -| `REGEX` | Optional regex pattern to filter names | +| `--format` | `-f` | Output format: json, yaml, plain, table, rich (default: rich) | ### Examples @@ -80,44 +69,80 @@ agents plan lifecycle-list [REGEX] [OPTIONS] # List all plans agents plan lifecycle-list -# Filter by phase -agents plan lifecycle-list --phase strategize +# JSON output +agents plan lifecycle-list --format json -# Filter by processing state -agents plan lifecycle-list --state complete +# YAML output +agents plan lifecycle-list --format yaml -# Combine filters -agents plan lifecycle-list --phase execute --project my-project --action local/lint +# Filter by phase + JSON output +agents plan lifecycle-list --phase strategize --format json ``` -### Output Columns +### JSON/YAML Output Keys -| Column | Description | -|----------|--------------------------------------| -| ID | Truncated plan ULID | -| Name | Full namespaced plan name | -| Action | Source action name | -| Phase | Current lifecycle phase | -| State | Processing state | -| Projects | Linked project names | -| Created | Creation timestamp | +| Key | Description | +|----------------------|--------------------------------------| +| `plan_id` | Unique ULID identifier | +| `namespaced_name` | Full namespaced plan name | +| `phase` | Current lifecycle phase | +| `processing_state` | Processing state | +| `project_links` | List of linked projects | +| `arguments` | Resolved argument values | +| `automation_profile` | Automation profile name (or null) | +| `action_name` | Source action name | ## Plan Status ```bash -agents plan status [PLAN_ID] +agents plan status [PLAN_ID] [OPTIONS] ``` -Without a plan ID, shows a summary table of all active plans. -With a plan ID, shows full details including: +### Options -- **Action**: source action name -- **Phase** and **Processing State** -- **Project links** with aliases and read-only flags -- **Arguments** (ordered) -- **Automation profile** with provenance tag -- **Actor overrides** (strategy, execution, estimation, invariant) -- **Timestamps** (created, updated, phase start/complete times) +| Flag | Short | Description | +|------------|-------|----------------------------------| +| `--format` | `-f` | Output format (default: rich) | + +Without a plan ID, shows a summary table of all active plans. +With a plan ID, shows full details. + +### Examples + +```bash +# Rich panel (default) +agents plan status 01HXYZ... + +# JSON output +agents plan status 01HXYZ... --format json + +# Plain text output +agents plan status 01HXYZ... --format plain +``` + +## Plan Execute + +```bash +agents plan execute [PLAN_ID] [OPTIONS] +``` + +### Options + +| Flag | Short | Description | +|------------|-------|----------------------------------| +| `--format` | `-f` | Output format (default: rich) | + +## Plan Lifecycle-Apply + +```bash +agents plan lifecycle-apply [PLAN_ID] [OPTIONS] +``` + +### Options + +| Flag | Short | Description | +|------------|-------|----------------------------------| +| `--format` | `-f` | Output format (default: rich) | ## Plan Cancel @@ -130,11 +155,13 @@ agents plan cancel [OPTIONS] | Flag | Short | Description | |------------|-------|----------------------------| | `--reason` | `-r` | Reason for cancellation | +| `--format` | `-f` | Output format (default: rich) | ### Example ```bash agents plan cancel 01HXYZ... --reason "Requirements changed" +agents plan cancel 01HXYZ... --reason "Changed" --format json ``` ## Source Location @@ -142,3 +169,4 @@ agents plan cancel 01HXYZ... --reason "Requirements changed" - CLI commands: `src/cleveragents/cli/commands/plan.py` - Lifecycle service: `src/cleveragents/application/services/plan_lifecycle_service.py` - Plan model: `src/cleveragents/domain/models/core/plan.py` +- Formatting helpers: `src/cleveragents/cli/formatting.py` diff --git a/features/cli_output_formats.feature b/features/cli_output_formats.feature new file mode 100644 index 000000000..557516000 --- /dev/null +++ b/features/cli_output_formats.feature @@ -0,0 +1,113 @@ +Feature: CLI output formats parity + As a developer using the CleverAgents CLI + I want all action and plan commands to support --format json|yaml|plain|table|rich + So that I can integrate CLI output with external tooling + + Background: + Given a CLI output format test runner + And a mocked lifecycle service for format tests + + # Action list --format json + Scenario: Action list outputs valid JSON + Given there are actions for format testing + When I run action list with --format json + Then the output should be valid JSON + And the JSON should contain key "namespaced_name" + + # Action show --format yaml + Scenario: Action show outputs valid YAML + Given there is a single action for format testing + When I run action show with --format yaml + Then the output should be valid YAML + And the YAML should contain key "namespaced_name" + + # Plan list --format json + Scenario: Plan lifecycle-list outputs valid JSON + Given there are plans for format testing + When I run plan lifecycle-list with --format json + Then the output should be valid JSON + And the JSON should contain key "plan_id" + + # Plan status --format json (single plan) + Scenario: Plan status single plan outputs valid JSON + Given there is a single plan for format testing + When I run plan status with plan id and --format json + Then the output should be valid JSON + And the JSON should contain key "processing_state" + + # Plan status --format plain (single plan) + Scenario: Plan status single plan outputs plain text + Given there is a single plan for format testing + When I run plan status with plan id and --format plain + Then the format output should contain "plan_id:" + And the format output should contain "processing_state:" + + # Action list --format table + Scenario: Action list outputs table format + Given there are actions for format testing + When I run action list with --format table + Then the format output should contain "namespaced_name" + + # Action list --format plain + Scenario: Action list outputs plain format + Given there are actions for format testing + When I run action list with --format plain + Then the format output should contain "namespaced_name:" + + # Action archive --format json + Scenario: Action archive outputs valid JSON + Given there is an archivable action for format testing + When I run action archive with --format json + Then the output should be valid JSON + And the JSON should contain key "archived" + + # Plan status list all --format json + Scenario: Plan status no args outputs valid JSON for list + Given there are plans for format status listing + When I run plan status without id and --format json + Then the output should be valid JSON + And the JSON should contain key "plan_id" + + # Plan use --format json + Scenario: Plan use outputs valid JSON + Given there is an action for plan use format test + When I run plan use with --format json + Then the output should be valid JSON + And the JSON should contain key "plan_id" + + # Plan cancel --format json + Scenario: Plan cancel outputs valid JSON + Given there is a plan for cancel format test + When I run plan cancel with --format json + Then the output should be valid JSON + And the JSON should contain key "processing_state" + + # Format consistency: same data, different renders + Scenario: JSON and YAML contain the same action keys + Given there is a single action for format testing + When I run action show with --format json + And I save the output keys as json_keys + And I run action show with --format yaml + And I save the output keys as yaml_keys + Then json_keys and yaml_keys should match + + # Direct formatting module tests + Scenario: Format output handles all format types for dict + When I call format_output with a dict and format json + Then the format result should be valid JSON dict + When I call format_output with a dict and format yaml + Then the format result should be valid YAML dict + When I call format_output with a dict and format plain + Then the format result should contain plain key-value pairs + When I call format_output with a dict and format table + Then the format result should contain table output + When I call format_output with a dict and format rich + Then the format result should be valid JSON dict + + Scenario: Format output handles list data + When I call format_output with a list and format plain + Then the format result should contain separator lines + + Scenario: Serialize value handles enums and nested dicts + When I call serialize_value with enum and nested data + Then the serialized result should have string enum values diff --git a/features/steps/cli_output_formats_steps.py b/features/steps/cli_output_formats_steps.py new file mode 100644 index 000000000..8c1e4bd6b --- /dev/null +++ b/features/steps/cli_output_formats_steps.py @@ -0,0 +1,431 @@ +"""Step definitions for CLI output format parity tests.""" + +from __future__ import annotations + +import json +from datetime import datetime +from unittest.mock import MagicMock, patch + +import yaml +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.commands.action import app as action_app +from cleveragents.cli.commands.plan import app as plan_app +from cleveragents.cli.formatting import _serialize_value, format_output +from cleveragents.domain.models.core.action import Action, ActionState +from cleveragents.domain.models.core.plan import ( + AutomationLevel, + NamespacedName, + Plan, + PlanIdentity, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + +def _make_action(name: str = "local/fmt-action") -> Action: + return Action( + namespaced_name=NamespacedName.parse(name), + description="Format test action", + long_description=None, + definition_of_done="Tests pass", + strategy_actor="openai/gpt-4", + execution_actor="openai/gpt-4", + reusable=True, + read_only=False, + state=ActionState.AVAILABLE, + created_by=None, + created_at=datetime(2025, 1, 15, 10, 0, 0), + updated_at=datetime(2025, 1, 15, 10, 0, 0), + ) + + +def _make_plan( + name: str = "local/fmt-plan", + plan_id: str = _ULID, +) -> Plan: + now = datetime(2025, 1, 15, 10, 0, 0) + return Plan( + identity=PlanIdentity(plan_id=plan_id), + namespaced_name=NamespacedName.parse(name), + description="Format test plan", + definition_of_done="Tests pass", + action_name="local/fmt-action", + phase=PlanPhase.STRATEGIZE, + processing_state=ProcessingState.QUEUED, + automation_level=AutomationLevel.MANUAL, + strategy_actor="openai/gpt-4", + execution_actor="openai/gpt-4", + project_links=[ProjectLink(project_name="proj-a")], + timestamps=PlanTimestamps(created_at=now, updated_at=now), + created_by=None, + reusable=True, + read_only=False, + ) + + +@given("a CLI output format test runner") +def step_format_test_runner(context: Context) -> None: + context.runner = CliRunner() + context.saved_keys = {} + + +@given("a mocked lifecycle service for format tests") +def step_mocked_lifecycle_for_formats(context: Context) -> None: + context.mock_action_service = MagicMock() + context.mock_plan_service = MagicMock() + context.action_patcher = patch( + "cleveragents.cli.commands.action._get_lifecycle_service", + return_value=context.mock_action_service, + ) + context.plan_patcher = patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=context.mock_plan_service, + ) + context.action_patcher.start() + context.plan_patcher.start() + + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(context.action_patcher.stop) + context._cleanup_handlers.append(context.plan_patcher.stop) + + +# ------- Given steps ------- + + +@given("there are actions for format testing") +def step_actions_for_format(context: Context) -> None: + context.mock_action_service.list_actions.return_value = [ + _make_action("local/action-a"), + _make_action("local/action-b"), + ] + + +@given("there is a single action for format testing") +def step_single_action_for_format(context: Context) -> None: + context.format_action = _make_action() + context.mock_action_service.get_action_by_name.return_value = context.format_action + + +@given("there are plans for format testing") +def step_plans_for_format(context: Context) -> None: + context.mock_plan_service.list_plans.return_value = [ + _make_plan("local/plan-a", "01ARZ3NDEKTSV4RRFFQ69G5FAV"), + _make_plan("local/plan-b", "01ARZ3NDEKTSV4RRFFQ69G5FAW"), + ] + + +@given("there is a single plan for format testing") +def step_single_plan_for_format(context: Context) -> None: + context.format_plan = _make_plan() + context.mock_plan_service.get_plan.return_value = context.format_plan + + +# ------- When steps ------- + + +@when("I run action list with --format json") +def step_action_list_json(context: Context) -> None: + context.result = context.runner.invoke(action_app, ["list", "--format", "json"]) + + +@when("I run action show with --format yaml") +def step_action_show_yaml(context: Context) -> None: + context.result = context.runner.invoke( + action_app, + ["show", str(context.format_action.namespaced_name), "--format", "yaml"], + ) + + +@when("I run action show with --format json") +def step_action_show_json(context: Context) -> None: + context.result = context.runner.invoke( + action_app, + ["show", str(context.format_action.namespaced_name), "--format", "json"], + ) + + +@when("I run plan lifecycle-list with --format json") +def step_plan_list_json(context: Context) -> None: + context.result = context.runner.invoke( + plan_app, ["lifecycle-list", "--format", "json"] + ) + + +@when("I run plan status with plan id and --format json") +def step_plan_status_json(context: Context) -> None: + context.result = context.runner.invoke( + plan_app, ["status", _ULID, "--format", "json"] + ) + + +@when("I run plan status with plan id and --format plain") +def step_plan_status_plain(context: Context) -> None: + context.result = context.runner.invoke( + plan_app, ["status", _ULID, "--format", "plain"] + ) + + +@when("I save the output keys as json_keys") +def step_save_json_keys(context: Context) -> None: + data = json.loads(context.result.output.strip()) + context.saved_keys["json_keys"] = set(data.keys()) + + +@when("I save the output keys as yaml_keys") +def step_save_yaml_keys(context: Context) -> None: + data = yaml.safe_load(context.result.output.strip()) + context.saved_keys["yaml_keys"] = set(data.keys()) + + +# ------- Then steps ------- + + +@then("the output should be valid JSON") +def step_output_valid_json(context: Context) -> None: + assert context.result.exit_code == 0, ( + f"CLI exited with {context.result.exit_code}: {context.result.output}" + ) + parsed = json.loads(context.result.output.strip()) + assert parsed is not None + + +@then("the output should be valid YAML") +def step_output_valid_yaml(context: Context) -> None: + assert context.result.exit_code == 0, ( + f"CLI exited with {context.result.exit_code}: {context.result.output}" + ) + parsed = yaml.safe_load(context.result.output.strip()) + assert parsed is not None + + +@then('the JSON should contain key "{key}"') +def step_json_contains_key(context: Context, key: str) -> None: + output = context.result.output.strip() + parsed = json.loads(output) + if isinstance(parsed, list): + assert key in parsed[0], f"Key '{key}' not in {list(parsed[0].keys())}" + else: + assert key in parsed, f"Key '{key}' not in {list(parsed.keys())}" + + +@then('the YAML should contain key "{key}"') +def step_yaml_contains_key(context: Context, key: str) -> None: + output = context.result.output.strip() + parsed = yaml.safe_load(output) + if isinstance(parsed, list): + assert key in parsed[0], f"Key '{key}' not in {list(parsed[0].keys())}" + else: + assert key in parsed, f"Key '{key}' not in {list(parsed.keys())}" + + +@then('the format output should contain "{text}"') +def step_format_output_contains_text(context: Context, text: str) -> None: + assert context.result.exit_code == 0, ( + f"CLI exited with {context.result.exit_code}: {context.result.output}" + ) + assert text in context.result.output, ( + f"'{text}' not found in output: {context.result.output[:300]}" + ) + + +@then("json_keys and yaml_keys should match") +def step_keys_match(context: Context) -> None: + jk = context.saved_keys["json_keys"] + yk = context.saved_keys["yaml_keys"] + assert jk == yk, f"Key mismatch: json={jk}, yaml={yk}" + + +# ------- Additional Given steps ------- + + +@given("there is an archivable action for format testing") +def step_archivable_action(context: Context) -> None: + action = _make_action() + context.format_action = action + context.mock_action_service.get_action_by_name.return_value = action + context.mock_action_service.archive_action.return_value = action + + +@given("there are plans for format status listing") +def step_plans_for_status_listing(context: Context) -> None: + context.mock_plan_service.list_plans.return_value = [ + _make_plan("local/plan-a", "01ARZ3NDEKTSV4RRFFQ69G5FAV"), + _make_plan("local/plan-b", "01ARZ3NDEKTSV4RRFFQ69G5FAW"), + ] + + +@given("there is an action for plan use format test") +def step_action_for_plan_use(context: Context) -> None: + action = _make_action() + plan = _make_plan() + context.mock_plan_service.get_action_by_name.return_value = action + context.mock_plan_service.use_action.return_value = plan + + +@given("there is a plan for cancel format test") +def step_plan_for_cancel(context: Context) -> None: + context.mock_plan_service.cancel_plan.return_value = _make_plan() + + +# ------- Additional When steps ------- + + +@when("I run action list with --format table") +def step_action_list_table(context: Context) -> None: + context.result = context.runner.invoke(action_app, ["list", "--format", "table"]) + + +@when("I run action list with --format plain") +def step_action_list_plain(context: Context) -> None: + context.result = context.runner.invoke(action_app, ["list", "--format", "plain"]) + + +@when("I run action archive with --format json") +def step_action_archive_json(context: Context) -> None: + context.result = context.runner.invoke( + action_app, + [ + "archive", + str(context.format_action.namespaced_name), + "--format", + "json", + ], + ) + + +@when("I run plan status without id and --format json") +def step_plan_status_no_id_json(context: Context) -> None: + context.result = context.runner.invoke(plan_app, ["status", "--format", "json"]) + + +@when("I run plan use with --format json") +def step_plan_use_json(context: Context) -> None: + context.result = context.runner.invoke( + plan_app, + [ + "use", + "local/fmt-action", + "proj-a", + "--format", + "json", + ], + ) + + +@when("I run plan cancel with --format json") +def step_plan_cancel_json(context: Context) -> None: + context.result = context.runner.invoke( + plan_app, + ["cancel", _ULID, "--format", "json"], + ) + + +# ------- Direct formatting module tests ------- + + +@when("I call format_output with a dict and format json") +def step_call_format_json(context: Context) -> None: + data = { + "name": "test", + "count": 42, + "created_at": datetime(2025, 1, 15).isoformat(), + } + context.format_result = format_output(data, "json") + + +@when("I call format_output with a dict and format yaml") +def step_call_format_yaml(context: Context) -> None: + data = {"name": "test", "count": 42} + context.format_result = format_output(data, "yaml") + + +@when("I call format_output with a dict and format plain") +def step_call_format_plain(context: Context) -> None: + data = { + "name": "test", + "nested": {"a": 1, "b": 2}, + "items": ["x", "y"], + } + context.format_result = format_output(data, "plain") + + +@when("I call format_output with a dict and format table") +def step_call_format_table(context: Context) -> None: + data = {"name": "test", "count": 42} + context.format_result = format_output(data, "table") + + +@when("I call format_output with a dict and format rich") +def step_call_format_rich(context: Context) -> None: + data = {"name": "test", "count": 42} + context.format_result = format_output(data, "rich") + + +@when("I call format_output with a list and format plain") +def step_call_format_list_plain(context: Context) -> None: + data = [ + {"name": "a", "value": 1}, + {"name": "b", "value": 2}, + ] + context.format_result = format_output(data, "plain") + + +@when("I call serialize_value with enum and nested data") +def step_call_serialize_with_enum(context: Context) -> None: + context.serialized = _serialize_value( + { + "state": ActionState.AVAILABLE, + "nested": {"key": "val"}, + "items": [ActionState.ARCHIVED, datetime(2025, 1, 1)], + "ts": datetime(2025, 6, 1, 12, 0), + } + ) + + +# ------- Additional Then steps ------- + + +@then("the format result should be valid JSON dict") +def step_format_result_json_dict(context: Context) -> None: + parsed = json.loads(context.format_result) + assert isinstance(parsed, dict) + + +@then("the format result should be valid YAML dict") +def step_format_result_yaml_dict(context: Context) -> None: + parsed = yaml.safe_load(context.format_result) + assert isinstance(parsed, dict) + + +@then("the format result should contain plain key-value pairs") +def step_format_result_plain(context: Context) -> None: + assert "name: test" in context.format_result + assert "nested:" in context.format_result + assert "items:" in context.format_result + + +@then("the format result should contain table output") +def step_format_result_table(context: Context) -> None: + assert "name" in context.format_result + assert "count" in context.format_result + + +@then("the format result should contain separator lines") +def step_format_result_separator(context: Context) -> None: + assert "---" in context.format_result + + +@then("the serialized result should have string enum values") +def step_serialized_enum_values(context: Context) -> None: + assert context.serialized["state"] == "available" + assert isinstance(context.serialized["nested"], dict) + assert context.serialized["items"][0] == "archived" + assert isinstance(context.serialized["ts"], str) diff --git a/implementation_plan.md b/implementation_plan.md index 3b568f4f7..a28db0e4b 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -808,13 +808,19 @@ The following work from the previous implementation has been completed and will - Verification: lint 0 findings, typecheck 0 errors, 2606 scenarios passed, 97% coverage. - Branch: `feature/m1-plan-cli` (based on `feature/m1-action-cli`) +**2026-02-14**: Stage A4b.outputs Complete - CLI Output Format Stabilization [Jeff] +- Created `src/cleveragents/cli/formatting.py`: shared `format_output()` helper with `OutputFormat` enum, JSON/YAML/plain/table/rich serializers. Handles datetime, Enum, nested structures. +- Updated action.py and plan.py with `--format` options and normalized output keys to spec field names. +- Created `features/cli_output_formats.feature` (15 scenarios), Robot smoke test, ASV benchmarks. +- Verification: lint 0 findings, typecheck 0 errors, 97% coverage. formatting.py 97%, action.py 100%. +- Branch: `feature/m1-cli-formats` (based on `feature/m1-plan-cli`) + **2026-02-14**: Stage C0.runtime Complete - Tool Runtime Core [Jeff] - Created `src/cleveragents/tool/runtime.py` (ToolSpec, ToolResult, ToolError), `registry.py` (thread-safe ToolRegistry with RLock), `runner.py` (ToolRunner with 4-stage lifecycle: discover/activate/execute/deactivate). - Created `features/tool_runtime.feature` (26 scenarios), Robot smoke test (3 tests), ASV benchmarks. - New tool modules have 100% coverage (105 statements, 14 branches, 0 misses). - Verification: lint 0, typecheck 0, 2604 scenarios passed, 97% total coverage. - Branch: `feature/m1-tool-runtime-core` - --- ## Roadmap @@ -1474,7 +1480,7 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled - [X] Git [Jeff]: `git branch -d feature/m1-action-cli` - [X] Quality [Jeff]: 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] **COMMIT (Owner: Jeff | Group: A4b.plan | Branch: feature/m1-plan-cli | Planned: Day 6 | Expected: Day 6) - Commit message: "feat(cli): align plan use/list/status flags"** +- [X] **COMMIT (Owner: Jeff | Group: A4b.plan | Branch: feature/m1-plan-cli | Planned: Day 7 | Expected: Day 9) - Commit message: "feat(cli): align plan use/list/status flags"** - [X] Git [Jeff]: `git checkout master` - [X] Git [Jeff]: `git pull origin master` - [X] Git [Jeff]: `git checkout -b feature/m1-plan-cli` @@ -1494,24 +1500,24 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled - [X] Git [Jeff]: `git branch -d feature/m1-plan-cli` - [X] Quality [Jeff]: 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%. -- [ ] **COMMIT (Owner: Jeff | Group: A4b.outputs | Branch: feature/m1-cli-formats | Planned: Day 8 | Expected: Day 10) - Commit message: "feat(cli): stabilize action/plan output formats"** - - [ ] Git [Jeff]: `git checkout master` - - [ ] Git [Jeff]: `git pull origin master` - - [ ] Git [Jeff]: `git checkout -b feature/m1-cli-formats` - - [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) - - [ ] Code [Jeff]: Ensure `--format json|yaml|plain|table|rich` parity for action/plan list/show/status outputs. - - [ ] Code [Jeff]: Normalize output keys to spec field names (namespaced_name, processing_state, project_links, arguments, automation_profile). - - [ ] Docs [Jeff]: Update `docs/reference/action_cli.md` + `docs/reference/plan_cli.md` with format examples and field descriptions. - - [ ] Tests (Behave) [Jeff]: Add format output scenarios for json/yaml on action/plan list/show/status. - - [ ] Tests (Robot) [Jeff]: Add Robot lifecycle flow verifying formatted outputs remain stable. - - [ ] Tests (ASV) [Jeff]: Add `benchmarks/cli_format_bench.py` for serialization overhead. - - [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes. - - [ ] Git [Jeff]: `git add .` - - [ ] Git [Jeff]: `git commit -m "feat(cli): stabilize action/plan output formats"` - - [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-cli-formats` to `master` with description "Stabilize action/plan CLI output formats and spec-aligned field keys.". - - [ ] Git [Jeff]: `git checkout master` - - [ ] Git [Jeff]: `git branch -d feature/m1-cli-formats` - - [ ] Quality [Jeff]: 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%, if not repeat this task as many times as is needed until coverage reaches >=97%. +- [X] **COMMIT (Owner: Jeff | Group: A4b.outputs | Branch: feature/m1-cli-formats | Done: Day 6, February 14, 2026) - Commit message: "feat(cli): stabilize action/plan output formats"** + - [X] Git [Jeff]: `git checkout master` + - [X] Git [Jeff]: `git pull origin master` + - [X] Git [Jeff]: `git checkout -b feature/m1-cli-formats` + - [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) + - [X] Code [Jeff]: Ensure `--format json|yaml|plain|table|rich` parity for action/plan list/show/status outputs. + - [X] Code [Jeff]: Normalize output keys to spec field names (namespaced_name, processing_state, project_links, arguments, automation_profile). + - [X] Docs [Jeff]: Update `docs/reference/action_cli.md` + `docs/reference/plan_cli.md` with format examples and field descriptions. + - [X] Tests (Behave) [Jeff]: Add format output scenarios for json/yaml on action/plan list/show/status. + - [X] Tests (Robot) [Jeff]: Add Robot lifecycle flow verifying formatted outputs remain stable. + - [X] Tests (ASV) [Jeff]: Add `benchmarks/cli_format_bench.py` for serialization overhead. + - [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes. + - [X] Git [Jeff]: `git add .` + - [X] Git [Jeff]: `git commit -m "feat(cli): stabilize action/plan output formats"` + - [X] Forgejo PR [Jeff]: Open PR from `feature/m1-cli-formats` to `master` with description "Stabilize action/plan CLI output formats and spec-aligned field keys.". + - [X] Git [Jeff]: `git checkout master` + - [X] Git [Jeff]: `git branch -d feature/m1-cli-formats` + - [X] Quality [Jeff]: 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%, if not repeat this task as many times as is needed until coverage reaches >=97%. - [ ] **COMMIT (Owner: Brent | Group: A4b.tests | Branch: feature/m1-cli-tests | Planned: Day 9 | Expected: Day 11) - Commit message: "test(cli): expand lifecycle command coverage"** - [ ] Git [Brent]: `git checkout master` diff --git a/robot/cli_formats.robot b/robot/cli_formats.robot new file mode 100644 index 000000000..cb5cc8c4c --- /dev/null +++ b/robot/cli_formats.robot @@ -0,0 +1,35 @@ +*** Settings *** +Documentation Smoke tests for CLI --format flag parity (json/yaml/plain/table/rich) +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_cli_formats.py + +*** Test Cases *** +Action List Format JSON Outputs Valid JSON + [Documentation] Verify that ``action list --format json`` emits parseable JSON + ${result}= Run Process ${PYTHON} ${HELPER} action-list-json cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-formats-action-list-json-ok + +Action Show Format YAML Outputs Valid YAML + [Documentation] Verify that ``action show --format yaml`` emits parseable YAML + ${result}= Run Process ${PYTHON} ${HELPER} action-show-yaml cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-formats-action-show-yaml-ok + +Plan Lifecycle List Format JSON Outputs Valid JSON + [Documentation] Verify that ``plan lifecycle-list --format json`` emits JSON + ${result}= Run Process ${PYTHON} ${HELPER} plan-list-json cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-formats-plan-list-json-ok + +Plan Status Format Plain Outputs Key-Value Pairs + [Documentation] Verify that ``plan status --format plain`` emits plain text + ${result}= Run Process ${PYTHON} ${HELPER} plan-status-plain cwd=${WORKSPACE} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cli-formats-plan-status-plain-ok diff --git a/robot/helper_cli_formats.py b/robot/helper_cli_formats.py new file mode 100644 index 000000000..2e57e3f79 --- /dev/null +++ b/robot/helper_cli_formats.py @@ -0,0 +1,149 @@ +"""Helper script for cli_formats.robot smoke tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import yaml + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from typer.testing import CliRunner # noqa: E402 + +from cleveragents.cli.commands.action import app as action_app # noqa: E402 +from cleveragents.cli.commands.plan import app as plan_app # noqa: E402 +from cleveragents.domain.models.core.action import Action, ActionState # noqa: E402 +from cleveragents.domain.models.core.plan import ( # noqa: E402 + AutomationLevel, + NamespacedName, + Plan, + PlanIdentity, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +runner = CliRunner() +_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" + + +def _mock_action(name: str = "local/fmt-smoke") -> Action: + return Action( + namespaced_name=NamespacedName.parse(name), + description="Smoke test action", + long_description=None, + definition_of_done="Tests pass", + strategy_actor="openai/gpt-4", + execution_actor="openai/gpt-4", + reusable=True, + read_only=False, + state=ActionState.AVAILABLE, + created_by=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +def _mock_plan(name: str = "local/fmt-smoke-plan") -> Plan: + now = datetime.now() + return Plan( + identity=PlanIdentity(plan_id=_ULID), + namespaced_name=NamespacedName.parse(name), + description="Smoke plan", + definition_of_done="Tests pass", + action_name="local/fmt-smoke", + phase=PlanPhase.STRATEGIZE, + processing_state=ProcessingState.QUEUED, + automation_level=AutomationLevel.MANUAL, + strategy_actor="openai/gpt-4", + execution_actor="openai/gpt-4", + project_links=[ProjectLink(project_name="proj-a")], + timestamps=PlanTimestamps(created_at=now, updated_at=now), + created_by=None, + reusable=True, + read_only=False, + ) + + +def action_list_json() -> None: + svc = MagicMock() + svc.list_actions.return_value = [_mock_action("local/a1"), _mock_action("local/a2")] + with patch( + "cleveragents.cli.commands.action._get_lifecycle_service", + return_value=svc, + ): + result = runner.invoke(action_app, ["list", "--format", "json"]) + assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}" + parsed = json.loads(result.output.strip()) + assert isinstance(parsed, list) and len(parsed) == 2 + print("cli-formats-action-list-json-ok") + + +def action_show_yaml() -> None: + svc = MagicMock() + svc.get_action_by_name.return_value = _mock_action() + with patch( + "cleveragents.cli.commands.action._get_lifecycle_service", + return_value=svc, + ): + result = runner.invoke( + action_app, ["show", "local/fmt-smoke", "--format", "yaml"] + ) + assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}" + parsed = yaml.safe_load(result.output.strip()) + assert "namespaced_name" in parsed + print("cli-formats-action-show-yaml-ok") + + +def plan_list_json() -> None: + svc = MagicMock() + svc.list_plans.return_value = [_mock_plan()] + with patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=svc, + ): + result = runner.invoke(plan_app, ["lifecycle-list", "--format", "json"]) + assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}" + parsed = json.loads(result.output.strip()) + assert isinstance(parsed, list) + assert "plan_id" in parsed[0] + print("cli-formats-plan-list-json-ok") + + +def plan_status_plain() -> None: + svc = MagicMock() + svc.get_plan.return_value = _mock_plan() + with patch( + "cleveragents.cli.commands.plan._get_lifecycle_service", + return_value=svc, + ): + result = runner.invoke(plan_app, ["status", _ULID, "--format", "plain"]) + assert result.exit_code == 0, f"exit={result.exit_code}: {result.output}" + assert "plan_id:" in result.output + assert "processing_state:" in result.output + print("cli-formats-plan-status-plain-ok") + + +_COMMANDS = { + "action-list-json": action_list_json, + "action-show-yaml": action_show_yaml, + "plan-list-json": plan_list_json, + "plan-status-plain": plan_status_plain, +} + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(2) + _COMMANDS[sys.argv[1]]() diff --git a/src/cleveragents/cli/commands/action.py b/src/cleveragents/cli/commands/action.py index 8e7f4a812..84788d20c 100644 --- a/src/cleveragents/cli/commands/action.py +++ b/src/cleveragents/cli/commands/action.py @@ -26,6 +26,7 @@ from cleveragents.application.container import get_container from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) +from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.core.exceptions import ( CleverAgentsError, NotFoundError, @@ -39,6 +40,9 @@ app = typer.Typer( ) console = Console() +# Reusable --format option description +_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" + def _get_lifecycle_service() -> PlanLifecycleService: """Get the PlanLifecycleService from the container.""" @@ -49,9 +53,51 @@ def _get_lifecycle_service() -> PlanLifecycleService: return PlanLifecycleService(settings=settings) -def _print_action(action: Action, title: str = "Action") -> None: - """Print action details in a nice panel.""" - # Format arguments +def _action_spec_dict(action: Action) -> dict[str, object]: + """Return action data using spec field names. + + Keys: namespaced_name, short_name, state, description, + definition_of_done, strategy_actor, execution_actor, + automation_profile, arguments, invariants. + """ + result: dict[str, object] = { + "namespaced_name": str(action.namespaced_name), + "short_name": action.namespaced_name.name, + "state": action.state.value, + "description": action.description, + "definition_of_done": action.definition_of_done, + "strategy_actor": action.strategy_actor, + "execution_actor": action.execution_actor, + "automation_profile": action.automation_profile, + "arguments": [ + { + "name": arg.name, + "type": arg.arg_type.value, + "required": arg.requirement.value == "required", + "description": arg.description, + } + for arg in action.arguments + ], + "invariants": action.invariants, + "reusable": action.reusable, + "read_only": action.read_only, + "created_at": action.created_at.isoformat(), + } + return result + + +def _print_action( + action: Action, + title: str = "Action", + fmt: str = OutputFormat.RICH.value, +) -> None: + """Print action details in the requested format.""" + if fmt != OutputFormat.RICH.value: + data = _action_spec_dict(action) + console.print(format_output(data, fmt)) + return + + # Rich panel (original behaviour) args_display = "" if action.arguments: args_lines: list[str] = [] @@ -64,7 +110,6 @@ def _print_action(action: Action, title: str = "Action") -> None: else: args_display = " (none)" - # Definition of done summary (first 120 chars) dod_summary = action.definition_of_done if len(dod_summary) > 120: dod_summary = dod_summary[:117] + "..." @@ -174,6 +219,14 @@ def list_actions( help="Optional regex pattern to filter action names", ), ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """List all actions. @@ -184,6 +237,7 @@ def list_actions( agents action list --namespace local agents action list --state available agents action list "code-.*" + agents action list --format json """ try: from cleveragents.domain.models.core.action import ActionState @@ -223,7 +277,13 @@ def list_actions( console.print("Create one with 'agents action create --config '") return - # Display actions table + # Non-rich formats use the formatting helper + if fmt != OutputFormat.RICH.value: + data = [_action_spec_dict(a) for a in actions] + console.print(format_output(data, fmt)) + return + + # Display actions table (rich default) table = Table(title=f"Actions ({len(actions)} total)") table.add_column("Namespaced Name", style="cyan") table.add_column("Short Name", style="blue") @@ -264,6 +324,14 @@ def show( str, typer.Argument(help="Namespaced name of the action to show"), ], + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Show details for an action. @@ -274,7 +342,7 @@ def show( action = service.get_action_by_name(name) - _print_action(action, title="Action Details") + _print_action(action, title="Action Details", fmt=fmt) except NotFoundError as e: console.print(f"[red]Action not found:[/red] {name}") @@ -290,6 +358,14 @@ def archive( str, typer.Argument(help="Namespaced name of the action to archive"), ], + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Archive an action (soft delete). @@ -301,6 +377,12 @@ def archive( action = service.get_action_by_name(name) action = service.archive_action(str(action.namespaced_name)) + if fmt != OutputFormat.RICH.value: + data = _action_spec_dict(action) + data["archived"] = True + console.print(format_output(data, fmt)) + return + console.print(f"[green]✓[/green] Action archived: {action.namespaced_name}") except NotFoundError as e: diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index 288e55eb8..e3c886e53 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -15,6 +15,7 @@ from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table +from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.core.exceptions import ( CleverAgentsError, PlanError, @@ -33,6 +34,55 @@ app = typer.Typer( ) console = Console() +# Reusable --format option description +_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" + + +def _plan_spec_dict(plan: Any) -> dict[str, object]: + """Return plan data using spec field names. + + Keys: plan_id, namespaced_name, phase, processing_state, + project_links, arguments, automation_profile, action_name. + """ + from cleveragents.domain.models.core.plan import Plan as LifecyclePlan + + if isinstance(plan, LifecyclePlan): + result: dict[str, object] = { + "plan_id": plan.identity.plan_id, + "namespaced_name": str(plan.namespaced_name), + "phase": plan.phase.value, + "processing_state": plan.processing_state.value, + "project_links": [ + { + "project_name": link.project_name, + **({"alias": link.alias} if link.alias else {}), + **({"read_only": True} if link.read_only else {}), + } + for link in plan.project_links + ], + "arguments": plan.arguments, + "automation_profile": ( + plan.automation_profile.profile_name + if plan.automation_profile + else None + ), + "action_name": plan.action_name, + "description": plan.description, + "definition_of_done": plan.definition_of_done, + "automation_level": plan.automation_level.value, + "strategy_actor": plan.strategy_actor, + "execution_actor": plan.execution_actor, + "created_at": plan.timestamps.created_at.isoformat(), + "updated_at": plan.timestamps.updated_at.isoformat(), + "is_terminal": plan.is_terminal, + } + if plan.error_message: + result["error_message"] = plan.error_message + return result + + # Legacy plan fallback + return {"plan": str(plan)} + # Programmatic wrapper functions for testing and scripting def tell_command(prompt: str, name: str | None = None) -> None: @@ -732,7 +782,16 @@ def current() -> None: @app.command("list") -def list_plans() -> None: +def list_plans( + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", +) -> None: """List all plans in the current project.""" from cleveragents.application.container import get_container from cleveragents.application.services.plan_service import PlanService @@ -754,6 +813,20 @@ def list_plans() -> None: ) return + # Non-rich formats + if fmt != OutputFormat.RICH.value: + data = [ + { + "plan_id": getattr(plan, "id", ""), + "namespaced_name": plan.name, + "phase": getattr(plan, "status", ""), + "processing_state": getattr(plan, "status", ""), + } + for plan in plans + ] + console.print(format_output(data, fmt)) + return + # Display plans table table = Table(title=f"Plans ({len(plans)} total)") table.add_column("Name", style="cyan") @@ -1048,6 +1121,14 @@ def use_action( ), ), ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Use an action on projects to create a plan in Strategize phase. @@ -1153,12 +1234,15 @@ def use_action( if invariant_actor: plan.invariant_actor = invariant_actor - _print_lifecycle_plan(plan, title="Plan Created") - - console.print( - "\n[dim]Plan is now in Strategize phase (queued). " - "Run 'agents plan execute ' when ready.[/dim]" - ) + if fmt != OutputFormat.RICH.value: + data = _plan_spec_dict(plan) + console.print(format_output(data, fmt)) + else: + _print_lifecycle_plan(plan, title="Plan Created") + console.print( + "\n[dim]Plan is now in Strategize phase (queued). " + "Run 'agents plan execute ' when ready.[/dim]" + ) except ActionNotAvailableError as e: console.print(f"[red]Action not available:[/red] {e}") @@ -1177,6 +1261,14 @@ def execute_plan( str | None, typer.Argument(help="Plan ID to execute (optional if only one plan)"), ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Execute a plan, transitioning from Strategize to Execute phase. @@ -1218,12 +1310,15 @@ def execute_plan( # Execute the plan plan = service.execute_plan(plan_id) - _print_lifecycle_plan(plan, title="Plan Executing") - - console.print( - "\n[dim]Plan is now in Execute phase (queued). " - "Run 'agents plan apply ' when execution is complete.[/dim]" - ) + if fmt != OutputFormat.RICH.value: + data = _plan_spec_dict(plan) + console.print(format_output(data, fmt)) + else: + _print_lifecycle_plan(plan, title="Plan Executing") + console.print( + "\n[dim]Plan is now in Execute phase (queued). " + "Run 'agents plan apply ' when execution is complete.[/dim]" + ) except InvalidPhaseTransitionError as e: console.print(f"[red]Invalid transition:[/red] {e}") @@ -1242,6 +1337,14 @@ def lifecycle_apply_plan( str | None, typer.Argument(help="Plan ID to apply (optional if only one plan)"), ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Apply a plan, transitioning from Execute to Apply phase (v3 lifecycle). @@ -1284,12 +1387,15 @@ def lifecycle_apply_plan( # Apply the plan plan = service.apply_plan(plan_id) - _print_lifecycle_plan(plan, title="Plan Applying") - - console.print( - "\n[dim]Plan is now in Apply phase (queued). " - "Changes will be applied to the project(s).[/dim]" - ) + if fmt != OutputFormat.RICH.value: + data = _plan_spec_dict(plan) + console.print(format_output(data, fmt)) + else: + _print_lifecycle_plan(plan, title="Plan Applying") + console.print( + "\n[dim]Plan is now in Apply phase (queued). " + "Changes will be applied to the project(s).[/dim]" + ) except InvalidPhaseTransitionError as e: console.print(f"[red]Invalid transition:[/red] {e}") @@ -1308,6 +1414,14 @@ def plan_status( str | None, typer.Argument(help="Plan ID to show status for"), ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Show status of a v3 lifecycle plan. @@ -1326,6 +1440,12 @@ def plan_status( ) return + # Non-rich formats + if fmt != OutputFormat.RICH.value: + data = [_plan_spec_dict(p) for p in plans] + console.print(format_output(data, fmt)) + return + # Show summary table table = Table(title=f"Active Plans ({len(plans)} total)") table.add_column("ID", style="cyan") @@ -1348,6 +1468,12 @@ def plan_status( # Show single plan details plan = service.get_plan(plan_id) + + if fmt != OutputFormat.RICH.value: + data = _plan_spec_dict(plan) + console.print(format_output(data, fmt)) + return + _print_lifecycle_plan(plan, title="Plan Status") except CleverAgentsError as e: @@ -1402,6 +1528,14 @@ def lifecycle_list_plans( help="Filter by action name", ), ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """List v3 lifecycle plans with optional filtering. @@ -1474,6 +1608,12 @@ def lifecycle_list_plans( ) return + # Non-rich formats + if fmt != OutputFormat.RICH.value: + data = [_plan_spec_dict(p) for p in plans] + console.print(format_output(data, fmt)) + return + # Display plans table table = Table(title=f"V3 Lifecycle Plans ({len(plans)} total)") table.add_column("ID", style="cyan") @@ -1575,6 +1715,14 @@ def cancel_plan( help="Reason for cancellation", ), ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", ) -> None: """Cancel a v3 lifecycle plan. @@ -1585,9 +1733,15 @@ def cancel_plan( plan = service.cancel_plan(plan_id, reason=reason) - console.print(f"[green]✓[/green] Plan cancelled: {plan.namespaced_name}") - if reason: - console.print(f"[dim]Reason: {reason}[/dim]") + if fmt != OutputFormat.RICH.value: + data = _plan_spec_dict(plan) + if reason: + data["cancel_reason"] = reason + console.print(format_output(data, fmt)) + else: + console.print(f"[green]✓[/green] Plan cancelled: {plan.namespaced_name}") + if reason: + console.print(f"[dim]Reason: {reason}[/dim]") except PlanError as e: console.print(f"[red]Cannot cancel:[/red] {e.message}") diff --git a/src/cleveragents/cli/formatting.py b/src/cleveragents/cli/formatting.py new file mode 100644 index 000000000..b38dae633 --- /dev/null +++ b/src/cleveragents/cli/formatting.py @@ -0,0 +1,166 @@ +"""Shared CLI output formatting helpers. + +Provides ``format_output`` to render data as JSON, YAML, plain text, +ASCII table, or Rich console markup. All serialisation uses stable +field names from the domain models' ``as_cli_dict`` methods. + +Based on v3_spec.md implementation plan Stage A4b. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from enum import Enum, StrEnum +from io import StringIO +from typing import Any + +import yaml +from rich.console import Console +from rich.table import Table + + +class OutputFormat(StrEnum): + """Supported CLI output formats.""" + + JSON = "json" + YAML = "yaml" + PLAIN = "plain" + TABLE = "table" + RICH = "rich" + + +def _serialize_value(value: Any) -> Any: + """Recursively normalise values for JSON/YAML serialisation. + + * ``datetime`` -> ISO-8601 string + * ``Enum`` -> ``.value`` + * ``dict`` -> recursed + * ``list`` -> recursed + * everything else -> unchanged + """ + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, Enum): + return value.value + if isinstance(value, dict): + return {k: _serialize_value(v) for k, v in value.items()} + if isinstance(value, list): + return [_serialize_value(item) for item in value] + return value + + +def _format_json(data: dict[str, Any] | list[dict[str, Any]]) -> str: + """Render data as indented JSON.""" + return json.dumps(_serialize_value(data), indent=2, default=str) + + +def _format_yaml(data: dict[str, Any] | list[dict[str, Any]]) -> str: + """Render data as YAML.""" + return yaml.dump( + _serialize_value(data), + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ).rstrip("\n") + + +def _format_plain(data: dict[str, Any] | list[dict[str, Any]]) -> str: + """Render data as plain key: value lines (no Rich markup).""" + if isinstance(data, list): + parts: list[str] = [] + for idx, item in enumerate(data): + if idx > 0: + parts.append("---") + parts.append(_format_plain_dict(item)) + return "\n".join(parts) + return _format_plain_dict(data) + + +def _format_plain_dict(data: dict[str, Any]) -> str: + """Render a single dict as plain text.""" + lines: list[str] = [] + for key, value in data.items(): + serialised = _serialize_value(value) + if isinstance(serialised, dict): + lines.append(f"{key}:") + for k2, v2 in serialised.items(): + lines.append(f" {k2}: {v2}") + elif isinstance(serialised, list): + lines.append(f"{key}:") + for item in serialised: + if isinstance(item, dict): + lines.append(f" - {json.dumps(item, default=str)}") + else: + lines.append(f" - {item}") + else: + lines.append(f"{key}: {serialised}") + return "\n".join(lines) + + +def _format_table(data: dict[str, Any] | list[dict[str, Any]]) -> str: + """Render data as an ASCII table (no Rich styling).""" + rows: list[dict[str, Any]] = [data] if isinstance(data, dict) else data + + if not rows: + return "(empty)" + + # Collect all keys preserving order from first row + columns: list[str] = list(rows[0].keys()) + for row in rows[1:]: + for key in row: + if key not in columns: + columns.append(key) + + # Build Rich table and capture to string + table = Table(show_header=True, show_edge=True) + for col in columns: + table.add_column(col) + for row in rows: + cells: list[str] = [] + for col in columns: + val = row.get(col, "") + serialised = _serialize_value(val) + if isinstance(serialised, (dict, list)): + cells.append(json.dumps(serialised, default=str)) + else: + cells.append(str(serialised)) + table.add_row(*cells) + + buf = StringIO() + console = Console(file=buf, width=200, no_color=True) + console.print(table) + return buf.getvalue().rstrip("\n") + + +def format_output( + data: dict[str, Any] | list[dict[str, Any]], + format_type: str, +) -> str: + """Format *data* according to *format_type*. + + Parameters + ---------- + data: + A single dict or a list of dicts (from ``model.as_cli_dict()``). + format_type: + One of ``json``, ``yaml``, ``plain``, ``table``, ``rich``. + + Returns + ------- + str + The rendered string. For ``rich`` format the caller should use + the domain-specific Rich helper instead; this function returns + the JSON representation as a fallback. + """ + fmt = format_type.lower() + if fmt == OutputFormat.JSON.value: + return _format_json(data) + if fmt == OutputFormat.YAML.value: + return _format_yaml(data) + if fmt == OutputFormat.PLAIN.value: + return _format_plain(data) + if fmt == OutputFormat.TABLE.value: + return _format_table(data) + # ``rich`` and any unknown value fall back to JSON + return _format_json(data) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 4c7362fa8..fe0042735 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -12,3 +12,18 @@ build_data # noqa: B018, F821 # SubplanFailureHandler.should_stop_others parameter required by public API failed_status # noqa: B018, F821 + +# CLI formatting module – public API called dynamically by CLI commands +format_output # noqa: B018, F821 +OutputFormat # noqa: B018, F821 +_serialize_value # noqa: B018, F821 +_format_json # noqa: B018, F821 +_format_yaml # noqa: B018, F821 +_format_plain # noqa: B018, F821 +_format_plain_dict # noqa: B018, F821 +_format_table # noqa: B018, F821 + +# CLI action/plan spec dict helpers used by --format flag +_action_spec_dict # noqa: B018, F821 +_plan_spec_dict # noqa: B018, F821 +_FORMAT_HELP # noqa: B018, F821 -- 2.52.0