Skip to content
Empty file added cli-for-agent
Empty file.
32 changes: 32 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,35 @@ export async function post(

return data;
}

/**
* Sends a PATCH request to the Recoup API.
*
* @param path - API endpoint path.
* @param body - Request body.
* @returns Parsed JSON response.
*/
export async function patch(
path: string,
body: Record<string, unknown>,
): Promise<ApiResponse> {
const baseUrl = getBaseUrl();
const url = new URL(path, baseUrl);

const response = await fetch(url.toString(), {
method: "PATCH",
headers: {
"x-api-key": getApiKey(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});

const data: ApiResponse = await response.json();

if (!response.ok || data.status === "error") {
throw new Error(data.error || data.message || `Request failed: ${response.status}`);
}

return data;
}
12 changes: 12 additions & 0 deletions src/commands/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { templatesCommand } from "./content/templatesCommand.js";
import { validateCommand } from "./content/validateCommand.js";
import { estimateCommand } from "./content/estimateCommand.js";
import { createCommand } from "./content/createCommand.js";
import { imageCommand } from "./content/imageCommand.js";
import { videoCommand } from "./content/videoCommand.js";
import { textCommand } from "./content/textCommand.js";
import { audioCommand } from "./content/audioCommand.js";
import { editCommand } from "./content/editCommand.js";
import { upscaleCommand } from "./content/upscaleCommand.js";

export const contentCommand = new Command("content")
.description("Content-creation pipeline commands");
Expand All @@ -11,3 +17,9 @@ contentCommand.addCommand(templatesCommand);
contentCommand.addCommand(validateCommand);
contentCommand.addCommand(estimateCommand);
contentCommand.addCommand(createCommand);
contentCommand.addCommand(imageCommand);
contentCommand.addCommand(videoCommand);
contentCommand.addCommand(textCommand);
contentCommand.addCommand(audioCommand);
contentCommand.addCommand(editCommand);
contentCommand.addCommand(upscaleCommand);
20 changes: 20 additions & 0 deletions src/commands/content/audioCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createPrimitiveCommand } from "./createPrimitiveCommand.js";

export const audioCommand = createPrimitiveCommand(
"transcribe",
"Transcribe audio into timestamped text",
"/api/content/transcribe",
[
{ flag: "--url <urls>", description: "Comma-separated audio URLs to transcribe" },
{ flag: "--model <id>", description: "Model ID (default: fal-ai/whisper)" },
],
(opts) => {
const audioUrls: string[] = opts.url
? String(opts.url).split(",").map((s: string) => s.trim()).filter(Boolean)
: [];
return {
audio_urls: audioUrls,
...(opts.model && { model: opts.model }),
};
},
);
57 changes: 57 additions & 0 deletions src/commands/content/createPrimitiveCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Command } from "commander";
import { post } from "../../client.js";
import { getErrorMessage } from "../../getErrorMessage.js";
import { printError, printJson } from "../../output.js";

interface PrimitiveOption {
flag: string;
description: string;
defaultValue?: string;
}

/**
* Creates a CLI command that POSTs to a content primitive endpoint.
* Each primitive only defines what is unique: name, endpoint, and options.
*/
export function createPrimitiveCommand(
name: string,
description: string,
endpoint: string,
options: PrimitiveOption[],
buildBody: (opts: Record<string, unknown>) => Record<string, unknown>,
): Command {
const cmd = new Command(name).description(description);

for (const opt of options) {
if (opt.defaultValue !== undefined) {
cmd.option(opt.flag, opt.description, opt.defaultValue);
} else {
cmd.option(opt.flag, opt.description);
}
}

cmd.option("--json", "Output as JSON");

cmd.action(async (opts: Record<string, unknown>) => {
try {
const body = buildBody(opts);
const data = await post(endpoint, body);

if (opts.json) {
printJson(data);
return;
}

if (data.runId) {
console.log(`Run started: ${data.runId}`);
console.log("Use `recoup tasks status --run <runId>` to check progress.");
} else {
printJson(data);
}
} catch (err) {
printError(getErrorMessage(err));
}
});

return cmd;
}
82 changes: 82 additions & 0 deletions src/commands/content/editCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Command } from "commander";
import { patch } from "../../client.js";
import { getErrorMessage } from "../../getErrorMessage.js";
import { printError, printJson } from "../../output.js";

