From 3c1551c248206216480ced1f0d192e15615c7871 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Wed, 6 May 2026 20:19:05 -0700 Subject: [PATCH] feat(cues): add bulk-delete subcommand (cueapi #650 parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `cueapi bulk-delete ...` — 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) --- cueapi/cli.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 25 +++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/cueapi/cli.py b/cueapi/cli.py index 728f4cd..1a00162 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -449,6 +449,74 @@ def delete(ctx: click.Context, cue_id: str, yes: bool) -> None: click.echo(str(e)) +@main.command(name="bulk-delete") +@click.argument("cue_ids", nargs=-1, required=True) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.pass_context +def bulk_delete(ctx: click.Context, cue_ids: tuple, yes: bool) -> None: + """Delete multiple cues in a single call (max 100, hosted PR #650). + + 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 requires X-Confirm-Destructive: true header (sent automatically + after the local --yes confirmation). + """ + if not cue_ids: + echo_error("At least one cue ID required") + return + if len(cue_ids) > 100: + echo_error(f"Max 100 IDs per call; got {len(cue_ids)}. Split into batches.") + return + + if not yes: + click.echo(f"\nAbout to bulk-delete {len(cue_ids)} cue(s):") + for cue_id in list(cue_ids)[:10]: + click.echo(f" - {cue_id}") + if len(cue_ids) > 10: + click.echo(f" ... and {len(cue_ids) - 10} more") + if not click.confirm("\nProceed?"): + click.echo("Cancelled.") + return + + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + # X-Confirm-Destructive is required by the server (mirrors + # POST /v1/auth/key/regenerate pattern). + resp = client.post( + "/cues/bulk-delete", + json={"ids": list(cue_ids)}, + headers={"X-Confirm-Destructive": "true"}, + ) + if resp.status_code == 200: + data = resp.json() + deleted = data.get("deleted", []) + skipped = data.get("skipped", []) + click.echo() + if deleted: + echo_success(f"Deleted {len(deleted)} cue(s)") + for cue_id in deleted[:10]: + click.echo(f" ✓ {cue_id}") + if len(deleted) > 10: + click.echo(f" ... and {len(deleted) - 10} more") + if skipped: + echo_info("Skipped:", f"{len(skipped)} (not found or not owned)") + for cue_id in skipped[:10]: + click.echo(f" · {cue_id}") + if len(skipped) > 10: + click.echo(f" ... and {len(skipped) - 10} more") + click.echo() + elif resp.status_code == 400: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Bad request (HTTP 400, {error.get('code', '?')})")) + else: + echo_error(f"Failed (HTTP {resp.status_code})") + except click.ClickException as e: + click.echo(str(e)) + + # --- Fire (ad-hoc trigger / messaging via cues) --- diff --git a/tests/test_cli.py b/tests/test_cli.py index 093bebc..4312e97 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3618,3 +3618,28 @@ def test_agents_presence_requires_ref(): result = runner.invoke(main, ["agents", "presence"]) assert result.exit_code != 0 assert "ref" in result.output.lower() or "missing" in result.output.lower() + + +# --- bulk-delete (hosted PR #650 parity) --- + + +def test_bulk_delete_help(): + result = runner.invoke(main, ["bulk-delete", "--help"]) + assert result.exit_code == 0 + assert "100" in result.output # max IDs callout + assert "--yes" in result.output + + +def test_bulk_delete_requires_at_least_one_id(): + result = runner.invoke(main, ["bulk-delete"]) + assert result.exit_code != 0 + + +def test_bulk_delete_rejects_more_than_100_ids_pre_request(): + """Pin: client-side cap of 100 prevents server roundtrip on obvious overruns.""" + ids = [f"cue_test{i:03d}" for i in range(101)] + result = runner.invoke(main, ["bulk-delete", "--yes"] + ids) + # echo_error prints to the user; the command early-returns. The + # exit code shape is implementation-detail (echo_error may raise + # SystemExit). What matters is the cap message appears. + assert "100" in result.output