Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ $ cloudconvert command --engine=ffmpeg --command=ffmpeg --arguments="-i /input/u
```

Command output is captured and printed by default. Use `--no-capture-output` to disable this.

#### AI Agent (Beta)

Describe in plain language what should be done with your files, and an AI agent creates the required tasks. The output files of these tasks are downloaded. If the agent answers a question instead, its answer is printed:

```
$ cloudconvert ai/agent --prompt="Convert all files to PDF and merge them into one PDF, in alphabetical order of the file names." *.docx
$ cloudconvert ai/agent --prompt="Make this video smaller, at most 30 MB." input.mp4
$ cloudconvert ai/agent --prompt="How many pages does this PDF have?" input.pdf
```

Files are optional if the prompt names the files to import:

```
$ cloudconvert ai/agent --prompt="Convert https://example.com/report.docx to PDF."
```

Use `--engine` to choose the AI model. You can list the available models using `cloudconvert parameters ai/agent --alternatives`. The `ai/agent` task itself does not consume credits; the tasks created by the agent consume credits as usual. See CloudConvert's [AI documentation](https://cloudconvert.com/docs/operations/ai) for details.

## All Options

Expand Down Expand Up @@ -142,6 +160,8 @@ Commands:
<files..>
cloudconvert command <files..> Execute custom ffmpeg, imagemagick, or
graphicsmagick commands
cloudconvert ai/agent [files..] Let an AI agent process files as
described in a prompt (beta)
cloudconvert credits Print the remaining conversion credits of
your account
cloudconvert parameters <operation> List available task parameters for an
Expand Down
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"glob": "^13.0.6",
"log-symbols": "^7.0.1",
"ora": "^9.4.0",
"socket.io-client": "^4.8.4",
"yargs": "^18.0.0"
},
"devDependencies": {
Expand Down
23 changes: 21 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ora from 'ora';
import yargs from 'yargs/yargs';
import type { Argv } from 'yargs';

import { createAgentCommand } from './commands/agent.js';
import { createCaptureWebsiteCommand } from './commands/capture-website.js';
import { createCommandCommand } from './commands/command.js';
import { createConvertCommand } from './commands/convert.js';
Expand All @@ -20,7 +21,7 @@ import { runJob } from './job.js';
import type { CliArguments, CloudConvertClient, Logger, TaskData } from './types.js';

export interface CliDependencies {
createClient: (apiKey: string, useSandbox: boolean) => CloudConvertClient;
createClient: (apiKey: string, useSandbox: boolean, apiUrl?: string) => CloudConvertClient;
runJob: JobRunner;
logger: Logger;
exitProcess: boolean;
Expand Down Expand Up @@ -50,6 +51,7 @@ export function createCli(args: string[], partialDependencies: Partial<CliDepend
.command(createThumbnailCommand(dependencies.runJob))
.command(createWatermarkCommand(dependencies.runJob))
.command(createCommandCommand(dependencies.runJob))
.command(createAgentCommand(dependencies.runJob))
.demandCommand()
.option('api-key', {
alias: 'apikey',
Expand All @@ -66,6 +68,13 @@ export function createCli(args: string[], partialDependencies: Partial<CliDepend
default: false,
global: true
})
.option('api-url', {
describe: 'Use another API endpoint, such as a local development server. Overrides --sandbox',
type: 'string',
default: process.env.CLOUDCONVERT_API_URL,
defaultDescription: 'CLOUDCONVERT_API_URL environment variable',
global: true
})
.option('output-dir', {
alias: 'outputdir',
describe: 'Set the directory for storing the output files. defaults to the working directory',
Expand All @@ -88,13 +97,23 @@ export function createCli(args: string[], partialDependencies: Partial<CliDepend
normalizeAliases(argv as unknown as CliArguments);

Object.defineProperty(argv, 'cloudconvert', {
value: dependencies.createClient(String(argv.apikey), Boolean(argv.sandbox)),
value: dependencies.createClient(
String(argv.apikey),
Boolean(argv.sandbox),
typeof argv.apiUrl === 'string' && argv.apiUrl !== '' ? argv.apiUrl : undefined
),
enumerable: false,
configurable: true
});
})
.middleware(argv => {
const cliArguments = argv as unknown as CliArguments;
// Not enumerable, so that it is not sent as task parameter
Object.defineProperty(argv, 'filePatterns', {
value: cliArguments.files ?? [],
enumerable: false,
configurable: true
});
cliArguments.files = expandFilePatterns(cliArguments.files);
})
.fail((message, err, yargsInstance) => {
Expand Down
60 changes: 58 additions & 2 deletions src/cloudconvert.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,63 @@
import CloudConvert from 'cloudconvert';
import { io, type Socket } from 'socket.io-client';

import type { CloudConvertClient } from './types.js';

export function createCloudConvertClient(apiKey: string, useSandbox: boolean): CloudConvertClient {
return new CloudConvert(apiKey, useSandbox) as unknown as CloudConvertClient;
export function createCloudConvertClient(apiKey: string, useSandbox: boolean, apiUrl?: string): CloudConvertClient {
const client = apiUrl ? new CustomEndpointCloudConvert(apiKey, apiUrl) : new CloudConvert(apiKey, useSandbox);

return client as unknown as CloudConvertClient;
}

/**
* Sends the API calls to another endpoint, such as a local development server. The socket is
* expected on the host with "socketio." instead of "api.", as with api.cloudconvert.com.
*/
class CustomEndpointCloudConvert extends CloudConvert {
private readonly baseUrl: string;
private readonly socketUrl: string;

constructor(apiKey: string, apiUrl: string) {
super(apiKey);
this.baseUrl = normalizeApiUrl(apiUrl);
this.socketUrl = getSocketUrl(this.baseUrl);
}

override async call(...[method, route, parameters, options]: Parameters<CloudConvert['call']>): Promise<any> {
return await this.callWithBase(this.baseUrl, method, route, parameters, options);
}

override subscribe(...args: Parameters<CloudConvert['subscribe']>): void {
// The SDK connects to the socket of api.cloudconvert.com, unless it is connected already
const state = this as unknown as { socket?: Socket; subscribedChannels?: Map<string, boolean> };

if (!state.socket) {
state.socket = io(this.socketUrl, { transports: ['websocket'] });
state.subscribedChannels = new Map();
}

super.subscribe(...args);
}
}

export function normalizeApiUrl(apiUrl: string): string {
const url = new URL(apiUrl);

if (url.pathname === '/') {
url.pathname = '/v2/';
} else if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}

return url.toString();
}

export function getSocketUrl(apiUrl: string): string {
const url = new URL(apiUrl);

if (url.hostname.startsWith('api.')) {
url.hostname = `socketio.${url.hostname.slice('api.'.length)}`;
}

return url.origin;
}
59 changes: 59 additions & 0 deletions src/commands/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Argv, CommandModule } from 'yargs';

import type { CliArguments } from '../types.js';
import { addTaskParameterMiddleware, collectTaskParameters } from './task-parameters.js';
import type { JobRunner } from './types.js';

interface AgentArguments extends CliArguments {
prompt: string;
engine?: string;
}

export function createAgentCommand(runJob: JobRunner): CommandModule<object, object> {
return {
command: 'ai/agent [files..]',
aliases: ['ai-agent', 'agent', 'ai'],
describe: 'Let an AI agent process files as described in a prompt (beta)',
builder: yargs => buildAgentOptions(yargs),
handler: async rawArgv => {
const argv = rawArgv as unknown as AgentArguments;

// Files are optional, as the prompt can name the files to import, but not a mistyped path
if ((argv.filePatterns ?? []).length > 0 && (argv.files ?? []).length < 1) {
throw new Error(`No files found: ${(argv.filePatterns ?? []).join(', ')}`);
}

await runJob(argv, {
operation: 'ai/agent',
prompt: argv.prompt,
...(argv.engine ? { engine: argv.engine } : {}),
...collectTaskParameters(argv)
});
}
};
}

function buildAgentOptions(argv: Argv<object>): Argv<object> {
return addTaskParameterMiddleware(
argv
.positional('files', {
description: 'Path(s) to file(s) the agent works on. Optional if the prompt names the files to import'
})
.option('prompt', {
describe: 'Describe in plain language what should be done with the files',
type: 'string',
demandOption: true
})
.option('engine', {
describe: 'Set the AI model. List the models with: cloudconvert parameters ai/agent --alternatives',
type: 'string'
})
.example('cloudconvert ai/agent --prompt="Convert to PDF/A-2b." input.pdf', '')
.example('cloudconvert ai/agent --prompt="Make this video smaller, at most 30 MB." input.mp4', '')
.example('cloudconvert ai/agent --prompt="How many pages does this PDF have?" input.pdf', '')
.example('cloudconvert ai/agent --prompt="Convert https://example.com/report.docx to PDF."', '')
)
.help()
.hide('version')
.epilogue('Find possible task parameters with: cloudconvert parameters ai/agent');
}
3 changes: 2 additions & 1 deletion src/commands/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export function createParametersCommand(): CommandModule<object, object> {
builder: yargs =>
yargs
.positional('operation', {
description: 'Operation to inspect, for example: convert, optimize, thumbnail, watermark or capture-website',
description:
'Operation to inspect, for example: convert, optimize, thumbnail, watermark, capture-website or ai/agent',
type: 'string'
})
.option('input-format', {
Expand Down
41 changes: 33 additions & 8 deletions src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface InterruptSignalEmitter {

const JOB_STATUS_POLL_INTERVAL_MS = 30_000;
const JOB_CANCELLED_MESSAGE = 'Job cancelled.';
const AGENT_OPERATION = 'ai/agent';
const AGENT_NO_OUTPUT_CODE = 'AGENT_NO_OUTPUT';

export interface RunJobDependencies {
spinner: Spinner;
Expand All @@ -54,12 +56,15 @@ export function buildJobTemplate(
process: createProcessTaskData(taskData, secondaryInputFiles),
export: {
operation: 'export/url',
input: 'process'
input: 'process',
// The export of an agent which has answered a question without output files is cancelled
...(taskData.operation === AGENT_OPERATION ? { ignore_error: true } : {})
}
}
};

if (files) {
// ai/agent tasks can import the files named in the prompt instead
if (files && files.length > 0) {
jobData.tasks.process.input = [];

files.forEach((_, index) => {
Expand Down Expand Up @@ -193,6 +198,7 @@ async function waitForCompletion(
if (failedJob) {
printFailedTaskMessages(failedJob.tasks, logger);
printCommandTaskOutputs(failedJob.tasks, logger);
printAgentSuggestions(failedJob.tasks, logger);
}
spinner.fail('Job failed!');
reject(new Error('Job failed!'));
Expand Down Expand Up @@ -224,6 +230,7 @@ async function finishCompletedJob(
spinner.succeed('Done!');
printTaskMessages(finishedJob.tasks, logger);
printCommandTaskOutputs(finishedJob.tasks, logger);
printAgentSuggestions(finishedJob.tasks, logger);
return finishedJob;
} catch (error) {
closeCloudConvertSocket(argv.cloudconvert);
Expand Down Expand Up @@ -283,11 +290,17 @@ function subscribeToJobEvents(
});

void cloudconvert.jobs.subscribeTaskEvent(jobId, 'failed', event => {
logger.error(
`Task \`${event.task.name}\` failed: ${event.task.message ?? ''} (Code: ${
event.task.code ?? 'UNKNOWN_ERROR'
})`
);
const message = `Task \`${event.task.name}\` failed: ${event.task.message ?? ''} (Code: ${
event.task.code ?? 'UNKNOWN_ERROR'
})`;

