feat(cli): align plan use/list/status flags
CI / coverage (push) Blocked by required conditions
CI / build (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / docker (push) Blocked by required conditions
CI / security (push) Waiting to run
CI / quality (push) Waiting to run
CI / unit_tests (push) Waiting to run
CI / integration_tests (push) Waiting to run
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 26s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 4m31s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 9m18s
CI / coverage (pull_request) Successful in 6m54s
CI / docker (pull_request) Successful in 39s

- plan use: accept positional PROJECT args, add --automation-profile,
  --invariant (repeatable), and actor override flags
- lifecycle-list: add --state/--processing-state, --action filters,
  optional positional REGEX for name filtering, Action column in output
- plan status: enhanced detail view with processing state, project
  links, arguments order, automation profile provenance, actor
  overrides, automation level, and full timestamps
- Add docs/reference/plan_cli.md reference documentation
- Add Behave feature (17 scenarios) with mock service steps
- Add Robot smoke test (7 cases) with import-safe helper
- Add ASV benchmarks for plan use/list/status commands
This commit was merged in pull request #62.
This commit is contained in:
2026-02-14 14:21:00 +00:00
parent d98666651f
commit 65988669a6
8 changed files with 1549 additions and 45 deletions
+314
View File
@@ -0,0 +1,314 @@
"""Helper script for plan_cli_spec.robot smoke tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
# Ensure the local source tree takes priority over any installed copy.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
# Remove any *other* src entries (e.g. /app/src from the parent clone)
sys.path = [_SRC] + [
p
for p in sys.path
if p != _SRC and not (p.endswith("/src") and "cleveragents" not in p.split("/")[-1])
]
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Flush any already-loaded cleveragents modules so they reimport from _SRC
for _mod_name in sorted(sys.modules):
if _mod_name.startswith("cleveragents"):
del sys.modules[_mod_name]
from typer.testing import CliRunner # 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"
def _mock_action(name: str = "local/smoke-action") -> Action:
return Action(
namespaced_name=NamespacedName.parse(name),
description="Smoke test action",
long_description=None,
definition_of_done="All smoke 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(
project_links: list[ProjectLink] | None = None,
arguments: dict[str, object] | None = None,
) -> Plan:
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse("local/smoke-plan"),
description="Smoke test plan",
definition_of_done="All smoke tests pass",
action_name="local/smoke-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
project_links=project_links or [],
arguments=dict(arguments) if arguments else {},
arguments_order=list((arguments or {}).keys()),
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
),
estimation_actor="openai/gpt-4",
invariant_actor="openai/gpt-4",
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),
)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def use_positional_projects() -> None:
"""Verify plan use accepts multiple positional projects."""
mock_service = MagicMock()
mock_service.get_action_by_name.return_value = _mock_action()
mock_service.use_action.return_value = _mock_plan(
project_links=[
ProjectLink(project_name="proj-a"),
ProjectLink(project_name="proj-b"),
],
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(
plan_app, ["use", "local/smoke-action", "proj-a", "proj-b"]
)
if result.exit_code == 0:
print("plan-cli-use-positional-ok")
else:
print(f"FAIL: use returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def use_automation_profile() -> None:
"""Verify plan use accepts --automation-profile."""
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/smoke-action",
"proj-a",
"--automation-profile",
"trusted",
],
)
if result.exit_code == 0:
print("plan-cli-use-profile-ok")
else:
print(f"FAIL: use returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def use_invariant() -> None:
"""Verify plan use accepts repeatable --invariant."""
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/smoke-action",
"proj-a",
"--invariant",
"No warnings",
"--invariant",
"Keep compat",
],
)
if result.exit_code == 0:
print("plan-cli-use-invariant-ok")
else:
print(f"FAIL: use returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def use_actor_overrides() -> None:
"""Verify plan use accepts actor override flags."""
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/smoke-action",
"proj-a",
"--strategy-actor",
"openai/gpt-4",
"--execution-actor",
"anthropic/claude-3",
"--estimation-actor",
"openai/gpt-4",
"--invariant-actor",
"openai/gpt-4",
],
)
if result.exit_code == 0:
print("plan-cli-use-actors-ok")
else:
print(f"FAIL: use returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def list_filters() -> None:
"""Verify lifecycle-list accepts filter flags."""
mock_service = MagicMock()
mock_service.list_plans.return_value = [
_mock_plan(project_links=[ProjectLink(project_name="proj-a")]),
]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
r1 = runner.invoke(plan_app, ["lifecycle-list", "--phase", "strategize"])
r2 = runner.invoke(plan_app, ["lifecycle-list", "--state", "queued"])
r3 = runner.invoke(plan_app, ["lifecycle-list", "--project", "proj-a"])
r4 = runner.invoke(
plan_app, ["lifecycle-list", "--action", "local/smoke-action"]
)
if all(r.exit_code == 0 for r in [r1, r2, r3, r4]):
print("plan-cli-list-filters-ok")
else:
codes = [r1.exit_code, r2.exit_code, r3.exit_code, r4.exit_code]
print(f"FAIL: list filters exit codes: {codes}", file=sys.stderr)
sys.exit(1)
def status_fields() -> None:
"""Verify plan status renders required fields."""
mock_service = MagicMock()
plan = _mock_plan(
project_links=[ProjectLink(project_name="proj-a", alias="api")],
arguments={"coverage": 80},
)
mock_service.get_plan.return_value = plan
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:
output = result.output.lower()
required = ["action", "phase", "processing state", "projects", "created"]
missing = [f for f in required if f not in output]
if missing:
print(f"FAIL: missing fields: {missing}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
print("plan-cli-status-fields-ok")
else:
print(f"FAIL: status returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def cancel_reason() -> None:
"""Verify plan cancel accepts --reason."""
mock_service = MagicMock()
plan = _mock_plan()
plan.processing_state = ProcessingState.CANCELLED
mock_service.cancel_plan.return_value = plan
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(
plan_app, ["cancel", _PLAN_ULID, "--reason", "Requirements changed"]
)
if result.exit_code == 0 and "Requirements changed" in result.output:
print("plan-cli-cancel-reason-ok")
else:
print(f"FAIL: cancel returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
_COMMANDS = {
"use-positional-projects": use_positional_projects,
"use-automation-profile": use_automation_profile,
"use-invariant": use_invariant,
"use-actor-overrides": use_actor_overrides,
"list-filters": list_filters,
"status-fields": status_fields,
"cancel-reason": cancel_reason,
}
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()