From 5a32049da3453791656605577b6f74176b8f12cc Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:56:51 -0500 Subject: [PATCH 01/12] feat(content): add content command group and status polling --- __tests__/commands/content.test.ts | 125 ++++++++++++++++++++++++ src/bin.ts | 2 + src/commands/content.ts | 150 +++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 __tests__/commands/content.test.ts create mode 100644 src/commands/content.ts diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts new file mode 100644 index 0000000..0275a01 --- /dev/null +++ b/__tests__/commands/content.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +import { contentCommand } from "../../src/commands/content.js"; +import { get, post } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("content command", () => { + it("lists templates", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + templates: [ + { name: "artist-caption-bedroom", description: "Moody purple bedroom setting" }, + ], + }); + + await contentCommand.parseAsync(["templates"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/content/templates"); + expect(logSpy).toHaveBeenCalledWith( + "- artist-caption-bedroom: Moody purple bedroom setting", + ); + }); + + it("validates an artist", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + artist_slug: "gatsby-grace", + ready: true, + missing: [], + }); + + await contentCommand.parseAsync(["validate", "--artist", "gatsby-grace"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/content/validate", { + artist_slug: "gatsby-grace", + }); + expect(logSpy).toHaveBeenCalledWith("Artist: gatsby-grace"); + }); + + it("estimates content cost", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + per_video_estimate_usd: 0.82, + total_estimate_usd: 1.64, + }); + + await contentCommand.parseAsync(["estimate", "--batch", "2"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/content/estimate", { + lipsync: "false", + batch: "2", + compare: "false", + }); + expect(logSpy).toHaveBeenCalledWith("Per video: $0.82"); + }); + + it("creates content run", async () => { + vi.mocked(post).mockResolvedValue({ + runId: "run_abc123", + status: "triggered", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "gatsby-grace", "--template", "artist-caption-bedroom"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/content/create", { + artist_slug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + }); + expect(logSpy).toHaveBeenCalledWith("Run started: run_abc123"); + }); + + it("shows run status and video URL", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + runs: [ + { + id: "run_abc123", + status: "COMPLETED", + output: { + video: { + signedUrl: "https://example.com/video.mp4", + }, + }, + }, + ], + }); + + await contentCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/tasks/runs", { runId: "run_abc123" }); + expect(logSpy).toHaveBeenCalledWith("Video URL: https://example.com/video.mp4"); + }); + + it("prints error when API call fails", async () => { + vi.mocked(get).mockRejectedValue(new Error("Request failed")); + + await contentCommand.parseAsync(["templates"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: Request failed"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + diff --git a/src/bin.ts b/src/bin.ts index f687860..c215dd6 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -8,6 +8,7 @@ import { sandboxesCommand } from "./commands/sandboxes.js"; import { songsCommand } from "./commands/songs.js"; import { notificationsCommand } from "./commands/notifications.js"; import { orgsCommand } from "./commands/orgs.js"; +import { contentCommand } from "./commands/content.js"; const pkgPath = join(__dirname, "..", "package.json"); const { version } = JSON.parse(readFileSync(pkgPath, "utf-8")); @@ -26,5 +27,6 @@ program.addCommand(songsCommand); program.addCommand(notificationsCommand); program.addCommand(sandboxesCommand); program.addCommand(orgsCommand); +program.addCommand(contentCommand); program.parse(); diff --git a/src/commands/content.ts b/src/commands/content.ts new file mode 100644 index 0000000..e12cf36 --- /dev/null +++ b/src/commands/content.ts @@ -0,0 +1,150 @@ +import { Command } from "commander"; +import { get, post } from "../client.js"; +import { printError, printJson } from "../output.js"; + +export const contentCommand = new Command("content") + .description("Content-creation pipeline commands"); + +const templatesCommand = new Command("templates") + .description("List available content templates") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/content/templates"); + if (opts.json) { + printJson(data); + return; + } + + const templates = Array.isArray(data.templates) ? data.templates : []; + if (templates.length === 0) { + console.log("No templates available."); + return; + } + + for (const template of templates as Array>) { + console.log(`- ${template.name}: ${template.description}`); + } + } catch (err) { + printError((err as Error).message); + } + }); + +const validateCommand = new Command("validate") + .description("Validate whether an artist is ready for content creation") + .requiredOption("--artist ", "Artist slug") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/content/validate", { + artist_slug: opts.artist, + }); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`Artist: ${data.artist_slug}`); + console.log(`Ready: ${data.ready ? "yes" : "no"}`); + if (Array.isArray(data.missing) && data.missing.length > 0) { + console.log("Missing:"); + for (const item of data.missing as Array>) { + console.log(`- ${item.file}`); + } + } + } catch (err) { + printError((err as Error).message); + } + }); + +const estimateCommand = new Command("estimate") + .description("Estimate content creation cost") + .option("--lipsync", "Estimate for lipsync mode") + .option("--batch ", "Number of videos", "1") + .option("--compare", "Include comparison profiles") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/content/estimate", { + lipsync: opts.lipsync ? "true" : "false", + batch: String(opts.batch || "1"), + compare: opts.compare ? "true" : "false", + }); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`Per video: $${data.per_video_estimate_usd}`); + console.log(`Total: $${data.total_estimate_usd}`); + } catch (err) { + printError((err as Error).message); + } + }); + +const createCommand = new Command("create") + .description("Trigger content creation pipeline") + .requiredOption("--artist ", "Artist slug") + .option("--template ", "Template name", "artist-caption-bedroom") + .option("--lipsync", "Enable lipsync mode") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await post("/api/content/create", { + artist_slug: opts.artist, + template: opts.template, + lipsync: !!opts.lipsync, + }); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`Run started: ${data.runId}`); + console.log("Use `recoup content status --run ` to poll status."); + } catch (err) { + printError((err as Error).message); + } + }); + +const statusCommand = new Command("status") + .description("Poll content creation run status") + .requiredOption("--run ", "Trigger.dev run ID") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/tasks/runs", { runId: opts.run }); + if (opts.json) { + printJson(data); + return; + } + + const runs = Array.isArray(data.runs) ? data.runs : []; + const run = runs[0] as Record | undefined; + if (!run) { + console.log("Run not found."); + return; + } + + console.log(`Run: ${run.id}`); + console.log(`Status: ${run.status}`); + + const output = run.output as Record | undefined; + const video = (output?.video || null) as Record | null; + if (video?.signedUrl) { + console.log(`Video URL: ${video.signedUrl}`); + } + } catch (err) { + printError((err as Error).message); + } + }); + +contentCommand.addCommand(templatesCommand); +contentCommand.addCommand(validateCommand); +contentCommand.addCommand(estimateCommand); +contentCommand.addCommand(createCommand); +contentCommand.addCommand(statusCommand); + From da3b9b60c305beedfb5be99d056cd84fc3d97779 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:59:46 -0500 Subject: [PATCH 02/12] feat: add --caption-length flag to content create command - supports short, medium, long (defaults to short) - update test for new param --- __tests__/commands/content.test.ts | 1 + src/commands/content.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index 0275a01..4a1603b 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -87,6 +87,7 @@ describe("content command", () => { artist_slug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, + caption_length: "short", }); expect(logSpy).toHaveBeenCalledWith("Run started: run_abc123"); }); diff --git a/src/commands/content.ts b/src/commands/content.ts index e12cf36..b8ae3a0 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -89,6 +89,7 @@ const createCommand = new Command("create") .requiredOption("--artist ", "Artist slug") .option("--template ", "Template name", "artist-caption-bedroom") .option("--lipsync", "Enable lipsync mode") + .option("--caption-length ", "Caption length: short, medium, long", "short") .option("--json", "Output as JSON") .action(async opts => { try { @@ -96,6 +97,7 @@ const createCommand = new Command("create") artist_slug: opts.artist, template: opts.template, lipsync: !!opts.lipsync, + caption_length: opts.captionLength, }); if (opts.json) { From 86a0f0b766d747d9c20d3b5616d3bab7e7ba9f08 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:35:12 -0500 Subject: [PATCH 03/12] feat: add --upscale flag to content create command --- __tests__/commands/content.test.ts | 1 + src/commands/content.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index 4a1603b..ecf6a39 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -88,6 +88,7 @@ describe("content command", () => { template: "artist-caption-bedroom", lipsync: false, caption_length: "short", + upscale: false, }); expect(logSpy).toHaveBeenCalledWith("Run started: run_abc123"); }); diff --git a/src/commands/content.ts b/src/commands/content.ts index b8ae3a0..9bae3c3 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -90,6 +90,7 @@ const createCommand = new Command("create") .option("--template ", "Template name", "artist-caption-bedroom") .option("--lipsync", "Enable lipsync mode") .option("--caption-length ", "Caption length: short, medium, long", "short") + .option("--upscale", "Upscale image and video for higher quality") .option("--json", "Output as JSON") .action(async opts => { try { @@ -98,6 +99,7 @@ const createCommand = new Command("create") template: opts.template, lipsync: !!opts.lipsync, caption_length: opts.captionLength, + upscale: !!opts.upscale, }); if (opts.json) { From 6da0ef5e22ffc73a95325c7a3ba3b30cbe77430e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:50:59 -0500 Subject: [PATCH 04/12] feat: add --batch flag to content create command --- __tests__/commands/content.test.ts | 1 + src/commands/content.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index ecf6a39..fa27092 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -89,6 +89,7 @@ describe("content command", () => { lipsync: false, caption_length: "short", upscale: false, + batch: 1, }); expect(logSpy).toHaveBeenCalledWith("Run started: run_abc123"); }); diff --git a/src/commands/content.ts b/src/commands/content.ts index 9bae3c3..959f79d 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -91,6 +91,7 @@ const createCommand = new Command("create") .option("--lipsync", "Enable lipsync mode") .option("--caption-length ", "Caption length: short, medium, long", "short") .option("--upscale", "Upscale image and video for higher quality") + .option("--batch ", "Generate multiple videos in parallel", "1") .option("--json", "Output as JSON") .action(async opts => { try { @@ -100,6 +101,7 @@ const createCommand = new Command("create") lipsync: !!opts.lipsync, caption_length: opts.captionLength, upscale: !!opts.upscale, + batch: parseInt(opts.batch, 10), }); if (opts.json) { @@ -107,7 +109,14 @@ const createCommand = new Command("create") return; } - console.log(`Run started: ${data.runId}`); + if (data.runIds) { + console.log(`Batch started: ${data.runIds.length} videos`); + for (const id of data.runIds as string[]) { + console.log(` - ${id}`); + } + } else { + console.log(`Run started: ${data.runId}`); + } console.log("Use `recoup content status --run ` to poll status."); } catch (err) { printError((err as Error).message); From 50a6c4baefca9964e68b42fd177105e028427440 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:34:50 -0500 Subject: [PATCH 05/12] fix: add test for non-default flags (CodeRabbit nitpick) - test caption-length, upscale, and batch with explicit values --- __tests__/commands/content.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index fa27092..17e9456 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -94,6 +94,27 @@ describe("content command", () => { expect(logSpy).toHaveBeenCalledWith("Run started: run_abc123"); }); + it("creates content run with custom flags", async () => { + vi.mocked(post).mockResolvedValue({ + runId: "run_xyz789", + status: "triggered", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "test-artist", "--caption-length", "long", "--upscale", "--batch", "3"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/content/create", { + artist_slug: "test-artist", + template: "artist-caption-bedroom", + lipsync: false, + caption_length: "long", + upscale: true, + batch: 3, + }); + }); + it("shows run status and video URL", async () => { vi.mocked(get).mockResolvedValue({ status: "success", From 5448d3a17192f6cdef0ba376165f8a2e885c2118 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:59:00 -0500 Subject: [PATCH 06/12] refactor: read runIds array instead of runId (matches API change) --- __tests__/commands/content.test.ts | 4 ++-- src/commands/content.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index 17e9456..1647e67 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -74,7 +74,7 @@ describe("content command", () => { it("creates content run", async () => { vi.mocked(post).mockResolvedValue({ - runId: "run_abc123", + runIds: ["run_abc123"], status: "triggered", }); @@ -91,7 +91,7 @@ describe("content command", () => { upscale: false, batch: 1, }); - expect(logSpy).toHaveBeenCalledWith("Run started: run_abc123"); + expect(logSpy).toHaveBeenCalledWith(`Run started: run_abc123`); }); it("creates content run with custom flags", async () => { diff --git a/src/commands/content.ts b/src/commands/content.ts index 959f79d..7cacb38 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -109,13 +109,14 @@ const createCommand = new Command("create") return; } - if (data.runIds) { - console.log(`Batch started: ${data.runIds.length} videos`); - for (const id of data.runIds as string[]) { + const runIds = data.runIds as string[]; + if (runIds.length === 1) { + console.log(`Run started: ${runIds[0]}`); + } else { + console.log(`Batch started: ${runIds.length} videos`); + for (const id of runIds) { console.log(` - ${id}`); } - } else { - console.log(`Run started: ${data.runId}`); } console.log("Use `recoup content status --run ` to poll status."); } catch (err) { From a1f2283aa0dcf3fa3f9c50ebe3b0f0591178377c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:16:56 -0500 Subject: [PATCH 07/12] refactor: use --artist for account ID, remove slug references --- __tests__/commands/content.test.ts | 14 +++++++------- src/commands/content.ts | 9 ++++----- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index 1647e67..a594c47 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -42,17 +42,17 @@ describe("content command", () => { it("validates an artist", async () => { vi.mocked(get).mockResolvedValue({ status: "success", - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", ready: true, missing: [], }); - await contentCommand.parseAsync(["validate", "--artist", "gatsby-grace"], { from: "user" }); + await contentCommand.parseAsync(["validate", "--artist", "550e8400-e29b-41d4-a716-446655440000"], { from: "user" }); expect(get).toHaveBeenCalledWith("/api/content/validate", { - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", }); - expect(logSpy).toHaveBeenCalledWith("Artist: gatsby-grace"); + expect(logSpy).toHaveBeenCalledWith("Ready: yes"); }); it("estimates content cost", async () => { @@ -79,12 +79,12 @@ describe("content command", () => { }); await contentCommand.parseAsync( - ["create", "--artist", "gatsby-grace", "--template", "artist-caption-bedroom"], + ["create", "--artist", "550e8400-e29b-41d4-a716-446655440000", "--template", "artist-caption-bedroom"], { from: "user" }, ); expect(post).toHaveBeenCalledWith("/api/content/create", { - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", template: "artist-caption-bedroom", lipsync: false, caption_length: "short", @@ -106,7 +106,7 @@ describe("content command", () => { ); expect(post).toHaveBeenCalledWith("/api/content/create", { - artist_slug: "test-artist", + artist_account_id: "test-artist", template: "artist-caption-bedroom", lipsync: false, caption_length: "long", diff --git a/src/commands/content.ts b/src/commands/content.ts index 7cacb38..8fea5f2 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -32,12 +32,12 @@ const templatesCommand = new Command("templates") const validateCommand = new Command("validate") .description("Validate whether an artist is ready for content creation") - .requiredOption("--artist ", "Artist slug") + .requiredOption("--artist ", "Artist account ID") .option("--json", "Output as JSON") .action(async opts => { try { const data = await get("/api/content/validate", { - artist_slug: opts.artist, + artist_account_id: opts.artist, }); if (opts.json) { @@ -45,7 +45,6 @@ const validateCommand = new Command("validate") return; } - console.log(`Artist: ${data.artist_slug}`); console.log(`Ready: ${data.ready ? "yes" : "no"}`); if (Array.isArray(data.missing) && data.missing.length > 0) { console.log("Missing:"); @@ -86,7 +85,7 @@ const estimateCommand = new Command("estimate") const createCommand = new Command("create") .description("Trigger content creation pipeline") - .requiredOption("--artist ", "Artist slug") + .requiredOption("--artist ", "Artist account ID") .option("--template ", "Template name", "artist-caption-bedroom") .option("--lipsync", "Enable lipsync mode") .option("--caption-length ", "Caption length: short, medium, long", "short") @@ -96,7 +95,7 @@ const createCommand = new Command("create") .action(async opts => { try { const data = await post("/api/content/create", { - artist_slug: opts.artist, + artist_account_id: opts.artist, template: opts.template, lipsync: !!opts.lipsync, caption_length: opts.captionLength, From b50e5b2a58d02d72f48f69939dc195be4f9c266c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 12 Mar 2026 19:11:30 -0500 Subject: [PATCH 08/12] refactor: move run status to `recoup tasks status`, remove from content - Create tasks command with status subcommand - Remove status subcommand from content command - Update create hint to reference `recoup tasks status` - Add tasks tests, update content tests Co-Authored-By: Claude Opus 4.6 --- __tests__/commands/content.test.ts | 28 ++++------ __tests__/commands/tasks.test.ts | 85 ++++++++++++++++++++++++++++++ src/bin.ts | 2 + src/commands/content.ts | 35 +----------- src/commands/tasks.ts | 40 ++++++++++++++ 5 files changed, 139 insertions(+), 51 deletions(-) create mode 100644 __tests__/commands/tasks.test.ts create mode 100644 src/commands/tasks.ts diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index a594c47..6def5de 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -115,26 +115,20 @@ describe("content command", () => { }); }); - it("shows run status and video URL", async () => { - vi.mocked(get).mockResolvedValue({ - status: "success", - runs: [ - { - id: "run_abc123", - status: "COMPLETED", - output: { - video: { - signedUrl: "https://example.com/video.mp4", - }, - }, - }, - ], + it("shows tasks status hint after create", async () => { + vi.mocked(post).mockResolvedValue({ + runIds: ["run_abc123"], + status: "triggered", }); - await contentCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + await contentCommand.parseAsync( + ["create", "--artist", "550e8400-e29b-41d4-a716-446655440000"], + { from: "user" }, + ); - expect(get).toHaveBeenCalledWith("/api/tasks/runs", { runId: "run_abc123" }); - expect(logSpy).toHaveBeenCalledWith("Video URL: https://example.com/video.mp4"); + expect(logSpy).toHaveBeenCalledWith( + "Use `recoup tasks status --run ` to check progress.", + ); }); it("prints error when API call fails", async () => { diff --git a/__tests__/commands/tasks.test.ts b/__tests__/commands/tasks.test.ts new file mode 100644 index 0000000..389e1ff --- /dev/null +++ b/__tests__/commands/tasks.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +import { tasksCommand } from "../../src/commands/tasks.js"; +import { get } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("tasks command", () => { + it("shows run status", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + runs: [ + { + id: "run_abc123", + status: "COMPLETED", + output: {}, + }, + ], + }); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(get).toHaveBeenCalledWith("/api/tasks/runs", { runId: "run_abc123" }); + expect(logSpy).toHaveBeenCalledWith("Run: run_abc123"); + expect(logSpy).toHaveBeenCalledWith("Status: COMPLETED"); + }); + + it("shows video URL when available", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + runs: [ + { + id: "run_abc123", + status: "COMPLETED", + output: { + video: { + signedUrl: "https://example.com/video.mp4", + }, + }, + }, + ], + }); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith("Video URL: https://example.com/video.mp4"); + }); + + it("shows run not found", async () => { + vi.mocked(get).mockResolvedValue({ + status: "success", + runs: [], + }); + + await tasksCommand.parseAsync(["status", "--run", "run_missing"], { from: "user" }); + + expect(logSpy).toHaveBeenCalledWith("Run not found."); + }); + + it("prints error when API call fails", async () => { + vi.mocked(get).mockRejectedValue(new Error("Request failed")); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: Request failed"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/bin.ts b/src/bin.ts index c215dd6..5608963 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -9,6 +9,7 @@ import { songsCommand } from "./commands/songs.js"; import { notificationsCommand } from "./commands/notifications.js"; import { orgsCommand } from "./commands/orgs.js"; import { contentCommand } from "./commands/content.js"; +import { tasksCommand } from "./commands/tasks.js"; const pkgPath = join(__dirname, "..", "package.json"); const { version } = JSON.parse(readFileSync(pkgPath, "utf-8")); @@ -27,6 +28,7 @@ program.addCommand(songsCommand); program.addCommand(notificationsCommand); program.addCommand(sandboxesCommand); program.addCommand(orgsCommand); +program.addCommand(tasksCommand); program.addCommand(contentCommand); program.parse(); diff --git a/src/commands/content.ts b/src/commands/content.ts index 8fea5f2..9fc5ee1 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -117,39 +117,7 @@ const createCommand = new Command("create") console.log(` - ${id}`); } } - console.log("Use `recoup content status --run ` to poll status."); - } catch (err) { - printError((err as Error).message); - } - }); - -const statusCommand = new Command("status") - .description("Poll content creation run status") - .requiredOption("--run ", "Trigger.dev run ID") - .option("--json", "Output as JSON") - .action(async opts => { - try { - const data = await get("/api/tasks/runs", { runId: opts.run }); - if (opts.json) { - printJson(data); - return; - } - - const runs = Array.isArray(data.runs) ? data.runs : []; - const run = runs[0] as Record | undefined; - if (!run) { - console.log("Run not found."); - return; - } - - console.log(`Run: ${run.id}`); - console.log(`Status: ${run.status}`); - - const output = run.output as Record | undefined; - const video = (output?.video || null) as Record | null; - if (video?.signedUrl) { - console.log(`Video URL: ${video.signedUrl}`); - } + console.log("Use `recoup tasks status --run ` to check progress."); } catch (err) { printError((err as Error).message); } @@ -159,5 +127,4 @@ contentCommand.addCommand(templatesCommand); contentCommand.addCommand(validateCommand); contentCommand.addCommand(estimateCommand); contentCommand.addCommand(createCommand); -contentCommand.addCommand(statusCommand); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts new file mode 100644 index 0000000..d31c0d4 --- /dev/null +++ b/src/commands/tasks.ts @@ -0,0 +1,40 @@ +import { Command } from "commander"; +import { get } from "../client.js"; +import { printError, printJson } from "../output.js"; + +export const tasksCommand = new Command("tasks") + .description("Check the status of background task runs"); + +const statusCommand = new Command("status") + .description("Check the status of a task run") + .requiredOption("--run ", "Trigger.dev run ID") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/tasks/runs", { runId: opts.run }); + if (opts.json) { + printJson(data); + return; + } + + const runs = Array.isArray(data.runs) ? data.runs : []; + const run = runs[0] as Record | undefined; + if (!run) { + console.log("Run not found."); + return; + } + + console.log(`Run: ${run.id}`); + console.log(`Status: ${run.status}`); + + const output = run.output as Record | undefined; + const video = (output?.video || null) as Record | null; + if (video?.signedUrl) { + console.log(`Video URL: ${video.signedUrl}`); + } + } catch (err) { + printError((err as Error).message); + } + }); + +tasksCommand.addCommand(statusCommand); From 79cf7b39ea04643603fd0f5e48103077f58f1852 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 12 Mar 2026 19:17:10 -0500 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20error=20handling,=20input=20validation,=20runIds=20?= =?UTF-8?q?guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract getErrorMessage() to output.ts for safe non-Error handling - Validate --batch as positive integer, --caption-length as short|medium|long - Guard data.runIds before dereferencing in content create - Fix custom flags test mock to use runIds array - Add tests for all three issues Co-Authored-By: Claude Opus 4.6 --- __tests__/commands/content.test.ts | 90 +++++++++++++++++++++++------- __tests__/commands/tasks.test.ts | 9 +++ src/commands/content.ts | 39 ++++++++++--- src/commands/tasks.ts | 4 +- src/output.ts | 4 ++ 5 files changed, 114 insertions(+), 32 deletions(-) diff --git a/__tests__/commands/content.test.ts b/__tests__/commands/content.test.ts index 6def5de..37717cf 100644 --- a/__tests__/commands/content.test.ts +++ b/__tests__/commands/content.test.ts @@ -94,27 +94,6 @@ describe("content command", () => { expect(logSpy).toHaveBeenCalledWith(`Run started: run_abc123`); }); - it("creates content run with custom flags", async () => { - vi.mocked(post).mockResolvedValue({ - runId: "run_xyz789", - status: "triggered", - }); - - await contentCommand.parseAsync( - ["create", "--artist", "test-artist", "--caption-length", "long", "--upscale", "--batch", "3"], - { from: "user" }, - ); - - expect(post).toHaveBeenCalledWith("/api/content/create", { - artist_account_id: "test-artist", - template: "artist-caption-bedroom", - lipsync: false, - caption_length: "long", - upscale: true, - batch: 3, - }); - }); - it("shows tasks status hint after create", async () => { vi.mocked(post).mockResolvedValue({ runIds: ["run_abc123"], @@ -131,6 +110,15 @@ describe("content command", () => { ); }); + it("handles non-Error thrown values gracefully", async () => { + vi.mocked(get).mockRejectedValue("plain string error"); + + await contentCommand.parseAsync(["templates"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: plain string error"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it("prints error when API call fails", async () => { vi.mocked(get).mockRejectedValue(new Error("Request failed")); @@ -139,5 +127,65 @@ describe("content command", () => { expect(errorSpy).toHaveBeenCalledWith("Error: Request failed"); expect(exitSpy).toHaveBeenCalledWith(1); }); + + it("errors when --batch is not a positive integer", async () => { + await contentCommand.parseAsync( + ["create", "--artist", "test-artist", "--batch", "abc"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith("Error: --batch must be a positive integer"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors when --caption-length is invalid", async () => { + await contentCommand.parseAsync( + ["create", "--artist", "test-artist", "--caption-length", "huge"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: --caption-length must be one of: short, medium, long", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors when API returns no runIds", async () => { + vi.mocked(post).mockResolvedValue({ + status: "error", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "test-artist"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith( + "Error: Response did not include any run IDs", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("creates content run with batch flag and shows batch output", async () => { + vi.mocked(post).mockResolvedValue({ + runIds: ["run_1", "run_2", "run_3"], + status: "triggered", + }); + + await contentCommand.parseAsync( + ["create", "--artist", "test-artist", "--caption-length", "long", "--upscale", "--batch", "3"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/content/create", { + artist_account_id: "test-artist", + template: "artist-caption-bedroom", + lipsync: false, + caption_length: "long", + upscale: true, + batch: 3, + }); + expect(logSpy).toHaveBeenCalledWith("Batch started: 3 videos"); + }); }); diff --git a/__tests__/commands/tasks.test.ts b/__tests__/commands/tasks.test.ts index 389e1ff..733a26b 100644 --- a/__tests__/commands/tasks.test.ts +++ b/__tests__/commands/tasks.test.ts @@ -82,4 +82,13 @@ describe("tasks command", () => { expect(errorSpy).toHaveBeenCalledWith("Error: Request failed"); expect(exitSpy).toHaveBeenCalledWith(1); }); + + it("handles non-Error thrown values gracefully", async () => { + vi.mocked(get).mockRejectedValue("plain string error"); + + await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" }); + + expect(errorSpy).toHaveBeenCalledWith("Error: plain string error"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); diff --git a/src/commands/content.ts b/src/commands/content.ts index 9fc5ee1..c31f338 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -1,6 +1,16 @@ import { Command } from "commander"; import { get, post } from "../client.js"; -import { printError, printJson } from "../output.js"; +import { getErrorMessage, printError, printJson } from "../output.js"; + +const ALLOWED_CAPTION_LENGTHS = new Set(["short", "medium", "long"]); + +const parsePositiveInt = (value: string, flag: string): number => { + const parsed = parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${flag} must be a positive integer`); + } + return parsed; +}; export const contentCommand = new Command("content") .description("Content-creation pipeline commands"); @@ -26,7 +36,7 @@ const templatesCommand = new Command("templates") console.log(`- ${template.name}: ${template.description}`); } } catch (err) { - printError((err as Error).message); + printError(getErrorMessage(err)); } }); @@ -53,7 +63,7 @@ const validateCommand = new Command("validate") } } } catch (err) { - printError((err as Error).message); + printError(getErrorMessage(err)); } }); @@ -65,9 +75,10 @@ const estimateCommand = new Command("estimate") .option("--json", "Output as JSON") .action(async opts => { try { + const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); const data = await get("/api/content/estimate", { lipsync: opts.lipsync ? "true" : "false", - batch: String(opts.batch || "1"), + batch: String(batch), compare: opts.compare ? "true" : "false", }); @@ -79,7 +90,7 @@ const estimateCommand = new Command("estimate") console.log(`Per video: $${data.per_video_estimate_usd}`); console.log(`Total: $${data.total_estimate_usd}`); } catch (err) { - printError((err as Error).message); + printError(getErrorMessage(err)); } }); @@ -94,13 +105,18 @@ const createCommand = new Command("create") .option("--json", "Output as JSON") .action(async opts => { try { + const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); + if (!ALLOWED_CAPTION_LENGTHS.has(opts.captionLength)) { + throw new Error("--caption-length must be one of: short, medium, long"); + } + const data = await post("/api/content/create", { artist_account_id: opts.artist, template: opts.template, lipsync: !!opts.lipsync, caption_length: opts.captionLength, upscale: !!opts.upscale, - batch: parseInt(opts.batch, 10), + batch, }); if (opts.json) { @@ -108,7 +124,13 @@ const createCommand = new Command("create") return; } - const runIds = data.runIds as string[]; + const runIds = Array.isArray(data.runIds) + ? data.runIds.filter((id): id is string => typeof id === "string") + : []; + if (runIds.length === 0) { + throw new Error("Response did not include any run IDs"); + } + if (runIds.length === 1) { console.log(`Run started: ${runIds[0]}`); } else { @@ -119,7 +141,7 @@ const createCommand = new Command("create") } console.log("Use `recoup tasks status --run ` to check progress."); } catch (err) { - printError((err as Error).message); + printError(getErrorMessage(err)); } }); @@ -127,4 +149,3 @@ contentCommand.addCommand(templatesCommand); contentCommand.addCommand(validateCommand); contentCommand.addCommand(estimateCommand); contentCommand.addCommand(createCommand); - diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index d31c0d4..f923db8 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { get } from "../client.js"; -import { printError, printJson } from "../output.js"; +import { getErrorMessage, printError, printJson } from "../output.js"; export const tasksCommand = new Command("tasks") .description("Check the status of background task runs"); @@ -33,7 +33,7 @@ const statusCommand = new Command("status") console.log(`Video URL: ${video.signedUrl}`); } } catch (err) { - printError((err as Error).message); + printError(getErrorMessage(err)); } }); diff --git a/src/output.ts b/src/output.ts index ce424b7..1164be2 100644 --- a/src/output.ts +++ b/src/output.ts @@ -34,6 +34,10 @@ export function printTable( } } +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + export function printError(message: string): void { console.error(`Error: ${message}`); process.exit(1); From c8ba602b0f452c895399c2b00a094b5caebd840e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 12 Mar 2026 19:36:44 -0500 Subject: [PATCH 10/12] refactor: extract getErrorMessage to its own file (SRP) Co-Authored-By: Claude Opus 4.6 --- src/commands/content.ts | 3 ++- src/commands/tasks.ts | 3 ++- src/getErrorMessage.ts | 3 +++ src/output.ts | 4 ---- 4 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 src/getErrorMessage.ts diff --git a/src/commands/content.ts b/src/commands/content.ts index c31f338..8916c86 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { get, post } from "../client.js"; -import { getErrorMessage, printError, printJson } from "../output.js"; +import { getErrorMessage } from "../getErrorMessage.js"; +import { printError, printJson } from "../output.js"; const ALLOWED_CAPTION_LENGTHS = new Set(["short", "medium", "long"]); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index f923db8..7e9fab3 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { get } from "../client.js"; -import { getErrorMessage, printError, printJson } from "../output.js"; +import { getErrorMessage } from "../getErrorMessage.js"; +import { printError, printJson } from "../output.js"; export const tasksCommand = new Command("tasks") .description("Check the status of background task runs"); diff --git a/src/getErrorMessage.ts b/src/getErrorMessage.ts new file mode 100644 index 0000000..6c83786 --- /dev/null +++ b/src/getErrorMessage.ts @@ -0,0 +1,3 @@ +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/output.ts b/src/output.ts index 1164be2..ce424b7 100644 --- a/src/output.ts +++ b/src/output.ts @@ -34,10 +34,6 @@ export function printTable( } } -export function getErrorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - export function printError(message: string): void { console.error(`Error: ${message}`); process.exit(1); From 8f2f622194505b07fae52071ce64edc340524934 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 12 Mar 2026 19:40:07 -0500 Subject: [PATCH 11/12] refactor: split content command into SRP files - parsePositiveInt.ts - templatesCommand.ts - validateCommand.ts - estimateCommand.ts - createCommand.ts - content.ts now only composes subcommands Co-Authored-By: Claude Opus 4.6 --- src/commands/content.ts | 147 +---------------------- src/commands/content/createCommand.ts | 58 +++++++++ src/commands/content/estimateCommand.ts | 32 +++++ src/commands/content/parsePositiveInt.ts | 7 ++ src/commands/content/templatesCommand.ts | 29 +++++ src/commands/content/validateCommand.ts | 31 +++++ 6 files changed, 161 insertions(+), 143 deletions(-) create mode 100644 src/commands/content/createCommand.ts create mode 100644 src/commands/content/estimateCommand.ts create mode 100644 src/commands/content/parsePositiveInt.ts create mode 100644 src/commands/content/templatesCommand.ts create mode 100644 src/commands/content/validateCommand.ts diff --git a/src/commands/content.ts b/src/commands/content.ts index 8916c86..6180412 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -1,151 +1,12 @@ import { Command } from "commander"; -import { get, post } from "../client.js"; -import { getErrorMessage } from "../getErrorMessage.js"; -import { printError, printJson } from "../output.js"; - -const ALLOWED_CAPTION_LENGTHS = new Set(["short", "medium", "long"]); - -const parsePositiveInt = (value: string, flag: string): number => { - const parsed = parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error(`${flag} must be a positive integer`); - } - return parsed; -}; +import { templatesCommand } from "./content/templatesCommand.js"; +import { validateCommand } from "./content/validateCommand.js"; +import { estimateCommand } from "./content/estimateCommand.js"; +import { createCommand } from "./content/createCommand.js"; export const contentCommand = new Command("content") .description("Content-creation pipeline commands"); -const templatesCommand = new Command("templates") - .description("List available content templates") - .option("--json", "Output as JSON") - .action(async opts => { - try { - const data = await get("/api/content/templates"); - if (opts.json) { - printJson(data); - return; - } - - const templates = Array.isArray(data.templates) ? data.templates : []; - if (templates.length === 0) { - console.log("No templates available."); - return; - } - - for (const template of templates as Array>) { - console.log(`- ${template.name}: ${template.description}`); - } - } catch (err) { - printError(getErrorMessage(err)); - } - }); - -const validateCommand = new Command("validate") - .description("Validate whether an artist is ready for content creation") - .requiredOption("--artist ", "Artist account ID") - .option("--json", "Output as JSON") - .action(async opts => { - try { - const data = await get("/api/content/validate", { - artist_account_id: opts.artist, - }); - - if (opts.json) { - printJson(data); - return; - } - - console.log(`Ready: ${data.ready ? "yes" : "no"}`); - if (Array.isArray(data.missing) && data.missing.length > 0) { - console.log("Missing:"); - for (const item of data.missing as Array>) { - console.log(`- ${item.file}`); - } - } - } catch (err) { - printError(getErrorMessage(err)); - } - }); - -const estimateCommand = new Command("estimate") - .description("Estimate content creation cost") - .option("--lipsync", "Estimate for lipsync mode") - .option("--batch ", "Number of videos", "1") - .option("--compare", "Include comparison profiles") - .option("--json", "Output as JSON") - .action(async opts => { - try { - const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); - const data = await get("/api/content/estimate", { - lipsync: opts.lipsync ? "true" : "false", - batch: String(batch), - compare: opts.compare ? "true" : "false", - }); - - if (opts.json) { - printJson(data); - return; - } - - console.log(`Per video: $${data.per_video_estimate_usd}`); - console.log(`Total: $${data.total_estimate_usd}`); - } catch (err) { - printError(getErrorMessage(err)); - } - }); - -const createCommand = new Command("create") - .description("Trigger content creation pipeline") - .requiredOption("--artist ", "Artist account ID") - .option("--template ", "Template name", "artist-caption-bedroom") - .option("--lipsync", "Enable lipsync mode") - .option("--caption-length ", "Caption length: short, medium, long", "short") - .option("--upscale", "Upscale image and video for higher quality") - .option("--batch ", "Generate multiple videos in parallel", "1") - .option("--json", "Output as JSON") - .action(async opts => { - try { - const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); - if (!ALLOWED_CAPTION_LENGTHS.has(opts.captionLength)) { - throw new Error("--caption-length must be one of: short, medium, long"); - } - - const data = await post("/api/content/create", { - artist_account_id: opts.artist, - template: opts.template, - lipsync: !!opts.lipsync, - caption_length: opts.captionLength, - upscale: !!opts.upscale, - batch, - }); - - if (opts.json) { - printJson(data); - return; - } - - const runIds = Array.isArray(data.runIds) - ? data.runIds.filter((id): id is string => typeof id === "string") - : []; - if (runIds.length === 0) { - throw new Error("Response did not include any run IDs"); - } - - if (runIds.length === 1) { - console.log(`Run started: ${runIds[0]}`); - } else { - console.log(`Batch started: ${runIds.length} videos`); - for (const id of runIds) { - console.log(` - ${id}`); - } - } - console.log("Use `recoup tasks status --run ` to check progress."); - } catch (err) { - printError(getErrorMessage(err)); - } - }); - contentCommand.addCommand(templatesCommand); contentCommand.addCommand(validateCommand); contentCommand.addCommand(estimateCommand); diff --git a/src/commands/content/createCommand.ts b/src/commands/content/createCommand.ts new file mode 100644 index 0000000..9738a24 --- /dev/null +++ b/src/commands/content/createCommand.ts @@ -0,0 +1,58 @@ +import { Command } from "commander"; +import { post } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; +import { parsePositiveInt } from "./parsePositiveInt.js"; + +const ALLOWED_CAPTION_LENGTHS = new Set(["short", "medium", "long"]); + +export const createCommand = new Command("create") + .description("Trigger content creation pipeline") + .requiredOption("--artist ", "Artist account ID") + .option("--template ", "Template name", "artist-caption-bedroom") + .option("--lipsync", "Enable lipsync mode") + .option("--caption-length ", "Caption length: short, medium, long", "short") + .option("--upscale", "Upscale image and video for higher quality") + .option("--batch ", "Generate multiple videos in parallel", "1") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); + if (!ALLOWED_CAPTION_LENGTHS.has(opts.captionLength)) { + throw new Error("--caption-length must be one of: short, medium, long"); + } + + const data = await post("/api/content/create", { + artist_account_id: opts.artist, + template: opts.template, + lipsync: !!opts.lipsync, + caption_length: opts.captionLength, + upscale: !!opts.upscale, + batch, + }); + + if (opts.json) { + printJson(data); + return; + } + + const runIds = Array.isArray(data.runIds) + ? data.runIds.filter((id): id is string => typeof id === "string") + : []; + if (runIds.length === 0) { + throw new Error("Response did not include any run IDs"); + } + + if (runIds.length === 1) { + console.log(`Run started: ${runIds[0]}`); + } else { + console.log(`Batch started: ${runIds.length} videos`); + for (const id of runIds) { + console.log(` - ${id}`); + } + } + console.log("Use `recoup tasks status --run ` to check progress."); + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/estimateCommand.ts b/src/commands/content/estimateCommand.ts new file mode 100644 index 0000000..7126e12 --- /dev/null +++ b/src/commands/content/estimateCommand.ts @@ -0,0 +1,32 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; +import { parsePositiveInt } from "./parsePositiveInt.js"; + +export const estimateCommand = new Command("estimate") + .description("Estimate content creation cost") + .option("--lipsync", "Estimate for lipsync mode") + .option("--batch ", "Number of videos", "1") + .option("--compare", "Include comparison profiles") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch"); + const data = await get("/api/content/estimate", { + lipsync: opts.lipsync ? "true" : "false", + batch: String(batch), + compare: opts.compare ? "true" : "false", + }); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`Per video: $${data.per_video_estimate_usd}`); + console.log(`Total: $${data.total_estimate_usd}`); + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/parsePositiveInt.ts b/src/commands/content/parsePositiveInt.ts new file mode 100644 index 0000000..43dca7e --- /dev/null +++ b/src/commands/content/parsePositiveInt.ts @@ -0,0 +1,7 @@ +export function parsePositiveInt(value: string, flag: string): number { + const parsed = parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${flag} must be a positive integer`); + } + return parsed; +} diff --git a/src/commands/content/templatesCommand.ts b/src/commands/content/templatesCommand.ts new file mode 100644 index 0000000..94e2814 --- /dev/null +++ b/src/commands/content/templatesCommand.ts @@ -0,0 +1,29 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; + +export const templatesCommand = new Command("templates") + .description("List available content templates") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/content/templates"); + if (opts.json) { + printJson(data); + return; + } + + const templates = Array.isArray(data.templates) ? data.templates : []; + if (templates.length === 0) { + console.log("No templates available."); + return; + } + + for (const template of templates as Array>) { + console.log(`- ${template.name}: ${template.description}`); + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/validateCommand.ts b/src/commands/content/validateCommand.ts new file mode 100644 index 0000000..886b876 --- /dev/null +++ b/src/commands/content/validateCommand.ts @@ -0,0 +1,31 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; + +export const validateCommand = new Command("validate") + .description("Validate whether an artist is ready for content creation") + .requiredOption("--artist ", "Artist account ID") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/content/validate", { + artist_account_id: opts.artist, + }); + + if (opts.json) { + printJson(data); + return; + } + + console.log(`Ready: ${data.ready ? "yes" : "no"}`); + if (Array.isArray(data.missing) && data.missing.length > 0) { + console.log("Missing:"); + for (const item of data.missing as Array>) { + console.log(`- ${item.file}`); + } + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); From b4f8eaf8a3f82741d3f6ae5a879f61686c662a38 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 12 Mar 2026 19:52:25 -0500 Subject: [PATCH 12/12] refactor: split tasks command into SRP files - statusCommand.ts extracted to tasks/ subdirectory - tasks.ts now only composes subcommands Co-Authored-By: Claude Opus 4.6 --- src/commands/tasks.ts | 36 +---------------------------- src/commands/tasks/statusCommand.ts | 36 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 src/commands/tasks/statusCommand.ts diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 7e9fab3..7379930 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,41 +1,7 @@ import { Command } from "commander"; -import { get } from "../client.js"; -import { getErrorMessage } from "../getErrorMessage.js"; -import { printError, printJson } from "../output.js"; +import { statusCommand } from "./tasks/statusCommand.js"; export const tasksCommand = new Command("tasks") .description("Check the status of background task runs"); -const statusCommand = new Command("status") - .description("Check the status of a task run") - .requiredOption("--run ", "Trigger.dev run ID") - .option("--json", "Output as JSON") - .action(async opts => { - try { - const data = await get("/api/tasks/runs", { runId: opts.run }); - if (opts.json) { - printJson(data); - return; - } - - const runs = Array.isArray(data.runs) ? data.runs : []; - const run = runs[0] as Record | undefined; - if (!run) { - console.log("Run not found."); - return; - } - - console.log(`Run: ${run.id}`); - console.log(`Status: ${run.status}`); - - const output = run.output as Record | undefined; - const video = (output?.video || null) as Record | null; - if (video?.signedUrl) { - console.log(`Video URL: ${video.signedUrl}`); - } - } catch (err) { - printError(getErrorMessage(err)); - } - }); - tasksCommand.addCommand(statusCommand); diff --git a/src/commands/tasks/statusCommand.ts b/src/commands/tasks/statusCommand.ts new file mode 100644 index 0000000..58aac17 --- /dev/null +++ b/src/commands/tasks/statusCommand.ts @@ -0,0 +1,36 @@ +import { Command } from "commander"; +import { get } from "../../client.js"; +import { getErrorMessage } from "../../getErrorMessage.js"; +import { printError, printJson } from "../../output.js"; + +export const statusCommand = new Command("status") + .description("Check the status of a task run") + .requiredOption("--run ", "Trigger.dev run ID") + .option("--json", "Output as JSON") + .action(async opts => { + try { + const data = await get("/api/tasks/runs", { runId: opts.run }); + if (opts.json) { + printJson(data); + return; + } + + const runs = Array.isArray(data.runs) ? data.runs : []; + const run = runs[0] as Record | undefined; + if (!run) { + console.log("Run not found."); + return; + } + + console.log(`Run: ${run.id}`); + console.log(`Status: ${run.status}`); + + const output = run.output as Record | undefined; + const video = (output?.video || null) as Record | null; + if (video?.signedUrl) { + console.log(`Video URL: ${video.signedUrl}`); + } + } catch (err) { + printError(getErrorMessage(err)); + } + });