fix(cli): make --plan and --action repeatable in agents invariant add
CI / push-validation (pull_request) Successful in 20s
CI / quality (pull_request) Successful in 21s
CI / lint (pull_request) Failing after 24s
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 1m14s
CI / typecheck (pull_request) Successful in 1m26s
CI / coverage (pull_request) Has been skipped
CI / unit_tests (pull_request) Successful in 5m12s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 6m4s
CI / integration_tests (pull_request) Successful in 7m46s
CI / status-check (pull_request) Failing after 1s

Change --plan and --action flags in `agents invariant add` from single-value
str | None options to repeatable list[str] options, allowing users to attach
the same invariant to multiple plans or actions in a single command.

- Add `_resolve_scope_targets()` helper that returns a list of
  (InvariantScope, source_name) pairs for repeatable flag support
- Update `add` command to loop over all targets and create one invariant
  per target, printing each created invariant
- Update non-rich output to emit a list when multiple targets are given
- Add BDD scenarios in features/invariant_cli_new_coverage.feature for
  repeatable --plan and --action flags
- Add step definitions in features/steps/invariant_cli_new_coverage_steps.py

ISSUES CLOSED: #9070
This commit is contained in:
2026-04-14 11:29:28 +00:00
committed by HAL9000
parent 64b1f4c0b6
commit 445cb17bfb
5 changed files with 362 additions and 12 deletions
@@ -159,3 +159,96 @@ Feature: Invariant CLI commands coverage
When I invoke invariant remove with "--yes" for id "MISSING_ID"
Then the invariant CLI exit code should be non-zero
And the invariant CLI output should contain "not found"
# === _resolve_scope_targets helper ===
Scenario: Resolve scope targets with --global flag returns single GLOBAL target
When I resolve invariant scope targets with global flag set
Then the resolved invariant targets should have 1 entry
And the first resolved target scope should be "global"
And the first resolved target source should be "system"
Scenario: Resolve scope targets with --project flag returns single PROJECT target
When I resolve invariant scope targets with project "myapp"
Then the resolved invariant targets should have 1 entry
And the first resolved target scope should be "project"
And the first resolved target source should be "myapp"
Scenario: Resolve scope targets with single --plan returns one PLAN target
When I resolve invariant scope targets with plans "PLAN-001"
Then the resolved invariant targets should have 1 entry
And the first resolved target scope should be "plan"
And the first resolved target source should be "PLAN-001"
Scenario: Resolve scope targets with two --plan values returns two PLAN targets
When I resolve invariant scope targets with plans "PLAN-001,PLAN-002"
Then the resolved invariant targets should have 2 entries
And the first resolved target scope should be "plan"
And the first resolved target source should be "PLAN-001"
And the second resolved target scope should be "plan"
And the second resolved target source should be "PLAN-002"
Scenario: Resolve scope targets with single --action returns one ACTION target
When I resolve invariant scope targets with actions "deploy"
Then the resolved invariant targets should have 1 entry
And the first resolved target scope should be "action"
And the first resolved target source should be "deploy"
Scenario: Resolve scope targets with two --action values returns two ACTION targets
When I resolve invariant scope targets with actions "deploy,rollback"
Then the resolved invariant targets should have 2 entries
And the first resolved target scope should be "action"
And the first resolved target source should be "deploy"
And the second resolved target scope should be "action"
And the second resolved target source should be "rollback"
Scenario: Resolve scope targets with no flags defaults to GLOBAL
When I resolve invariant scope targets with no flags
Then the resolved invariant targets should have 1 entry
And the first resolved target scope should be "global"
And the first resolved target source should be "system"
Scenario: Resolve scope targets with conflicting flags raises BadParameter
When I resolve invariant scope targets with global and plans "PLAN-001"
Then a BadParameter error should be raised for invariant scope targets
# === invariant add with repeatable --plan flags ===
Scenario: Add invariant with two --plan flags creates two invariants
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--plan PLAN-001 --plan PLAN-002" and text "No prod deletes"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
And the invariant add service was called 2 times
Scenario: Add invariant with two --action flags creates two invariants
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--action deploy --action rollback" and text "Require approval"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
And the invariant add service was called 2 times
Scenario: Add invariant with single --plan flag creates one invariant
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--plan PLAN-001" and text "Single plan invariant"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
And the invariant add service was called 1 time
Scenario: Add invariant with single --action flag creates one invariant
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--action deploy" and text "Single action invariant"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "Invariant added"
And the invariant add service was called 1 time
Scenario: Add invariant with two --plan flags in json format outputs list
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--plan PLAN-001 --plan PLAN-002 --format json" and text "No prod deletes"
Then the invariant CLI exit code should be 0
And the invariant CLI output should contain "No prod deletes"
Scenario: Add invariant with conflicting --plan and --global flags raises error
Given a mocked InvariantService for invariant CLI add
When I invoke invariant add with "--global --plan PLAN-001" and text "conflict"
Then the invariant CLI exit code should be non-zero
@@ -10,13 +10,14 @@ from datetime import datetime
from unittest.mock import MagicMock, patch
import typer
from behave import given, then, when
from behave import given, then, use_step_matcher, when
from typer.testing import CliRunner
from cleveragents.cli.commands.invariant import (
_get_service,
_invariant_dict,
_resolve_scope,
_resolve_scope_targets,
app,
)
from cleveragents.core.exceptions import CleverAgentsError, NotFoundError
@@ -359,3 +360,126 @@ def step_mock_service_not_found_remove(context):
resource_type="invariant", resource_id="MISSING_ID"
)
_patch_svc(context, svc)
# ================================================================
# _resolve_scope_targets helper steps
# ================================================================
@when("I resolve invariant scope targets with global flag set")
def step_resolve_targets_global(context):
context.resolved_targets = _resolve_scope_targets(
is_global=True, project=None, plans=[], actions=[]
)
@when('I resolve invariant scope targets with project "{project}"')
def step_resolve_targets_project(context, project):
context.resolved_targets = _resolve_scope_targets(
is_global=False, project=project, plans=[], actions=[]
)
@when('I resolve invariant scope targets with plans "{plans_csv}"')
def step_resolve_targets_plans(context, plans_csv):
plans = plans_csv.split(",")
context.resolved_targets = _resolve_scope_targets(
is_global=False, project=None, plans=plans, actions=[]
)
@when('I resolve invariant scope targets with actions "{actions_csv}"')
def step_resolve_targets_actions(context, actions_csv):
actions = actions_csv.split(",")
context.resolved_targets = _resolve_scope_targets(
is_global=False, project=None, plans=[], actions=actions
)
@when("I resolve invariant scope targets with no flags")
def step_resolve_targets_default(context):
context.resolved_targets = _resolve_scope_targets(
is_global=False, project=None, plans=[], actions=[]
)
@when('I resolve invariant scope targets with global and plans "{plans_csv}"')
def step_resolve_targets_conflicting(context, plans_csv):
plans = plans_csv.split(",")
context.inv_bad_parameter_raised = False
try:
_resolve_scope_targets(
is_global=True, project=None, plans=plans, actions=[]
)
except typer.BadParameter:
context.inv_bad_parameter_raised = True
@then("the resolved invariant targets should have {count:d} entries")
def step_check_targets_count_plural(context, count):
assert len(context.resolved_targets) == count, (
f"Expected {count} targets, got {len(context.resolved_targets)}"
)
@then("the resolved invariant targets should have {count:d} entry")
def step_check_targets_count_singular(context, count):
assert len(context.resolved_targets) == count, (
f"Expected {count} targets, got {len(context.resolved_targets)}"
)
@then('the first resolved target scope should be "{scope}"')
def step_check_first_target_scope(context, scope):
actual_scope, _ = context.resolved_targets[0]
assert actual_scope.value == scope, (
f"Expected first target scope '{scope}', got '{actual_scope.value}'"
)
@then('the first resolved target source should be "{source}"')
def step_check_first_target_source(context, source):
_, actual_source = context.resolved_targets[0]
assert actual_source == source, (
f"Expected first target source '{source}', got '{actual_source}'"
)
@then('the second resolved target scope should be "{scope}"')
def step_check_second_target_scope(context, scope):
actual_scope, _ = context.resolved_targets[1]
assert actual_scope.value == scope, (
f"Expected second target scope '{scope}', got '{actual_scope.value}'"
)
@then('the second resolved target source should be "{source}"')
def step_check_second_target_source(context, source):
_, actual_source = context.resolved_targets[1]
assert actual_source == source, (
f"Expected second target source '{source}', got '{actual_source}'"
)
@then("a BadParameter error should be raised for invariant scope targets")
def step_check_bad_parameter_targets(context):
assert context.inv_bad_parameter_raised, (
"Expected typer.BadParameter to be raised for scope targets"
)
# Use regex step matcher for the call count check to handle "1 time" and "2 times"
use_step_matcher("re")
@then(r"the invariant add service was called (?P<count>\d+) times?")
def step_check_add_called_n_times(context, count):
expected = int(count)
actual = context.inv_mock_svc.add_invariant.call_count
assert actual == expected, (
f"Expected add_invariant to be called {expected} time(s), got {actual}"
)
use_step_matcher("parse")
+81 -11
View File
@@ -17,16 +17,22 @@ Each invariant belongs to exactly one scope. Pass the matching flag:
- ``--global``: System-wide invariant
- ``--project PROJECT``: Project-scoped invariant
- ``--action ACTION``: Action-template invariant
- ``--plan PLAN_ID``: Plan-specific invariant
- ``--action ACTION``: Action-template invariant (repeatable)
- ``--plan PLAN_ID``: Plan-specific invariant (repeatable)
If no scope flag is given, ``--global`` is assumed.
The ``--plan`` and ``--action`` flags are repeatable: you may pass them
multiple times to attach the same invariant to several plans or actions
in a single command.
## Examples
```bash
agents invariant add --global "Never delete production data"
agents invariant add --project myapp "All API changes need tests"
agents invariant add --plan PLAN-001 --plan PLAN-002 "Never delete production data"
agents invariant add --action deploy --action rollback "Require approval"
agents invariant list --effective --project myapp
agents invariant remove INV_ULID
```
@@ -75,6 +81,10 @@ def _resolve_scope(
) -> tuple[InvariantScope, str]:
"""Resolve scope flag combination to (scope, source_name).
This helper is used for single-value resolution (e.g. in ``list``).
For the ``add`` command, use ``_resolve_scope_targets`` which handles
repeatable ``--plan`` and ``--action`` flags.
Raises:
typer.BadParameter: On conflicting or missing flags.
"""
@@ -97,6 +107,49 @@ def _resolve_scope(
return InvariantScope.GLOBAL, "system"
def _resolve_scope_targets(
is_global: bool,
project: str | None,
plans: list[str],
actions: list[str],
) -> list[tuple[InvariantScope, str]]:
"""Resolve scope flags to a list of (scope, source_name) pairs.
Supports repeatable ``--plan`` and ``--action`` flags so that a single
``agents invariant add`` call can create the same invariant for multiple
plans or actions.
Args:
is_global: Whether ``--global`` was passed.
project: Value of ``--project`` (single-value flag).
plans: All values passed via ``--plan`` (may be empty).
actions: All values passed via ``--action`` (may be empty).
Returns:
A list of ``(InvariantScope, source_name)`` pairs — one per target.
Raises:
typer.BadParameter: When conflicting scope flags are provided.
"""
flags_set = sum(
[is_global, project is not None, bool(plans), bool(actions)]
)
if flags_set > 1:
raise typer.BadParameter(
"Specify at most one scope flag: --global, --project, --plan, or --action"
)
if project is not None:
return [(InvariantScope.PROJECT, project)]
if plans:
return [(InvariantScope.PLAN, p) for p in plans]
if actions:
return [(InvariantScope.ACTION, a) for a in actions]
# Default to global
return [(InvariantScope.GLOBAL, "system")]
def _invariant_dict(inv: Invariant) -> dict[str, object]:
"""Serialise an Invariant for non-rich output formats."""
return {
@@ -118,29 +171,46 @@ def add(
project: Annotated[
str | None, typer.Option("--project", help="Project name")
] = None,
plan: Annotated[str | None, typer.Option("--plan", help="Plan ID (ULID)")] = None,
action: Annotated[str | None, typer.Option("--action", help="Action name")] = None,
plan: Annotated[
list[str], typer.Option("--plan", help="Plan ID (ULID); repeatable")
] = [], # noqa: B006
action: Annotated[
list[str], typer.Option("--action", help="Action name; repeatable")
] = [], # noqa: B006
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""Add a new invariant constraint.
The ``--plan`` and ``--action`` flags are repeatable: pass them multiple
times to attach the same invariant to several plans or actions at once.
Examples:
agents invariant add --global "Never delete production data"
agents invariant add --project myapp "All API changes need tests"
agents invariant add --plan PLAN-001 --plan PLAN-002 "No prod deletes"
agents invariant add --action deploy --action rollback "Require approval"
"""
try:
scope, source_name = _resolve_scope(is_global, project, plan, action)
targets = _resolve_scope_targets(is_global, project, plan, action)
service = _get_service()
inv = service.add_invariant(text=text, scope=scope, source_name=source_name)
created: list[Invariant] = []
for scope, source_name in targets:
inv = service.add_invariant(
text=text, scope=scope, source_name=source_name
)
created.append(inv)
if fmt != OutputFormat.RICH.value:
console.print(format_output(_invariant_dict(inv), fmt))
data = [_invariant_dict(inv) for inv in created]
output: object = data[0] if len(data) == 1 else data
console.print(format_output(output, fmt))
return
console.print(f"[green]Invariant added:[/green] {inv.id}")
console.print(f" Text: {inv.text}")
console.print(f" Scope: {inv.scope.value}")
console.print(f" Source: {inv.source_name}")
for inv in created:
console.print(f"[green]Invariant added:[/green] {inv.id}")
console.print(f" Text: {inv.text}")
console.print(f" Scope: {inv.scope.value}")
console.print(f" Source: {inv.source_name}")
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
+19
View File
@@ -0,0 +1,19 @@
Test Framework: generic
Total Tests: 3
Passed: 2
Failed: 1
--- Test Results ---
✓ Output Block 1
✓ nox > Running session lint
✗ Error Output
--- Failed Tests ---
✗ Error Output
nox > Running session lint
nox > Reusing existing virtual environment at .nox/lint.
nox > uv pip install 'ruff>=0.15,<0.16'
nox > ruff check src/ scripts/ examples/ features/ robot/
nox > Session lint was successful.
+44
View File
@@ -0,0 +1,44 @@
{
"framework": "generic",
"tests": [
{
"name": "Output Block 1",
"passed": true,
"output": [
"All checks passed!"
],
"rawOutput": "All checks passed!"
},
{
"name": "nox > Running session lint",
"passed": true,
"output": [
"nox > Running session lint",
"nox > Reusing existing virtual environment at .nox/lint.",
"nox > uv pip install 'ruff>=0.15,<0.16'",
"nox > ruff check src/ scripts/ examples/ features/ robot/",
"nox > Session lint was successful."
],
"rawOutput": "nox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful."
},
{
"name": "Error Output",
"passed": false,
"output": [
"nox > Running session lint",
"nox > Reusing existing virtual environment at .nox/lint.",
"nox > uv pip install 'ruff>=0.15,<0.16'",
"nox > ruff check src/ scripts/ examples/ features/ robot/",
"nox > Session lint was successful.",
""
],
"rawOutput": "nox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful.\n"
}
],
"summary": {
"total": 3,
"passed": 2,
"failed": 1
},
"rawOutput": "All checks passed!\n\nnox > Running session lint\nnox > Reusing existing virtual environment at .nox/lint.\nnox > uv pip install 'ruff>=0.15,<0.16'\nnox > ruff check src/ scripts/ examples/ features/ robot/\nnox > Session lint was successful."
}