From 9c91134a8710c51b42a876e9591aa3fbc777ca8b Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Thu, 16 Apr 2026 11:30:14 -0700 Subject: [PATCH 1/4] feat: add --file config support and --wait polling to robots commands with enhanced validation --- src/commands/robots/_shared.ts | 117 +++++++++++++++ src/commands/robots/ask-questions.test.ts | 126 +++++++++++++++- src/commands/robots/ask-questions.ts | 107 ++++++++++++-- src/commands/robots/find-key-moments.test.ts | 99 ++++++++++++- src/commands/robots/find-key-moments.ts | 70 +++++++-- src/commands/robots/generate-chapters.test.ts | 90 +++++++++++- src/commands/robots/generate-chapters.ts | 97 ++++++++++++- src/commands/robots/get.ts | 58 +++----- src/commands/robots/moderate.test.ts | 93 +++++++++++- src/commands/robots/moderate.ts | 110 +++++++++++--- src/commands/robots/summarize.test.ts | 128 ++++++++++++++++- src/commands/robots/summarize.ts | 134 ++++++++++++++++-- .../robots/translate-captions.test.ts | 67 ++++++++- src/commands/robots/translate-captions.ts | 80 ++++++++--- 14 files changed, 1246 insertions(+), 130 deletions(-) create mode 100644 src/commands/robots/_shared.ts diff --git a/src/commands/robots/_shared.ts b/src/commands/robots/_shared.ts new file mode 100644 index 0000000..b27ae11 --- /dev/null +++ b/src/commands/robots/_shared.ts @@ -0,0 +1,117 @@ +import { readFile } from 'node:fs/promises'; +import type Mux from '@mux/mux-node'; +import type { + AskQuestionsJob, + FindKeyMomentsJob, + GenerateChaptersJob, + ModerateJob, + SummarizeJob, + TranslateCaptionsJob, +} from '@mux/mux-node/resources/robots-preview/jobs'; + +export type AnyRobotsJob = + | AskQuestionsJob + | FindKeyMomentsJob + | GenerateChaptersJob + | ModerateJob + | SummarizeJob + | TranslateCaptionsJob; + +export type RobotsWorkflow = AnyRobotsJob['workflow']; + +export const FILE_MUTEX_MSG = + '--file cannot be combined with other parameter flags. Use one or the other.'; + +const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'errored']); + +export function isTerminalStatus(status: string): boolean { + return TERMINAL_STATUSES.has(status); +} + +export async function loadJobParameters( + filePath: string, + assetIdFromPositional: string, +): Promise { + let content: string; + try { + content = await readFile(filePath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Config file not found: ${filePath}`); + } + throw err; + } + + let parsed: T; + try { + parsed = JSON.parse(content) as T; + } catch (err) { + throw new Error( + `Invalid JSON in config file ${filePath}: ${(err as Error).message}`, + ); + } + + if (parsed.asset_id && parsed.asset_id !== assetIdFromPositional) { + throw new Error( + `asset_id in config file (${parsed.asset_id}) does not match positional argument (${assetIdFromPositional}).`, + ); + } + parsed.asset_id = assetIdFromPositional; + return parsed; +} + +export function retrieveRobotsJob( + mux: Mux, + workflow: RobotsWorkflow, + jobId: string, +): Promise { + switch (workflow) { + case 'ask-questions': + return mux.robotsPreview.jobs.askQuestions.retrieve(jobId); + case 'find-key-moments': + return mux.robotsPreview.jobs.findKeyMoments.retrieve(jobId); + case 'generate-chapters': + return mux.robotsPreview.jobs.generateChapters.retrieve(jobId); + case 'moderate': + return mux.robotsPreview.jobs.moderate.retrieve(jobId); + case 'summarize': + return mux.robotsPreview.jobs.summarize.retrieve(jobId); + case 'translate-captions': + return mux.robotsPreview.jobs.translateCaptions.retrieve(jobId); + } +} + +export async function pollForRobotsJob( + mux: Mux, + workflow: RobotsWorkflow, + jobId: string, + jsonOutput: boolean, +): Promise { + const POLL_INTERVAL_MS = 3000; + const MAX_POLL_TIME_MS = 15 * 60 * 1000; + const start = Date.now(); + + if (!jsonOutput) { + process.stderr.write('Waiting for job to complete'); + } + + while (Date.now() - start < MAX_POLL_TIME_MS) { + const job = await retrieveRobotsJob(mux, workflow, jobId); + if (isTerminalStatus(job.status)) { + if (!jsonOutput) { + process.stderr.write(` ${job.status}!\n`); + } + return job; + } + if (!jsonOutput) { + process.stderr.write('.'); + } + await sleep(POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for job ${jobId} to complete`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/commands/robots/ask-questions.test.ts b/src/commands/robots/ask-questions.test.ts index 9f9db05..5dafbd8 100644 --- a/src/commands/robots/ask-questions.test.ts +++ b/src/commands/robots/ask-questions.test.ts @@ -7,16 +7,22 @@ import { spyOn, test, } from 'bun:test'; -import { askQuestionsCommand } from './ask-questions.ts'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { askQuestionsCommand, parseQuestion } from './ask-questions.ts'; // Note: These tests focus on CLI flag parsing and command structure // They do NOT test the actual Mux API integration (that's tested via E2E) describe('mux robots ask-questions', () => { + let tempDir: string; let exitSpy: Mock; let consoleErrorSpy: Mock; - beforeEach(() => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called'); }) as never); @@ -24,7 +30,8 @@ describe('mux robots ask-questions', () => { consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); }); @@ -71,6 +78,20 @@ describe('mux robots ask-questions', () => { .find((o) => o.name === 'json'); expect(opt).toBeDefined(); }); + + test('has --wait flag', () => { + const opt = askQuestionsCommand + .getOptions() + .find((o) => o.name === 'wait'); + expect(opt).toBeDefined(); + }); + + test('has --file flag', () => { + const opt = askQuestionsCommand + .getOptions() + .find((o) => o.name === 'file'); + expect(opt).toBeDefined(); + }); }); describe('Input validation', () => { @@ -84,4 +105,103 @@ describe('mux robots ask-questions', () => { expect(exitSpy).toHaveBeenCalled(); }); }); + + describe('parseQuestion helper', () => { + test('plain question without pipe yields no answer_options', () => { + expect(parseQuestion('What is this?')).toEqual({ + question: 'What is this?', + }); + }); + + test('question with pipe splits answer_options on commas', () => { + expect(parseQuestion('How many speakers?|one,two,three or more')).toEqual( + { + question: 'How many speakers?', + answer_options: ['one', 'two', 'three or more'], + }, + ); + }); + + test('trims whitespace around question and options', () => { + expect(parseQuestion(' Q? | a , b , c ')).toEqual({ + question: 'Q?', + answer_options: ['a', 'b', 'c'], + }); + }); + + test('splits only on first pipe so options may contain pipes', () => { + expect(parseQuestion('Choose?|a|b,c|d')).toEqual({ + question: 'Choose?', + answer_options: ['a|b', 'c|d'], + }); + }); + + test('throws on empty question', () => { + expect(() => parseQuestion('|a,b')).toThrow(/question/i); + }); + + test('throws when pipe is present but options list is empty', () => { + expect(() => parseQuestion('Q?|')).toThrow(/answer_options/i); + }); + }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await askQuestionsCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when config file is invalid JSON', async () => { + const configPath = join(tempDir, 'bad.json'); + await writeFile(configPath, '{ bad'); + try { + await askQuestionsCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/invalid json/i); + }); + + test('errors when --file combined with --question', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile( + configPath, + JSON.stringify({ + questions: [{ question: 'What is this?' }], + }), + ); + try { + await askQuestionsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--question', + 'Other?', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + + test('requires either --file or --question', async () => { + try { + await askQuestionsCommand.parse(['asset_abc']); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/src/commands/robots/ask-questions.ts b/src/commands/robots/ask-questions.ts index c6fc8d9..df10ef9 100644 --- a/src/commands/robots/ask-questions.ts +++ b/src/commands/robots/ask-questions.ts @@ -5,21 +5,59 @@ import type { } from '@mux/mux-node/resources/robots-preview/jobs'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; interface AskQuestionsOptions { - question: string[]; + question?: string[]; languageCode?: string; passthrough?: string; + file?: string; + wait?: boolean; json?: boolean; } -export const askQuestionsCommand = new Command() +export function parseQuestion(raw: string): AskQuestionsJobParameters.Question { + const pipeIdx = raw.indexOf('|'); + if (pipeIdx === -1) { + const question = raw.trim(); + if (!question) { + throw new Error('question text cannot be empty'); + } + return { question }; + } + + const question = raw.slice(0, pipeIdx).trim(); + if (!question) { + throw new Error('question text cannot be empty'); + } + + const answer_options = raw + .slice(pipeIdx + 1) + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + if (answer_options.length === 0) { + throw new Error( + 'answer_options cannot be empty after "|". Use "question?|opt1,opt2" or drop the pipe for yes/no.', + ); + } + + return { question, answer_options }; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const askQuestionsCommand: Command = new Command() .description('Create a job to ask questions about a video and get answers') .arguments('') .option( '--question ', - 'Question to ask about the video. Can be specified multiple times.', - { collect: true, required: true }, + 'Question to ask. Format: "text" or "text|opt1,opt2,opt3" for custom answers (defaults to yes/no). Can be specified multiple times.', + { collect: true }, ) .option( '--language-code ', @@ -29,22 +67,65 @@ export const askQuestionsCommand = new Command() '--passthrough ', 'Arbitrary metadata returned in API responses (max 255 chars)', ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (questions, language_code, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) .option('--json', 'Output JSON instead of pretty format') .action(async (options: AskQuestionsOptions, assetId: string) => { try { - const parameters: AskQuestionsJobParameters = { - asset_id: assetId, - questions: options.question.map((q) => ({ question: q })), - }; - if (options.languageCode !== undefined) - parameters.language_code = options.languageCode; + const hasShapeFlags = + (options.question && options.question.length > 0) || + options.languageCode !== undefined; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + if ( + !options.file && + (!options.question || options.question.length === 0) + ) { + throw new Error( + 'Must provide at least one --question, or a --file with questions.', + ); + } + + let parameters: AskQuestionsJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { + asset_id: assetId, + questions: (options.question ?? []).map(parseQuestion), + }; + if (options.languageCode !== undefined) { + parameters.language_code = options.languageCode; + } + } const body: AskQuestionCreateParams = { parameters }; if (options.passthrough !== undefined) body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - const job = await mux.robotsPreview.jobs.askQuestions.create(body); + let job = await mux.robotsPreview.jobs.askQuestions.create(body); + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'ask-questions', + job.id, + Boolean(options.json), + )) as typeof job; + } if (options.json) { console.log(JSON.stringify(job, null, 2)); @@ -54,6 +135,10 @@ export const askQuestionsCommand = new Command() console.log('Ask questions job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); + if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } } catch (error) { await handleCommandError(error, 'robots', 'ask-questions', options); } diff --git a/src/commands/robots/find-key-moments.test.ts b/src/commands/robots/find-key-moments.test.ts index 2253539..32a659d 100644 --- a/src/commands/robots/find-key-moments.test.ts +++ b/src/commands/robots/find-key-moments.test.ts @@ -7,16 +7,22 @@ import { spyOn, test, } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { findKeyMomentsCommand } from './find-key-moments.ts'; // Note: These tests focus on CLI flag parsing and command structure // They do NOT test the actual Mux API integration (that's tested via E2E) describe('mux robots find-key-moments', () => { + let tempDir: string; let exitSpy: Mock; let consoleErrorSpy: Mock; - beforeEach(() => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called'); }) as never); @@ -24,7 +30,8 @@ describe('mux robots find-key-moments', () => { consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); }); @@ -78,6 +85,20 @@ describe('mux robots find-key-moments', () => { .find((o) => o.name === 'json'); expect(opt).toBeDefined(); }); + + test('has --wait flag', () => { + const opt = findKeyMomentsCommand + .getOptions() + .find((o) => o.name === 'wait'); + expect(opt).toBeDefined(); + }); + + test('has --file flag', () => { + const opt = findKeyMomentsCommand + .getOptions() + .find((o) => o.name === 'file'); + expect(opt).toBeDefined(); + }); }); describe('Input validation', () => { @@ -90,5 +111,79 @@ describe('mux robots find-key-moments', () => { expect(exitSpy).toHaveBeenCalled(); }); + + test('rejects --max-moments above 10', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await findKeyMomentsCommand.parse(['asset_abc', '--max-moments', '11']); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/max-moments|10/i); + }); + + test('rejects --max-moments below 1', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await findKeyMomentsCommand.parse(['asset_abc', '--max-moments', '0']); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/max-moments|1/i); + }); + + test('rejects --target-duration-min-ms without --target-duration-max-ms', async () => { + try { + await findKeyMomentsCommand.parse([ + 'asset_abc', + '--target-duration-min-ms', + '5000', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/target-duration/i); + }); + }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await findKeyMomentsCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when --file combined with a shape flag', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ max_moments: 3 })); + try { + await findKeyMomentsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--max-moments', + '3', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); }); }); diff --git a/src/commands/robots/find-key-moments.ts b/src/commands/robots/find-key-moments.ts index e014e70..fed02cf 100644 --- a/src/commands/robots/find-key-moments.ts +++ b/src/commands/robots/find-key-moments.ts @@ -5,21 +5,39 @@ import type { } from '@mux/mux-node/resources/robots-preview/jobs'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; interface FindKeyMomentsOptions { maxMoments?: number; targetDurationMinMs?: number; targetDurationMaxMs?: number; passthrough?: string; + file?: string; + wait?: boolean; json?: boolean; } -export const findKeyMomentsCommand = new Command() +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const findKeyMomentsCommand: Command = new Command() .description('Create a job to find key moments and highlights in a video') .arguments('') .option( '--max-moments ', - 'Maximum number of key moments to extract (default 5)', + 'Maximum number of key moments to extract (1-10, default 5)', + { + value: (value: number): number => { + if (!Number.isInteger(value) || value < 1 || value > 10) { + throw new Error( + `--max-moments must be an integer between 1 and 10 (got: ${value})`, + ); + } + return value; + }, + }, ) .option( '--target-duration-min-ms ', @@ -33,6 +51,14 @@ export const findKeyMomentsCommand = new Command() '--passthrough ', 'Arbitrary metadata returned in API responses (max 255 chars)', ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (max_moments, target_duration_ms, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) .option('--json', 'Output JSON instead of pretty format') .action(async (options: FindKeyMomentsOptions, assetId: string) => { try { @@ -44,11 +70,26 @@ export const findKeyMomentsCommand = new Command() ); } - const parameters: FindKeyMomentsJobParameters = { asset_id: assetId }; - if (options.maxMoments !== undefined) - parameters.max_moments = options.maxMoments; - if (minMs !== undefined && maxMs !== undefined) { - parameters.target_duration_ms = { min: minMs, max: maxMs }; + const hasShapeFlags = + options.maxMoments !== undefined || minMs !== undefined; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: FindKeyMomentsJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { asset_id: assetId }; + if (options.maxMoments !== undefined) + parameters.max_moments = options.maxMoments; + if (minMs !== undefined && maxMs !== undefined) { + parameters.target_duration_ms = { min: minMs, max: maxMs }; + } } const body: FindKeyMomentCreateParams = { parameters }; @@ -56,7 +97,16 @@ export const findKeyMomentsCommand = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - const job = await mux.robotsPreview.jobs.findKeyMoments.create(body); + let job = await mux.robotsPreview.jobs.findKeyMoments.create(body); + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'find-key-moments', + job.id, + Boolean(options.json), + )) as typeof job; + } if (options.json) { console.log(JSON.stringify(job, null, 2)); @@ -66,6 +116,10 @@ export const findKeyMomentsCommand = new Command() console.log('Find key moments job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); + if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } } catch (error) { await handleCommandError(error, 'robots', 'find-key-moments', options); } diff --git a/src/commands/robots/generate-chapters.test.ts b/src/commands/robots/generate-chapters.test.ts index e23eedf..fb06a3e 100644 --- a/src/commands/robots/generate-chapters.test.ts +++ b/src/commands/robots/generate-chapters.test.ts @@ -7,16 +7,22 @@ import { spyOn, test, } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { generateChaptersCommand } from './generate-chapters.ts'; // Note: These tests focus on CLI flag parsing and command structure // They do NOT test the actual Mux API integration (that's tested via E2E) describe('mux robots generate-chapters', () => { + let tempDir: string; let exitSpy: Mock; let consoleErrorSpy: Mock; - beforeEach(() => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called'); }) as never); @@ -24,7 +30,8 @@ describe('mux robots generate-chapters', () => { consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); }); @@ -71,6 +78,48 @@ describe('mux robots generate-chapters', () => { .find((o) => o.name === 'json'); expect(opt).toBeDefined(); }); + + test('has --wait flag', () => { + const opt = generateChaptersCommand + .getOptions() + .find((o) => o.name === 'wait'); + expect(opt).toBeDefined(); + }); + + test('has --file flag', () => { + const opt = generateChaptersCommand + .getOptions() + .find((o) => o.name === 'file'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-task flag', () => { + const opt = generateChaptersCommand + .getOptions() + .find((o) => o.name === 'prompt-task'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-output-format flag', () => { + const opt = generateChaptersCommand + .getOptions() + .find((o) => o.name === 'prompt-output-format'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-chapter-guidelines flag', () => { + const opt = generateChaptersCommand + .getOptions() + .find((o) => o.name === 'prompt-chapter-guidelines'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-title-guidelines flag', () => { + const opt = generateChaptersCommand + .getOptions() + .find((o) => o.name === 'prompt-title-guidelines'); + expect(opt).toBeDefined(); + }); }); describe('Input validation', () => { @@ -84,4 +133,41 @@ describe('mux robots generate-chapters', () => { expect(exitSpy).toHaveBeenCalled(); }); }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await generateChaptersCommand.parse([ + 'asset_abc', + '--file', + configPath, + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when --file combined with a shape flag', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ language_code: 'en' })); + try { + await generateChaptersCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--language-code', + 'fr', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + }); }); diff --git a/src/commands/robots/generate-chapters.ts b/src/commands/robots/generate-chapters.ts index 31413a4..87e5e9f 100644 --- a/src/commands/robots/generate-chapters.ts +++ b/src/commands/robots/generate-chapters.ts @@ -5,15 +5,27 @@ import type { } from '@mux/mux-node/resources/robots-preview/jobs'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; interface GenerateChaptersOptions { languageCode?: string; outputLanguageCode?: string; + promptTask?: string; + promptOutputFormat?: string; + promptChapterGuidelines?: string; + promptTitleGuidelines?: string; passthrough?: string; + file?: string; + wait?: boolean; json?: boolean; } -export const generateChaptersCommand = new Command() +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const generateChaptersCommand: Command = new Command() .description('Create a job to automatically generate chapters for a video') .arguments('') .option( @@ -24,25 +36,78 @@ export const generateChaptersCommand = new Command() '--output-language-code ', 'BCP 47 language code for the output chapter titles', ) + .option( + '--prompt-task ', + 'Override the core task instruction for chapter generation', + ) + .option( + '--prompt-output-format ', + 'Override the JSON output format instructions', + ) + .option( + '--prompt-chapter-guidelines ', + 'Override the chapter density and timing constraints', + ) + .option( + '--prompt-title-guidelines ', + 'Override the chapter title style requirements', + ) .option( '--passthrough ', 'Arbitrary metadata returned in API responses (max 255 chars)', ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (language_code, prompt_overrides, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) .option('--json', 'Output JSON instead of pretty format') .action(async (options: GenerateChaptersOptions, assetId: string) => { try { - const parameters: GenerateChaptersJobParameters = { asset_id: assetId }; - if (options.languageCode !== undefined) - parameters.language_code = options.languageCode; - if (options.outputLanguageCode !== undefined) - parameters.output_language_code = options.outputLanguageCode; + const promptOverrides = buildPromptOverrides(options); + const hasShapeFlags = + options.languageCode !== undefined || + options.outputLanguageCode !== undefined || + promptOverrides !== undefined; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: GenerateChaptersJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { asset_id: assetId }; + if (options.languageCode !== undefined) + parameters.language_code = options.languageCode; + if (options.outputLanguageCode !== undefined) + parameters.output_language_code = options.outputLanguageCode; + if (promptOverrides !== undefined) + parameters.prompt_overrides = promptOverrides; + } const body: GenerateChapterCreateParams = { parameters }; if (options.passthrough !== undefined) body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - const job = await mux.robotsPreview.jobs.generateChapters.create(body); + let job = await mux.robotsPreview.jobs.generateChapters.create(body); + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'generate-chapters', + job.id, + Boolean(options.json), + )) as typeof job; + } if (options.json) { console.log(JSON.stringify(job, null, 2)); @@ -52,7 +117,25 @@ export const generateChaptersCommand = new Command() console.log('Generate chapters job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); + if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } } catch (error) { await handleCommandError(error, 'robots', 'generate-chapters', options); } }); + +function buildPromptOverrides( + options: GenerateChaptersOptions, +): GenerateChaptersJobParameters.PromptOverrides | undefined { + const overrides: GenerateChaptersJobParameters.PromptOverrides = {}; + if (options.promptTask !== undefined) overrides.task = options.promptTask; + if (options.promptOutputFormat !== undefined) + overrides.output_format = options.promptOutputFormat; + if (options.promptChapterGuidelines !== undefined) + overrides.chapter_guidelines = options.promptChapterGuidelines; + if (options.promptTitleGuidelines !== undefined) + overrides.title_guidelines = options.promptTitleGuidelines; + return Object.keys(overrides).length > 0 ? overrides : undefined; +} diff --git a/src/commands/robots/get.ts b/src/commands/robots/get.ts index 158a48f..0dd95fb 100644 --- a/src/commands/robots/get.ts +++ b/src/commands/robots/get.ts @@ -1,55 +1,30 @@ import { Command } from '@cliffy/command'; -import type Mux from '@mux/mux-node'; -import type { - AskQuestionsJob, - FindKeyMomentsJob, - GenerateChaptersJob, - ModerateJob, - SummarizeJob, - TranslateCaptionsJob, -} from '@mux/mux-node/resources/robots-preview/jobs'; import { handleCommandError } from '@/lib/errors.ts'; import { formatCreatedAt } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { type RobotsWorkflow, retrieveRobotsJob } from './_shared.ts'; -type AnyJob = - | AskQuestionsJob - | FindKeyMomentsJob - | GenerateChaptersJob - | ModerateJob - | SummarizeJob - | TranslateCaptionsJob; - -type Workflow = AnyJob['workflow']; +const WORKFLOWS: RobotsWorkflow[] = [ + 'ask-questions', + 'find-key-moments', + 'generate-chapters', + 'moderate', + 'summarize', + 'translate-captions', +]; interface GetOptions { workflow: string; json?: boolean; } -function retrieveJob( - mux: Mux, - workflow: string, - jobId: string, -): Promise { - switch (workflow as Workflow) { - case 'ask-questions': - return mux.robotsPreview.jobs.askQuestions.retrieve(jobId); - case 'find-key-moments': - return mux.robotsPreview.jobs.findKeyMoments.retrieve(jobId); - case 'generate-chapters': - return mux.robotsPreview.jobs.generateChapters.retrieve(jobId); - case 'moderate': - return mux.robotsPreview.jobs.moderate.retrieve(jobId); - case 'summarize': - return mux.robotsPreview.jobs.summarize.retrieve(jobId); - case 'translate-captions': - return mux.robotsPreview.jobs.translateCaptions.retrieve(jobId); - default: - throw new Error( - `Unknown workflow: ${workflow}. Must be one of: ask-questions, find-key-moments, generate-chapters, moderate, summarize, translate-captions.`, - ); +function assertWorkflow(workflow: string): RobotsWorkflow { + if (!WORKFLOWS.includes(workflow as RobotsWorkflow)) { + throw new Error( + `Unknown workflow: ${workflow}. Must be one of: ${WORKFLOWS.join(', ')}.`, + ); } + return workflow as RobotsWorkflow; } export const getCommand = new Command() @@ -63,8 +38,9 @@ export const getCommand = new Command() .option('--json', 'Output JSON instead of pretty format') .action(async (options: GetOptions, jobId: string) => { try { + const workflow = assertWorkflow(options.workflow); const mux = await createAuthenticatedMuxClient(); - const job = await retrieveJob(mux, options.workflow, jobId); + const job = await retrieveRobotsJob(mux, workflow, jobId); if (options.json) { console.log(JSON.stringify(job, null, 2)); diff --git a/src/commands/robots/moderate.test.ts b/src/commands/robots/moderate.test.ts index 96cc0ad..bccd98f 100644 --- a/src/commands/robots/moderate.test.ts +++ b/src/commands/robots/moderate.test.ts @@ -7,16 +7,22 @@ import { spyOn, test, } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { moderateCommand } from './moderate.ts'; // Note: These tests focus on CLI flag parsing and command structure // They do NOT test the actual Mux API integration (that's tested via E2E) describe('mux robots moderate', () => { + let tempDir: string; let exitSpy: Mock; let consoleErrorSpy: Mock; - beforeEach(() => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called'); }) as never); @@ -24,7 +30,8 @@ describe('mux robots moderate', () => { consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); }); @@ -88,6 +95,16 @@ describe('mux robots moderate', () => { const opt = moderateCommand.getOptions().find((o) => o.name === 'json'); expect(opt).toBeDefined(); }); + + test('has --wait flag', () => { + const opt = moderateCommand.getOptions().find((o) => o.name === 'wait'); + expect(opt).toBeDefined(); + }); + + test('has --file flag', () => { + const opt = moderateCommand.getOptions().find((o) => o.name === 'file'); + expect(opt).toBeDefined(); + }); }); describe('Input validation', () => { @@ -100,5 +117,77 @@ describe('mux robots moderate', () => { expect(exitSpy).toHaveBeenCalled(); }); + + test('rejects --sampling-interval below 5', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await moderateCommand.parse(['asset_abc', '--sampling-interval', '3']); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/sampling-interval|minimum|5/i); + }); + + test('rejects --threshold-sexual outside 0..1', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await moderateCommand.parse(['asset_abc', '--threshold-sexual', '1.5']); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/threshold|0.*1/i); + }); + }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await moderateCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when config file is invalid JSON', async () => { + const configPath = join(tempDir, 'bad.json'); + await writeFile(configPath, '{ not json'); + try { + await moderateCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/invalid json/i); + }); + + test('errors when --file combined with a shape flag', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ max_samples: 10 })); + try { + await moderateCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--max-samples', + '20', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); }); }); diff --git a/src/commands/robots/moderate.ts b/src/commands/robots/moderate.ts index 2dc3a72..c3800e1 100644 --- a/src/commands/robots/moderate.ts +++ b/src/commands/robots/moderate.ts @@ -5,6 +5,11 @@ import type { } from '@mux/mux-node/resources/robots-preview/jobs'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; interface ModerateOptions { languageCode?: string; @@ -13,10 +18,25 @@ interface ModerateOptions { thresholdSexual?: number; thresholdViolence?: number; passthrough?: string; + file?: string; + wait?: boolean; json?: boolean; } -export const moderateCommand = new Command() +function threshold(label: string) { + return (value: string): number => { + const n = Number(value); + if (!Number.isFinite(n) || n < 0 || n > 1) { + throw new Error( + `--${label} must be a number between 0 and 1 (got: ${value})`, + ); + } + return n; + }; +} + +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const moderateCommand: Command = new Command() .description( 'Create a moderation job to analyze video content for policy violations', ) @@ -28,42 +48,81 @@ export const moderateCommand = new Command() .option( '--sampling-interval ', 'Interval in seconds between sampled thumbnails (min 5)', + { + value: (value: number): number => { + if (value < 5) { + throw new Error( + `--sampling-interval minimum is 5 seconds (got: ${value})`, + ); + } + return value; + }, + }, ) .option( '--max-samples ', 'Maximum number of thumbnails to sample', ) .option( - '--threshold-sexual ', + '--threshold-sexual ', 'Score threshold (0.0-1.0) for sexual content (default 0.7)', + { value: threshold('threshold-sexual') }, ) .option( - '--threshold-violence ', + '--threshold-violence ', 'Score threshold (0.0-1.0) for violent content (default 0.8)', + { value: threshold('threshold-violence') }, ) .option( '--passthrough ', 'Arbitrary metadata returned in API responses (max 255 chars)', ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (thresholds, sampling_interval, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) .option('--json', 'Output JSON instead of pretty format') .action(async (options: ModerateOptions, assetId: string) => { try { - const parameters: ModerateJobParameters = { asset_id: assetId }; - if (options.languageCode !== undefined) - parameters.language_code = options.languageCode; - if (options.samplingInterval !== undefined) - parameters.sampling_interval = options.samplingInterval; - if (options.maxSamples !== undefined) - parameters.max_samples = options.maxSamples; - if ( + const hasShapeFlags = + options.languageCode !== undefined || + options.samplingInterval !== undefined || + options.maxSamples !== undefined || options.thresholdSexual !== undefined || - options.thresholdViolence !== undefined - ) { - parameters.thresholds = {}; - if (options.thresholdSexual !== undefined) - parameters.thresholds.sexual = options.thresholdSexual; - if (options.thresholdViolence !== undefined) - parameters.thresholds.violence = options.thresholdViolence; + options.thresholdViolence !== undefined; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: ModerateJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { asset_id: assetId }; + if (options.languageCode !== undefined) + parameters.language_code = options.languageCode; + if (options.samplingInterval !== undefined) + parameters.sampling_interval = options.samplingInterval; + if (options.maxSamples !== undefined) + parameters.max_samples = options.maxSamples; + if ( + options.thresholdSexual !== undefined || + options.thresholdViolence !== undefined + ) { + parameters.thresholds = {}; + if (options.thresholdSexual !== undefined) + parameters.thresholds.sexual = options.thresholdSexual; + if (options.thresholdViolence !== undefined) + parameters.thresholds.violence = options.thresholdViolence; + } } const body: ModerateCreateParams = { parameters }; @@ -71,7 +130,16 @@ export const moderateCommand = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - const job = await mux.robotsPreview.jobs.moderate.create(body); + let job = await mux.robotsPreview.jobs.moderate.create(body); + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'moderate', + job.id, + Boolean(options.json), + )) as typeof job; + } if (options.json) { console.log(JSON.stringify(job, null, 2)); @@ -81,6 +149,10 @@ export const moderateCommand = new Command() console.log('Moderate job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); + if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } } catch (error) { await handleCommandError(error, 'robots', 'moderate', options); } diff --git a/src/commands/robots/summarize.test.ts b/src/commands/robots/summarize.test.ts index edaabf0..9a2ba81 100644 --- a/src/commands/robots/summarize.test.ts +++ b/src/commands/robots/summarize.test.ts @@ -7,16 +7,22 @@ import { spyOn, test, } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { summarizeCommand } from './summarize.ts'; // Note: These tests focus on CLI flag parsing and command structure // They do NOT test the actual Mux API integration (that's tested via E2E) describe('mux robots summarize', () => { + let tempDir: string; let exitSpy: Mock; let consoleErrorSpy: Mock; - beforeEach(() => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called'); }) as never); @@ -24,7 +30,8 @@ describe('mux robots summarize', () => { consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); }); @@ -72,6 +79,51 @@ describe('mux robots summarize', () => { const opt = summarizeCommand.getOptions().find((o) => o.name === 'json'); expect(opt).toBeDefined(); }); + + test('has --wait flag', () => { + const opt = summarizeCommand.getOptions().find((o) => o.name === 'wait'); + expect(opt).toBeDefined(); + }); + + test('has --file flag', () => { + const opt = summarizeCommand.getOptions().find((o) => o.name === 'file'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-task flag', () => { + const opt = summarizeCommand + .getOptions() + .find((o) => o.name === 'prompt-task'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-title flag', () => { + const opt = summarizeCommand + .getOptions() + .find((o) => o.name === 'prompt-title'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-description flag', () => { + const opt = summarizeCommand + .getOptions() + .find((o) => o.name === 'prompt-description'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-keywords flag', () => { + const opt = summarizeCommand + .getOptions() + .find((o) => o.name === 'prompt-keywords'); + expect(opt).toBeDefined(); + }); + + test('has --prompt-quality-guidelines flag', () => { + const opt = summarizeCommand + .getOptions() + .find((o) => o.name === 'prompt-quality-guidelines'); + expect(opt).toBeDefined(); + }); }); describe('Input validation', () => { @@ -84,5 +136,77 @@ describe('mux robots summarize', () => { expect(exitSpy).toHaveBeenCalled(); }); + + test('rejects invalid --tone value', async () => { + let errorThrown = false; + let errorMessage = ''; + try { + await summarizeCommand.parse(['asset_abc', '--tone', 'angry']); + } catch (error) { + errorThrown = true; + errorMessage = error instanceof Error ? error.message : String(error); + } + expect(errorThrown).toBe(true); + expect(errorMessage).toMatch(/tone/i); + }); + }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await summarizeCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when config file is invalid JSON', async () => { + const configPath = join(tempDir, 'bad.json'); + await writeFile(configPath, '{ not json'); + try { + await summarizeCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/invalid json/i); + }); + + test('errors when --file combined with a shape flag', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ tone: 'neutral' })); + try { + await summarizeCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--tone', + 'playful', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + + test('errors when file asset_id disagrees with positional', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ asset_id: 'other_asset' })); + try { + await summarizeCommand.parse(['asset_abc', '--file', configPath]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/asset_id/i); + }); }); }); diff --git a/src/commands/robots/summarize.ts b/src/commands/robots/summarize.ts index 257aa4f..45a481a 100644 --- a/src/commands/robots/summarize.ts +++ b/src/commands/robots/summarize.ts @@ -5,21 +5,35 @@ import type { } from '@mux/mux-node/resources/robots-preview/jobs'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; type Tone = NonNullable; +const VALID_TONES: Tone[] = ['neutral', 'playful', 'professional']; interface SummarizeOptions { - tone?: string; + tone?: Tone; languageCode?: string; outputLanguageCode?: string; titleLength?: number; descriptionLength?: number; tagCount?: number; + promptTask?: string; + promptTitle?: string; + promptDescription?: string; + promptKeywords?: string; + promptQualityGuidelines?: string; passthrough?: string; + file?: string; + wait?: boolean; json?: boolean; } -export const summarizeCommand = new Command() +// biome-ignore lint/suspicious/noExplicitAny: Cliffy's chained types are too complex for TS to infer +export const summarizeCommand: Command = new Command() .description( 'Create a summarize job to generate a title, description, and tags for a video', ) @@ -27,6 +41,16 @@ export const summarizeCommand = new Command() .option( '--tone ', 'Tone of the summary (neutral, playful, professional)', + { + value: (value: string): Tone => { + if (!VALID_TONES.includes(value as Tone)) { + throw new Error( + `Invalid --tone: ${value}. Must be one of: ${VALID_TONES.join(', ')}`, + ); + } + return value as Tone; + }, + }, ) .option( '--language-code ', @@ -45,32 +69,93 @@ export const summarizeCommand = new Command() 'Maximum description length in words', ) .option('--tag-count ', 'Maximum number of tags to generate') + .option( + '--prompt-task ', + 'Override the core task instruction for summarization', + ) + .option( + '--prompt-title ', + 'Override the title generation requirements', + ) + .option( + '--prompt-description ', + 'Override the description generation requirements', + ) + .option( + '--prompt-keywords ', + 'Override the keyword/tag extraction requirements', + ) + .option( + '--prompt-quality-guidelines ', + 'Override the quality standards for analysis', + ) .option( '--passthrough ', 'Arbitrary metadata returned in API responses (max 255 chars)', ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (tone, prompt_overrides, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) .option('--json', 'Output JSON instead of pretty format') .action(async (options: SummarizeOptions, assetId: string) => { try { - const parameters: SummarizeJobParameters = { asset_id: assetId }; - if (options.tone !== undefined) parameters.tone = options.tone as Tone; - if (options.languageCode !== undefined) - parameters.language_code = options.languageCode; - if (options.outputLanguageCode !== undefined) - parameters.output_language_code = options.outputLanguageCode; - if (options.titleLength !== undefined) - parameters.title_length = options.titleLength; - if (options.descriptionLength !== undefined) - parameters.description_length = options.descriptionLength; - if (options.tagCount !== undefined) - parameters.tag_count = options.tagCount; + const promptOverrides = buildPromptOverrides(options); + const hasShapeFlags = + options.tone !== undefined || + options.languageCode !== undefined || + options.outputLanguageCode !== undefined || + options.titleLength !== undefined || + options.descriptionLength !== undefined || + options.tagCount !== undefined || + promptOverrides !== undefined; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: SummarizeJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + parameters = { asset_id: assetId }; + if (options.tone !== undefined) parameters.tone = options.tone; + if (options.languageCode !== undefined) + parameters.language_code = options.languageCode; + if (options.outputLanguageCode !== undefined) + parameters.output_language_code = options.outputLanguageCode; + if (options.titleLength !== undefined) + parameters.title_length = options.titleLength; + if (options.descriptionLength !== undefined) + parameters.description_length = options.descriptionLength; + if (options.tagCount !== undefined) + parameters.tag_count = options.tagCount; + if (promptOverrides !== undefined) + parameters.prompt_overrides = promptOverrides; + } const body: SummarizeCreateParams = { parameters }; if (options.passthrough !== undefined) body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - const job = await mux.robotsPreview.jobs.summarize.create(body); + let job = await mux.robotsPreview.jobs.summarize.create(body); + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'summarize', + job.id, + Boolean(options.json), + )) as typeof job; + } if (options.json) { console.log(JSON.stringify(job, null, 2)); @@ -80,7 +165,26 @@ export const summarizeCommand = new Command() console.log('Summarize job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); + if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } } catch (error) { await handleCommandError(error, 'robots', 'summarize', options); } }); + +function buildPromptOverrides( + options: SummarizeOptions, +): SummarizeJobParameters.PromptOverrides | undefined { + const overrides: SummarizeJobParameters.PromptOverrides = {}; + if (options.promptTask !== undefined) overrides.task = options.promptTask; + if (options.promptTitle !== undefined) overrides.title = options.promptTitle; + if (options.promptDescription !== undefined) + overrides.description = options.promptDescription; + if (options.promptKeywords !== undefined) + overrides.keywords = options.promptKeywords; + if (options.promptQualityGuidelines !== undefined) + overrides.quality_guidelines = options.promptQualityGuidelines; + return Object.keys(overrides).length > 0 ? overrides : undefined; +} diff --git a/src/commands/robots/translate-captions.test.ts b/src/commands/robots/translate-captions.test.ts index c50eb1f..69d5738 100644 --- a/src/commands/robots/translate-captions.test.ts +++ b/src/commands/robots/translate-captions.test.ts @@ -7,16 +7,22 @@ import { spyOn, test, } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { translateCaptionsCommand } from './translate-captions.ts'; // Note: These tests focus on CLI flag parsing and command structure // They do NOT test the actual Mux API integration (that's tested via E2E) describe('mux robots translate-captions', () => { + let tempDir: string; let exitSpy: Mock; let consoleErrorSpy: Mock; - beforeEach(() => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-')); + exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit called'); }) as never); @@ -24,7 +30,8 @@ describe('mux robots translate-captions', () => { consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); exitSpy?.mockRestore(); consoleErrorSpy?.mockRestore(); }); @@ -80,6 +87,20 @@ describe('mux robots translate-captions', () => { .find((o) => o.name === 'json'); expect(opt).toBeDefined(); }); + + test('has --wait flag', () => { + const opt = translateCaptionsCommand + .getOptions() + .find((o) => o.name === 'wait'); + expect(opt).toBeDefined(); + }); + + test('has --file flag', () => { + const opt = translateCaptionsCommand + .getOptions() + .find((o) => o.name === 'file'); + expect(opt).toBeDefined(); + }); }); describe('Input validation', () => { @@ -93,4 +114,46 @@ describe('mux robots translate-captions', () => { expect(exitSpy).toHaveBeenCalled(); }); }); + + describe('--file mode', () => { + test('errors when config file does not exist', async () => { + const configPath = join(tempDir, 'nope.json'); + try { + await translateCaptionsCommand.parse([ + 'asset_abc', + '--file', + configPath, + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/file not found/i); + }); + + test('errors when --file combined with --track-id', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile( + configPath, + JSON.stringify({ track_id: 'track_1', to_language_code: 'es' }), + ); + try { + await translateCaptionsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--track-id', + 'track_2', + '--to-language-code', + 'es', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); + }); }); diff --git a/src/commands/robots/translate-captions.ts b/src/commands/robots/translate-captions.ts index 025731c..23afb26 100644 --- a/src/commands/robots/translate-captions.ts +++ b/src/commands/robots/translate-captions.ts @@ -5,12 +5,19 @@ import type { } from '@mux/mux-node/resources/robots-preview/jobs'; import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; +import { + FILE_MUTEX_MSG, + loadJobParameters, + pollForRobotsJob, +} from './_shared.ts'; interface TranslateCaptionsOptions { - trackId: string; - toLanguageCode: string; + trackId?: string; + toLanguageCode?: string; upload?: boolean; passthrough?: string; + file?: string; + wait?: boolean; json?: boolean; } @@ -20,15 +27,10 @@ export const translateCaptionsCommand: Command = new Command() 'Create a job to translate captions on a video to another language', ) .arguments('') - .option( - '--track-id ', - 'Source caption track ID to translate', - { required: true }, - ) + .option('--track-id ', 'Source caption track ID to translate') .option( '--to-language-code ', 'BCP 47 language code for the translated output (e.g. "es", "ja")', - { required: true }, ) .option( '--no-upload', @@ -38,16 +40,49 @@ export const translateCaptionsCommand: Command = new Command() '--passthrough ', 'Arbitrary metadata returned in API responses (max 255 chars)', ) + .option( + '-f, --file ', + 'JSON config file with the full parameters object (track_id, to_language_code, etc.)', + ) + .option( + '--wait', + 'Wait for the job to reach a terminal status (polls up to 15 minutes)', + ) .option('--json', 'Output JSON instead of pretty format') .action(async (options: TranslateCaptionsOptions, assetId: string) => { try { - const parameters: TranslateCaptionsJobParameters = { - asset_id: assetId, - track_id: options.trackId, - to_language_code: options.toLanguageCode, - }; - if (options.upload === false) { - parameters.upload_to_mux = false; + const hasShapeFlags = + options.trackId !== undefined || + options.toLanguageCode !== undefined || + options.upload === false; + + if (options.file && hasShapeFlags) { + throw new Error(FILE_MUTEX_MSG); + } + + let parameters: TranslateCaptionsJobParameters; + if (options.file) { + parameters = await loadJobParameters( + options.file, + assetId, + ); + } else { + if (!options.trackId) { + throw new Error('--track-id is required (or provide via --file)'); + } + if (!options.toLanguageCode) { + throw new Error( + '--to-language-code is required (or provide via --file)', + ); + } + parameters = { + asset_id: assetId, + track_id: options.trackId, + to_language_code: options.toLanguageCode, + }; + if (options.upload === false) { + parameters.upload_to_mux = false; + } } const body: TranslateCaptionCreateParams = { parameters }; @@ -55,7 +90,16 @@ export const translateCaptionsCommand: Command = new Command() body.passthrough = options.passthrough; const mux = await createAuthenticatedMuxClient(); - const job = await mux.robotsPreview.jobs.translateCaptions.create(body); + let job = await mux.robotsPreview.jobs.translateCaptions.create(body); + + if (options.wait) { + job = (await pollForRobotsJob( + mux, + 'translate-captions', + job.id, + Boolean(options.json), + )) as typeof job; + } if (options.json) { console.log(JSON.stringify(job, null, 2)); @@ -65,6 +109,10 @@ export const translateCaptionsCommand: Command = new Command() console.log('Translate captions job created'); console.log(` Job ID: ${job.id}`); console.log(` Status: ${job.status}`); + if (options.wait && job.outputs) { + console.log('Outputs:'); + console.log(JSON.stringify(job.outputs, null, 2)); + } } catch (error) { await handleCommandError(error, 'robots', 'translate-captions', options); } From 121bdb10d76d4873348beb15c8206b95def9c7ac Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Thu, 16 Apr 2026 11:44:29 -0700 Subject: [PATCH 2/4] refactor: conditionally display job creation messages only in non-JSON output mode --- src/commands/robots/ask-questions.ts | 9 ++++++--- src/commands/robots/find-key-moments.ts | 9 ++++++--- src/commands/robots/generate-chapters.ts | 9 ++++++--- src/commands/robots/moderate.ts | 9 ++++++--- src/commands/robots/summarize.ts | 9 ++++++--- src/commands/robots/translate-captions.ts | 9 ++++++--- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/commands/robots/ask-questions.ts b/src/commands/robots/ask-questions.ts index df10ef9..e44e526 100644 --- a/src/commands/robots/ask-questions.ts +++ b/src/commands/robots/ask-questions.ts @@ -118,6 +118,12 @@ export const askQuestionsCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.askQuestions.create(body); + if (!options.json) { + console.log('Ask questions job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + if (options.wait) { job = (await pollForRobotsJob( mux, @@ -132,9 +138,6 @@ export const askQuestionsCommand: Command = new Command() return; } - console.log('Ask questions job created'); - console.log(` Job ID: ${job.id}`); - console.log(` Status: ${job.status}`); if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); diff --git a/src/commands/robots/find-key-moments.ts b/src/commands/robots/find-key-moments.ts index fed02cf..40d9038 100644 --- a/src/commands/robots/find-key-moments.ts +++ b/src/commands/robots/find-key-moments.ts @@ -99,6 +99,12 @@ export const findKeyMomentsCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.findKeyMoments.create(body); + if (!options.json) { + console.log('Find key moments job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + if (options.wait) { job = (await pollForRobotsJob( mux, @@ -113,9 +119,6 @@ export const findKeyMomentsCommand: Command = new Command() return; } - console.log('Find key moments job created'); - console.log(` Job ID: ${job.id}`); - console.log(` Status: ${job.status}`); if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); diff --git a/src/commands/robots/generate-chapters.ts b/src/commands/robots/generate-chapters.ts index 87e5e9f..ea81ae3 100644 --- a/src/commands/robots/generate-chapters.ts +++ b/src/commands/robots/generate-chapters.ts @@ -100,6 +100,12 @@ export const generateChaptersCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.generateChapters.create(body); + if (!options.json) { + console.log('Generate chapters job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + if (options.wait) { job = (await pollForRobotsJob( mux, @@ -114,9 +120,6 @@ export const generateChaptersCommand: Command = new Command() return; } - console.log('Generate chapters job created'); - console.log(` Job ID: ${job.id}`); - console.log(` Status: ${job.status}`); if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); diff --git a/src/commands/robots/moderate.ts b/src/commands/robots/moderate.ts index c3800e1..c0a830d 100644 --- a/src/commands/robots/moderate.ts +++ b/src/commands/robots/moderate.ts @@ -132,6 +132,12 @@ export const moderateCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.moderate.create(body); + if (!options.json) { + console.log('Moderate job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + if (options.wait) { job = (await pollForRobotsJob( mux, @@ -146,9 +152,6 @@ export const moderateCommand: Command = new Command() return; } - console.log('Moderate job created'); - console.log(` Job ID: ${job.id}`); - console.log(` Status: ${job.status}`); if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); diff --git a/src/commands/robots/summarize.ts b/src/commands/robots/summarize.ts index 45a481a..7dd0c48 100644 --- a/src/commands/robots/summarize.ts +++ b/src/commands/robots/summarize.ts @@ -148,6 +148,12 @@ export const summarizeCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.summarize.create(body); + if (!options.json) { + console.log('Summarize job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + if (options.wait) { job = (await pollForRobotsJob( mux, @@ -162,9 +168,6 @@ export const summarizeCommand: Command = new Command() return; } - console.log('Summarize job created'); - console.log(` Job ID: ${job.id}`); - console.log(` Status: ${job.status}`); if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); diff --git a/src/commands/robots/translate-captions.ts b/src/commands/robots/translate-captions.ts index 23afb26..a0371d5 100644 --- a/src/commands/robots/translate-captions.ts +++ b/src/commands/robots/translate-captions.ts @@ -92,6 +92,12 @@ export const translateCaptionsCommand: Command = new Command() const mux = await createAuthenticatedMuxClient(); let job = await mux.robotsPreview.jobs.translateCaptions.create(body); + if (!options.json) { + console.log('Translate captions job created'); + console.log(` Job ID: ${job.id}`); + console.log(` Status: ${job.status}`); + } + if (options.wait) { job = (await pollForRobotsJob( mux, @@ -106,9 +112,6 @@ export const translateCaptionsCommand: Command = new Command() return; } - console.log('Translate captions job created'); - console.log(` Job ID: ${job.id}`); - console.log(` Status: ${job.status}`); if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); From 442a12af32ca792ed6edeb03df4f7dbcbd01a04e Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Thu, 16 Apr 2026 13:59:39 -0700 Subject: [PATCH 3/4] feat: fix validation order to check file mutex before duration parameter pairing --- src/commands/robots/find-key-moments.test.ts | 19 +++++++++++++++++++ src/commands/robots/find-key-moments.ts | 15 +++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/commands/robots/find-key-moments.test.ts b/src/commands/robots/find-key-moments.test.ts index 32a659d..2c72e51 100644 --- a/src/commands/robots/find-key-moments.test.ts +++ b/src/commands/robots/find-key-moments.test.ts @@ -185,5 +185,24 @@ describe('mux robots find-key-moments', () => { const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; expect(msg).toMatch(/--file cannot be combined/i); }); + + test('errors with FILE_MUTEX when --file combined with only --target-duration-max-ms', async () => { + const configPath = join(tempDir, 'config.json'); + await writeFile(configPath, JSON.stringify({ max_moments: 3 })); + try { + await findKeyMomentsCommand.parse([ + 'asset_abc', + '--file', + configPath, + '--target-duration-max-ms', + '5000', + ]); + } catch (_error) { + // Expected + } + expect(exitSpy).toHaveBeenCalledWith(1); + const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toMatch(/--file cannot be combined/i); + }); }); }); diff --git a/src/commands/robots/find-key-moments.ts b/src/commands/robots/find-key-moments.ts index 40d9038..5eb7ca6 100644 --- a/src/commands/robots/find-key-moments.ts +++ b/src/commands/robots/find-key-moments.ts @@ -64,19 +64,22 @@ export const findKeyMomentsCommand: Command = new Command() try { const minMs = options.targetDurationMinMs; const maxMs = options.targetDurationMaxMs; - if ((minMs === undefined) !== (maxMs === undefined)) { - throw new Error( - '--target-duration-min-ms and --target-duration-max-ms must be provided together.', - ); - } const hasShapeFlags = - options.maxMoments !== undefined || minMs !== undefined; + options.maxMoments !== undefined || + minMs !== undefined || + maxMs !== undefined; if (options.file && hasShapeFlags) { throw new Error(FILE_MUTEX_MSG); } + if ((minMs === undefined) !== (maxMs === undefined)) { + throw new Error( + '--target-duration-min-ms and --target-duration-max-ms must be provided together.', + ); + } + let parameters: FindKeyMomentsJobParameters; if (options.file) { parameters = await loadJobParameters( From 06bfb771b9f4f6118c6464b57d2df12264cf73fc Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Thu, 16 Apr 2026 14:13:06 -0700 Subject: [PATCH 4/4] feat: add job completion validation for robots commands with error handling --- src/commands/robots/_shared.test.ts | 56 +++++++++++++++++++++++ src/commands/robots/_shared.ts | 8 ++++ src/commands/robots/ask-questions.ts | 8 ++-- src/commands/robots/find-key-moments.ts | 8 ++-- src/commands/robots/generate-chapters.ts | 8 ++-- src/commands/robots/moderate.ts | 8 ++-- src/commands/robots/summarize.ts | 8 ++-- src/commands/robots/translate-captions.ts | 8 ++-- 8 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 src/commands/robots/_shared.test.ts diff --git a/src/commands/robots/_shared.test.ts b/src/commands/robots/_shared.test.ts new file mode 100644 index 0000000..17ee577 --- /dev/null +++ b/src/commands/robots/_shared.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; +import type { AnyRobotsJob } from './_shared.ts'; +import { assertJobCompleted } from './_shared.ts'; + +const baseJob = { + id: 'rjob_xyz', + workflow: 'summarize', + created_at: 0, + updated_at: 0, + units_consumed: 0, + parameters: { asset_id: 'asset_x' }, +} as unknown as AnyRobotsJob; + +describe('assertJobCompleted', () => { + test('returns silently on status=completed', () => { + expect(() => + assertJobCompleted({ ...baseJob, status: 'completed' } as AnyRobotsJob), + ).not.toThrow(); + }); + + test('throws on status=errored', () => { + expect(() => + assertJobCompleted({ ...baseJob, status: 'errored' } as AnyRobotsJob), + ).toThrow(/errored/i); + }); + + test('throws on status=cancelled', () => { + expect(() => + assertJobCompleted({ ...baseJob, status: 'cancelled' } as AnyRobotsJob), + ).toThrow(/cancelled/i); + }); + + test('includes job.errors details when present', () => { + const job = { + ...baseJob, + status: 'errored', + errors: [ + { type: 'processing_error', message: 'asset not ready' }, + { type: 'timeout', message: 'took too long' }, + ], + } as unknown as AnyRobotsJob; + expect(() => assertJobCompleted(job)).toThrow( + /processing_error: asset not ready.*timeout: took too long/, + ); + }); + + test('includes job id in the error message', () => { + expect(() => + assertJobCompleted({ + ...baseJob, + id: 'rjob_abc123', + status: 'errored', + } as AnyRobotsJob), + ).toThrow(/rjob_abc123/); + }); +}); diff --git a/src/commands/robots/_shared.ts b/src/commands/robots/_shared.ts index b27ae11..80c99d3 100644 --- a/src/commands/robots/_shared.ts +++ b/src/commands/robots/_shared.ts @@ -81,6 +81,14 @@ export function retrieveRobotsJob( } } +export function assertJobCompleted(job: AnyRobotsJob): void { + if (job.status === 'completed') return; + const details = job.errors?.length + ? `: ${job.errors.map((e) => `${e.type}: ${e.message}`).join('; ')}` + : ''; + throw new Error(`Job ${job.id} ended with status "${job.status}"${details}`); +} + export async function pollForRobotsJob( mux: Mux, workflow: RobotsWorkflow, diff --git a/src/commands/robots/ask-questions.ts b/src/commands/robots/ask-questions.ts index e44e526..069ec97 100644 --- a/src/commands/robots/ask-questions.ts +++ b/src/commands/robots/ask-questions.ts @@ -6,6 +6,7 @@ import type { import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { + assertJobCompleted, FILE_MUTEX_MSG, loadJobParameters, pollForRobotsJob, @@ -135,13 +136,12 @@ export const askQuestionsCommand: Command = new Command() if (options.json) { console.log(JSON.stringify(job, null, 2)); - return; - } - - if (options.wait && job.outputs) { + } else if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); } + + if (options.wait) assertJobCompleted(job); } catch (error) { await handleCommandError(error, 'robots', 'ask-questions', options); } diff --git a/src/commands/robots/find-key-moments.ts b/src/commands/robots/find-key-moments.ts index 5eb7ca6..0e9d811 100644 --- a/src/commands/robots/find-key-moments.ts +++ b/src/commands/robots/find-key-moments.ts @@ -6,6 +6,7 @@ import type { import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { + assertJobCompleted, FILE_MUTEX_MSG, loadJobParameters, pollForRobotsJob, @@ -119,13 +120,12 @@ export const findKeyMomentsCommand: Command = new Command() if (options.json) { console.log(JSON.stringify(job, null, 2)); - return; - } - - if (options.wait && job.outputs) { + } else if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); } + + if (options.wait) assertJobCompleted(job); } catch (error) { await handleCommandError(error, 'robots', 'find-key-moments', options); } diff --git a/src/commands/robots/generate-chapters.ts b/src/commands/robots/generate-chapters.ts index ea81ae3..69ecfce 100644 --- a/src/commands/robots/generate-chapters.ts +++ b/src/commands/robots/generate-chapters.ts @@ -6,6 +6,7 @@ import type { import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { + assertJobCompleted, FILE_MUTEX_MSG, loadJobParameters, pollForRobotsJob, @@ -117,13 +118,12 @@ export const generateChaptersCommand: Command = new Command() if (options.json) { console.log(JSON.stringify(job, null, 2)); - return; - } - - if (options.wait && job.outputs) { + } else if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); } + + if (options.wait) assertJobCompleted(job); } catch (error) { await handleCommandError(error, 'robots', 'generate-chapters', options); } diff --git a/src/commands/robots/moderate.ts b/src/commands/robots/moderate.ts index c0a830d..572a1c3 100644 --- a/src/commands/robots/moderate.ts +++ b/src/commands/robots/moderate.ts @@ -6,6 +6,7 @@ import type { import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { + assertJobCompleted, FILE_MUTEX_MSG, loadJobParameters, pollForRobotsJob, @@ -149,13 +150,12 @@ export const moderateCommand: Command = new Command() if (options.json) { console.log(JSON.stringify(job, null, 2)); - return; - } - - if (options.wait && job.outputs) { + } else if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); } + + if (options.wait) assertJobCompleted(job); } catch (error) { await handleCommandError(error, 'robots', 'moderate', options); } diff --git a/src/commands/robots/summarize.ts b/src/commands/robots/summarize.ts index 7dd0c48..5839929 100644 --- a/src/commands/robots/summarize.ts +++ b/src/commands/robots/summarize.ts @@ -6,6 +6,7 @@ import type { import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { + assertJobCompleted, FILE_MUTEX_MSG, loadJobParameters, pollForRobotsJob, @@ -165,13 +166,12 @@ export const summarizeCommand: Command = new Command() if (options.json) { console.log(JSON.stringify(job, null, 2)); - return; - } - - if (options.wait && job.outputs) { + } else if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); } + + if (options.wait) assertJobCompleted(job); } catch (error) { await handleCommandError(error, 'robots', 'summarize', options); } diff --git a/src/commands/robots/translate-captions.ts b/src/commands/robots/translate-captions.ts index a0371d5..20eb466 100644 --- a/src/commands/robots/translate-captions.ts +++ b/src/commands/robots/translate-captions.ts @@ -6,6 +6,7 @@ import type { import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { + assertJobCompleted, FILE_MUTEX_MSG, loadJobParameters, pollForRobotsJob, @@ -109,13 +110,12 @@ export const translateCaptionsCommand: Command = new Command() if (options.json) { console.log(JSON.stringify(job, null, 2)); - return; - } - - if (options.wait && job.outputs) { + } else if (options.wait && job.outputs) { console.log('Outputs:'); console.log(JSON.stringify(job.outputs, null, 2)); } + + if (options.wait) assertJobCompleted(job); } catch (error) { await handleCommandError(error, 'robots', 'translate-captions', options); }