Skip to content
Open
3 changes: 2 additions & 1 deletion src/commands/annotations/create.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';

Expand Down Expand Up @@ -37,7 +38,7 @@ export const createCommand = new Command()

const annotation = await mux.data.annotations.create(body as never);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify(annotation, null, 2));
} else {
console.log(`Annotation ID: ${annotation.id}`);
Expand Down
5 changes: 3 additions & 2 deletions src/commands/annotations/delete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';
import { confirmPrompt } from '@/lib/prompt.ts';
Expand All @@ -22,7 +23,7 @@ export const deleteCommand = new Command()
// Confirm deletion unless --force flag is provided
if (!options.force) {
// For JSON mode, require explicit --force flag for safety
if (options.json) {
if (wantsJson(options)) {
throw new Error(
'Deletion requires --force flag when using --json output',
);
Expand All @@ -41,7 +42,7 @@ export const deleteCommand = new Command()

await mux.data.annotations.delete(annotationId);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify({ success: true, annotationId }, null, 2));
} else {
console.log(`Annotation ${annotationId} deleted successfully.`);
Expand Down
3 changes: 2 additions & 1 deletion src/commands/annotations/get.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';

Expand All @@ -16,7 +17,7 @@ export const getCommand = new Command()

const annotation = await mux.data.annotations.retrieve(annotationId);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify(annotation, null, 2));
} else {
console.log(`Annotation ID: ${annotation.id}`);
Expand Down
3 changes: 2 additions & 1 deletion src/commands/annotations/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';

Expand Down Expand Up @@ -56,7 +57,7 @@ export const listCommand = new Command()

const response = await mux.data.annotations.list(params as never);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify(response, null, 2));
return;
}
Expand Down
3 changes: 2 additions & 1 deletion src/commands/annotations/update.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';

Expand Down Expand Up @@ -41,7 +42,7 @@ export const updateCommand = new Command()
body as never,
);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify(annotation, null, 2));
} else {
console.log('Annotation updated successfully.');
Expand Down
99 changes: 99 additions & 0 deletions src/commands/assets/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setAgentMode } from '@/lib/context.ts';
import { createCommand } from './create.ts';

// Note: These tests focus on CLI flag parsing and input validation
Expand All @@ -36,6 +37,7 @@ describe('mux assets create command', () => {
await rm(tempDir, { recursive: true, force: true });
exitSpy?.mockRestore();
consoleErrorSpy?.mockRestore();
setAgentMode(false);
});

describe('Flag combinations and validation', () => {
Expand Down Expand Up @@ -190,6 +192,83 @@ describe('mux assets create command', () => {
const errorMessage = consoleErrorSpy.mock.calls[0][0];
expect(errorMessage).toMatch(/No files found matching pattern/i);
});

test('fails fast in agent mode when multiple files need confirmation and -y is omitted', async () => {
setAgentMode(true);
const fileA = join(tempDir, 'a.mp4');
const fileB = join(tempDir, 'b.mp4');
await writeFile(fileA, 'fake video content');
await writeFile(fileB, 'fake video content');

try {
await createCommand.parse(['--upload', fileA, '--upload', fileB]);
} catch (_error) {
// Expected to throw via mocked process.exit
}

expect(exitSpy).toHaveBeenCalledWith(1);
const parsed = JSON.parse(String(consoleErrorSpy.mock.calls[0][0]));
expect(parsed.error).toMatch(/-y/);
expect(parsed.error).toMatch(/2 files/);
});

test('fails fast with --json when multiple files need confirmation and -y is omitted', async () => {
const fileA = join(tempDir, 'a.mp4');
const fileB = join(tempDir, 'b.mp4');
await writeFile(fileA, 'fake video content');
await writeFile(fileB, 'fake video content');

try {
await createCommand.parse([
'--upload',
fileA,
'--upload',
fileB,
'--json',
]);
} catch (_error) {
// Expected to throw via mocked process.exit
}

expect(exitSpy).toHaveBeenCalledWith(1);
const parsed = JSON.parse(String(consoleErrorSpy.mock.calls[0][0]));
expect(parsed.error).toMatch(/-y/);
});

test('does not require -y for a single file in agent mode', async () => {
// Isolate credentials so the command fails at the auth step, which
// comes after the confirmation check, without touching the network.
const originalXdg = process.env.XDG_CONFIG_HOME;
const originalTokenId = process.env.MUX_TOKEN_ID;
const originalTokenSecret = process.env.MUX_TOKEN_SECRET;
process.env.XDG_CONFIG_HOME = tempDir;
delete process.env.MUX_TOKEN_ID;
delete process.env.MUX_TOKEN_SECRET;

try {
setAgentMode(true);
const fileA = join(tempDir, 'a.mp4');
await writeFile(fileA, 'fake video content');

try {
await createCommand.parse(['--upload', fileA]);
} catch (_error) {
// Expected to fail at the auth step
}

expect(exitSpy).toHaveBeenCalledWith(1);
const errorMessage = String(consoleErrorSpy.mock.calls[0]?.[0] || '');
expect(errorMessage).not.toContain('-y');
} finally {
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXdg;
if (originalTokenId === undefined) delete process.env.MUX_TOKEN_ID;
else process.env.MUX_TOKEN_ID = originalTokenId;
if (originalTokenSecret === undefined)
delete process.env.MUX_TOKEN_SECRET;
else process.env.MUX_TOKEN_SECRET = originalTokenSecret;
}
});
});