// The agent tries to correct the cause of a failed task it has created
if (event.task.parent_task_id) {
logger.info(message);
return;
}

logger.error(message);
});

void cloudconvert.jobs.subscribeEvent(jobId, 'failed', event => {
Expand Down Expand Up @@ -401,11 +414,23 @@ async function downloadExportedFiles(
}

function printTaskMessages(tasks: JobTask[], logger: Logger): void {
for (const task of tasks.filter(task => task.message)) {
for (const task of tasks.filter(task => task.message && task.code !== AGENT_NO_OUTPUT_CODE)) {
logger.info(`Task \`${task.name}\`: ${task.message}`);
}
}

function printAgentSuggestions(tasks: JobTask[], logger: Logger): void {
for (const task of tasks) {
const suggestions = task.result?.suggestions;

if (task.operation !== AGENT_OPERATION || !Array.isArray(suggestions) || suggestions.length === 0) {
continue;
}

logger.info(`Task \`${task.name}\` suggestions:\n${suggestions.map(suggestion => `- ${suggestion}`).join('\n')}`);
}
}

function printCommandTaskOutputs(tasks: JobTask[], logger: Logger): void {
for (const task of tasks) {
if (task.operation !== 'command' || typeof task.result?.output !== 'string' || task.result.output.length === 0) {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CliArguments {
overwrite: boolean;
parameter?: ParameterValues;
files?: string[];
filePatterns?: string[];
format?: string;
url?: string;
cloudconvert: CloudConvertClient;
Expand Down
Loading
Loading