Skip to content

Commit e4d4548

Browse files
sidneyswiftsweetmantechclaude
authored
feat: add recoup content command suite (#13)
* feat(content): add content command group and status polling * feat: add --caption-length flag to content create command - supports short, medium, long (defaults to short) - update test for new param * feat: add --upscale flag to content create command * feat: add --batch flag to content create command * fix: add test for non-default flags (CodeRabbit nitpick) - test caption-length, upscale, and batch with explicit values * refactor: read runIds array instead of runId (matches API change) * refactor: use --artist for account ID, remove slug references * 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 <noreply@anthropic.com> * fix: address CodeRabbit review — error handling, input validation, runIds guard - 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 <noreply@anthropic.com> * refactor: extract getErrorMessage to its own file (SRP) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Sweets Sweetman <sweetmantech@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9738866 commit e4d4548

12 files changed

Lines changed: 505 additions & 0 deletions

File tree

__tests__/commands/content.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../../src/client.js", () => ({
4+
get: vi.fn(),
5+
post: vi.fn(),
6+
}));
7+
8+
import { contentCommand } from "../../src/commands/content.js";
9+
import { get, post } from "../../src/client.js";
10+
11+
let logSpy: ReturnType<typeof vi.spyOn>;
12+
let errorSpy: ReturnType<typeof vi.spyOn>;
13+
let exitSpy: ReturnType<typeof vi.spyOn>;
14+
15+
beforeEach(() => {
16+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
17+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
18+
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
19+
});
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
});
24+
25+
describe("content command", () => {
26+
it("lists templates", async () => {
27+
vi.mocked(get).mockResolvedValue({
28+
status: "success",
29+
templates: [
30+
{ name: "artist-caption-bedroom", description: "Moody purple bedroom setting" },
31+
],
32+
});
33+
34+
await contentCommand.parseAsync(["templates"], { from: "user" });
35+
36+
expect(get).toHaveBeenCalledWith("/api/content/templates");
37+
expect(logSpy).toHaveBeenCalledWith(
38+
"- artist-caption-bedroom: Moody purple bedroom setting",
39+
);
40+
});
41+
42+
it("validates an artist", async () => {
43+
vi.mocked(get).mockResolvedValue({
44+
status: "success",
45+
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
46+
ready: true,
47+
missing: [],
48+
});
49+
50+
await contentCommand.parseAsync(["validate", "--artist", "550e8400-e29b-41d4-a716-446655440000"], { from: "user" });
51+
52+
expect(get).toHaveBeenCalledWith("/api/content/validate", {
53+
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
54+
});
55+
expect(logSpy).toHaveBeenCalledWith("Ready: yes");
56+
});
57+
58+
it("estimates content cost", async () => {
59+
vi.mocked(get).mockResolvedValue({
60+
status: "success",
61+
per_video_estimate_usd: 0.82,
62+
total_estimate_usd: 1.64,
63+
});
64+
65+
await contentCommand.parseAsync(["estimate", "--batch", "2"], { from: "user" });
66+
67+
expect(get).toHaveBeenCalledWith("/api/content/estimate", {
68+
lipsync: "false",
69+
batch: "2",
70+
compare: "false",
71+
});
72+
expect(logSpy).toHaveBeenCalledWith("Per video: $0.82");
73+
});
74+
75+
it("creates content run", async () => {
76+
vi.mocked(post).mockResolvedValue({
77+
runIds: ["run_abc123"],
78+
status: "triggered",
79+
});
80+
81+
await contentCommand.parseAsync(
82+
["create", "--artist", "550e8400-e29b-41d4-a716-446655440000", "--template", "artist-caption-bedroom"],
83+
{ from: "user" },
84+
);
85+
86+
expect(post).toHaveBeenCalledWith("/api/content/create", {
87+
artist_account_id: "550e8400-e29b-41d4-a716-446655440000",
88+
template: "artist-caption-bedroom",
89+
lipsync: false,
90+
caption_length: "short",
91+
upscale: false,
92+
batch: 1,
93+
});
94+
expect(logSpy).toHaveBeenCalledWith(`Run started: run_abc123`);
95+
});
96+
97+
it("shows tasks status hint after create", async () => {
98+
vi.mocked(post).mockResolvedValue({
99+
runIds: ["run_abc123"],
100+
status: "triggered",
101+
});
102+
103+
await contentCommand.parseAsync(
104+
["create", "--artist", "550e8400-e29b-41d4-a716-446655440000"],
105+
{ from: "user" },
106+
);
107+
108+
expect(logSpy).toHaveBeenCalledWith(
109+
"Use `recoup tasks status --run <runId>` to check progress.",
110+
);
111+
});
112+
113+
it("handles non-Error thrown values gracefully", async () => {
114+
vi.mocked(get).mockRejectedValue("plain string error");
115+
116+
await contentCommand.parseAsync(["templates"], { from: "user" });
117+
118+
expect(errorSpy).toHaveBeenCalledWith("Error: plain string error");
119+
expect(exitSpy).toHaveBeenCalledWith(1);
120+
});
121+
122+
it("prints error when API call fails", async () => {
123+
vi.mocked(get).mockRejectedValue(new Error("Request failed"));
124+
125+
await contentCommand.parseAsync(["templates"], { from: "user" });
126+
127+
expect(errorSpy).toHaveBeenCalledWith("Error: Request failed");
128+
expect(exitSpy).toHaveBeenCalledWith(1);
129+
});
130+
131+
it("errors when --batch is not a positive integer", async () => {
132+
await contentCommand.parseAsync(
133+
["create", "--artist", "test-artist", "--batch", "abc"],
134+
{ from: "user" },
135+
);
136+
137+
expect(errorSpy).toHaveBeenCalledWith("Error: --batch must be a positive integer");
138+
expect(exitSpy).toHaveBeenCalledWith(1);
139+
});
140+
141+
it("errors when --caption-length is invalid", async () => {
142+
await contentCommand.parseAsync(
143+
["create", "--artist", "test-artist", "--caption-length", "huge"],
144+
{ from: "user" },
145+
);
146+
147+
expect(errorSpy).toHaveBeenCalledWith(
148+
"Error: --caption-length must be one of: short, medium, long",
149+
);
150+
expect(exitSpy).toHaveBeenCalledWith(1);
151+
});
152+
153+
it("errors when API returns no runIds", async () => {
154+
vi.mocked(post).mockResolvedValue({
155+
status: "error",
156+
});
157+
158+
await contentCommand.parseAsync(
159+
["create", "--artist", "test-artist"],
160+
{ from: "user" },
161+
);
162+
163+
expect(errorSpy).toHaveBeenCalledWith(
164+
"Error: Response did not include any run IDs",
165+
);
166+
expect(exitSpy).toHaveBeenCalledWith(1);
167+
});
168+
169+
it("creates content run with batch flag and shows batch output", async () => {
170+
vi.mocked(post).mockResolvedValue({
171+
runIds: ["run_1", "run_2", "run_3"],
172+
status: "triggered",
173+
});
174+
175+
await contentCommand.parseAsync(
176+
["create", "--artist", "test-artist", "--caption-length", "long", "--upscale", "--batch", "3"],
177+
{ from: "user" },
178+
);
179+
180+
expect(post).toHaveBeenCalledWith("/api/content/create", {
181+
artist_account_id: "test-artist",
182+
template: "artist-caption-bedroom",
183+
lipsync: false,
184+
caption_length: "long",
185+
upscale: true,
186+
batch: 3,
187+
});
188+
expect(logSpy).toHaveBeenCalledWith("Batch started: 3 videos");
189+
});
190+
});
191+

