diff --git a/cli-for-agent b/cli-for-agent new file mode 100644 index 0000000..e69de29 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.ts b/src/commands/content.ts index 6180412..000de28 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -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"); @@ -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); 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/audioCommand.ts b/src/commands/content/audioCommand.ts new file mode 100644 index 0000000..b239790 --- /dev/null +++ b/src/commands/content/audioCommand.ts @@ -0,0 +1,20 @@ +import { createPrimitiveCommand } from "./createPrimitiveCommand.js"; + +export const audioCommand = createPrimitiveCommand( + "transcribe", + "Transcribe audio into timestamped text", + "/api/content/transcribe", + [ + { flag: "--url ", description: "Comma-separated audio URLs to transcribe" }, + { flag: "--model ", 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 }), + }; + }, +); 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/editCommand.ts b/src/commands/content/editCommand.ts new file mode 100644 index 0000000..f7a7034 --- /dev/null +++ b/src/commands/content/editCommand.ts @@ -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") + .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 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 ` 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 new file mode 100644 index 0000000..3970189 --- /dev/null +++ b/src/commands/content/imageCommand.ts @@ -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 ", 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) => ({ + ...(opts.prompt && { prompt: opts.prompt }), + ...(opts.referenceImage && { reference_image_url: opts.referenceImage }), + ...(opts.model && { model: opts.model }), + }), +); 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/textCommand.ts b/src/commands/content/textCommand.ts new file mode 100644 index 0000000..1755651 --- /dev/null +++ b/src/commands/content/textCommand.ts @@ -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 ", description: "Subject or theme for caption generation" }, + { flag: "--length ", description: "Text length: short, medium, long", defaultValue: "short" }, + ], + (opts) => ({ + topic: opts.topic, + length: opts.length, + }), +); diff --git a/src/commands/content/upscaleCommand.ts b/src/commands/content/upscaleCommand.ts new file mode 100644 index 0000000..6d9b035 --- /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/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..633eccb --- /dev/null +++ b/src/commands/content/videoCommand.ts @@ -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 ", description: "Mode: prompt, animate, reference, extend, first-last, lipsync" }, + { flag: "--prompt ", description: "Text prompt describing the 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" }, + { flag: "--template ", description: "Template ID for video generation config (moods, movements). Optional — overrides prompt with template defaults." }, + ], + (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 }), + ...(opts.template && { template: opts.template }), + }), +);