diff --git a/parity-manifest.json b/parity-manifest.json index d11c638..5be1fb6 100644 --- a/parity-manifest.json +++ b/parity-manifest.json @@ -17,6 +17,7 @@ "GET /v1/cues/{id}": {"tool": "cueapi_get_cue"}, "PATCH /v1/cues/{id}": {"tool": "cueapi_update_cue, cueapi_pause_cue, cueapi_resume_cue (pause/resume are PATCH variants with status field, not separate endpoints)"}, "DELETE /v1/cues/{id}": {"tool": "cueapi_delete_cue"}, + "POST /v1/cues/bulk-delete": {"tool": "cueapi_bulk_delete_cues — wraps cueapi #650 / cli #46. Per-ID atomic, max 100 per call. Sends X-Confirm-Destructive: true automatically."}, "POST /v1/cues/{id}/fire": {"tool": "cueapi_fire_cue"}, "GET /v1/executions": {"tool": "cueapi_list_executions"}, "GET /v1/executions/{id}": {"tool": "cueapi_get_execution"}, diff --git a/src/tools.ts b/src/tools.ts index d21ccc2..04f9d91 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -74,6 +74,16 @@ const cueIdSchema = z.object({ cue_id: z.string().describe("CueAPI cue ID (e.g. 'cue_...')"), }); +const bulkDeleteCuesSchema = z.object({ + ids: z + .array(z.string()) + .min(1) + .max(100) + .describe( + "Cue IDs to delete (1-100 per call). 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)." + ), +}); + const updateCueSchema = z.object({ cue_id: z.string().describe("CueAPI cue ID to update"), name: z.string().min(1).optional().describe("New cue name"), @@ -405,6 +415,21 @@ export const tools: ToolDefinition[] = [ `/v1/cues/${encodeURIComponent(args.cue_id)}` ), }, + { + name: "cueapi_bulk_delete_cues", + description: + "Delete multiple cues in a single call (max 100). Returns {deleted, skipped} — per-ID atomic, not batch atomic. IDs that don't exist or aren't owned by the caller land in 'skipped' (silent skip on miss; no info leak about other tenants' cues). Cascade FK handles executions + dispatch_outbox cleanup. Sends X-Confirm-Destructive: true automatically (server requires it for any bulk-destructive endpoint).", + schema: bulkDeleteCuesSchema, + handler: async (client, args) => + client.request( + "POST", + "/v1/cues/bulk-delete", + { ids: args.ids }, + undefined, + undefined, + { "X-Confirm-Destructive": "true" } + ), + }, { name: "cueapi_list_executions", description: diff --git a/tests/tools.test.ts b/tests/tools.test.ts index bfc739c..254b5e8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1227,3 +1227,50 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => { }); }); }); + +describe("cueapi_bulk_delete_cues — schema + HTTP contract", () => { + it("registers the tool with the cueapi_* naming convention", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues"); + expect(tool).toBeDefined(); + expect(tool!.description.length).toBeGreaterThan(20); + }); + + it("schema rejects empty ids array", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + expect(() => tool.schema.parse({ ids: [] })).toThrow(); + }); + + it("schema rejects more than 100 ids", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + const tooMany = Array.from({ length: 101 }, (_, i) => `cue_${i}`); + expect(() => tool.schema.parse({ ids: tooMany })).toThrow(); + }); + + it("schema accepts exactly 100 ids (boundary)", () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + const exactlyMax = Array.from({ length: 100 }, (_, i) => `cue_${i}`); + expect(() => tool.schema.parse({ ids: exactlyMax })).not.toThrow(); + }); + + it("handler POSTs to /v1/cues/bulk-delete with X-Confirm-Destructive header", async () => { + const tool = tools.find((t) => t.name === "cueapi_bulk_delete_cues")!; + const calls: unknown[] = []; + const mockClient = { + request: vi.fn(async (...args: unknown[]) => { + calls.push(args); + return { deleted: ["cue_a"], skipped: [] }; + }), + } as unknown as CueAPIClient; + + await tool.handler(mockClient, { ids: ["cue_a"] }); + + expect(calls).toHaveLength(1); + const [method, path, body, query, apiKey, headers] = calls[0] as unknown[]; + expect(method).toBe("POST"); + expect(path).toBe("/v1/cues/bulk-delete"); + expect(body).toEqual({ ids: ["cue_a"] }); + expect(query).toBeUndefined(); + expect(apiKey).toBeUndefined(); + expect(headers).toEqual({ "X-Confirm-Destructive": "true" }); + }); +});