__tests__/commands/tasks.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../../src/client.js", () => ({
4+
get: vi.fn(),
5+
post: vi.fn(),
6+
}));
7+
8+
import { tasksCommand } from "../../src/commands/tasks.js";
9+
import { get } from "../../src/client.js";
10+
11+
let logSpy: ReturnType<typeof vi.spyOn>;
12+
let errorSpy: ReturnType<typeof vi.spyOn>;
13+
let exitSpy: ReturnType<typeof vi.spyOn>;
14+
15+
beforeEach(() => {
16+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
17+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
18+
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
19+
});
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
});
24+
25+
describe("tasks command", () => {
26+
it("shows run status", async () => {
27+
vi.mocked(get).mockResolvedValue({
28+
status: "success",
29+
runs: [
30+
{
31+
id: "run_abc123",
32+
status: "COMPLETED",
33+
output: {},
34+
},
35+
],
36+
});
37+
38+
await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });
39+
40+
expect(get).toHaveBeenCalledWith("/api/tasks/runs", { runId: "run_abc123" });
41+
expect(logSpy).toHaveBeenCalledWith("Run: run_abc123");
42+
expect(logSpy).toHaveBeenCalledWith("Status: COMPLETED");
43+
});
44+
45+
it("shows video URL when available", async () => {
46+
vi.mocked(get).mockResolvedValue({
47+
status: "success",
48+
runs: [
49+
{
50+
id: "run_abc123",
51+
status: "COMPLETED",
52+
output: {
53+
video: {
54+
signedUrl: "https://example.com/video.mp4",
55+
},
56+
},
57+
},
58+
],
59+
});
60+
61+
await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });
62+
63+
expect(logSpy).toHaveBeenCalledWith("Video URL: https://example.com/video.mp4");
64+
});
65+
66+
it("shows run not found", async () => {
67+
vi.mocked(get).mockResolvedValue({
68+
status: "success",
69+
runs: [],
70+
});
71+
72+
await tasksCommand.parseAsync(["status", "--run", "run_missing"], { from: "user" });
73+
74+
expect(logSpy).toHaveBeenCalledWith("Run not found.");
75+
});
76+
77+
it("prints error when API call fails", async () => {
78+
vi.mocked(get).mockRejectedValue(new Error("Request failed"));
79+
80+
await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });
81+
82+
expect(errorSpy).toHaveBeenCalledWith("Error: Request failed");
83+
expect(exitSpy).toHaveBeenCalledWith(1);
84+
});
85+
86+
it("handles non-Error thrown values gracefully", async () => {
87+
vi.mocked(get).mockRejectedValue("plain string error");
88+
89+
await tasksCommand.parseAsync(["status", "--run", "run_abc123"], { from: "user" });
90+
91+
expect(errorSpy).toHaveBeenCalledWith("Error: plain string error");
92+
expect(exitSpy).toHaveBeenCalledWith(1);
93+
});
94+
});

