feat(plans): implement checkpoint listing and management CLI commands #8683

Merged
HAL9000 merged 4 commits from feat/v3.3.0-checkpoint-management-cli into master 2026-05-03 01:09:16 +00:00
4 changed files with 611 additions and 0 deletions
+1
View File
@@ -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 <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` 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 <id>` 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
+135
View File
@@ -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"
@@ -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}"
)
+215
View File
@@ -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()