Skip to content

Commit a533d44

Browse files
mikemolinetclaude
andcommitted
feat(cues): add bulk-delete subcommand (cueapi #650 parity)
Adds `cueapi bulk-delete <id1> <id2> ...` — variadic args, max 100 IDs per call. Wraps POST /v1/cues/bulk-delete (cueapi #650, "feat(cues): bulk delete + stale-cue discovery filters"). Behavior: - Per-ID atomic, NOT batch atomic — IDs that don't exist OR aren't owned by the caller land in the response's `skipped` array (silent skip on miss; no info leak about other tenants' cues). - Cascade FK handles executions + dispatch_outbox cleanup server-side. - Server requires X-Confirm-Destructive: true header (sent automatically after the local --yes confirmation). - --yes / -y skips the confirmation prompt for CI usage. - Client-side cap of 100 IDs prevents server roundtrip on obvious overruns; over-cap calls print error + early-return. Output: ✓ N deleted · M skipped (not found or not owned) Truncates lists at 10 entries with "... and X more" tail for readability on large bulk operations. 3 new tests: - test_bulk_delete_help (pins --yes flag + 100 cap callout) - test_bulk_delete_requires_at_least_one_id (variadic min) - test_bulk_delete_rejects_more_than_100_ids_pre_request (client cap pin) 186/186 tests pass. Source: drift audit handoff/cueapi-package-drift-2026-05-06; Backlog row was filed as "messages bulk-delete" but the actual cueapi #650 is CUES bulk-delete (mis-recall caught at port time). cueapi-main confirmed cueapi-cli lane mine via [CC-CUEAPI-CLI-LANE-OPTION-B-YOURS]. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 582815a commit a533d44

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

cueapi/cli.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,74 @@ def delete(ctx: click.Context, cue_id: str, yes: bool) -> None:
447447
click.echo(str(e))
448448

449449

450+
@main.command(name="bulk-delete")
451+
@click.argument("cue_ids", nargs=-1, required=True)
452+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
453+
@click.pass_context
454+
def bulk_delete(ctx: click.Context, cue_ids: tuple, yes: bool) -> None:
455+
"""Delete multiple cues in a single call (max 100, hosted PR #650).
456+
457+
Per-ID atomic, NOT batch atomic — IDs that don't exist OR aren't owned
458+
by the caller land in the response's `skipped` array (silent skip on
459+
miss, no info leak about other tenants' cues). Cascade FK handles
460+
executions + dispatch_outbox cleanup.
461+
462+
Server requires X-Confirm-Destructive: true header (sent automatically
463+
after the local --yes confirmation).
464+
"""
465+
if not cue_ids:
466+
echo_error("At least one cue ID required")
467+
return
468+
if len(cue_ids) > 100:
469+
echo_error(f"Max 100 IDs per call; got {len(cue_ids)}. Split into batches.")
470+
return
471+
472+
if not yes:
473+
click.echo(f"\nAbout to bulk-delete {len(cue_ids)} cue(s):")
474+
for cue_id in list(cue_ids)[:10]:
475+
click.echo(f" - {cue_id}")
476+
if len(cue_ids) > 10:
477+
click.echo(f" ... and {len(cue_ids) - 10} more")
478+
if not click.confirm("\nProceed?"):
479+
click.echo("Cancelled.")
480+
return
481+
482+
try:
483+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
484+
# X-Confirm-Destructive is required by the server (mirrors
485+
# POST /v1/auth/key/regenerate pattern).
486+
resp = client.post(
487+
"/cues/bulk-delete",
488+
json={"ids": list(cue_ids)},
489+
headers={"X-Confirm-Destructive": "true"},
490+
)
491+
if resp.status_code == 200:
492+
data = resp.json()
493+
deleted = data.get("deleted", [])
494+
skipped = data.get("skipped", [])
495+
click.echo()
496+
if deleted:
497+
echo_success(f"Deleted {len(deleted)} cue(s)")
498+
for cue_id in deleted[:10]:
499+
click.echo(f" ✓ {cue_id}")
500+
if len(deleted) > 10:
501+
click.echo(f" ... and {len(deleted) - 10} more")
502+
if skipped:
503+
echo_info("Skipped:", f"{len(skipped)} (not found or not owned)")
504+
for cue_id in skipped[:10]:
505+
click.echo(f" · {cue_id}")
506+
if len(skipped) > 10:
507+
click.echo(f" ... and {len(skipped) - 10} more")
508+
click.echo()
509+
elif resp.status_code == 400:
510+
error = resp.json().get("detail", {}).get("error", {})
511+
echo_error(error.get("message", f"Bad request (HTTP 400, {error.get('code', '?')})"))
512+
else:
513+
echo_error(f"Failed (HTTP {resp.status_code})")
514+
except click.ClickException as e:
515+
click.echo(str(e))
516+
517+
450518
# --- Fire (ad-hoc trigger / messaging via cues) ---
451519

452520

tests/test_cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2746,3 +2746,28 @@ def test_message_to_help_lists_mode_flag():
27462746
assert "--mode" in result.output
27472747
for choice in ("live", "bg", "inbox", "webhook", "auto"):
27482748
assert choice in result.output
2749+
2750+
2751+
# --- bulk-delete (hosted PR #650 parity) ---
2752+
2753+
2754+
def test_bulk_delete_help():
2755+
result = runner.invoke(main, ["bulk-delete", "--help"])
2756+
assert result.exit_code == 0
2757+
assert "100" in result.output # max IDs callout
2758+
assert "--yes" in result.output
2759+
2760+
2761+
def test_bulk_delete_requires_at_least_one_id():
2762+
result = runner.invoke(main, ["bulk-delete"])
2763+
assert result.exit_code != 0
2764+
2765+
2766+
def test_bulk_delete_rejects_more_than_100_ids_pre_request():
2767+
"""Pin: client-side cap of 100 prevents server roundtrip on obvious overruns."""
2768+
ids = [f"cue_test{i:03d}" for i in range(101)]
2769+
result = runner.invoke(main, ["bulk-delete", "--yes"] + ids)
2770+
# echo_error prints to the user; the command early-returns. The
2771+
# exit code shape is implementation-detail (echo_error may raise
2772+
# SystemExit). What matters is the cap message appears.
2773+
assert "100" in result.output

0 commit comments

Comments
 (0)