interface EditOperation {
type: string;
[key: string]: unknown;
}

export const editCommand = new Command("edit")
.description("Edit media with an operations pipeline or a template preset")
.option("--video <url>", "Input video URL")
.option("--audio <url>", "Input audio URL")
.option("--template <name>", "Template name for deterministic edit config")
.option("--trim-start <seconds>", "Trim start time in seconds")
.option("--trim-duration <seconds>", "Trim duration in seconds")
.option("--crop-aspect <ratio>", "Crop to aspect ratio (e.g. 9:16)")
.option("--overlay-text <content>", "Overlay text content")
.option("--text-color <color>", "Text color", "white")
.option("--text-position <pos>", "Text position: top, center, bottom", "bottom")
.option("--mux-audio <url>", "Mux audio URL into video")
.option("--output-format <format>", "Output format: mp4, webm, mov", "mp4")
.option("--json", "Output as JSON")
.action(async (opts: Record<string, unknown>) => {
try {
const operations: EditOperation[] = [];

if (opts.trimStart || opts.trimDuration) {
operations.push({
type: "trim",
start: Number(opts.trimStart ?? 0),
duration: Number(opts.trimDuration ?? 15),
});
}
Comment on lines +29 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Invalid numeric input produces NaN without user feedback.

If a user passes a non-numeric value (e.g., --trim-start abc), Number("abc") yields NaN, which will be sent to the API and likely cause confusing errors.

Consider validating and providing clear feedback:

🛡️ Proposed fix to validate numeric inputs
       if (opts.trimStart || opts.trimDuration) {
+        const start = opts.trimStart !== undefined ? Number(opts.trimStart) : 0;
+        const duration = opts.trimDuration !== undefined ? Number(opts.trimDuration) : 15;
+        if (Number.isNaN(start) || Number.isNaN(duration)) {
+          throw new Error("--trim-start and --trim-duration must be valid numbers");
+        }
         operations.push({
           type: "trim",
-          start: Number(opts.trimStart ?? 0),
-          duration: Number(opts.trimDuration ?? 15),
+          start,
+          duration,
         });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (opts.trimStart || opts.trimDuration) {
operations.push({
type: "trim",
start: Number(opts.trimStart ?? 0),
duration: Number(opts.trimDuration ?? 15),
});
}
if (opts.trimStart || opts.trimDuration) {
const start = opts.trimStart !== undefined ? Number(opts.trimStart) : 0;
const duration = opts.trimDuration !== undefined ? Number(opts.trimDuration) : 15;
if (Number.isNaN(start) || Number.isNaN(duration)) {
throw new Error("--trim-start and --trim-duration must be valid numbers");
}
operations.push({
type: "trim",
start,
duration,
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/editCommand.ts` around lines 29 - 35, The trim block
currently converts opts.trimStart and opts.trimDuration with Number(...) which
yields NaN for invalid input; update the logic around the operations.push for
"trim" (referencing opts.trimStart, opts.trimDuration, and the operations array)
to validate both values before pushing: attempt to coerce to numbers, check with
Number.isFinite (or !Number.isNaN) and if either is invalid, print a clear
user-facing error (or throw/exit) indicating which flag is malformed (e.g.,
--trim-start or --trim-duration) and stop the command; only push the trim
operation when both parsed numbers are valid.


if (opts.cropAspect) {
operations.push({ type: "crop", aspect: opts.cropAspect });
}

if (opts.overlayText) {
operations.push({
type: "overlay_text",
content: opts.overlayText,
color: opts.textColor ?? "white",
position: opts.textPosition ?? "bottom",
});
}

if (opts.muxAudio) {
operations.push({
type: "mux_audio",
audio_url: opts.muxAudio,
replace: true,
});
}

const body: Record<string, unknown> = {
...(opts.video && { video_url: opts.video }),
...(opts.audio && { audio_url: opts.audio }),
...(opts.template && { template: opts.template }),
...(operations.length > 0 && { operations }),
output_format: opts.outputFormat ?? "mp4",
};

const data = await patch("/api/content/video", body);

if (opts.json) {
printJson(data);
return;
}

if (data.runId) {
console.log(`Run started: ${data.runId}`);
console.log("Use `recoup tasks status --run <runId>` to check progress.");
} else {
printJson(data);
}
} catch (err) {
printError(getErrorMessage(err));
}
});
Comment on lines +11 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Export a function instead of a constant command object in this file.

This file currently exports editCommand as a constant; project guidance requires one exported function per TypeScript file.

Suggested refactor
-export const editCommand = new Command("edit")
-  .description("Edit content — trim, crop, overlay text, or add audio")
+export function editCommand(): Command {
+  return new Command("edit")
+  .description("Edit content — trim, crop, overlay text, or add audio")
   .option("--video <url>", "Input video URL")
   .option("--audio <url>", "Input audio URL")
   .option("--template <name>", "Template name for deterministic edit config")
   .option("--trim-start <seconds>", "Trim start time in seconds")
   .option("--trim-duration <seconds>", "Trim duration in seconds")
   .option("--crop-aspect <ratio>", "Crop to aspect ratio (e.g. 9:16)")
   .option("--overlay-text <content>", "Overlay text content")
   .option("--text-color <color>", "Text color", "white")
   .option("--text-position <pos>", "Text position: top, center, bottom", "bottom")
   .option("--mux-audio <url>", "Mux audio URL into video")
   .option("--output-format <format>", "Output format: mp4, webm, mov", "mp4")
   .option("--json", "Output as JSON")
   .action(async (opts: Record<string, unknown>) => {
     ...
   });
+}

As per coding guidelines: src/**/*.ts: Follow Single Responsibility Principle with one exported function per file.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const editCommand = new Command("edit")
.description("Edit content — trim, crop, resize, overlay text, or add audio")
.option("--video <url>", "Input video URL")
.option("--audio <url>", "Input audio URL")
.option("--template <name>", "Template name for deterministic edit config")
.option("--trim-start <seconds>", "Trim start time in seconds")
.option("--trim-duration <seconds>", "Trim duration in seconds")
.option("--crop-aspect <ratio>", "Crop to aspect ratio (e.g. 9:16)")
.option("--overlay-text <content>", "Overlay text content")
.option("--text-color <color>", "Text color", "white")
.option("--text-position <pos>", "Text position: top, center, bottom", "bottom")
.option("--mux-audio <url>", "Mux audio URL into video")
.option("--output-format <format>", "Output format: mp4, webm, mov", "mp4")
.option("--json", "Output as JSON")
.action(async (opts: Record<string, unknown>) => {
try {
const operations: EditOperation[] = [];
if (opts.trimStart || opts.trimDuration) {
operations.push({
type: "trim",
start: Number(opts.trimStart ?? 0),
duration: Number(opts.trimDuration ?? 15),
});
}
if (opts.cropAspect) {
operations.push({ type: "crop", aspect: opts.cropAspect });
}
if (opts.overlayText) {
operations.push({
type: "overlay_text",
content: opts.overlayText,
color: opts.textColor ?? "white",
position: opts.textPosition ?? "bottom",
});
}
if (opts.muxAudio) {
operations.push({
type: "mux_audio",
audio_url: opts.muxAudio,
replace: true,
});
}
const body: Record<string, unknown> = {
...(opts.video && { video_url: opts.video }),
...(opts.audio && { audio_url: opts.audio }),
...(opts.template && { template: opts.template }),
...(operations.length > 0 && { operations }),
output_format: opts.outputFormat ?? "mp4",
};
const data = await patch("/api/content", body);
if (opts.json) {
printJson(data);
return;
}
if (data.runId) {
console.log(`Run started: ${data.runId}`);
console.log("Use `recoup tasks status --run <runId>` to check progress.");
} else {
printJson(data);
}
} catch (err) {
printError(getErrorMessage(err));
}
});
export function editCommand(): Command {
return new Command("edit")
.description("Edit content — trim, crop, overlay text, or add audio")
.option("--video <url>", "Input video URL")
.option("--audio <url>", "Input audio URL")
.option("--template <name>", "Template name for deterministic edit config")
.option("--trim-start <seconds>", "Trim start time in seconds")
.option("--trim-duration <seconds>", "Trim duration in seconds")
.option("--crop-aspect <ratio>", "Crop to aspect ratio (e.g. 9:16)")
.option("--overlay-text <content>", "Overlay text content")
.option("--text-color <color>", "Text color", "white")
.option("--text-position <pos>", "Text position: top, center, bottom", "bottom")
.option("--mux-audio <url>", "Mux audio URL into video")
.option("--output-format <format>", "Output format: mp4, webm, mov", "mp4")
.option("--json", "Output as JSON")
.action(async (opts: Record<string, unknown>) => {
try {
const operations: EditOperation[] = [];
if (opts.trimStart || opts.trimDuration) {
operations.push({
type: "trim",
start: Number(opts.trimStart ?? 0),
duration: Number(opts.trimDuration ?? 15),
});
}
if (opts.cropAspect) {
operations.push({ type: "crop", aspect: opts.cropAspect });
}
if (opts.overlayText) {
operations.push({
type: "overlay_text",
content: opts.overlayText,
color: opts.textColor ?? "white",
position: opts.textPosition ?? "bottom",
});
}
if (opts.muxAudio) {
operations.push({
type: "mux_audio",
audio_url: opts.muxAudio,
replace: true,
});
}
const body: Record<string, unknown> = {
...(opts.video && { video_url: opts.video }),
...(opts.audio && { audio_url: opts.audio }),
...(opts.template && { template: opts.template }),
...(operations.length > 0 && { operations }),
output_format: opts.outputFormat ?? "mp4",
};
const data = await patch("/api/content", body);
if (opts.json) {
printJson(data);
return;
}
if (data.runId) {
console.log(`Run started: ${data.runId}`);
console.log("Use `recoup tasks status --run <runId>` to check progress.");
} else {
printJson(data);
}
} catch (err) {
printError(getErrorMessage(err));
}
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/editCommand.ts` around lines 11 - 82, Replace the
exported constant editCommand with an exported function (e.g., export function
createEditCommand(): Command) that constructs and returns the Command instance;
move the .description/.option/.action builder code into that function body and
return the new Command. Update usages to call createEditCommand() where the
command is registered. Ensure the function signature returns a Command and
preserve all option names and the action logic (references: editCommand, the
action callback, and the operations/body building code).

17 changes: 17 additions & 0 deletions src/commands/content/imageCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createPrimitiveCommand } from "./createPrimitiveCommand.js";

export const imageCommand = createPrimitiveCommand(
"image",
"Generate an AI image from a prompt and optional reference image",
"/api/content/image",
[
{ flag: "--prompt <text>", description: "Image generation prompt" },
{ flag: "--reference-image <url>", description: "Reference image URL for conditioning" },
{ flag: "--model <id>", description: "Model ID (default: fal-ai/nano-banana-pro/edit)" },
],
(opts) => ({
...(opts.prompt && { prompt: opts.prompt }),
...(opts.referenceImage && { reference_image_url: opts.referenceImage }),
...(opts.model && { model: opts.model }),
}),
);
15 changes: 15 additions & 0 deletions src/commands/content/textCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createPrimitiveCommand } from "./createPrimitiveCommand.js";

export const textCommand = createPrimitiveCommand(
"caption",
"Generate on-screen caption text for a social video",
"/api/content/caption",
[
{ flag: "--topic <text>", description: "Subject or theme for caption generation" },
{ flag: "--length <size>", description: "Text length: short, medium, long", defaultValue: "short" },
],
(opts) => ({
topic: opts.topic,
length: opts.length,
}),
);
15 changes: 15 additions & 0 deletions src/commands/content/upscaleCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createPrimitiveCommand } from "./createPrimitiveCommand.js";

export const upscaleCommand = createPrimitiveCommand(
"upscale",
"Upscale an image or video",
"/api/content/upscale",
[
{ flag: "--url <url>", description: "URL of the image or video to upscale" },
{ flag: "--type <type>", description: "Type: image or video", defaultValue: "image" },
],
(opts) => ({
url: opts.url,
type: opts.type,
}),
Comment on lines +11 to +14
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other commands handle required options in this codebase
rg -n "requiredOption" --type=ts

Repository: recoupable/cli

Length of output: 439


🏁 Script executed:

cat src/commands/content/upscaleCommand.ts

Repository: recoupable/cli

Length of output: 498


🏁 Script executed:

cat src/commands/content/createPrimitiveCommand.ts

Repository: recoupable/cli

Length of output: 1540


Add validation for required --url option or enhance the factory to support required options.

The current implementation uses .option() instead of .requiredOption(), which means --url is optional. If the user calls content upscale without --url, the body will contain url: undefined, causing an API error. Other commands in this codebase (statusCommand, notifications, validateCommand, createCommand) follow the .requiredOption() pattern for their required options. Either add validation in buildBody to ensure url is provided, or extend createPrimitiveCommand to support a required flag in the option definition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/upscaleCommand.ts` around lines 11 - 14, The upscale
command currently treats --url as optional so buildBody (the factory mapping
(opts) => ({ url: opts.url, type: opts.type })) can produce url: undefined; fix
by making --url required to match other commands: update the option registration
used by createPrimitiveCommand to use requiredOption for the url flag (or, if
changing createPrimitiveCommand API is not possible, add an explicit validation
in buildBody that throws/returns a clear error when opts.url is missing). Target
symbols: the createPrimitiveCommand call for the upscale command and its
buildBody mapping (the (opts) => ({ url: opts.url, type: opts.type })) and
ensure the option definition for url uses requiredOption('--url <url>') or that
buildBody validates opts.url.

);
35 changes: 35 additions & 0 deletions src/commands/content/videoCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createPrimitiveCommand } from "./createPrimitiveCommand.js";

export const videoCommand = createPrimitiveCommand(
"video",
"Generate a video (prompt, animate, reference, extend, first-last, or lipsync)",
"/api/content/video",
[
{ flag: "--mode <mode>", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" },
{ flag: "--prompt <text>", description: "Text prompt describing the video" },
{ flag: "--image <url>", description: "Image URL (animate, reference, first-last, lipsync)" },
{ flag: "--end-image <url>", description: "End frame image URL (first-last mode)" },
{ flag: "--video <url>", description: "Video URL to extend (extend mode)" },
{ flag: "--audio <url>", description: "Audio URL (lipsync mode)" },
{ flag: "--aspect-ratio <ratio>", description: "auto, 16:9, or 9:16", defaultValue: "auto" },
{ flag: "--duration <dur>", description: "4s, 6s, 7s, or 8s", defaultValue: "8s" },
{ flag: "--resolution <res>", description: "720p, 1080p, or 4k", defaultValue: "720p" },
{ flag: "--negative-prompt <text>", description: "What to avoid in the video" },
{ flag: "--generate-audio", description: "Generate audio for the video" },
{ flag: "--model <id>", description: "Override model ID" },
],
(opts) => ({
...(opts.mode && { mode: opts.mode }),
Comment on lines +8 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate --mode against allowed values before request build.

Line 8 documents a fixed enum, but Line 22 forwards any value. Invalid modes currently fail late at the API boundary; fail-fast in CLI would improve UX and reduce avoidable requests.

✅ Suggested validation
+const ALLOWED_VIDEO_MODES = new Set([
+  "prompt",
+  "animate",
+  "reference",
+  "extend",
+  "first-last",
+  "lipsync",
+]);
+
 export const videoCommand = createPrimitiveCommand(
@@
-  (opts) => ({
+  (opts) => ({
+    ...(opts.mode &&
+      !ALLOWED_VIDEO_MODES.has(String(opts.mode)) && {
+        // createPrimitiveCommand catches thrown errors and prints nicely
+        ...(function () {
+          throw new Error("Invalid --mode. Allowed: prompt, animate, reference, extend, first-last, lipsync");
+        })(),
+      }),
     ...(opts.mode && { mode: opts.mode }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{ flag: "--mode <mode>", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" },
{ flag: "--prompt <text>", description: "Text prompt describing the video" },
{ flag: "--image <url>", description: "Image URL (animate, reference, first-last, lipsync)" },
{ flag: "--end-image <url>", description: "End frame image URL (first-last mode)" },
{ flag: "--video <url>", description: "Video URL to extend (extend mode)" },
{ flag: "--audio <url>", description: "Audio URL (lipsync mode)" },
{ flag: "--aspect-ratio <ratio>", description: "auto, 16:9, or 9:16", defaultValue: "auto" },
{ flag: "--duration <dur>", description: "4s, 6s, 7s, or 8s", defaultValue: "8s" },
{ flag: "--resolution <res>", description: "720p, 1080p, or 4k", defaultValue: "720p" },
{ flag: "--negative-prompt <text>", description: "What to avoid in the video" },
{ flag: "--generate-audio", description: "Generate audio for the video" },
{ flag: "--model <id>", description: "Override model ID" },
],
(opts) => ({
...(opts.mode && { mode: opts.mode }),
const ALLOWED_VIDEO_MODES = new Set([
"prompt",
"animate",
"reference",
"extend",
"first-last",
"lipsync",
]);
export const videoCommand = createPrimitiveCommand(
{
name: "video",
// ... other configuration
},
[
{ flag: "--mode <mode>", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" },
{ flag: "--prompt <text>", description: "Text prompt describing the video" },
{ flag: "--image <url>", description: "Image URL (animate, reference, first-last, lipsync)" },
{ flag: "--end-image <url>", description: "End frame image URL (first-last mode)" },
{ flag: "--video <url>", description: "Video URL to extend (extend mode)" },
{ flag: "--audio <url>", description: "Audio URL (lipsync mode)" },
{ flag: "--aspect-ratio <ratio>", description: "auto, 16:9, or 9:16", defaultValue: "auto" },
{ flag: "--duration <dur>", description: "4s, 6s, 7s, or 8s", defaultValue: "8s" },
{ flag: "--resolution <res>", description: "720p, 1080p, or 4k", defaultValue: "720p" },
{ flag: "--negative-prompt <text>", description: "What to avoid in the video" },
{ flag: "--generate-audio", description: "Generate audio for the video" },
{ flag: "--model <id>", description: "Override model ID" },
],
(opts) => ({
...(opts.mode &&
!ALLOWED_VIDEO_MODES.has(String(opts.mode)) && {
// createPrimitiveCommand catches thrown errors and prints nicely
...(function () {
throw new Error("Invalid --mode. Allowed: prompt, animate, reference, extend, first-last, lipsync");
})(),
}),
...(opts.mode && { mode: opts.mode }),
// ... rest of the opts handler
}),
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/videoCommand.ts` around lines 8 - 22, The CLI should
validate opts.mode against the allowed values ("prompt", "animate", "reference",
"extend", "first-last", "lipsync") before building the request; inside the
factory/handler around the use of opts.mode in videoCommand.ts (the code that
spreads ...(opts.mode && { mode: opts.mode })), add a fail-fast check that
returns a user-friendly error (or throws/prints and exits) when opts.mode is
provided but not one of the allowed strings so invalid modes are rejected
client-side instead of failing at the API boundary.

...(opts.prompt && { prompt: opts.prompt }),
...(opts.image && { image_url: opts.image }),
...(opts.endImage && { end_image_url: opts.endImage }),
...(opts.video && { video_url: opts.video }),
...(opts.audio && { audio_url: opts.audio }),
aspect_ratio: opts.aspectRatio,
duration: opts.duration,
resolution: opts.resolution,
...(opts.negativePrompt && { negative_prompt: opts.negativePrompt }),
generate_audio: !!opts.generateAudio,
...(opts.model && { model: opts.model }),
}),
);
Comment on lines +3 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Export a function instead of an exported command object.

This file currently exports a constant (videoCommand) rather than an exported function, which violates the repository SRP/export rule.

♻️ Proposed refactor
-export const videoCommand = createPrimitiveCommand(
-  "video",
-  "Generate a video (prompt, animate, reference, extend, first-last, or lipsync)",
-  "/api/content/video",
-  [
-    { flag: "--mode <mode>", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" },
-    { flag: "--prompt <text>", description: "Text prompt describing the video" },
-    { flag: "--image <url>", description: "Image URL (animate, reference, first-last, lipsync)" },
-    { flag: "--end-image <url>", description: "End frame image URL (first-last mode)" },
-    { flag: "--video <url>", description: "Video URL to extend (extend mode)" },
-    { flag: "--audio <url>", description: "Audio URL (lipsync mode)" },
-    { flag: "--aspect-ratio <ratio>", description: "auto, 16:9, or 9:16", defaultValue: "auto" },
-    { flag: "--duration <dur>", description: "4s, 6s, 7s, or 8s", defaultValue: "8s" },
-    { flag: "--resolution <res>", description: "720p, 1080p, or 4k", defaultValue: "720p" },
-    { flag: "--negative-prompt <text>", description: "What to avoid in the video" },
-    { flag: "--generate-audio", description: "Generate audio for the video" },
-    { flag: "--model <id>", description: "Override model ID" },
-  ],
-  (opts) => ({
-    ...(opts.mode && { mode: opts.mode }),
-    ...(opts.prompt && { prompt: opts.prompt }),
-    ...(opts.image && { image_url: opts.image }),
-    ...(opts.endImage && { end_image_url: opts.endImage }),
-    ...(opts.video && { video_url: opts.video }),
-    ...(opts.audio && { audio_url: opts.audio }),
-    aspect_ratio: opts.aspectRatio,
-    duration: opts.duration,
-    resolution: opts.resolution,
-    ...(opts.negativePrompt && { negative_prompt: opts.negativePrompt }),
-    generate_audio: !!opts.generateAudio,
-    ...(opts.model && { model: opts.model }),
-  }),
-);
+export function createVideoCommand() {
+  return createPrimitiveCommand(
+    "video",
+    "Generate a video (prompt, animate, reference, extend, first-last, or lipsync)",
+    "/api/content/video",
+    [
+      { flag: "--mode <mode>", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" },
+      { flag: "--prompt <text>", description: "Text prompt describing the video" },
+      { flag: "--image <url>", description: "Image URL (animate, reference, first-last, lipsync)" },
+      { flag: "--end-image <url>", description: "End frame image URL (first-last mode)" },
+      { flag: "--video <url>", description: "Video URL to extend (extend mode)" },
+      { flag: "--audio <url>", description: "Audio URL (lipsync mode)" },
+      { flag: "--aspect-ratio <ratio>", description: "auto, 16:9, or 9:16", defaultValue: "auto" },
+      { flag: "--duration <dur>", description: "4s, 6s, 7s, or 8s", defaultValue: "8s" },
+      { flag: "--resolution <res>", description: "720p, 1080p, or 4k", defaultValue: "720p" },
+      { flag: "--negative-prompt <text>", description: "What to avoid in the video" },
+      { flag: "--generate-audio", description: "Generate audio for the video" },
+      { flag: "--model <id>", description: "Override model ID" },
+    ],
+    (opts) => ({
+      ...(opts.mode && { mode: opts.mode }),
+      ...(opts.prompt && { prompt: opts.prompt }),
+      ...(opts.image && { image_url: opts.image }),
+      ...(opts.endImage && { end_image_url: opts.endImage }),
+      ...(opts.video && { video_url: opts.video }),
+      ...(opts.audio && { audio_url: opts.audio }),
+      aspect_ratio: opts.aspectRatio,
+      duration: opts.duration,
+      resolution: opts.resolution,
+      ...(opts.negativePrompt && { negative_prompt: opts.negativePrompt }),
+      generate_audio: !!opts.generateAudio,
+      ...(opts.model && { model: opts.model }),
+    }),
+  );
+}

As per coding guidelines, src/**/*.ts: Follow Single Responsibility Principle with one exported function per file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/videoCommand.ts` around lines 3 - 35, The file currently
exports a constant videoCommand built via createPrimitiveCommand; change this to
export a function (e.g., export function createVideoCommand()) that returns the
same createPrimitiveCommand(...) object so the file exports a single function
per SRP/export rule; update any callers to invoke createVideoCommand() instead
of importing videoCommand, keep the inner mapping logic (opts -> payload) and
all referenced symbols (createPrimitiveCommand, opts.mode, opts.prompt,
opts.image, opts.endImage, opts.video, opts.audio, opts.aspectRatio,
opts.duration, opts.resolution, opts.negativePrompt, opts.generateAudio,
opts.model) unchanged except for moving them inside the new function body and
returning the command object.

Loading