describe('Output formatting flags', () => {
Expand All @@ -206,6 +285,26 @@ describe('mux assets create command', () => {
.find((opt) => opt.name === 'yes');
expect(yesOption).toBeDefined();
});

test('emits machine-readable JSON errors in agent mode without --json', async () => {
setAgentMode(true);

try {
await createCommand.parse([
'--url',
'https://example.com/video.mp4',
'--file',
join(tempDir, 'config.json'),
]);
} catch (_error) {
// Expected to throw via mocked process.exit
}

expect(exitSpy).toHaveBeenCalledWith(1);
const errorMessage = String(consoleErrorSpy.mock.calls[0][0]);
const parsed = JSON.parse(errorMessage);
expect(parsed.error).toMatch(/Cannot use multiple input methods/);
});
});

describe('Optional flags', () => {
Expand Down
34 changes: 22 additions & 12 deletions src/commands/assets/create.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from '@cliffy/command';
import type Mux from '@mux/mux-node';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { expandGlobPattern, uploadFile } from '@/lib/file-upload.ts';
import { parseAssetConfig } from '@/lib/json-config.ts';
Expand Down Expand Up @@ -112,7 +113,7 @@ async function createFromUploads(

// Show files and confirm (unless -y flag)
if (!options.yes && files.length > 1) {
if (!options.json) {
if (!wantsJson(options)) {
Comment thread
cursor[bot] marked this conversation as resolved.
console.log(`Found ${files.length} files to upload:`);
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
for (const file of files) {
Expand All @@ -136,7 +137,7 @@ async function createFromUploads(

// Upload each file
for (const file of files) {
if (!options.json) {
if (!wantsJson(options)) {
console.log(`Uploading ${file.name}...`);
}

Expand Down Expand Up @@ -187,7 +188,7 @@ async function createFromUploads(

// Upload the file
await uploadFile(file.path, upload.url, upload.id, (percent) => {
if (!options.json && percent === 100) {
if (!wantsJson(options) && percent === 100) {
Comment thread
cursor[bot] marked this conversation as resolved.
console.log(`${file.name} uploaded`);
}
});
Expand Down Expand Up @@ -348,6 +349,7 @@ export const createCommand = new Command()
// biome-ignore lint: Cliffy infers upload as string but collect makes it string[]
.action(async (options: any, ...args: unknown[]) => {
const opts = options as CreateOptions;
const json = wantsJson(opts);
// Merge positional args (from shell glob expansion) into upload list
const extraFiles = args
.flat()
Expand Down Expand Up @@ -381,6 +383,14 @@ export const createCommand = new Command()
`No files found matching pattern: ${opts.upload.join(', ')}`,
);
}
// Interactive confirmation is not possible in machine-readable mode;
// fail fast with recovery guidance instead of blocking on stdin.
const uniqueFileCount = new Set(allFiles.map((f) => f.path)).size;
if (json && !opts.yes && uniqueFileCount > 1) {
throw new Error(
`Uploading ${uniqueFileCount} files requires confirmation, which is not available with --json or in agent mode. Re-run with -y/--yes to skip the prompt.`,
);
}
}

// Initialize authenticated Mux client
Expand All @@ -392,7 +402,7 @@ export const createCommand = new Command()
if (opts.url) {
result = await createFromUrl(mux, opts.url, opts);

if (opts.json) {
if (json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`Asset created: ${result.id}`);
Expand All @@ -406,7 +416,7 @@ export const createCommand = new Command()
} else if (opts.upload) {
result = await createFromUploads(mux, opts.upload, opts);

if (opts.json) {
if (json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\n${result.length} file(s) uploaded successfully`);
Expand All @@ -417,7 +427,7 @@ export const createCommand = new Command()
} else if (opts.file) {
result = await createFromConfig(mux, opts.file, opts);

if (opts.json) {
if (json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`Asset created: ${result.id}`);
Expand All @@ -435,7 +445,7 @@ export const createCommand = new Command()

// Wait for asset processing if requested
if (opts.wait && !Array.isArray(result) && result.id) {
if (!opts.json) {
if (!json) {
console.log('\nWaiting for asset to be ready...');
}

Expand All @@ -448,32 +458,32 @@ export const createCommand = new Command()
asset = await mux.video.assets.retrieve(result.id);
attempts++;

if (!opts.json) {
if (!json) {
process.stdout.write('.');
}
}

if (!opts.json) {
if (!json) {
console.log();
}

if (asset.status === 'ready') {
if (!opts.json) {
if (!json) {
console.log('Asset is ready!');
}
} else if (asset.status === 'errored') {
throw new Error(
`Asset processing failed: ${asset.errors?.messages?.join(', ') || 'Unknown error'}`,
);
} else {
if (!opts.json) {
if (!json) {
console.log(
`WARNING: Asset is still processing. Run 'mux assets get ${asset.id}' to check status.`,
);
}
}

if (opts.json) {
if (json) {
console.log(JSON.stringify(asset, null, 2));
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/commands/assets/delete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';
import { confirmPrompt } from '@/lib/prompt.ts';
Expand All @@ -21,7 +22,7 @@ export const deleteCommand = new Command()
// Confirm deletion unless --force flag is provided
if (!options.force) {
// For JSON mode, require explicit --force flag for safety
if (options.json) {
if (wantsJson(options)) {
throw new Error(
'Deletion requires --force flag when using --json output',
);
Expand All @@ -41,7 +42,7 @@ export const deleteCommand = new Command()
// Delete the asset
await mux.video.assets.delete(assetId);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify({ success: true, assetId }, null, 2));
} else {
console.log(`Asset ${assetId} deleted successfully`);
Expand Down
3 changes: 2 additions & 1 deletion src/commands/assets/get.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { formatAsset } from '@/lib/formatters.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';
Expand All @@ -19,7 +20,7 @@ export const getCommand = new Command()
// Fetch asset details
const asset = await mux.video.assets.retrieve(assetId);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify(asset, null, 2));
} else {
formatAsset(asset);
Expand Down
3 changes: 2 additions & 1 deletion src/commands/assets/input-info.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from '@cliffy/command';
import { wantsJson } from '@/lib/context.ts';
import { handleCommandError } from '@/lib/errors.ts';
import { createAuthenticatedMuxClient } from '@/lib/mux.ts';

Expand All @@ -16,7 +17,7 @@ export const inputInfoCommand = new Command()

const inputInfo = await mux.video.assets.retrieveInputInfo(assetId);

if (options.json) {
if (wantsJson(options)) {
console.log(JSON.stringify(inputInfo, null, 2));
} else {
if (inputInfo.length === 0) {
Expand Down
Loading
Loading