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;
}
14 changes: 14 additions & 0 deletions src/commands/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ 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";
import { analyzeCommand } from "./content/analyzeCommand.js";

export const contentCommand = new Command("content")
.description("Content-creation pipeline commands");
Expand All @@ -11,3 +18,10 @@ 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);
contentCommand.addCommand(analyzeCommand);
56 changes: 56 additions & 0 deletions src/commands/content/analyzeCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Command } from "commander";
import { post } from "../../client.js";
import { getErrorMessage } from "../../getErrorMessage.js";
import { printError, printJson } from "../../output.js";

function parsePositiveInt(value: string): number {
const n = parseInt(value, 10);
if (Number.isNaN(n) || n <= 0) {
throw new Error(`Expected a positive integer, got "${value}"`);
}
return n;
Comment on lines +6 to +11
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /repo && find . -type f -name "analyzeCommand.ts" | head -5

Repository: recoupable/cli

Length of output: 113


🏁 Script executed:

cat -n src/commands/content/analyzeCommand.ts | head -60

Repository: recoupable/cli

Length of output: 2120


🏁 Script executed:

python3 << 'EOF'
# Test parseInt behavior with the mentioned inputs
test_cases = [
    ("12abc", "partial input with letters"),
    ("1.5", "decimal string"),
    ("10", "valid integer string"),
    ("0", "zero"),
    ("-5", "negative"),
    ("", "empty string")
]

for test_val, desc in test_cases:
    result = int(test_val.split('.')[0].split('a')[0].split('b')[0].split('c')[0]) if test_val and test_val[0].isdigit() else None
    print(f"Testing '{test_val}' ({desc})")
    # Simulate JavaScript parseInt(value, 10)
    import re
    match = re.match(r'^[+-]?\d+', test_val)
    js_parse_result = int(match.group(0)) if match else float('nan')
    print(f"  JavaScript parseInt('{test_val}', 10) would return: {js_parse_result if not (isinstance(js_parse_result, float) and js_parse_result != js_parse_result) else 'NaN'}")
    
    # Test the proposed regex
    regex_match = re.match(r'^[1-9]\d*$', test_val)
    print(f"  Regex /^[1-9]\\d*$/.test('{test_val}'): {bool(regex_match)}")
    print()
EOF

Repository: recoupable/cli

Length of output: 772


parsePositiveInt is currently accepting malformed values.

parseInt will accept partial inputs like "12abc" (returns 12) and "1.5" (returns 1), so this function can silently coerce invalid user input.

Proposed fix
 function parsePositiveInt(value: string): number {
-  const n = parseInt(value, 10);
-  if (Number.isNaN(n) || n <= 0) {
+  if (!/^[1-9]\d*$/.test(value)) {
     throw new Error(`Expected a positive integer, got "${value}"`);
   }
-  return n;
+  return Number(value);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/analyzeCommand.ts` around lines 6 - 11, parsePositiveInt
currently uses parseInt which accepts partial/malformed strings (e.g., "12abc",
"1.5"); change validation to only allow a strictly positive integer string
before parsing by ensuring the input matches a whole-digits pattern (e.g.,
/^\d+$/) and then convert to a number and confirm it's > 0; update
parsePositiveInt to first test the raw value with that regex (rejecting empty
strings, decimals, and embedded letters) and only then call parseInt/Number and
return the integer, throwing the same Error message on invalid input.

}

export const analyzeCommand = new Command("analyze")
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 instance.

Line 14 violates the repository rule requiring one exported function per file under src/**/*.ts.

Refactor direction
-export const analyzeCommand = new Command("analyze")
-  .description(
-    "Analyze a video with AI — describe scenes, check quality, evaluate content",
-  )
+export function createAnalyzeCommand(): Command {
+  return new Command("analyze")
+    .description(
+      "Analyze a video with AI — describe scenes, check quality, evaluate content",
+    )
@@
-  .action(async (opts: Record<string, unknown>) => {
+    .action(async (opts: Record<string, unknown>) => {
@@
-  });
+    });
+}

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/analyzeCommand.ts` at line 14, The file currently
exports a constant analyzeCommand (export const analyzeCommand = new
Command("analyze")) which violates the rule requiring one exported function per
file; change this to export a function (e.g., export function
createAnalyzeCommand()) that constructs and returns the Command instance, move
any command configuration into that function, and update any import sites to
call createAnalyzeCommand() (or the chosen function name) instead of importing
the constant; ensure the function name replaces analyzeCommand as the exported
symbol.

.description(
"Analyze a video with AI — describe scenes, check quality, evaluate content",
)
.requiredOption("--video <url>", "Video URL to analyze")
.requiredOption("--prompt <text>", "What to analyze")
.option("--temperature <number>", "Sampling temperature (default: 0.2)", parseFloat)
.option("--max-tokens <number>", "Maximum output tokens", parsePositiveInt)
.option("--json", "Output raw JSON")
.action(async (opts: Record<string, unknown>) => {
try {
const body: Record<string, unknown> = {
video_url: opts.video,
prompt: opts.prompt,
};

if (opts.temperature !== undefined) {
body.temperature = opts.temperature;
} else {
body.temperature = 0.2;
}
Comment on lines +20 to +34
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "analyzeCommand.ts" -type f

Repository: recoupable/cli

Length of output: 98


🏁 Script executed:

cat -n ./src/commands/content/analyzeCommand.ts

Repository: recoupable/cli

Length of output: 2120


🏁 Script executed:

python3 << 'EOF'
import json

# Test JSON behavior with NaN
test_obj = {"temperature": float('nan')}
print("Python JSON with NaN:", json.dumps(test_obj))

# In JavaScript context - verify the claim about NaN serialization
# parseFloat behavior
print("\nparseFloat behavior:")
print("parseFloat('0.5'):", 0.5)
print("parseFloat('invalid'):", float('nan'))  # Returns NaN

# JSON.stringify in JS turns NaN to null
print("\nJSON serialization behavior:")
print("JSON.stringify({temp: NaN}) would be {temp: null}")
print("JSON.stringify({temp: 0.5}) would be {temp: 0.5}")

EOF

Repository: recoupable/cli

Length of output: 301


🏁 Script executed:

cat -n ./src/client.js

Repository: recoupable/cli

Length of output: 105


🏁 Script executed:

find . -name "client.*" -type f | head -10

Repository: recoupable/cli

Length of output: 100


🏁 Script executed:

cat -n ./src/client.ts

Repository: recoupable/cli

Length of output: 2980


Add validation to parseFloat for --temperature option to prevent NaN values from becoming null in the API payload.

Line 20 uses parseFloat without validation. Invalid input (e.g., --temperature abc) produces NaN, which JSON serialization converts to null, sending an unexpected value to the API instead of rejecting the input. Use a validation function matching the parsePositiveInt pattern already in the file.

Proposed fix
+function parseTemperature(value: string): number {
+  const n = Number(value);
+  if (!Number.isFinite(n)) {
+    throw new Error(`Expected a valid number, got "${value}"`);
+  }
+  return n;
+}
+
 export const analyzeCommand = new Command("analyze")
@@
-  .option("--temperature <number>", "Sampling temperature (default: 0.2)", parseFloat)
+  .option("--temperature <number>", "Sampling temperature (default: 0.2)", parseTemperature)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/analyzeCommand.ts` around lines 20 - 34, The
--temperature option currently uses raw parseFloat which can yield NaN and be
serialized as null; replace parseFloat with a validator function (modeled after
parsePositiveInt) — e.g., create parseTemperature that parses the input to a
number, checks Number.isFinite and that the value is within an acceptable range
(e.g., >= 0 and <= 2 or at least >= 0), and throws an error for invalid values;
update the option declaration to use parseTemperature and keep the existing
default fallback in the action where body.temperature is set so only validated
numeric values reach body.temperature.


if (opts.maxTokens !== undefined) {
body.max_tokens = opts.maxTokens;
}

const data = await post("/api/content/analyze", body);

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

const text = (data as Record<string, unknown>).text;
if (typeof text === "string") {
console.log(text);
} else {
printJson(data);
}
} catch (err) {
printError(getErrorMessage(err));
}
});
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 content — trim, crop, resize, overlay text, or add audio")
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

Description mentions resize, but no resize option exists.

Line 12 currently advertises a capability that this command does not implement, which can mislead users.

Suggested fix
-  .description("Edit content — trim, crop, resize, overlay text, or add audio")
+  .description("Edit content — trim, crop, overlay text, or add audio")
📝 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
.description("Edit content — trim, crop, resize, overlay text, or add audio")
.description("Edit content — trim, crop, overlay text, or add audio")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/content/editCommand.ts` at line 12, The command description
string for the edit command (the .description call in editCommand.ts)
incorrectly advertises "resize" even though no resize option is implemented;
update the .description text to remove "resize" or add the corresponding resize
option implementation; locate the .description("Edit content — trim, crop,
resize, overlay text, or add audio") call in the editCommand setup and either
remove the word "resize" from the string or implement a resize flag/option (and
wire it into the edit handling logic such as the edit handler function that
processes trim/crop/overlay/audio) so the description matches actual
capabilities.

.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", 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 }),
}),
);
2 changes: 1 addition & 1 deletion src/commands/content/templatesCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getErrorMessage } from "../../getErrorMessage.js";
import { printError, printJson } from "../../output.js";

export const templatesCommand = new Command("templates")
.description("List available content templates")
.description("List available content creation templates. Templates are optional — every primitive works without one. Templates provide curated creative recipes.")
.option("--json", "Output as JSON")
.action(async opts => {
try {
Expand Down
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.

);
37 changes: 37 additions & 0 deletions src/commands/content/videoCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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" },
{ flag: "--template <name>", description: "Template ID for video generation config (moods, movements). Optional — overrides prompt with template defaults." },
],
(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 }),
...(opts.template && { template: opts.template }),
}),
);
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