From b6bf3ef35ed22792d4de770c3c51d2dd644493e5 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:11:50 -0400 Subject: [PATCH 1/8] feat: add primitive CLI commands for modular content creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New commands under `recoup content`: - image — generate an AI image - video — generate video from image (or lipsync) - text — generate on-screen text - audio — select a song clip - render — combine video + audio + text - upscale — upscale image or video DRY: shared createPrimitiveCommand factory so each command only defines its name, endpoint, options, and body builder. Existing `recoup content create` (V1 full pipeline) is untouched. Made-with: Cursor --- src/commands/content.ts | 12 ++++ src/commands/content/audioCommand.ts | 22 +++++++ .../content/createPrimitiveCommand.ts | 57 +++++++++++++++++++ src/commands/content/imageCommand.ts | 19 +++++++ src/commands/content/renderCommand.ts | 27 +++++++++ src/commands/content/textCommand.ts | 19 +++++++ src/commands/content/upscaleCommand.ts | 15 +++++ src/commands/content/videoCommand.ts | 25 ++++++++ 8 files changed, 196 insertions(+) create mode 100644 src/commands/content/audioCommand.ts create mode 100644 src/commands/content/createPrimitiveCommand.ts create mode 100644 src/commands/content/imageCommand.ts create mode 100644 src/commands/content/renderCommand.ts create mode 100644 src/commands/content/textCommand.ts create mode 100644 src/commands/content/upscaleCommand.ts create mode 100644 src/commands/content/videoCommand.ts diff --git a/src/commands/content.ts b/src/commands/content.ts index 6180412..2a862e5 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -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 { renderCommand } from "./content/renderCommand.js"; +import { upscaleCommand } from "./content/upscaleCommand.js"; export const contentCommand = new Command("content") .description("Content-creation pipeline commands"); @@ -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(renderCommand); +contentCommand.addCommand(upscaleCommand); diff --git a/src/commands/content/audioCommand.ts b/src/commands/content/audioCommand.ts new file mode 100644 index 0000000..1a2804a --- /dev/null +++ b/src/commands/content/audioCommand.ts @@ -0,0 +1,22 @@ +import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; + +export const audioCommand = createPrimitiveCommand( + "audio", + "Select a song clip (transcribe, analyze, pick best moment)", + "/api/content/create/audio", + [ + { flag: "--artist ", description: "Artist account ID" }, + { flag: "--song ", description: "Comma-separated song slugs or URLs" }, + { flag: "--lipsync", description: "Prefer clips with lyrics for lipsync" }, + ], + (opts) => { + const songs: string[] | undefined = opts.song + ? String(opts.song).split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + return { + artist_account_id: opts.artist, + lipsync: !!opts.lipsync, + ...(songs && { songs }), + }; + }, +); diff --git a/src/commands/content/createPrimitiveCommand.ts b/src/commands/content/createPrimitiveCommand.ts new file mode 100644 index 0000000..3d1a5aa --- /dev/null +++ b/src/commands/content/createPrimitiveCommand.ts @@ -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) => Record, +): 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) => { + 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 ` to check progress."); + } else { + printJson(data); + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); + + return cmd; +} diff --git a/src/commands/content/imageCommand.ts b/src/commands/content/imageCommand.ts new file mode 100644 index 0000000..f07d64f --- /dev/null +++ b/src/commands/content/imageCommand.ts @@ -0,0 +1,19 @@ +import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; + +export const imageCommand = createPrimitiveCommand( + "image", + "Generate an AI image from a template and face guide", + "/api/content/create/image", + [ + { flag: "--artist ", description: "Artist account ID" }, + { flag: "--template ", description: "Template name", defaultValue: "artist-caption-bedroom" }, + { flag: "--face-guide ", description: "Face guide image URL (overrides artist default)" }, + { flag: "--prompt ", description: "Custom image prompt (overrides template)" }, + ], + (opts) => ({ + artist_account_id: opts.artist, + template: opts.template, + ...(opts.faceGuide && { face_guide_url: opts.faceGuide }), + ...(opts.prompt && { prompt: opts.prompt }), + }), +); diff --git a/src/commands/content/renderCommand.ts b/src/commands/content/renderCommand.ts new file mode 100644 index 0000000..4a0d3da --- /dev/null +++ b/src/commands/content/renderCommand.ts @@ -0,0 +1,27 @@ +import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; + +export const renderCommand = createPrimitiveCommand( + "render", + "Combine video + audio + text into a final social video", + "/api/content/create/render", + [ + { flag: "--video ", description: "Video URL" }, + { flag: "--audio ", description: "Song URL" }, + { flag: "--start ", description: "Audio start time in seconds" }, + { flag: "--duration ", description: "Audio duration in seconds" }, + { flag: "--text ", description: "On-screen text content" }, + { flag: "--font ", description: "Font file name" }, + { flag: "--has-audio", description: "Video already has audio baked in" }, + ], + (opts) => ({ + video_url: opts.video, + song_url: opts.audio, + audio_start_seconds: Number(opts.start ?? 0), + audio_duration_seconds: Number(opts.duration ?? 15), + text: { + content: opts.text ?? "", + ...(opts.font && { font: opts.font }), + }, + has_audio: !!opts.hasAudio, + }), +); diff --git a/src/commands/content/textCommand.ts b/src/commands/content/textCommand.ts new file mode 100644 index 0000000..b07a6c0 --- /dev/null +++ b/src/commands/content/textCommand.ts @@ -0,0 +1,19 @@ +import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; + +export const textCommand = createPrimitiveCommand( + "text", + "Generate on-screen text for a social video", + "/api/content/create/text", + [ + { flag: "--artist ", description: "Artist account ID" }, + { flag: "--song ", description: "Song name or slug" }, + { flag: "--template ", description: "Template name for text style" }, + { flag: "--length ", description: "Text length: short, medium, long", defaultValue: "short" }, + ], + (opts) => ({ + artist_account_id: opts.artist, + song: opts.song, + ...(opts.template && { template: opts.template }), + length: opts.length, + }), +); diff --git a/src/commands/content/upscaleCommand.ts b/src/commands/content/upscaleCommand.ts new file mode 100644 index 0000000..956c942 --- /dev/null +++ b/src/commands/content/upscaleCommand.ts @@ -0,0 +1,15 @@ +import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; + +export const upscaleCommand = createPrimitiveCommand( + "upscale", + "Upscale an image or video", + "/api/content/create/upscale", + [ + { flag: "--url ", description: "URL of the image or video to upscale" }, + { flag: "--type ", description: "Type: image or video", defaultValue: "image" }, + ], + (opts) => ({ + url: opts.url, + type: opts.type, + }), +); diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts new file mode 100644 index 0000000..1c8ebfc --- /dev/null +++ b/src/commands/content/videoCommand.ts @@ -0,0 +1,25 @@ +import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; + +export const videoCommand = createPrimitiveCommand( + "video", + "Generate a video from an image (or audio-to-video for lipsync)", + "/api/content/create/video", + [ + { flag: "--image ", description: "Image URL to animate" }, + { flag: "--template ", description: "Template name for motion prompt" }, + { flag: "--lipsync", description: "Use audio-to-video mode (requires --song-url)" }, + { flag: "--song-url ", description: "Song URL for lipsync mode" }, + { flag: "--start ", description: "Audio start time in seconds" }, + { flag: "--duration ", description: "Audio duration in seconds" }, + { flag: "--motion ", description: "Custom motion prompt" }, + ], + (opts) => ({ + image_url: opts.image, + ...(opts.template && { template: opts.template }), + lipsync: !!opts.lipsync, + ...(opts.songUrl && { song_url: opts.songUrl }), + ...(opts.start && { audio_start_seconds: Number(opts.start) }), + ...(opts.duration && { audio_duration_seconds: Number(opts.duration) }), + ...(opts.motion && { motion_prompt: opts.motion }), + }), +); From 9be84d8f7bbcb28a16774c6a350dc0206b6a9a5e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 04:38:40 -0400 Subject: [PATCH 2/8] refactor: rename content CLI commands to verb-qualifier pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content image → content generate-image content video → content generate-video content text → content generate-caption content audio → content transcribe-audio content render, upscale unchanged (already verbs) Matches API route rename and cli-for-agents naming convention. Made-with: Cursor --- src/commands/content/audioCommand.ts | 6 +++--- src/commands/content/imageCommand.ts | 4 ++-- src/commands/content/renderCommand.ts | 2 +- src/commands/content/textCommand.ts | 6 +++--- src/commands/content/upscaleCommand.ts | 2 +- src/commands/content/videoCommand.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/commands/content/audioCommand.ts b/src/commands/content/audioCommand.ts index 1a2804a..a026e23 100644 --- a/src/commands/content/audioCommand.ts +++ b/src/commands/content/audioCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const audioCommand = createPrimitiveCommand( - "audio", - "Select a song clip (transcribe, analyze, pick best moment)", - "/api/content/create/audio", + "transcribe-audio", + "Transcribe a song into timestamped lyrics", + "/api/content/transcribe-audio", [ { flag: "--artist ", description: "Artist account ID" }, { flag: "--song ", description: "Comma-separated song slugs or URLs" }, diff --git a/src/commands/content/imageCommand.ts b/src/commands/content/imageCommand.ts index f07d64f..38cef1a 100644 --- a/src/commands/content/imageCommand.ts +++ b/src/commands/content/imageCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const imageCommand = createPrimitiveCommand( - "image", + "generate-image", "Generate an AI image from a template and face guide", - "/api/content/create/image", + "/api/content/generate-image", [ { flag: "--artist ", description: "Artist account ID" }, { flag: "--template ", description: "Template name", defaultValue: "artist-caption-bedroom" }, diff --git a/src/commands/content/renderCommand.ts b/src/commands/content/renderCommand.ts index 4a0d3da..5aca27d 100644 --- a/src/commands/content/renderCommand.ts +++ b/src/commands/content/renderCommand.ts @@ -3,7 +3,7 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const renderCommand = createPrimitiveCommand( "render", "Combine video + audio + text into a final social video", - "/api/content/create/render", + "/api/content/render", [ { flag: "--video ", description: "Video URL" }, { flag: "--audio ", description: "Song URL" }, diff --git a/src/commands/content/textCommand.ts b/src/commands/content/textCommand.ts index b07a6c0..f651db6 100644 --- a/src/commands/content/textCommand.ts +++ b/src/commands/content/textCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const textCommand = createPrimitiveCommand( - "text", - "Generate on-screen text for a social video", - "/api/content/create/text", + "generate-caption", + "Generate on-screen caption text for a social video", + "/api/content/generate-caption", [ { flag: "--artist ", description: "Artist account ID" }, { flag: "--song ", description: "Song name or slug" }, diff --git a/src/commands/content/upscaleCommand.ts b/src/commands/content/upscaleCommand.ts index 956c942..6d9b035 100644 --- a/src/commands/content/upscaleCommand.ts +++ b/src/commands/content/upscaleCommand.ts @@ -3,7 +3,7 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const upscaleCommand = createPrimitiveCommand( "upscale", "Upscale an image or video", - "/api/content/create/upscale", + "/api/content/upscale", [ { flag: "--url ", description: "URL of the image or video to upscale" }, { flag: "--type ", description: "Type: image or video", defaultValue: "image" }, diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts index 1c8ebfc..d449a0e 100644 --- a/src/commands/content/videoCommand.ts +++ b/src/commands/content/videoCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const videoCommand = createPrimitiveCommand( - "video", + "generate-video", "Generate a video from an image (or audio-to-video for lipsync)", - "/api/content/create/video", + "/api/content/generate-video", [ { flag: "--image ", description: "Image URL to animate" }, { flag: "--template ", description: "Template name for motion prompt" }, From 0604709ed1bca1f0515f2ed37909e13a99cbdad1 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:21:26 -0400 Subject: [PATCH 3/8] refactor: update CLI for generic content primitives + edit command - generate-image: --reference-image replaces --face-guide, --model added - generate-video: --audio replaces --song-url, --model added, trim flags removed - generate-caption: --topic replaces --song/--artist - transcribe-audio: --url replaces --artist/--song, --model added - New edit command replaces render with template mode + manual operations Made-with: Cursor --- src/commands/content.ts | 4 +- src/commands/content/audioCommand.ts | 18 +++--- src/commands/content/editCommand.ts | 82 +++++++++++++++++++++++++++ src/commands/content/imageCommand.ts | 14 ++--- src/commands/content/renderCommand.ts | 27 --------- src/commands/content/textCommand.ts | 8 +-- src/commands/content/videoCommand.ts | 16 ++---- 7 files changed, 106 insertions(+), 63 deletions(-) create mode 100644 src/commands/content/editCommand.ts delete mode 100644 src/commands/content/renderCommand.ts diff --git a/src/commands/content.ts b/src/commands/content.ts index 2a862e5..86567a3 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -7,7 +7,7 @@ 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 { renderCommand } from "./content/renderCommand.js"; +import { editCommand } from "./content/editCommand.js"; import { upscaleCommand } from "./content/upscaleCommand.js"; export const contentCommand = new Command("content") @@ -21,5 +21,5 @@ contentCommand.addCommand(imageCommand); contentCommand.addCommand(videoCommand); contentCommand.addCommand(textCommand); contentCommand.addCommand(audioCommand); -contentCommand.addCommand(renderCommand); +contentCommand.addCommand(editCommand); contentCommand.addCommand(upscaleCommand); diff --git a/src/commands/content/audioCommand.ts b/src/commands/content/audioCommand.ts index a026e23..bd458e8 100644 --- a/src/commands/content/audioCommand.ts +++ b/src/commands/content/audioCommand.ts @@ -2,21 +2,19 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const audioCommand = createPrimitiveCommand( "transcribe-audio", - "Transcribe a song into timestamped lyrics", + "Transcribe audio into timestamped text", "/api/content/transcribe-audio", [ - { flag: "--artist ", description: "Artist account ID" }, - { flag: "--song ", description: "Comma-separated song slugs or URLs" }, - { flag: "--lipsync", description: "Prefer clips with lyrics for lipsync" }, + { flag: "--url ", description: "Comma-separated audio URLs to transcribe" }, + { flag: "--model ", description: "Model ID (default: fal-ai/whisper)" }, ], (opts) => { - const songs: string[] | undefined = opts.song - ? String(opts.song).split(",").map((s: string) => s.trim()).filter(Boolean) - : undefined; + const audioUrls: string[] = opts.url + ? String(opts.url).split(",").map((s: string) => s.trim()).filter(Boolean) + : []; return { - artist_account_id: opts.artist, - lipsync: !!opts.lipsync, - ...(songs && { songs }), + audio_urls: audioUrls, + ...(opts.model && { model: opts.model }), }; }, ); diff --git a/src/commands/content/editCommand.ts b/src/commands/content/editCommand.ts new file mode 100644 index 0000000..f697078 --- /dev/null +++ b/src/commands/content/editCommand.ts @@ -0,0 +1,82 @@ +import { Command } from "commander"; +import { post } 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 ", "Input video URL") + .option("--audio ", "Input audio URL") + .option("--template ", "Template name for deterministic edit config") + .option("--trim-start ", "Trim start time in seconds") + .option("--trim-duration ", "Trim duration in seconds") + .option("--crop-aspect ", "Crop to aspect ratio (e.g. 9:16)") + .option("--overlay-text ", "Overlay text content") + .option("--text-color ", "Text color", "white") + .option("--text-position ", "Text position: top, center, bottom", "bottom") + .option("--mux-audio ", "Mux audio URL into video") + .option("--output-format ", "Output format: mp4, webm, mov", "mp4") + .option("--json", "Output as JSON") + .action(async (opts: Record) => { + 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 = { + ...(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 post("/api/content/edit", body); + + if (opts.json) { + printJson(data); + return; + } + + if (data.runId) { + console.log(`Run started: ${data.runId}`); + console.log("Use `recoup tasks status --run ` to check progress."); + } else { + printJson(data); + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/imageCommand.ts b/src/commands/content/imageCommand.ts index 38cef1a..31ef864 100644 --- a/src/commands/content/imageCommand.ts +++ b/src/commands/content/imageCommand.ts @@ -2,18 +2,16 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const imageCommand = createPrimitiveCommand( "generate-image", - "Generate an AI image from a template and face guide", + "Generate an AI image from a prompt and optional reference image", "/api/content/generate-image", [ - { flag: "--artist ", description: "Artist account ID" }, - { flag: "--template ", description: "Template name", defaultValue: "artist-caption-bedroom" }, - { flag: "--face-guide ", description: "Face guide image URL (overrides artist default)" }, - { flag: "--prompt ", description: "Custom image prompt (overrides template)" }, + { flag: "--prompt ", description: "Image generation prompt" }, + { flag: "--reference-image ", description: "Reference image URL for conditioning" }, + { flag: "--model ", description: "Model ID (default: fal-ai/nano-banana-pro/edit)" }, ], (opts) => ({ - artist_account_id: opts.artist, - template: opts.template, - ...(opts.faceGuide && { face_guide_url: opts.faceGuide }), ...(opts.prompt && { prompt: opts.prompt }), + ...(opts.referenceImage && { reference_image_url: opts.referenceImage }), + ...(opts.model && { model: opts.model }), }), ); diff --git a/src/commands/content/renderCommand.ts b/src/commands/content/renderCommand.ts deleted file mode 100644 index 5aca27d..0000000 --- a/src/commands/content/renderCommand.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; - -export const renderCommand = createPrimitiveCommand( - "render", - "Combine video + audio + text into a final social video", - "/api/content/render", - [ - { flag: "--video ", description: "Video URL" }, - { flag: "--audio ", description: "Song URL" }, - { flag: "--start ", description: "Audio start time in seconds" }, - { flag: "--duration ", description: "Audio duration in seconds" }, - { flag: "--text ", description: "On-screen text content" }, - { flag: "--font ", description: "Font file name" }, - { flag: "--has-audio", description: "Video already has audio baked in" }, - ], - (opts) => ({ - video_url: opts.video, - song_url: opts.audio, - audio_start_seconds: Number(opts.start ?? 0), - audio_duration_seconds: Number(opts.duration ?? 15), - text: { - content: opts.text ?? "", - ...(opts.font && { font: opts.font }), - }, - has_audio: !!opts.hasAudio, - }), -); diff --git a/src/commands/content/textCommand.ts b/src/commands/content/textCommand.ts index f651db6..4b35325 100644 --- a/src/commands/content/textCommand.ts +++ b/src/commands/content/textCommand.ts @@ -5,15 +5,11 @@ export const textCommand = createPrimitiveCommand( "Generate on-screen caption text for a social video", "/api/content/generate-caption", [ - { flag: "--artist ", description: "Artist account ID" }, - { flag: "--song ", description: "Song name or slug" }, - { flag: "--template ", description: "Template name for text style" }, + { flag: "--topic ", description: "Subject or theme for caption generation" }, { flag: "--length ", description: "Text length: short, medium, long", defaultValue: "short" }, ], (opts) => ({ - artist_account_id: opts.artist, - song: opts.song, - ...(opts.template && { template: opts.template }), + topic: opts.topic, length: opts.length, }), ); diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts index d449a0e..6ae66ae 100644 --- a/src/commands/content/videoCommand.ts +++ b/src/commands/content/videoCommand.ts @@ -2,24 +2,20 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const videoCommand = createPrimitiveCommand( "generate-video", - "Generate a video from an image (or audio-to-video for lipsync)", + "Generate a video from an image (or audio-driven for lipsync)", "/api/content/generate-video", [ { flag: "--image ", description: "Image URL to animate" }, - { flag: "--template ", description: "Template name for motion prompt" }, - { flag: "--lipsync", description: "Use audio-to-video mode (requires --song-url)" }, - { flag: "--song-url ", description: "Song URL for lipsync mode" }, - { flag: "--start ", description: "Audio start time in seconds" }, - { flag: "--duration ", description: "Audio duration in seconds" }, + { flag: "--lipsync", description: "Use audio-to-video mode (requires --audio)" }, + { flag: "--audio ", description: "Audio URL for lipsync mode" }, { flag: "--motion ", description: "Custom motion prompt" }, + { flag: "--model ", description: "Model ID (default: fal-ai/veo3.1/fast/image-to-video)" }, ], (opts) => ({ image_url: opts.image, - ...(opts.template && { template: opts.template }), lipsync: !!opts.lipsync, - ...(opts.songUrl && { song_url: opts.songUrl }), - ...(opts.start && { audio_start_seconds: Number(opts.start) }), - ...(opts.duration && { audio_duration_seconds: Number(opts.duration) }), + ...(opts.audio && { audio_url: opts.audio }), ...(opts.motion && { motion_prompt: opts.motion }), + ...(opts.model && { model: opts.model }), }), ); From a1096c5511c814194197ebea5fc4ea95b2d59c46 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 06:04:55 -0400 Subject: [PATCH 4/8] fix: guard image_url against undefined in videoCommand Address CodeRabbit review: conditionally spread image_url to avoid sending undefined when --image flag is omitted. Made-with: Cursor --- src/commands/content/videoCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts index 6ae66ae..0ff6eeb 100644 --- a/src/commands/content/videoCommand.ts +++ b/src/commands/content/videoCommand.ts @@ -12,7 +12,7 @@ export const videoCommand = createPrimitiveCommand( { flag: "--model ", description: "Model ID (default: fal-ai/veo3.1/fast/image-to-video)" }, ], (opts) => ({ - image_url: opts.image, + ...(opts.image && { image_url: opts.image }), lipsync: !!opts.lipsync, ...(opts.audio && { audio_url: opts.audio }), ...(opts.motion && { motion_prompt: opts.motion }), From ac2c062b9e265551e9ba2baaa95073138c52b62a Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:16:40 -0400 Subject: [PATCH 5/8] fix: add --prompt flag to generate-video, make --image optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches API change — video generation accepts prompt, optional image, optional audio. Made-with: Cursor --- cli-for-agent | 0 src/commands/content/videoCommand.ts | 10 ++++++---- 2 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 cli-for-agent diff --git a/cli-for-agent b/cli-for-agent new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts index 0ff6eeb..e9d2b57 100644 --- a/src/commands/content/videoCommand.ts +++ b/src/commands/content/videoCommand.ts @@ -2,16 +2,18 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const videoCommand = createPrimitiveCommand( "generate-video", - "Generate a video from an image (or audio-driven for lipsync)", + "Generate a video, optionally from a reference image or audio", "/api/content/generate-video", [ - { flag: "--image ", description: "Image URL to animate" }, - { flag: "--lipsync", description: "Use audio-to-video mode (requires --audio)" }, + { flag: "--prompt ", description: "Text prompt describing the video" }, + { flag: "--image ", description: "Optional reference image URL" }, + { flag: "--lipsync", description: "Use audio-driven lipsync mode (requires --audio)" }, { flag: "--audio ", description: "Audio URL for lipsync mode" }, - { flag: "--motion ", description: "Custom motion prompt" }, + { flag: "--motion ", description: "Motion prompt (overrides --prompt for motion)" }, { flag: "--model ", description: "Model ID (default: fal-ai/veo3.1/fast/image-to-video)" }, ], (opts) => ({ + ...(opts.prompt && { prompt: opts.prompt }), ...(opts.image && { image_url: opts.image }), lipsync: !!opts.lipsync, ...(opts.audio && { audio_url: opts.audio }), From f3caf4d83d3f53b5d41775bfb0bf5079bdfe9dc9 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:06:23 -0400 Subject: [PATCH 6/8] feat: add --mode flag and full video generation options to CLI Supports all 6 modes: prompt, animate, reference, extend, first-last, lipsync. Adds --end-image, --video, --aspect-ratio, --duration, --resolution, --negative-prompt, --generate-audio. Made-with: Cursor --- src/commands/content/videoCommand.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts index e9d2b57..5723c56 100644 --- a/src/commands/content/videoCommand.ts +++ b/src/commands/content/videoCommand.ts @@ -2,22 +2,34 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const videoCommand = createPrimitiveCommand( "generate-video", - "Generate a video, optionally from a reference image or audio", + "Generate a video (prompt, animate, reference, extend, first-last, or lipsync)", "/api/content/generate-video", [ + { flag: "--mode ", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" }, { flag: "--prompt ", description: "Text prompt describing the video" }, - { flag: "--image ", description: "Optional reference image URL" }, - { flag: "--lipsync", description: "Use audio-driven lipsync mode (requires --audio)" }, - { flag: "--audio ", description: "Audio URL for lipsync mode" }, - { flag: "--motion ", description: "Motion prompt (overrides --prompt for motion)" }, - { flag: "--model ", description: "Model ID (default: fal-ai/veo3.1/fast/image-to-video)" }, + { flag: "--image ", description: "Image URL (animate, reference, first-last, lipsync)" }, + { flag: "--end-image ", description: "End frame image URL (first-last mode)" }, + { flag: "--video ", description: "Video URL to extend (extend mode)" }, + { flag: "--audio ", description: "Audio URL (lipsync mode)" }, + { flag: "--aspect-ratio ", description: "auto, 16:9, or 9:16", defaultValue: "auto" }, + { flag: "--duration ", description: "4s, 6s, 7s, or 8s", defaultValue: "8s" }, + { flag: "--resolution ", description: "720p, 1080p, or 4k", defaultValue: "720p" }, + { flag: "--negative-prompt ", description: "What to avoid in the video" }, + { flag: "--generate-audio", description: "Generate audio for the video" }, + { flag: "--model ", description: "Override model ID" }, ], (opts) => ({ + ...(opts.mode && { mode: opts.mode }), ...(opts.prompt && { prompt: opts.prompt }), ...(opts.image && { image_url: opts.image }), - lipsync: !!opts.lipsync, + ...(opts.endImage && { end_image_url: opts.endImage }), + ...(opts.video && { video_url: opts.video }), ...(opts.audio && { audio_url: opts.audio }), - ...(opts.motion && { motion_prompt: opts.motion }), + 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 }), }), ); From 473dea2dc17ef884cbbb98c30a8d0029dcfd290d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:44:31 -0400 Subject: [PATCH 7/8] refactor: simplify CLI command names and endpoints per code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate-image → image, generate-video → video, generate-caption → caption, transcribe-audio → transcribe. Edit uses PATCH /api/content/video. Added patch() to client. Made-with: Cursor --- src/client.ts | 32 ++++++++++++++++++++++++++++ src/commands/content/audioCommand.ts | 4 ++-- src/commands/content/editCommand.ts | 4 ++-- src/commands/content/imageCommand.ts | 4 ++-- src/commands/content/textCommand.ts | 4 ++-- src/commands/content/videoCommand.ts | 4 ++-- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/client.ts b/src/client.ts index d2d326a..43293df 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, +): Promise { + 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; +} diff --git a/src/commands/content/audioCommand.ts b/src/commands/content/audioCommand.ts index bd458e8..b239790 100644 --- a/src/commands/content/audioCommand.ts +++ b/src/commands/content/audioCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const audioCommand = createPrimitiveCommand( - "transcribe-audio", + "transcribe", "Transcribe audio into timestamped text", - "/api/content/transcribe-audio", + "/api/content/transcribe", [ { flag: "--url ", description: "Comma-separated audio URLs to transcribe" }, { flag: "--model ", description: "Model ID (default: fal-ai/whisper)" }, diff --git a/src/commands/content/editCommand.ts b/src/commands/content/editCommand.ts index f697078..9b56093 100644 --- a/src/commands/content/editCommand.ts +++ b/src/commands/content/editCommand.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { post } from "../../client.js"; +import { patch } from "../../client.js"; import { getErrorMessage } from "../../getErrorMessage.js"; import { printError, printJson } from "../../output.js"; @@ -63,7 +63,7 @@ export const editCommand = new Command("edit") output_format: opts.outputFormat ?? "mp4", }; - const data = await post("/api/content/edit", body); + const data = await patch("/api/content/video", body); if (opts.json) { printJson(data); diff --git a/src/commands/content/imageCommand.ts b/src/commands/content/imageCommand.ts index 31ef864..3970189 100644 --- a/src/commands/content/imageCommand.ts +++ b/src/commands/content/imageCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const imageCommand = createPrimitiveCommand( - "generate-image", + "image", "Generate an AI image from a prompt and optional reference image", - "/api/content/generate-image", + "/api/content/image", [ { flag: "--prompt ", description: "Image generation prompt" }, { flag: "--reference-image ", description: "Reference image URL for conditioning" }, diff --git a/src/commands/content/textCommand.ts b/src/commands/content/textCommand.ts index 4b35325..1755651 100644 --- a/src/commands/content/textCommand.ts +++ b/src/commands/content/textCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const textCommand = createPrimitiveCommand( - "generate-caption", + "caption", "Generate on-screen caption text for a social video", - "/api/content/generate-caption", + "/api/content/caption", [ { flag: "--topic ", description: "Subject or theme for caption generation" }, { flag: "--length ", description: "Text length: short, medium, long", defaultValue: "short" }, diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts index 5723c56..cf11988 100644 --- a/src/commands/content/videoCommand.ts +++ b/src/commands/content/videoCommand.ts @@ -1,9 +1,9 @@ import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; export const videoCommand = createPrimitiveCommand( - "generate-video", + "video", "Generate a video (prompt, animate, reference, extend, first-last, or lipsync)", - "/api/content/generate-video", + "/api/content/video", [ { flag: "--mode ", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" }, { flag: "--prompt ", description: "Text prompt describing the video" }, From 9ae2f32eeb1d04166f1357d2ccf2b180eed1fd5e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:45:46 -0400 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20content=20V2=20=E2=80=94=20analyze?= =?UTF-8?q?=20command,=20edit=20endpoint,=20video=20template=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add analyze command (POST /api/content/analyze) - Update edit command endpoint from /api/content/video to /api/content - Add --template flag to video command - Update templates command description (malleable-first) - Build passes, 74 tests pass Made-with: Cursor --- src/commands/content.ts | 2 + src/commands/content/analyzeCommand.ts | 56 ++++++++++++++++++++++++ src/commands/content/editCommand.ts | 4 +- src/commands/content/templatesCommand.ts | 2 +- src/commands/content/videoCommand.ts | 2 + 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/commands/content/analyzeCommand.ts diff --git a/src/commands/content.ts b/src/commands/content.ts index 86567a3..000de28 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -9,6 +9,7 @@ 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"); @@ -23,3 +24,4 @@ contentCommand.addCommand(textCommand); contentCommand.addCommand(audioCommand); contentCommand.addCommand(editCommand); contentCommand.addCommand(upscaleCommand); +contentCommand.addCommand(analyzeCommand); diff --git a/src/commands/content/analyzeCommand.ts b/src/commands/content/analyzeCommand.ts new file mode 100644 index 0000000..18a5b49 --- /dev/null +++ b/src/commands/content/analyzeCommand.ts @@ -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; +} + +export const analyzeCommand = new Command("analyze") + .description( + "Analyze a video with AI — describe scenes, check quality, evaluate content", + ) + .requiredOption("--video ", "Video URL to analyze") + .requiredOption("--prompt ", "What to analyze") + .option("--temperature ", "Sampling temperature (default: 0.2)", parseFloat) + .option("--max-tokens ", "Maximum output tokens", parsePositiveInt) + .option("--json", "Output raw JSON") + .action(async (opts: Record) => { + try { + const body: Record = { + video_url: opts.video, + prompt: opts.prompt, + }; + + if (opts.temperature !== undefined) { + body.temperature = opts.temperature; + } else { + body.temperature = 0.2; + } + + 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).text; + if (typeof text === "string") { + console.log(text); + } else { + printJson(data); + } + } catch (err) { + printError(getErrorMessage(err)); + } + }); diff --git a/src/commands/content/editCommand.ts b/src/commands/content/editCommand.ts index 9b56093..f7a7034 100644 --- a/src/commands/content/editCommand.ts +++ b/src/commands/content/editCommand.ts @@ -9,7 +9,7 @@ interface EditOperation { } export const editCommand = new Command("edit") - .description("Edit media with an operations pipeline or a template preset") + .description("Edit content — trim, crop, resize, overlay text, or add audio") .option("--video ", "Input video URL") .option("--audio ", "Input audio URL") .option("--template ", "Template name for deterministic edit config") @@ -63,7 +63,7 @@ export const editCommand = new Command("edit") output_format: opts.outputFormat ?? "mp4", }; - const data = await patch("/api/content/video", body); + const data = await patch("/api/content", body); if (opts.json) { printJson(data); diff --git a/src/commands/content/templatesCommand.ts b/src/commands/content/templatesCommand.ts index 94e2814..e14ec3f 100644 --- a/src/commands/content/templatesCommand.ts +++ b/src/commands/content/templatesCommand.ts @@ -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 { diff --git a/src/commands/content/videoCommand.ts b/src/commands/content/videoCommand.ts index cf11988..633eccb 100644 --- a/src/commands/content/videoCommand.ts +++ b/src/commands/content/videoCommand.ts @@ -17,6 +17,7 @@ export const videoCommand = createPrimitiveCommand( { flag: "--negative-prompt ", description: "What to avoid in the video" }, { flag: "--generate-audio", description: "Generate audio for the video" }, { flag: "--model ", description: "Override model ID" }, + { flag: "--template ", description: "Template ID for video generation config (moods, movements). Optional — overrides prompt with template defaults." }, ], (opts) => ({ ...(opts.mode && { mode: opts.mode }), @@ -31,5 +32,6 @@ export const videoCommand = createPrimitiveCommand( ...(opts.negativePrompt && { negative_prompt: opts.negativePrompt }), generate_audio: !!opts.generateAudio, ...(opts.model && { model: opts.model }), + ...(opts.template && { template: opts.template }), }), );