feature/m1-cli-formats #60

Merged
freemo merged 1 commits from feature/m1-cli-formats into master 2026-02-14 18:52:01 +00:00
12 changed files with 1452 additions and 106 deletions
+126
View File
@@ -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])
+56 -15
View File
@@ -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 <NAME>
agents action show <NAME> [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 <NAME>
agents action archive <NAME> [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`
+71 -43
View File
@@ -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 <PLAN_ID> [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`
+113
View File
@@ -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
+431
View File
@@ -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)
+26 -20
View File
@@ -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`
+35
View File
@@ -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 <id> --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
+149
View File
@@ -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]]()
+88 -6
View File
@@ -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 <file>'")
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:
+176 -22
View File
@@ -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 <id>' 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 <id>' 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 <id>' 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 <id>' 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}")
+166
View File
@@ -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)
+15
View File
@@ -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