workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
|
||||
type ToolResult = {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void;
|
||||
|
||||
function update(onUpdate: OnUpdate | undefined, text: string): void {
|
||||
onUpdate?.({ content: [{ type: 'text', text }] });
|
||||
}
|
||||
|
||||
function ensureFfmpeg(): boolean {
|
||||
try {
|
||||
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
execFileSync('sudo', ['apt-get', 'update'], { stdio: 'ignore', timeout: 60_000 });
|
||||
execFileSync('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { stdio: 'ignore', timeout: 120_000 });
|
||||
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argsString: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
|
||||
for (let i = 0; i < argsString.length; i++) {
|
||||
const ch = argsString[i]!;
|
||||
|
||||
if (ch === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
} else if (ch === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
} else if (ch === ' ' && !inSingle && !inDouble) {
|
||||
if (current.length > 0) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length > 0) args.push(current);
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
_toolCallId: string,
|
||||
params: { command: 'ffmpeg' | 'ffprobe'; args: string },
|
||||
_signal: AbortSignal | undefined,
|
||||
onUpdate?: OnUpdate,
|
||||
): Promise<ToolResult> {
|
||||
const { command, args: argsString } = params;
|
||||
|
||||
if (!argsString || argsString.trim().length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No arguments provided. See tool documentation for usage examples.' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
update(onUpdate, 'Checking ffmpeg installation...');
|
||||
|
||||
if (!ensureFfmpeg()) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Failed to install ffmpeg. Try manually: sudo apt-get update && sudo apt-get install -y ffmpeg' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const args = parseArgs(argsString);
|
||||
|
||||
// For ffprobe, run synchronously and return output
|
||||
if (command === 'ffprobe') {
|
||||
update(onUpdate, `Running ffprobe...`);
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffprobe', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
resolve({
|
||||
content: [{ type: 'text', text: `ffprobe failed (exit ${code}):\n${stderr.trim()}` }],
|
||||
isError: true,
|
||||
});
|
||||
} else {
|
||||
// ffprobe prints info to stderr by default, stdout for -print_format
|
||||
const output = stdout.trim() || stderr.trim();
|
||||
resolve({
|
||||
content: [{ type: 'text', text: output || 'No output' }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
resolve({
|
||||
content: [{ type: 'text', text: `ffprobe error: ${err.message}` }],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// For ffmpeg, stream progress
|
||||
update(onUpdate, `Running ffmpeg...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let progressBuf = '';
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
|
||||
// Parse ffmpeg progress from stderr (time= field)
|
||||
progressBuf += text;
|
||||
const lines = progressBuf.split('\r');
|
||||
progressBuf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const timeMatch = line.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/);
|
||||
const speedMatch = line.match(/speed=\s*([\d.]+x)/);
|
||||
if (timeMatch) {
|
||||
const progress = `Time: ${timeMatch[1]}${speedMatch ? ` | Speed: ${speedMatch[1]}` : ''}`;
|
||||
update(onUpdate, progress);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
// Extract the last meaningful error line from stderr
|
||||
const errLines = stderr.trim().split('\n');
|
||||
const lastLines = errLines.slice(-10).join('\n');
|
||||
resolve({
|
||||
content: [{ type: 'text', text: `ffmpeg failed (exit ${code}):\n${lastLines}` }],
|
||||
isError: true,
|
||||
});
|
||||
} else {
|
||||
const output = stdout.trim();
|
||||
resolve({
|
||||
content: [{ type: 'text', text: output ? `Done.\n\n${output}` : 'Done.' }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
resolve({
|
||||
content: [{ type: 'text', text: `ffmpeg error: ${err.message}` }],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user