fix(cli): add --namespace/-n option to agents plan list command #2616

Merged
freemo merged 1 commits from fix/plan-list-namespace-option into master 2026-04-03 21:40:46 +00:00
6 changed files with 201 additions and 5 deletions
+25
View File
@@ -61,6 +61,31 @@ Feature: Plan CLI spec alignment
And the plan spec use should pass argument "target_coverage" with value 80
# ---- plan list: filter combinations ----
Scenario: Plan list with --namespace filter (long form)
Given plan spec alignment plans exist
When I run plan list with namespace "myteam"
Then the plan spec list should succeed
And the plan spec list should pass namespace "myteam" to service
Scenario: Plan list with -n namespace filter (short form)
Given plan spec alignment plans exist
When I run plan list with short namespace "local"
Then the plan spec list should succeed
And the plan spec list should pass namespace "local" to service
Scenario: Plan list with --namespace shows Namespace in Filters panel
Given plan spec alignment plans exist
When I run plan list with namespace "myteam"
Then the plan spec list should succeed
And the plan spec list output should contain "Namespace"
And the plan spec list output should contain "myteam"
Scenario: Plan list with --namespace combined with --state
Given plan spec alignment plans exist
When I run plan list filtering by namespace "myteam" and processing state "queued"
Then the plan spec list should succeed
And the plan spec list should pass namespace "myteam" to service
Scenario: Plan list with --phase filter
Given plan spec alignment plans exist
When I run plan list with phase "strategize"
@@ -280,6 +280,30 @@ def step_plan_use_arg(context: Context, arg_str: str) -> None:
# ---------------------------------------------------------------------------
@when('I run plan list with namespace "{namespace}"')
def step_plan_list_namespace(context: Context, namespace: str) -> None:
"""Run list with --namespace filter (long form)."""
context.result = context.runner.invoke(plan_app, ["list", "--namespace", namespace])
@when('I run plan list with short namespace "{namespace}"')
def step_plan_list_short_namespace(context: Context, namespace: str) -> None:
"""Run list with -n namespace filter (short form)."""
context.result = context.runner.invoke(plan_app, ["list", "-n", namespace])
@when(
'I run plan list filtering by namespace "{namespace}" and processing state "{state}"'
)
def step_plan_list_namespace_and_state(
context: Context, namespace: str, state: str
) -> None:
"""Run list with --namespace and --state combined."""
context.result = context.runner.invoke(
plan_app, ["list", "--namespace", namespace, "--state", state]
)
@when('I run plan list with phase "{phase}"')
def step_plan_list_phase(context: Context, phase: str) -> None:
"""Run list with --phase filter."""
@@ -416,6 +440,30 @@ def step_plan_list_ok(context: Context) -> None:
)
@then('the plan spec list should pass namespace "{namespace}" to service')
def step_plan_list_namespace_passed(context: Context, namespace: str) -> None:
"""Verify the namespace was passed to list_plans service call."""
call_kwargs = context.mock_service.list_plans.call_args
assert call_kwargs is not None, "list_plans was not called"
# Support both positional and keyword argument styles
kwargs = call_kwargs[1] if call_kwargs[1] else {}
args = call_kwargs[0] if call_kwargs[0] else ()
passed_namespace = kwargs.get("namespace", args[0] if args else None)
assert passed_namespace == namespace, (
f"Expected namespace='{namespace}', got namespace='{passed_namespace}'"
)
@then('the plan spec list output should contain "{text}"')
def step_plan_list_output_contains(context: Context, text: str) -> None:
"""Verify the list output contains expected text (case-insensitive)."""
output_lower = context.result.output.lower()
text_lower = text.lower()
assert text_lower in output_lower, (
f"Expected '{text}' in list output but got:\n{context.result.output}"
)
@then("the plan spec status should succeed")
def step_plan_status_ok(context: Context) -> None:
"""Verify plan status succeeded."""
@@ -8,7 +8,9 @@ from cleveragents.domain.models.core.plan import NamespacedName
@when('I parse the namespaced name "{full_name}" expecting an error')
def step_when_parse_namespaced_name_expecting_error(context: Context, full_name: str) -> None:
def step_when_parse_namespaced_name_expecting_error(
context: Context, full_name: str
) -> None:
"""Parse a namespaced name string and capture any error."""
try:
context.namespaced_name = NamespacedName.parse(full_name)
@@ -18,8 +20,12 @@ def step_when_parse_namespaced_name_expecting_error(context: Context, full_name:
context.namespaced_name = None
@when('I construct a NamespacedName with namespace "{namespace}" and name "{name}" expecting an error')
def step_when_construct_namespaced_name_expecting_error(context: Context, namespace: str, name: str) -> None:
@when(
'I construct a NamespacedName with namespace "{namespace}" and name "{name}" expecting an error'
)
def step_when_construct_namespaced_name_expecting_error(
context: Context, namespace: str, name: str
) -> None:
"""Create a NamespacedName and capture any error."""
try:
context.namespaced_name = NamespacedName(namespace=namespace, name=name)
+16
View File
@@ -71,3 +71,19 @@ Plan Lifecycle List Shows Plans
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-list-ok
Plan List Namespace Option Filters By Namespace
[Documentation] Verify agents plan list --namespace myteam returns only plans in that namespace
${result}= Run Process ${PYTHON} ${HELPER} plan-list-namespace cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-list-namespace-ok
Plan List Short Namespace Option Works
[Documentation] Verify agents plan list -n myteam short-form alias works correctly
${result}= Run Process ${PYTHON} ${HELPER} plan-list-namespace-short cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cli-lifecycle-plan-list-namespace-short-ok
+80
View File
@@ -322,6 +322,84 @@ def full_lifecycle() -> None:
os.unlink(yaml_path)
def plan_list_namespace() -> None:
"""Verify plan list --namespace myteam filters plans by namespace."""
mock_service = MagicMock()
# Only plans in "myteam" namespace should be returned by the service
mock_service.list_plans.return_value = [
_mock_plan(name="myteam/plan-a", plan_id="01KHDE6WWS2171PWW3GJEBXZ8C"),
]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["list", "--namespace", "myteam"])
if result.exit_code != 0:
print(
f"FAIL: plan list --namespace returned {result.exit_code}",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
# Verify the namespace was passed to the service
call_kwargs = mock_service.list_plans.call_args
passed_namespace = call_kwargs[1].get("namespace") if call_kwargs[1] else None
if passed_namespace != "myteam":
print(
"FAIL: expected namespace='myteam', "
f"got namespace='{passed_namespace}'",
file=sys.stderr,
)
sys.exit(1)
# Verify Filters panel shows Namespace
if "Namespace" not in result.output or "myteam" not in result.output:
print(
"FAIL: Filters panel missing 'Namespace: myteam'",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
print("cli-lifecycle-plan-list-namespace-ok")
def plan_list_namespace_short() -> None:
"""Verify plan list -n myteam short-form alias works correctly."""
mock_service = MagicMock()
mock_service.list_plans.return_value = [
_mock_plan(name="myteam/plan-b", plan_id="01KHDE6WWS2171PWW3GJEBXZ8D"),
]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
):
result = runner.invoke(plan_app, ["list", "-n", "myteam"])
if result.exit_code != 0:
print(
f"FAIL: plan list -n returned {result.exit_code}",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
# Verify the namespace was passed to the service via short form
call_kwargs = mock_service.list_plans.call_args
passed_namespace = call_kwargs[1].get("namespace") if call_kwargs[1] else None
if passed_namespace != "myteam":
print(
"FAIL: expected namespace='myteam', "
f"got namespace='{passed_namespace}'",
file=sys.stderr,
)
sys.exit(1)
print("cli-lifecycle-plan-list-namespace-short-ok")
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
@@ -334,6 +412,8 @@ _COMMANDS = {
"plan-status": plan_status,
"plan-cancel": plan_cancel,
"plan-list": plan_list,
"plan-list-namespace": plan_list_namespace,
"plan-list-namespace-short": plan_list_namespace_short,
"full-lifecycle": full_lifecycle,
}
+23 -2
View File
@@ -2311,6 +2311,14 @@ def lifecycle_list_plans(
help="Optional regex pattern to filter plan names",
),
] = None,
namespace: Annotated[
str | None,
typer.Option(
"--namespace",
"-n",
help="Filter plans by namespace",
),
] = None,
phase: Annotated[
str | None,
typer.Option(
@@ -2363,6 +2371,12 @@ def lifecycle_list_plans(
This command lists plans created through the new plan lifecycle
(Strategize -> Execute -> Apply -> Applied).
Examples:
agents plan list
agents plan list --namespace myteam
agents plan list -n myteam --state processing
agents plan list --phase strategize
"""
try:
import re
@@ -2399,7 +2413,9 @@ def lifecycle_list_plans(
)
raise typer.Abort() from exc
plans = service.list_plans(phase=phase_filter, project_name=project_id)
plans = service.list_plans(
namespace=namespace, phase=phase_filter, project_name=project_id
)
# Apply processing state filter
if state_filter:
@@ -2472,6 +2488,11 @@ def lifecycle_list_plans(
# Display Filters panel (only if any filter is active)
active_filters = []
if namespace:
active_filters.append(f"[yellow]Namespace:[/yellow] {namespace}")
else:
active_filters.append("[yellow]Namespace:[/yellow] (any)")
if phase_filter:
active_filters.append(f"[yellow]Phase:[/yellow] {phase_filter}")
else:
@@ -2493,7 +2514,7 @@ def lifecycle_list_plans(
active_filters.append("[yellow]Action:[/yellow] (any)")
# Only show Filters panel if at least one filter is active
if phase_filter or state_filter or project_id or action_filter:
if namespace or phase_filter or state_filter or project_id or action_filter:
filters_text = "\n".join(active_filters)
filters_panel = Panel(filters_text, title="Filters", border_style="dim")
console.print(filters_panel)