src/bin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { sandboxesCommand } from "./commands/sandboxes.js";
88
import { songsCommand } from "./commands/songs.js";
99
import { notificationsCommand } from "./commands/notifications.js";
1010
import { orgsCommand } from "./commands/orgs.js";
11+
import { contentCommand } from "./commands/content.js";
12+
import { tasksCommand } from "./commands/tasks.js";
1113

1214
const pkgPath = join(__dirname, "..", "package.json");
1315
const { version } = JSON.parse(readFileSync(pkgPath, "utf-8"));
@@ -26,5 +28,7 @@ program.addCommand(songsCommand);
2628
program.addCommand(notificationsCommand);
2729
program.addCommand(sandboxesCommand);
2830
program.addCommand(orgsCommand);
31+
program.addCommand(tasksCommand);
32+
program.addCommand(contentCommand);
2933

3034
program.parse();

src/commands/content.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Command } from "commander";
2+
import { templatesCommand } from "./content/templatesCommand.js";
3+
import { validateCommand } from "./content/validateCommand.js";
4+
import { estimateCommand } from "./content/estimateCommand.js";
5+
import { createCommand } from "./content/createCommand.js";
6+
7+
export const contentCommand = new Command("content")
8+
.description("Content-creation pipeline commands");
9+
10+
contentCommand.addCommand(templatesCommand);
11+
contentCommand.addCommand(validateCommand);
12+
contentCommand.addCommand(estimateCommand);
13+
contentCommand.addCommand(createCommand);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Command } from "commander";
2+
import { post } from "../../client.js";
3+
import { getErrorMessage } from "../../getErrorMessage.js";
4+
import { printError, printJson } from "../../output.js";
5+
import { parsePositiveInt } from "./parsePositiveInt.js";
6+
7+
const ALLOWED_CAPTION_LENGTHS = new Set(["short", "medium", "long"]);
8+
9+
export const createCommand = new Command("create")
10+
.description("Trigger content creation pipeline")
11+
.requiredOption("--artist <id>", "Artist account ID")
12+
.option("--template <name>", "Template name", "artist-caption-bedroom")
13+
.option("--lipsync", "Enable lipsync mode")
14+
.option("--caption-length <length>", "Caption length: short, medium, long", "short")
15+
.option("--upscale", "Upscale image and video for higher quality")
16+
.option("--batch <count>", "Generate multiple videos in parallel", "1")
17+
.option("--json", "Output as JSON")
18+
.action(async opts => {
19+
try {
20+
const batch = parsePositiveInt(String(opts.batch ?? "1"), "--batch");
21+
if (!ALLOWED_CAPTION_LENGTHS.has(opts.captionLength)) {
22+
throw new Error("--caption-length must be one of: short, medium, long");
23+
}
24+
25+
const data = await post("/api/content/create", {
26+
artist_account_id: opts.artist,
27+
template: opts.template,
28+
lipsync: !!opts.lipsync,
29+
caption_length: opts.captionLength,
30+
upscale: !!opts.upscale,
31+
batch,
32+
});
33+
34+
if (opts.json) {
35+
printJson(data);
36+
return;
37+
}
38+
39+
const runIds = Array.isArray(data.runIds)
40+
? data.runIds.filter((id): id is string => typeof id === "string")
41+
: [];
42+
if (runIds.length === 0) {
43+
throw new Error("Response did not include any run IDs");
44+
}
45+
46+
if (runIds.length === 1) {
47+
console.log(`Run started: ${runIds[0]}`);
48+
} else {
49+
console.log(`Batch started: ${runIds.length} videos`);
50+
for (const id of runIds) {
51+
console.log(` - ${id}`);
52+
}
53+
}
54+
console.log("Use `recoup tasks status --run <runId>` to check progress.");
55+
} catch (err) {
56+
printError(getErrorMessage(err));
57+
}
58+
});

0 commit comments

Comments
 (0)