diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0312bd2..9941337f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list ` and `agents plan checkpoint-delete ` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting. - **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove ` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included. - **TDD: MCPToolAdapter.infer_resource_slots() TypeError with null properties** (#10470): Added a TDD issue-capture Behave scenario that reproduces the bug where diff --git a/features/checkpoint_cli_commands.feature b/features/checkpoint_cli_commands.feature new file mode 100644 index 000000000..c53331d16 --- /dev/null +++ b/features/checkpoint_cli_commands.feature @@ -0,0 +1,135 @@ +Feature: Checkpoint listing and management CLI commands + As a user + I want to list and delete checkpoints for a plan + So that I can view and manage rollback points through the CLI + + # =================================================================== + # checkpoint-list: empty state + # =================================================================== + + Scenario: Listing checkpoints when none exist shows empty message + Given a cp-cli mocked checkpoint service with no checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" in default format + Then the cp-cli command should succeed + And the cp-cli output should contain "No checkpoints found" + + Scenario: Listing checkpoints in json format when none exist returns empty list + Given a cp-cli mocked checkpoint service with no checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with format "json" + Then the cp-cli command should succeed + And the cp-cli output should contain "[]" + + # =================================================================== + # checkpoint-list: populated state + # =================================================================== + + Scenario: Listing multiple checkpoints shows table with IDs and timestamps + Given a cp-cli mocked checkpoint service with 3 checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" in default format + Then the cp-cli command should succeed + And the cp-cli output should contain "Fields: checkpoint_id" + + Scenario: Listing checkpoints in json format returns structured data + Given a cp-cli mocked checkpoint service with 2 checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with format "json" + Then the cp-cli command should succeed + And the cp-cli output should contain "checkpoint_id" + And the cp-cli output should contain "plan_id" + + Scenario: Listing checkpoints in yaml format returns structured data + Given a cp-cli mocked checkpoint service with 2 checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with format "yaml" + Then the cp-cli command should succeed + And the cp-cli output should contain "checkpoint_id" + + Scenario: Listing checkpoints in table format returns structured data + Given a cp-cli mocked checkpoint service with 2 checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with format "table" + Then the cp-cli command should succeed + And the cp-cli output should contain "checkpoint_id" + + # =================================================================== + # checkpoint-list: sort options + # =================================================================== + + Scenario: Listing checkpoints with sort desc returns newest first + Given a cp-cli mocked checkpoint service with 3 checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with sort "desc" + Then the cp-cli command should succeed + + Scenario: Listing checkpoints with sort asc returns oldest first + Given a cp-cli mocked checkpoint service with 3 checkpoints for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with sort "asc" + Then the cp-cli command should succeed + + # =================================================================== + # checkpoint-list: type filter + # =================================================================== + + Scenario: Listing checkpoints filtered by type manual + Given a cp-cli mocked checkpoint service with checkpoints of mixed types for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with type filter "manual" + Then the cp-cli command should succeed + + Scenario: Listing checkpoints filtered by type pre_write + Given a cp-cli mocked checkpoint service with checkpoints of mixed types for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" with type filter "pre_write" + Then the cp-cli command should succeed + + # =================================================================== + # checkpoint-list: error handling + # =================================================================== + + Scenario: Listing checkpoints when service raises CleverAgentsError aborts + Given a cp-cli mocked checkpoint service that raises CleverAgentsError on list + When I invoke cp-cli checkpoint-list for plan "01ARZ3NDEKTSV4RRFFQ69G5FAV" in default format + Then the cp-cli command should abort + And the cp-cli output should contain "Error" + + # =================================================================== + # checkpoint-delete: single deletion + # =================================================================== + + Scenario: Deleting a single checkpoint with --yes succeeds + Given a cp-cli mocked checkpoint service that can delete checkpoints + When I invoke cp-cli checkpoint-delete "01ARZ3NDEKTSV4RRFFQ69G5FAV" with yes flag + Then the cp-cli command should succeed + And the cp-cli output should contain "deleted" + + Scenario: Deleting a single checkpoint in json format returns structured data + Given a cp-cli mocked checkpoint service that can delete checkpoints + When I invoke cp-cli checkpoint-delete "01ARZ3NDEKTSV4RRFFQ69G5FAV" with yes flag and format "json" + Then the cp-cli command should succeed + And the cp-cli output should contain "deleted_count" + + # =================================================================== + # checkpoint-delete: batch deletion + # =================================================================== + + Scenario: Deleting multiple checkpoints in batch with --yes succeeds + Given a cp-cli mocked checkpoint service that can delete checkpoints + When I invoke cp-cli checkpoint-delete multiple IDs with yes flag + Then the cp-cli command should succeed + And the cp-cli output should contain "deleted" + + # =================================================================== + # checkpoint-delete: error handling + # =================================================================== + + Scenario: Deleting a non-existent checkpoint shows error message + Given a cp-cli mocked checkpoint service that raises ResourceNotFoundError on delete + When I invoke cp-cli checkpoint-delete "01ARZ3NDEKTSV4RRFFQ69G5FAV" with yes flag + Then the cp-cli command should abort + And the cp-cli output should contain "not found" + + Scenario: Deleting with no checkpoint IDs aborts + Given a cp-cli mocked checkpoint service that can delete checkpoints + When I invoke cp-cli checkpoint-delete with no IDs + Then the cp-cli command should abort + And the cp-cli output should contain "At least one checkpoint ID is required" + + Scenario: Cancelling deletion confirmation aborts + Given a cp-cli mocked checkpoint service that can delete checkpoints + When I invoke cp-cli checkpoint-delete "01ARZ3NDEKTSV4RRFFQ69G5FAV" and decline confirmation + Then the cp-cli command should abort + And the cp-cli output should contain "Deletion cancelled" diff --git a/features/steps/checkpoint_cli_commands_steps.py b/features/steps/checkpoint_cli_commands_steps.py new file mode 100644 index 000000000..30c927fdc --- /dev/null +++ b/features/steps/checkpoint_cli_commands_steps.py @@ -0,0 +1,260 @@ +"""Step definitions for checkpoint_cli_commands.feature.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.commands.plan import app as plan_app +from cleveragents.core.exceptions import CleverAgentsError, ResourceNotFoundError +from cleveragents.domain.models.core.checkpoint import Checkpoint, CheckpointMetadata + +_PLAN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" +_CP_ID_1 = "01ARZ3NDEKTSV4RRFFQ69G5FB1" +_CP_ID_2 = "01ARZ3NDEKTSV4RRFFQ69G5FB2" +_CP_ID_3 = "01ARZ3NDEKTSV4RRFFQ69G5FB3" + +_PATCH_CONTAINER = "cleveragents.application.container.get_container" + + +def _make_checkpoint( + checkpoint_id: str = _CP_ID_1, + plan_id: str = _PLAN_ID, + checkpoint_type: str = "manual", + reason: str = "test checkpoint", + phase: str = "execute", + created_at: datetime | None = None, +) -> Checkpoint: + """Build a Checkpoint domain object for testing.""" + return Checkpoint( + checkpoint_id=checkpoint_id, + plan_id=plan_id, + sandbox_ref="abc123", + checkpoint_type=checkpoint_type, + created_at=created_at or datetime(2026, 4, 1, 10, 0, 0, tzinfo=UTC), + metadata=CheckpointMetadata( + reason=reason, + source_tool="test", + phase=phase, + ), + ) + + +# ---------------------------------------------------------------------- +# Given steps +# ---------------------------------------------------------------------- + + +@given('a cp-cli mocked checkpoint service with no checkpoints for plan "{plan_id}"') +def step_cp_cli_no_checkpoints(context: Context, plan_id: str) -> None: + """Set up a checkpoint service that returns no checkpoints.""" + context.cp_cli_runner = CliRunner() + mock_container = MagicMock() + mock_svc = MagicMock() + mock_svc.list_checkpoints.return_value = [] + mock_container.checkpoint_service.return_value = mock_svc + context.cp_cli_mock_container = mock_container + + +@given( + 'a cp-cli mocked checkpoint service with {count:d} checkpoints for plan "{plan_id}"' +) +def step_cp_cli_n_checkpoints(context: Context, count: int, plan_id: str) -> None: + """Set up a checkpoint service that returns N checkpoints.""" + context.cp_cli_runner = CliRunner() + checkpoints = [ + _make_checkpoint( + checkpoint_id=f"01ARZ3NDEKTSV4RRFFQ69G5F{i:02d}", + plan_id=plan_id, + created_at=datetime(2026, 4, 1, 10, i, 0, tzinfo=UTC), + ) + for i in range(1, count + 1) + ] + mock_container = MagicMock() + mock_svc = MagicMock() + mock_svc.list_checkpoints.return_value = checkpoints + mock_container.checkpoint_service.return_value = mock_svc + context.cp_cli_mock_container = mock_container + + +@given( + 'a cp-cli mocked checkpoint service with checkpoints of mixed types for plan "{plan_id}"' +) +def step_cp_cli_mixed_type_checkpoints(context: Context, plan_id: str) -> None: + """Set up a checkpoint service with checkpoints of different types.""" + context.cp_cli_runner = CliRunner() + checkpoints = [ + _make_checkpoint( + checkpoint_id=_CP_ID_1, plan_id=plan_id, checkpoint_type="manual" + ), + _make_checkpoint( + checkpoint_id=_CP_ID_2, plan_id=plan_id, checkpoint_type="pre_write" + ), + _make_checkpoint( + checkpoint_id=_CP_ID_3, plan_id=plan_id, checkpoint_type="post_step" + ), + ] + mock_container = MagicMock() + mock_svc = MagicMock() + mock_svc.list_checkpoints.return_value = checkpoints + mock_container.checkpoint_service.return_value = mock_svc + context.cp_cli_mock_container = mock_container + + +@given("a cp-cli mocked checkpoint service that raises CleverAgentsError on list") +def step_cp_cli_list_error(context: Context) -> None: + """Set up a checkpoint service that raises CleverAgentsError on list.""" + context.cp_cli_runner = CliRunner() + mock_container = MagicMock() + mock_svc = MagicMock() + mock_svc.list_checkpoints.side_effect = CleverAgentsError("service error") + mock_container.checkpoint_service.return_value = mock_svc + context.cp_cli_mock_container = mock_container + + +@given("a cp-cli mocked checkpoint service that can delete checkpoints") +def step_cp_cli_delete_ok(context: Context) -> None: + """Set up a checkpoint service that successfully deletes checkpoints.""" + context.cp_cli_runner = CliRunner() + mock_container = MagicMock() + mock_svc = MagicMock() + mock_svc.delete_checkpoint.return_value = None + mock_container.checkpoint_service.return_value = mock_svc + context.cp_cli_mock_container = mock_container + + +@given("a cp-cli mocked checkpoint service that raises ResourceNotFoundError on delete") +def step_cp_cli_delete_not_found(context: Context) -> None: + """Set up a checkpoint service that raises ResourceNotFoundError on delete.""" + context.cp_cli_runner = CliRunner() + mock_container = MagicMock() + mock_svc = MagicMock() + mock_svc.delete_checkpoint.side_effect = ResourceNotFoundError( + resource_type="checkpoint", resource_id=_CP_ID_1 + ) + mock_container.checkpoint_service.return_value = mock_svc + context.cp_cli_mock_container = mock_container + + +# ---------------------------------------------------------------------- +# When steps +# ---------------------------------------------------------------------- + + +@when('I invoke cp-cli checkpoint-list for plan "{plan_id}" in default format') +def step_cp_cli_invoke_list(context: Context, plan_id: str) -> None: + """Invoke checkpoint-list command in default rich format.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-list", plan_id] + ) + + +@when('I invoke cp-cli checkpoint-list for plan "{plan_id}" with format "{fmt}"') +def step_cp_cli_invoke_list_fmt(context: Context, plan_id: str, fmt: str) -> None: + """Invoke checkpoint-list command with a specific output format.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-list", plan_id, "--format", fmt] + ) + + +@when('I invoke cp-cli checkpoint-list for plan "{plan_id}" with sort "{sort}"') +def step_cp_cli_invoke_list_sort(context: Context, plan_id: str, sort: str) -> None: + """Invoke checkpoint-list command with a sort option.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-list", plan_id, "--sort", sort] + ) + + +@when( + 'I invoke cp-cli checkpoint-list for plan "{plan_id}" with type filter "{cp_type}"' +) +def step_cp_cli_invoke_list_type(context: Context, plan_id: str, cp_type: str) -> None: + """Invoke checkpoint-list command with a type filter.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-list", plan_id, "--type", cp_type] + ) + + +@when('I invoke cp-cli checkpoint-delete "{cp_id}" with yes flag') +def step_cp_cli_invoke_delete_yes(context: Context, cp_id: str) -> None: + """Invoke checkpoint-delete command with --yes flag.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-delete", cp_id, "--yes"] + ) + + +@when('I invoke cp-cli checkpoint-delete "{cp_id}" with yes flag and format "{fmt}"') +def step_cp_cli_invoke_delete_yes_fmt(context: Context, cp_id: str, fmt: str) -> None: + """Invoke checkpoint-delete command with --yes flag and format.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-delete", cp_id, "--yes", "--format", fmt] + ) + + +@when("I invoke cp-cli checkpoint-delete multiple IDs with yes flag") +def step_cp_cli_invoke_delete_multiple(context: Context) -> None: + """Invoke checkpoint-delete command with multiple checkpoint IDs.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-delete", _CP_ID_1, _CP_ID_2, "--yes"] + ) + + +@when("I invoke cp-cli checkpoint-delete with no IDs") +def step_cp_cli_invoke_delete_no_ids(context: Context) -> None: + """Invoke checkpoint-delete command with no checkpoint IDs.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-delete"] + ) + + +@when('I invoke cp-cli checkpoint-delete "{cp_id}" and decline confirmation') +def step_cp_cli_invoke_delete_decline(context: Context, cp_id: str) -> None: + """Invoke checkpoint-delete command and decline the confirmation prompt.""" + with patch(_PATCH_CONTAINER, return_value=context.cp_cli_mock_container): + context.cp_cli_result = context.cp_cli_runner.invoke( + plan_app, ["checkpoint-delete", cp_id], input="n\n" + ) + + +# ---------------------------------------------------------------------- +# Then steps +# ---------------------------------------------------------------------- + + +@then("the cp-cli command should succeed") +def step_cp_cli_assert_success(context: Context) -> None: + """Assert the CLI exited with code 0.""" + assert context.cp_cli_result.exit_code == 0, ( + f"Expected exit_code=0, got {context.cp_cli_result.exit_code}. " + f"Output: {context.cp_cli_result.output}" + ) + + +@then("the cp-cli command should abort") +def step_cp_cli_assert_abort(context: Context) -> None: + """Assert the CLI exited with a non-zero code.""" + assert context.cp_cli_result.exit_code != 0, ( + f"Expected non-zero exit_code, got {context.cp_cli_result.exit_code}. " + f"Output: {context.cp_cli_result.output}" + ) + + +@then('the cp-cli output should contain "{text}"') +def step_cp_cli_assert_output_contains(context: Context, text: str) -> None: + """Assert the CLI output contains the expected text.""" + assert text in context.cp_cli_result.output, ( + f"Expected output to contain {text!r}. " + f"Actual output: {context.cp_cli_result.output}" + ) diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index dd583bdd7..96cec32a1 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -5119,3 +5119,218 @@ def tree_decisions_cmd( expand=False, ) ) + + +# --------------------------------------------------------------------------- +# plan checkpoint-list / checkpoint-delete +# --------------------------------------------------------------------------- + + +@app.command("checkpoint-list") +def checkpoint_list_cmd( + plan_id: Annotated[ + str, + typer.Argument(help="Plan ID (ULID) to list checkpoints for"), + ], + sort: Annotated[ + str, + typer.Option( + "--sort", + help="Sort order: asc (oldest first) or desc (newest first)", + ), + ] = "asc", + checkpoint_type: Annotated[ + str | None, + typer.Option( + "--type", + help="Filter by type: pre_write, post_step, manual, pre_decision", + ), + ] = None, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", +) -> None: + """List all checkpoints for a plan. + + Displays checkpoint ID, timestamp, type, and state summary for each + checkpoint associated with the given plan. + + Examples:: + + agents plan checkpoint-list PLAN123 + agents plan checkpoint-list PLAN123 --sort desc + agents plan checkpoint-list PLAN123 --type manual + agents plan checkpoint-list PLAN123 --format json + """ + from cleveragents.application.container import get_container + from cleveragents.core.exceptions import ResourceNotFoundError as RNF + + try: + container = get_container() + svc = container.checkpoint_service() + + checkpoints = svc.list_checkpoints(plan_id) + + # Apply type filter + if checkpoint_type is not None: + checkpoints = [ + cp for cp in checkpoints if cp.checkpoint_type == checkpoint_type + ] + + # Apply sort order + reverse = sort.lower() == "desc" + checkpoints = sorted(checkpoints, key=lambda cp: cp.created_at, reverse=reverse) + + if fmt != OutputFormat.RICH.value: + data: list[dict[str, object]] = [ + { + "checkpoint_id": cp.checkpoint_id, + "plan_id": cp.plan_id, + "checkpoint_type": cp.checkpoint_type, + "sandbox_ref": cp.sandbox_ref, + "created_at": cp.created_at.isoformat(), + "reason": cp.metadata.reason, + "phase": cp.metadata.phase, + "decision_id": cp.decision_id, + } + for cp in checkpoints + ] + console.print(format_output(data, fmt)) + return + + if not checkpoints: + console.print(f"[dim]No checkpoints found for plan {plan_id}.[/dim]") + return + + table = Table(title=f"Checkpoints for Plan {plan_id}", show_header=True) + table.add_column("Checkpoint ID", style="cyan", max_width=26) + table.add_column("Checkpoint Type", style="yellow") + table.add_column("Created", style="green") + table.add_column("Reason") + table.add_column("Phase") + table.add_column("Decision ID", style="dim", max_width=26) + + for cp in checkpoints: + table.add_row( + cp.checkpoint_id, + cp.checkpoint_type, + _format_relative_time(cp.created_at), + cp.metadata.reason or "(none)", + cp.metadata.phase or "(none)", + cp.decision_id or "(none)", + ) + + console.print(table) + console.print( + "[dim]Fields: checkpoint_id, checkpoint_type, created_at, reason, " + "phase, decision_id[/dim]" + ) + cp_word = "checkpoint" if len(checkpoints) == 1 else "checkpoints" + console.print( + f"[green bold]✓ OK[/green bold] {len(checkpoints)} {cp_word} listed" + ) + + except RNF as e: + console.print(f"[red]Not found:[/red] {e.message}") + raise typer.Abort() from e + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + +@app.command("checkpoint-delete") +def checkpoint_delete_cmd( + checkpoint_ids: Annotated[ + list[str] | None, + typer.Argument( + help="One or more checkpoint IDs to delete", + metavar="CHECKPOINT_ID", + ), + ] = None, + yes: Annotated[ + bool, + typer.Option( + "--yes", + "-y", + help="Skip confirmation prompt", + ), + ] = False, + fmt: Annotated[ + str, + typer.Option( + "--format", + "-f", + help=_FORMAT_HELP, + ), + ] = "rich", +) -> None: + """Delete one or more checkpoints by ID. + + Accepts one or more checkpoint IDs as positional arguments. + Prompts for confirmation unless --yes is supplied. + + Examples:: + + agents plan checkpoint-delete CP123 + agents plan checkpoint-delete CP123 CP456 --yes + agents plan checkpoint-delete CP123 --format json + """ + from cleveragents.application.container import get_container + from cleveragents.core.exceptions import ResourceNotFoundError as RNF + + ids: list[str] = list(checkpoint_ids or []) + if not ids: + console.print("[red]Error:[/red] At least one checkpoint ID is required.") + raise typer.Abort() + + if not yes: + cp_word = "checkpoint" if len(ids) == 1 else "checkpoints" + ids_display = ", ".join(ids) + confirm = typer.confirm(f"Delete {len(ids)} {cp_word}: {ids_display}?") + if not confirm: + console.print("[yellow]Deletion cancelled.[/yellow]") + raise typer.Abort() + + container = get_container() + svc = container.checkpoint_service() + + deleted: list[str] = [] + errors: list[dict[str, str]] = [] + + for cp_id in ids: + try: + svc.delete_checkpoint(cp_id) + deleted.append(cp_id) + except RNF: + errors.append({"checkpoint_id": cp_id, "error": "not found"}) + except CleverAgentsError as e: + errors.append({"checkpoint_id": cp_id, "error": e.message}) + + if fmt != OutputFormat.RICH.value: + result_data: dict[str, object] = { + "deleted": deleted, + "errors": errors, + "deleted_count": len(deleted), + "error_count": len(errors), + } + console.print(format_output(result_data, fmt)) + return + + if deleted: + cp_word = "checkpoint" if len(deleted) == 1 else "checkpoints" + console.print(f"[green bold]✓ OK[/green bold] {len(deleted)} {cp_word} deleted") + for cp_id in deleted: + console.print(f" [dim]Deleted:[/dim] {cp_id}") + + if errors: + for err in errors: + cp_id_val = err["checkpoint_id"] + err_val = err["error"] + console.print(f"[red]Error:[/red] {cp_id_val} — {err_val}") + if not deleted: + raise typer.Abort()