Compare commits

...

4 Commits

Author SHA1 Message Date
brent.edwards 11c246b7c0 feat: merge branch 'master' into feature/m1-cli-tests
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 29s
CI / security (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 5m31s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 16m10s
CI / coverage (pull_request) Failing after 8m43s
CI / docker (pull_request) Successful in 9s
2026-02-18 00:57:00 +00:00
brent.edwards 60d10cef7c Merge branch 'master' into feature/m1-cli-tests
CI / lint (pull_request) Waiting to run
CI / typecheck (pull_request) Waiting to run
CI / security (pull_request) Waiting to run
CI / quality (pull_request) Waiting to run
CI / unit_tests (pull_request) Waiting to run
CI / integration_tests (pull_request) Waiting to run
CI / coverage (pull_request) Blocked by required conditions
CI / build (pull_request) Waiting to run
CI / docker (pull_request) Blocked by required conditions
2026-02-18 00:42:19 +00:00
brent.edwards 9db88a9541 fix(test): commit edge row so delete step sees it in its own session
CI / lint (pull_request) Successful in 19s
CI / typecheck (pull_request) Successful in 30s
CI / coverage (pull_request) Waiting to run
CI / quality (pull_request) Successful in 16s
CI / security (pull_request) Successful in 39s
CI / build (pull_request) Successful in 21s
CI / integration_tests (pull_request) Successful in 7m32s
CI / unit_tests (pull_request) Successful in 15m52s
CI / docker (pull_request) Waiting to run
The "Deleting a resource with edges is rejected" scenario was failing
because the ResourceEdgeModel row was flushed but never committed.
Since ResourceRepository.delete() obtains its own session from the
factory, the uncommitted edge was invisible and ResourceHasEdgesError
was never raised.
2026-02-17 23:38:45 +00:00
brent.edwards b8d2bfe252 test(cli): expand lifecycle command coverage
CI / lint (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 35s
CI / security (pull_request) Successful in 36s
CI / quality (pull_request) Successful in 19s
CI / build (pull_request) Successful in 26s
CI / integration_tests (pull_request) Successful in 5m19s
CI / unit_tests (pull_request) Failing after 32m35s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Failing after 12m0s
2026-02-17 20:01:18 +00:00
8 changed files with 2263 additions and 15 deletions
+304
View File
@@ -0,0 +1,304 @@
"""ASV benchmarks for CLI argument parsing overhead.
Measures the performance of:
- Action CLI argument parsing (create, list, show, archive)
- Plan CLI argument parsing (use with various flag combinations)
- Plan lifecycle commands (execute, lifecycle-apply, status, cancel)
These benchmarks focus on CLI overhead (argument parsing + routing)
rather than service layer execution time, since the service is mocked.
"""
from __future__ import annotations
import importlib
import os
import sys
import tempfile
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,
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_runner = CliRunner()
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
_VALID_YAML = """\
name: local/bench-smoke-action
description: Smoke bench action
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: Benchmarks pass
"""
def _mock_action(name: str = "local/bench-smoke-action") -> Action:
return Action(
namespaced_name=NamespacedName.parse(name),
description="Smoke bench 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-smoke-plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
) -> Plan:
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse(name),
description="Smoke bench plan",
definition_of_done="Benchmarks pass",
action_name="local/bench-smoke-action",
phase=phase,
processing_state=state,
automation_level=AutomationLevel.MANUAL,
project_links=project_links or [],
arguments={"target_coverage": 80},
arguments_order=["target_coverage"],
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
),
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
class ActionCLIParsingSuite:
"""Benchmark action CLI argument parsing overhead."""
def setup(self) -> None:
"""Set up mock service and temp config file."""
self._mock_service = MagicMock()
self._mock_service.create_action.return_value = _mock_action()
self._mock_service.list_actions.return_value = [
_mock_action(f"local/action-{i}") for i in range(20)
]
self._mock_service.get_action_by_name.return_value = _mock_action()
self._mock_service.archive_action.return_value = _mock_action()
self._patcher = patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
# Create temp YAML file
fd, self._yaml_path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(_VALID_YAML)
def teardown(self) -> None:
self._patcher.stop()
if os.path.exists(self._yaml_path):
os.unlink(self._yaml_path)
def time_action_create(self) -> None:
"""Benchmark action create with --config."""
_runner.invoke(action_app, ["create", "--config", self._yaml_path])
def time_action_list(self) -> None:
"""Benchmark action list."""
_runner.invoke(action_app, ["list"])
def time_action_list_with_filters(self) -> None:
"""Benchmark action list with namespace and state filters."""
_runner.invoke(
action_app, ["list", "--namespace", "local", "--state", "available"]
)
def time_action_show(self) -> None:
"""Benchmark action show."""
_runner.invoke(action_app, ["show", "local/bench-smoke-action"])
def time_action_archive(self) -> None:
"""Benchmark action archive."""
_runner.invoke(action_app, ["archive", "local/bench-smoke-action"])
class PlanUseArgParsingSuite:
"""Benchmark plan use CLI argument parsing with various flag combinations."""
def setup(self) -> None:
"""Set up mock service."""
self._mock_service = MagicMock()
self._mock_service.get_action_by_name.return_value = _mock_action()
self._mock_service.use_action.return_value = _mock_plan(
project_links=[
ProjectLink(project_name="proj-a"),
ProjectLink(project_name="proj-b"),
]
)
self._patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_use_minimal(self) -> None:
"""Benchmark plan use with minimal args."""
_runner.invoke(plan_app, ["use", "local/bench-smoke-action", "proj-a"])
def time_use_multi_project(self) -> None:
"""Benchmark plan use with multiple projects."""
_runner.invoke(
plan_app,
["use", "local/bench-smoke-action", "proj-a", "proj-b", "proj-c"],
)
def time_use_with_all_flags(self) -> None:
"""Benchmark plan use with all optional flags."""
_runner.invoke(
plan_app,
[
"use",
"local/bench-smoke-action",
"proj-a",
"--automation-profile",
"trusted",
"--invariant",
"No warnings",
"--invariant",
"Keep compat",
"--strategy-actor",
"openai/gpt-4",
"--execution-actor",
"openai/gpt-4",
"--estimation-actor",
"openai/gpt-4",
"--invariant-actor",
"openai/gpt-4",
"--automation-level",
"full_automation",
"--arg",
"target_coverage=80",
"--arg",
"verbose=true",
],
)
def time_use_with_args_only(self) -> None:
"""Benchmark plan use with multiple --arg flags."""
_runner.invoke(
plan_app,
[
"use",
"local/bench-smoke-action",
"proj-a",
"--arg",
"coverage=80",
"--arg",
"verbose=true",
"--arg",
"ratio=3.14",
],
)
class PlanLifecycleCmdParsingSuite:
"""Benchmark plan lifecycle command parsing overhead."""
def setup(self) -> None:
"""Set up mock service."""
self._mock_service = MagicMock()
self._mock_service.execute_plan.return_value = _mock_plan(
phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED
)
self._mock_service.apply_plan.return_value = _mock_plan(
phase=PlanPhase.APPLY, state=ProcessingState.QUEUED
)
self._mock_service.get_plan.return_value = _mock_plan(
project_links=[ProjectLink(project_name="proj-a", alias="api")]
)
self._mock_service.cancel_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.CANCELLED
)
self._mock_service.list_plans.return_value = [
_mock_plan(f"local/plan-{i}") for i in range(10)
]
self._patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=self._mock_service,
)
self._patcher.start()
def teardown(self) -> None:
self._patcher.stop()
def time_execute(self) -> None:
"""Benchmark plan execute."""
_runner.invoke(plan_app, ["execute", _PLAN_ULID])
def time_lifecycle_apply(self) -> None:
"""Benchmark plan lifecycle-apply."""
_runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
def time_status_by_id(self) -> None:
"""Benchmark plan status with specific ID."""
_runner.invoke(plan_app, ["status", _PLAN_ULID])
def time_status_list_all(self) -> None:
"""Benchmark plan status listing all plans."""
_runner.invoke(plan_app, ["status"])
def time_cancel_with_reason(self) -> None:
"""Benchmark plan cancel with reason."""
_runner.invoke(plan_app, ["cancel", _PLAN_ULID, "--reason", "Benchmarking"])
def time_lifecycle_list(self) -> None:
"""Benchmark lifecycle-list."""
_runner.invoke(plan_app, ["lifecycle-list"])
def time_lifecycle_list_filtered(self) -> None:
"""Benchmark lifecycle-list with filters."""
_runner.invoke(
plan_app,
["lifecycle-list", "--phase", "strategize", "--state", "queued"],
)
+71
View File
@@ -247,6 +247,77 @@ Plan Execute Transitions Correctly
Should Contain ${result.stdout} execute
```
## CLI Lifecycle Test Suites
The CLI lifecycle suites provide comprehensive coverage for action and plan lifecycle
commands, including success paths, error paths, multi-project arguments, automation
profile overrides, invariants, actor overrides, phase visibility, and terminal
outcomes.
### Behave Suite: `features/cli_lifecycle_coverage.feature`
Covers 68 scenarios across these areas:
| Area | Scenarios | Description |
|------|-----------|-------------|
| Action CRUD | 13 | create, list, show, archive with success + error paths |
| Plan Use | 10 | basic use, multi-project, automation profile, invariants, actor overrides |
| Plan Status | 9 | phase visibility (strategize, execute, apply) and terminal outcomes (applied, constrained, errored, cancelled) |
| Plan Execute | 5 | execute success, phase transition, error paths |
| Plan Apply | 5 | lifecycle-apply success, terminal outcomes, error paths |
| Plan Cancel | 5 | cancel with reason, already-terminal guard, error paths |
| Plan List | 4 | lifecycle-list with and without filters |
| Negative cases | 17 | missing config, invalid args, invalid project names, unknown actions/resources |
Step definitions: `features/steps/cli_lifecycle_coverage_steps.py`
All step names are prefixed with `lifecycle coverage` to avoid `AmbiguousStep`
conflicts with existing steps.
### Robot Suite: `robot/cli_lifecycle_e2e.robot`
End-to-end integration tests exercising the full action-to-apply lifecycle through
the CLI entry points. Uses `robot/helper_cli_lifecycle_e2e.py` as a Python helper
that mocks the service layer while exercising real CLI argument parsing and output
formatting.
| Test Case | Description |
|-----------|-------------|
| Action Create From Config Via CLI | Creates an action from YAML config |
| Plan Use Creates Plan In Strategize Phase | Uses an action to create a plan |
| Plan Execute Transitions To Execute Phase | Executes a plan, checks phase |
| Plan Lifecycle Apply Transitions To Apply Phase | Applies a plan, checks phase |
| Plan Status Shows Plan Details | Verifies status output rendering |
| Plan Cancel Cancels Non-Terminal Plan | Cancels with a reason string |
| Full Lifecycle Action To Apply | End-to-end: create → use → execute → apply |
| Plan Lifecycle List Shows Plans | Verifies lifecycle-list output |
### ASV Benchmark: `benchmarks/plan_cli_smoke_bench.py`
Three benchmark suites measuring CLI argument parsing overhead (service layer is
mocked so only parsing + routing cost is measured):
- **`ActionCLIParsingSuite`** — `time_action_create`, `time_action_list`,
`time_action_list_with_filters`, `time_action_show`, `time_action_archive`
- **`PlanUseArgParsingSuite`** — `time_use_minimal`, `time_use_multi_project`,
`time_use_with_all_flags`, `time_use_with_args_only`
- **`PlanLifecycleCmdParsingSuite`** — `time_execute`, `time_lifecycle_apply`,
`time_status_by_id`, `time_status_list_all`, `time_cancel_with_reason`,
`time_lifecycle_list`, `time_lifecycle_list_filtered`
### Running the CLI Lifecycle Suites
```bash
# Behave only (our feature)
nox -s unit_tests -- features/cli_lifecycle_coverage.feature
# Robot only (our suite)
nox -s integration_tests -- --suite robot/cli_lifecycle_e2e.robot
# Benchmarks
nox -s benchmark
```
## CI Pipeline
The Forgejo CI pipeline runs these jobs in order:
+396
View File
@@ -0,0 +1,396 @@
Feature: CLI lifecycle command coverage for action and plan commands
As a developer
I want comprehensive CLI coverage for action/plan lifecycle commands
So that success paths, error paths, and terminal outcomes are verified
Background:
Given a lifecycle coverage CLI runner
And a lifecycle coverage mocked lifecycle service
# ===================================================================
# Action create/list/show/archive success + error paths
# ===================================================================
Scenario: Lifecycle coverage action create from valid config succeeds
Given a lifecycle coverage valid action config file
When I run lifecycle coverage action create with config
Then lifecycle coverage action create should succeed
And lifecycle coverage created action name should be "local/lc-action"
Scenario: Lifecycle coverage action create with missing config file fails
When I run lifecycle coverage action create with missing config
Then lifecycle coverage action CLI should abort
Scenario: Lifecycle coverage action create surfaces pydantic validation error
Given a lifecycle coverage invalid schema config file
When I run lifecycle coverage action create with invalid schema config
Then lifecycle coverage action CLI should abort
Scenario: Lifecycle coverage action create surfaces value error from config
Given a lifecycle coverage invalid value config file
When I run lifecycle coverage action create with value error config
Then lifecycle coverage action CLI should abort
Scenario: Lifecycle coverage action list returns all actions in table
Given lifecycle coverage mocked actions exist
When I run lifecycle coverage action list
Then lifecycle coverage action list should show table with 3 actions
Scenario: Lifecycle coverage action list with json format
Given lifecycle coverage mocked actions exist
When I run lifecycle coverage action list with format "json"
Then lifecycle coverage action list should succeed
Scenario: Lifecycle coverage action list with regex filter
Given lifecycle coverage mocked actions exist
When I run lifecycle coverage action list with regex "alpha"
Then lifecycle coverage action list filtered should succeed
Scenario: Lifecycle coverage action list with invalid regex aborts
Given lifecycle coverage mocked actions exist
When I run lifecycle coverage action list with invalid regex "[bad"
Then lifecycle coverage action CLI should abort
Scenario: Lifecycle coverage action show by namespaced name
Given lifecycle coverage a specific action "local/alpha-action" exists
When I run lifecycle coverage action show "local/alpha-action" in default format
Then lifecycle coverage action show should display details
Scenario: Lifecycle coverage action show with json format
Given lifecycle coverage a specific action "local/alpha-action" exists
When I run lifecycle coverage action show "local/alpha-action" with format "json"
Then lifecycle coverage action show should succeed
Scenario: Lifecycle coverage action show not found aborts
Given lifecycle coverage action not found for show
When I run lifecycle coverage action show "local/no-such-action" in default format
Then lifecycle coverage action not found should abort
Scenario: Lifecycle coverage action archive succeeds
Given lifecycle coverage an archivable action exists
When I run lifecycle coverage action archive "local/alpha-action" in default format
Then lifecycle coverage action archive should succeed
Scenario: Lifecycle coverage action archive with json format
Given lifecycle coverage an archivable action exists
When I run lifecycle coverage action archive "local/alpha-action" using format "json"
Then lifecycle coverage action archive json should succeed
Scenario: Lifecycle coverage action archive not found aborts
Given lifecycle coverage action not found for archive
When I run lifecycle coverage action archive "local/no-such-action" in default format
Then lifecycle coverage action not found should abort
# ===================================================================
# Plan use success + error paths
# ===================================================================
Scenario: Lifecycle coverage plan use creates plan in strategize phase
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use "local/lc-action" targeting project "proj-a"
Then lifecycle coverage plan use should succeed
And lifecycle coverage plan should be in strategize phase
Scenario: Lifecycle coverage plan use with multi-project args
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use "local/lc-action" with projects "proj-a" "proj-b" "proj-c"
Then lifecycle coverage plan use should succeed
And lifecycle coverage plan should link 3 projects
Scenario: Lifecycle coverage plan use with automation profile override
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with automation profile "careful-auto"
Then lifecycle coverage plan use should succeed
And lifecycle coverage plan automation profile should be "careful-auto"
Scenario: Lifecycle coverage plan use with invariants
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with invariants "No warnings" and "Keep API compat"
Then lifecycle coverage plan use should succeed
And lifecycle coverage plan should have 2 invariants
Scenario: Lifecycle coverage plan use with strategy actor override
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with strategy actor "anthropic/claude-3"
Then lifecycle coverage plan use should succeed
And lifecycle coverage plan strategy actor should be "anthropic/claude-3"
Scenario: Lifecycle coverage plan use with execution actor override
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with execution actor "openai/gpt-4o"
Then lifecycle coverage plan use should succeed
And lifecycle coverage plan execution actor should be "openai/gpt-4o"
Scenario: Lifecycle coverage plan use with estimation actor override
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with estimation actor "openai/gpt-4-turbo"
Then lifecycle coverage plan use should succeed
Scenario: Lifecycle coverage plan use with invariant actor override
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with invariant actor "anthropic/claude-3-opus"
Then lifecycle coverage plan use should succeed
Scenario: Lifecycle coverage plan use with arg name=value
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with arg "target_coverage=90"
Then lifecycle coverage plan use should succeed
Scenario: Lifecycle coverage plan use with automation level override
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with automation level "full_automation"
Then lifecycle coverage plan use should succeed
Scenario: Lifecycle coverage plan use invalid automation level aborts
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with invalid automation level "mega_auto"
Then lifecycle coverage plan use should abort
Scenario: Lifecycle coverage plan use invalid arg format aborts
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use with invalid arg "badformat"
Then lifecycle coverage plan use should abort
Scenario: Lifecycle coverage plan use action not available error
Given lifecycle coverage an action not available for plan use
When I run lifecycle coverage plan use "local/archived-action" targeting project "proj-a"
Then lifecycle coverage plan use should abort
Scenario: Lifecycle coverage plan use validation error
Given lifecycle coverage an action with validation error for plan use
When I run lifecycle coverage plan use "local/lc-action" targeting project "proj-a"
Then lifecycle coverage plan use should abort
Scenario: Lifecycle coverage plan use general error
Given lifecycle coverage an action with general error for plan use
When I run lifecycle coverage plan use "local/lc-action" targeting project "proj-a"
Then lifecycle coverage plan use should abort
Scenario: Lifecycle coverage plan use with json format output
Given lifecycle coverage an action for plan use exists
When I run lifecycle coverage plan use "local/lc-action" on project "proj-a" with format "json"
Then lifecycle coverage plan use should succeed
# ===================================================================
# Plan lifecycle-list success + error paths
# ===================================================================
Scenario: Lifecycle coverage plan lifecycle-list shows plans
Given lifecycle coverage plans exist for listing
When I run lifecycle coverage plan lifecycle-list
Then lifecycle coverage plan list should show table
Scenario: Lifecycle coverage plan lifecycle-list with json format
Given lifecycle coverage plans exist for listing
When I run lifecycle coverage plan lifecycle-list with format "json"
Then lifecycle coverage plan list should succeed
Scenario: Lifecycle coverage plan lifecycle-list empty shows message
Given lifecycle coverage no plans exist for listing
When I run lifecycle coverage plan lifecycle-list
Then lifecycle coverage plan list should show no plans message
Scenario: Lifecycle coverage plan lifecycle-list invalid phase aborts
When I run lifecycle coverage plan lifecycle-list with invalid phase "bogus"
Then lifecycle coverage plan list should abort
Scenario: Lifecycle coverage plan lifecycle-list invalid state aborts
When I run lifecycle coverage plan lifecycle-list with invalid state "bogus"
Then lifecycle coverage plan list should abort
# ===================================================================
# Plan status success + error paths
# ===================================================================
Scenario: Lifecycle coverage plan status shows specific plan
Given lifecycle coverage a specific plan exists for status
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show details
Scenario: Lifecycle coverage plan status lists all when no ID
Given lifecycle coverage plans exist for listing
When I run lifecycle coverage plan status without ID
Then lifecycle coverage plan status should show table
Scenario: Lifecycle coverage plan status no plans message
Given lifecycle coverage no plans exist for listing
When I run lifecycle coverage plan status without ID
Then lifecycle coverage plan status should show no plans
Scenario: Lifecycle coverage plan status with json format
Given lifecycle coverage a specific plan exists for status
When I run lifecycle coverage plan status with plan ID and format "json"
Then lifecycle coverage plan status should succeed
Scenario: Lifecycle coverage plan status shows error on service failure
Given lifecycle coverage plan status service error
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should abort
# ===================================================================
# Plan execute success + error paths
# ===================================================================
Scenario: Lifecycle coverage plan execute transitions to execute phase
Given lifecycle coverage a plan ready for execute exists
When I run lifecycle coverage plan execute with plan ID
Then lifecycle coverage plan execute should succeed
Scenario: Lifecycle coverage plan execute auto-selects single ready plan
Given lifecycle coverage a single plan ready for auto-execute exists
When I run lifecycle coverage plan execute without ID
Then lifecycle coverage plan execute should succeed
Scenario: Lifecycle coverage plan execute no ready plans aborts
Given lifecycle coverage no plans ready for execute
When I run lifecycle coverage plan execute without ID
Then lifecycle coverage plan execute should abort
Scenario: Lifecycle coverage plan execute multiple ready plans aborts
Given lifecycle coverage multiple plans ready for execute
When I run lifecycle coverage plan execute without ID
Then lifecycle coverage plan execute should abort
Scenario: Lifecycle coverage plan execute invalid transition aborts
Given lifecycle coverage a plan with invalid transition for execute
When I run lifecycle coverage plan execute with plan ID
Then lifecycle coverage plan execute should abort
Scenario: Lifecycle coverage plan execute plan not ready aborts
Given lifecycle coverage a plan not ready for execute
When I run lifecycle coverage plan execute with plan ID
Then lifecycle coverage plan execute should abort
Scenario: Lifecycle coverage plan execute with json format
Given lifecycle coverage a plan ready for execute exists
When I run lifecycle coverage plan execute with plan ID and format "json"
Then lifecycle coverage plan execute should succeed
# ===================================================================
# Plan lifecycle-apply success + error paths
# ===================================================================
Scenario: Lifecycle coverage plan lifecycle-apply transitions to apply phase
Given lifecycle coverage a plan ready for apply exists
When I run lifecycle coverage plan lifecycle-apply with plan ID
Then lifecycle coverage plan apply should succeed
Scenario: Lifecycle coverage plan lifecycle-apply auto-selects single ready plan
Given lifecycle coverage a single plan ready for auto-apply exists
When I run lifecycle coverage plan lifecycle-apply without ID
Then lifecycle coverage plan apply should succeed
Scenario: Lifecycle coverage plan lifecycle-apply no ready plans aborts
Given lifecycle coverage no plans ready for apply
When I run lifecycle coverage plan lifecycle-apply without ID
Then lifecycle coverage plan apply should abort
Scenario: Lifecycle coverage plan lifecycle-apply multiple ready plans aborts
Given lifecycle coverage multiple plans ready for apply
When I run lifecycle coverage plan lifecycle-apply without ID
Then lifecycle coverage plan apply should abort
Scenario: Lifecycle coverage plan lifecycle-apply with json format
Given lifecycle coverage a plan ready for apply exists
When I run lifecycle coverage plan lifecycle-apply with plan ID and format "json"
Then lifecycle coverage plan apply should succeed
# ===================================================================
# Plan cancel success + error paths
# ===================================================================
Scenario: Lifecycle coverage plan cancel with reason
Given lifecycle coverage a cancellable plan exists
When I run lifecycle coverage plan cancel providing reason "No longer needed"
Then lifecycle coverage plan cancel should succeed
And lifecycle coverage cancel output should contain "No longer needed"
Scenario: Lifecycle coverage plan cancel without reason
Given lifecycle coverage a cancellable plan exists
When I run lifecycle coverage plan cancel without reason
Then lifecycle coverage plan cancel should succeed
Scenario: Lifecycle coverage plan cancel terminal plan aborts
Given lifecycle coverage a terminal plan exists for cancel
When I run lifecycle coverage plan cancel providing reason "try cancel"
Then lifecycle coverage plan cancel should abort
Scenario: Lifecycle coverage plan cancel with json format
Given lifecycle coverage a cancellable plan exists
When I run lifecycle coverage plan cancel with reason "cleanup" and format "json"
Then lifecycle coverage plan cancel should succeed
# ===================================================================
# Action phase visibility in plan status
# ===================================================================
Scenario: Lifecycle coverage plan status shows action phase as strategize
Given lifecycle coverage a plan in strategize phase exists
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show phase "strategize"
Scenario: Lifecycle coverage plan status shows action phase as execute
Given lifecycle coverage a plan in execute phase exists
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show phase "execute"
Scenario: Lifecycle coverage plan status shows action phase as apply
Given lifecycle coverage a plan in apply phase exists
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show phase "apply"
# ===================================================================
# Apply terminal outcomes in plan status output
# ===================================================================
Scenario: Lifecycle coverage plan status shows applied terminal outcome
Given lifecycle coverage a plan with applied outcome exists
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show state "applied"
And lifecycle coverage plan should be terminal
Scenario: Lifecycle coverage plan status shows constrained terminal outcome
Given lifecycle coverage a plan with constrained outcome exists
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show state "constrained"
And lifecycle coverage plan should be terminal
Scenario: Lifecycle coverage plan status shows errored outcome
Given lifecycle coverage a plan with errored outcome exists
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show state "errored"
Scenario: Lifecycle coverage plan status shows cancelled terminal outcome
Given lifecycle coverage a plan with cancelled outcome exists
When I run lifecycle coverage plan status with plan ID
Then lifecycle coverage plan status should show state "cancelled"
And lifecycle coverage plan should be terminal
# ===================================================================
# Negative cases
# ===================================================================
Scenario: Lifecycle coverage action create with empty config file fails
Given a lifecycle coverage empty config file
When I run lifecycle coverage action create with empty config
Then lifecycle coverage action CLI should abort
Scenario: Lifecycle coverage plan use with unknown action name
Given lifecycle coverage unknown action for plan use
When I run lifecycle coverage plan use "local/does-not-exist" targeting project "proj-a"
Then lifecycle coverage plan use should abort
Scenario: Lifecycle coverage plan execute with general CleverAgentsError
Given lifecycle coverage a plan execute with general error
When I run lifecycle coverage plan execute with plan ID
Then lifecycle coverage plan execute should abort
Scenario: Lifecycle coverage plan lifecycle-apply with general error
Given lifecycle coverage a plan apply with general error
When I run lifecycle coverage plan lifecycle-apply with plan ID
Then lifecycle coverage plan apply should abort
Scenario: Lifecycle coverage plan cancel with general error
Given lifecycle coverage a plan cancel with general error
When I run lifecycle coverage plan cancel providing reason "err"
Then lifecycle coverage plan cancel should abort
File diff suppressed because it is too large Load Diff
@@ -652,6 +652,7 @@ def step_parent_child_linked(context: Context, type_name: str) -> None:
)
session.add(edge)
session.flush()
session.commit()
@when("the parent resource is deleted")
+15 -15
View File
@@ -1738,21 +1738,21 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [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`
- [ ] Git [Brent]: `git pull origin master`
- [ ] Git [Brent]: `git checkout -b feature/m1-cli-tests`
- [ ] Git [Brent]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Tests (Behave) [Brent]: Add CLI coverage for action create/list/show/archive and plan use/list/status/execute/apply/cancel (success + error paths).
- [ ] Tests (Behave) [Brent]: Add scenarios for plan use with multi-project args, automation profile overrides, invariants, and actor overrides.
- [ ] Tests (Behave) [Brent]: Add scenarios that assert Action phase visibility and Apply terminal outcomes (`applied`, `constrained`, `errored`, `cancelled`) in `plan status` output.
- [ ] Tests (Behave) [Brent]: Add negative cases for missing config, invalid args, invalid project names, and unknown actions/resources.
- [ ] Tests (Robot) [Brent]: Add end-to-end Robot suite for action → plan → execute → apply on a local repo (sandbox + ChangeSet capture verified).
- [ ] Docs [Brent]: Update `docs/development/testing.md` with CLI suites + fixtures.
- [ ] Tests (ASV) [Brent]: Add `benchmarks/plan_cli_smoke_bench.py` for CLI argument parsing overhead.
- [ ] Quality [Brent]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Brent]: `git add .`
- [ ] Git [Brent]: `git commit -m "test(cli): expand lifecycle command coverage"`
- [X] **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"**
- [X] Git [Brent]: `git checkout master`
- [X] Git [Brent]: `git pull origin master`
- [X] Git [Brent]: `git checkout -b feature/m1-cli-tests`
- [X] Git [Brent]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Tests (Behave) [Brent]: Add CLI coverage for action create/list/show/archive and plan use/list/status/execute/apply/cancel (success + error paths).
- [X] Tests (Behave) [Brent]: Add scenarios for plan use with multi-project args, automation profile overrides, invariants, and actor overrides.
- [X] Tests (Behave) [Brent]: Add scenarios that assert Action phase visibility and Apply terminal outcomes (`applied`, `constrained`, `errored`, `cancelled`) in `plan status` output.
- [X] Tests (Behave) [Brent]: Add negative cases for missing config, invalid args, invalid project names, and unknown actions/resources.
- [X] Tests (Robot) [Brent]: Add end-to-end Robot suite for action → plan → execute → apply on a local repo (sandbox + ChangeSet capture verified).
- [X] Docs [Brent]: Update `docs/development/testing.md` with CLI suites + fixtures.
- [X] Tests (ASV) [Brent]: Add `benchmarks/plan_cli_smoke_bench.py` for CLI argument parsing overhead.
- [X] Quality [Brent]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [X] Git [Brent]: `git add .`
- [X] Git [Brent]: `git commit -m "test(cli): expand lifecycle command coverage"`
- [ ] Forgejo PR [Brent]: Open PR from `feature/m1-cli-tests` to `master` with description "Expand Behave + Robot CLI coverage for action/plan lifecycle commands and error paths.".
- [ ] Git [Brent]: `git checkout master`
- [ ] Git [Brent]: `git branch -d feature/m1-cli-tests`
+73
View File
@@ -0,0 +1,73 @@
*** Settings ***
Documentation End-to-end smoke tests for action -> plan -> execute -> apply lifecycle via CLI helpers
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_cli_lifecycle_e2e.py
*** Test Cases ***
Action Create From Config Via CLI
[Documentation] Create an action from a YAML config file and verify it succeeds
${result}= Run Process ${PYTHON} ${HELPER} action-create cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-action-create-ok
Plan Use Creates Plan In Strategize Phase
[Documentation] Use an action to create a plan in Strategize phase
${result}= Run Process ${PYTHON} ${HELPER} plan-use cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-use-ok
Plan Execute Transitions To Execute Phase
[Documentation] Execute a plan, transitioning from Strategize to Execute
${result}= Run Process ${PYTHON} ${HELPER} plan-execute cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-execute-ok
Plan Lifecycle Apply Transitions To Apply Phase
[Documentation] Apply a plan, transitioning from Execute to Apply
${result}= Run Process ${PYTHON} ${HELPER} plan-apply cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-apply-ok
Plan Status Shows Plan Details
[Documentation] Verify plan status renders plan details
${result}= Run Process ${PYTHON} ${HELPER} plan-status cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-status-ok
Plan Cancel Cancels Non-Terminal Plan
[Documentation] Cancel a non-terminal plan
${result}= Run Process ${PYTHON} ${HELPER} plan-cancel cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-cancel-ok
Full Lifecycle Action To Apply
[Documentation] End-to-end: create action, use it, execute, apply
${result}= Run Process ${PYTHON} ${HELPER} full-lifecycle cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-full-e2e-ok
Plan Lifecycle List Shows Plans
[Documentation] Verify lifecycle-list renders plan table
${result}= Run Process ${PYTHON} ${HELPER} plan-list cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-list-ok
+335
View File
@@ -0,0 +1,335 @@
"""Helper script for cli_lifecycle_e2e.robot end-to-end smoke tests.
Each subcommand is self-contained and prints a sentinel on success.
"""
from __future__ import annotations
import os
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
# 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,
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
runner = CliRunner()
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8S"
_VALID_YAML = """\
name: local/e2e-action
description: E2E lifecycle action
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: All e2e tests pass
"""
def _mock_action(name: str = "local/e2e-action") -> Action:
return Action(
namespaced_name=NamespacedName.parse(name),
description="E2E lifecycle action",
long_description=None,
definition_of_done="All e2e tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
state=ActionState.AVAILABLE,
reusable=True,
read_only=False,
created_at=datetime.now(),
updated_at=datetime.now(),
created_by=None,
)
def _mock_plan(
name: str = "local/e2e-plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
plan_id: str = _PLAN_ULID,
) -> Plan:
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
description="E2E lifecycle plan",
definition_of_done="Tests pass",
action_name="local/e2e-action",
phase=phase,
processing_state=state,
automation_level=AutomationLevel.MANUAL,
project_links=project_links or [ProjectLink(project_name="proj-a")],
arguments={"target_coverage": 80},
arguments_order=["target_coverage"],
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
),
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
def _write_yaml(content: str) -> str:
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(content)
return path
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def action_create() -> None:
"""Verify action create from config file."""
mock_service = MagicMock()
mock_service.create_action.return_value = _mock_action()
yaml_path = _write_yaml(_VALID_YAML)
try:
with patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(action_app, ["create", "--config", yaml_path])
if result.exit_code == 0:
print("cli-lifecycle-action-create-ok")
else:
print(
f"FAIL: action create returned {result.exit_code}", file=sys.stderr
)
print(result.output, file=sys.stderr)
sys.exit(1)
finally:
os.unlink(yaml_path)
def plan_use() -> None:
"""Verify plan use creates a plan in Strategize phase."""
mock_service = MagicMock()
mock_service.get_action_by_name.return_value = _mock_action()
mock_service.use_action.return_value = _mock_plan()
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["use", "local/e2e-action", "proj-a"])
if result.exit_code == 0:
print("cli-lifecycle-plan-use-ok")
else:
print(f"FAIL: plan use returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_execute() -> None:
"""Verify plan execute transitions to Execute phase."""
mock_service = MagicMock()
mock_service.execute_plan.return_value = _mock_plan(
phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["execute", _PLAN_ULID])
if result.exit_code == 0:
print("cli-lifecycle-plan-execute-ok")
else:
print(f"FAIL: plan execute returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_apply() -> None:
"""Verify plan lifecycle-apply transitions to Apply phase."""
mock_service = MagicMock()
mock_service.apply_plan.return_value = _mock_plan(
phase=PlanPhase.APPLY, state=ProcessingState.QUEUED
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
if result.exit_code == 0:
print("cli-lifecycle-plan-apply-ok")
else:
print(f"FAIL: plan apply returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_status() -> None:
"""Verify plan status shows plan details."""
mock_service = MagicMock()
mock_service.get_plan.return_value = _mock_plan(
project_links=[ProjectLink(project_name="proj-a", alias="api")]
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["status", _PLAN_ULID])
if result.exit_code == 0:
print("cli-lifecycle-plan-status-ok")
else:
print(f"FAIL: plan status returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_cancel() -> None:
"""Verify plan cancel cancels a non-terminal plan."""
mock_service = MagicMock()
mock_service.cancel_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.CANCELLED
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(
plan_app, ["cancel", _PLAN_ULID, "--reason", "E2E test cancel"]
)
if result.exit_code == 0:
print("cli-lifecycle-plan-cancel-ok")
else:
print(f"FAIL: plan cancel returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def plan_list() -> None:
"""Verify lifecycle-list shows plan table."""
mock_service = MagicMock()
mock_service.list_plans.return_value = [
_mock_plan(name="local/plan-a", plan_id="01KHDE6WWS2171PWW3GJEBXZ8A"),
_mock_plan(name="local/plan-b", plan_id="01KHDE6WWS2171PWW3GJEBXZ8B"),
]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["lifecycle-list"])
if result.exit_code == 0:
print("cli-lifecycle-plan-list-ok")
else:
print(f"FAIL: plan list returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def full_lifecycle() -> None:
"""End-to-end: action create -> plan use -> execute -> apply."""
mock_service = MagicMock()
action = _mock_action()
mock_service.create_action.return_value = action
mock_service.get_action_by_name.return_value = action
plan_strat = _mock_plan(phase=PlanPhase.STRATEGIZE, state=ProcessingState.QUEUED)
plan_exec = _mock_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED)
plan_apply = _mock_plan(phase=PlanPhase.APPLY, state=ProcessingState.QUEUED)
mock_service.use_action.return_value = plan_strat
mock_service.execute_plan.return_value = plan_exec
mock_service.apply_plan.return_value = plan_apply
yaml_path = _write_yaml(_VALID_YAML)
try:
with (
patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
):
# Step 1: Create action
r1 = runner.invoke(action_app, ["create", "--config", yaml_path])
assert r1.exit_code == 0, f"action create failed: {r1.output}"
# Step 2: Plan use
r2 = runner.invoke(plan_app, ["use", "local/e2e-action", "proj-a"])
assert r2.exit_code == 0, f"plan use failed: {r2.output}"
# Step 3: Plan execute
r3 = runner.invoke(plan_app, ["execute", _PLAN_ULID])
assert r3.exit_code == 0, f"plan execute failed: {r3.output}"
# Step 4: Plan apply
r4 = runner.invoke(plan_app, ["lifecycle-apply", _PLAN_ULID])
assert r4.exit_code == 0, f"plan apply failed: {r4.output}"
print("cli-lifecycle-full-e2e-ok")
except AssertionError as exc:
print(f"FAIL: {exc}", file=sys.stderr)
sys.exit(1)
finally:
os.unlink(yaml_path)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
_COMMANDS = {
"action-create": action_create,
"plan-use": plan_use,
"plan-execute": plan_execute,
"plan-apply": plan_apply,
"plan-status": plan_status,
"plan-cancel": plan_cancel,
"plan-list": plan_list,
"full-lifecycle": full_lifecycle,
}
def main() -> None:
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]]()
if __name__ == "__main__":
main()