#!/usr/bin/env node const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const SKILL_NAME = 'fmode-ffmpeg'; const SOURCE_ROOT = path.resolve(__dirname, '..'); const SKILL_SOURCE = path.join(SOURCE_ROOT, 'skills', SKILL_NAME); const WORKSPACE_ROOT = process.cwd(); const GLOBAL_TARGET = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME); const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'skills', SKILL_NAME); const WORKSPACE_SKILLS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'skills'); const GLOBAL_SKILLS_ROOT = path.join(os.homedir(), '.claude', 'skills'); const INSTALL_COMMANDS = new Set(['install', 'workspace', 'install-workspace', 'check', 'smoke', 'path', 'help']); const BINARY_COMMANDS = new Set(['which', 'which-ffprobe', 'exec', 'run', 'probe', 'version']); function expandHome(value) { return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir()); } // --------------------------------------------------------------------------- // ffmpeg / ffprobe binary resolution (with integrity verification + repair) // --------------------------------------------------------------------------- const VERSION_BANNER = /(?:ffmpeg|ffprobe) version/i; function truthyEnv(value) { return value != null && value !== '' && value !== '0' && String(value).toLowerCase() !== 'false'; } // An existence check is not enough: an incomplete/corrupt ffmpeg-static download // can leave a wrong-sized or non-executable file on disk that fails at launch // (e.g. Windows "not a valid Win32 application" / EFTYPE). Verify the binary // actually runs before trusting it. function isRunnable(binPath) { if (!binPath || !fs.existsSync(binPath)) return false; const result = spawnSync(binPath, ['-version'], { encoding: 'utf8', timeout: 20000, shell: false, }); if (result.error || result.status !== 0) return false; return VERSION_BANNER.test(`${result.stdout || ''}${result.stderr || ''}`); } function staticModuleInfo(moduleName) { try { const mod = require(moduleName); const binPath = typeof mod === 'string' ? mod : (mod && (mod.path || mod)); const pkgDir = path.dirname(require.resolve(`${moduleName}/package.json`)); return { binPath, pkgDir }; } catch (_) { return null; } } // ffmpeg-static downloads its binary on install via `install.js`, caching the // download with @derhuerst/http-basic. install.js short-circuits when the binary // file already exists, and a corrupt cache entry would just be re-copied, so a // real repair must remove BOTH the binary and that download cache first. function repairFfmpegStatic(info) { if (!info || !info.pkgDir) return false; const installJs = path.join(info.pkgDir, 'install.js'); if (!fs.existsSync(installJs)) return false; // install.js short-circuits if the binary file still exists, so it MUST be // gone before we re-run it. fs.rmSync({force:true}) has been observed to // silently no-op on a file on some Windows setups, so use unlinkSync and, if // that fails (e.g. AV/lock), move the corrupt file aside as a fallback. if (info.binPath && fs.existsSync(info.binPath)) { try { fs.unlinkSync(info.binPath); } catch (_) { try { fs.renameSync(info.binPath, `${info.binPath}.corrupt-${Date.now()}`); } catch (_) { return false; } } if (fs.existsSync(info.binPath)) return false; // could not clear it; bail to fallback } try { const envPaths = require(require.resolve('env-paths', { paths: [info.pkgDir] })); const cacheDir = envPaths('ffmpeg-static').cache; if (cacheDir) fs.rmSync(cacheDir, { recursive: true, force: true }); } catch (_) { /* best-effort cache clear */ } // The download can hang on a flaky network, so bound it; otherwise resolution // would block indefinitely. Redirect the installer's stdout to our stderr so // `which` stdout stays clean. const timeoutMs = Number(process.env.FMODE_FFMPEG_REPAIR_TIMEOUT_MS) || 180000; const result = spawnSync(process.execPath, [installJs], { cwd: info.pkgDir, stdio: ['ignore', 2, 2], shell: false, env: process.env, timeout: timeoutMs, }); return !result.error && result.status === 0; } function resolveFfmpeg() { const envPath = process.env.FFMPEG_PATH; if (envPath && fs.existsSync(envPath)) return envPath; const info = staticModuleInfo('ffmpeg-static'); if (info && info.binPath) { if (isRunnable(info.binPath)) return info.binPath; if (!truthyEnv(process.env.FMODE_FFMPEG_NO_REPAIR)) { console.error('fmode-ffmpeg: bundled ffmpeg failed to launch (corrupt/incomplete download); re-downloading once...'); if (repairFfmpegStatic(info)) { const fresh = staticModuleInfo('ffmpeg-static'); if (fresh && isRunnable(fresh.binPath)) return fresh.binPath; } console.error('fmode-ffmpeg: bundled ffmpeg still unavailable after re-download. Falling back to system ffmpeg on PATH; set FFMPEG_PATH to a working ffmpeg to override.'); } } return 'ffmpeg'; } function resolveFfprobe() { const envPath = process.env.FFPROBE_PATH; if (envPath && fs.existsSync(envPath)) return envPath; const info = staticModuleInfo('ffprobe-static'); if (info && info.binPath) { if (isRunnable(info.binPath)) return info.binPath; // ffprobe-static ships the binary inside the package (no download step to // retry), so a corrupt copy can only be fixed by reinstalling the package. console.error('fmode-ffmpeg: bundled ffprobe failed to launch (corrupt install). Falling back to system ffprobe on PATH; set FFPROBE_PATH to override, or reinstall fmode-ffmpeg.'); } return 'ffprobe'; } function runBinary(binary, passthrough) { const result = spawnSync(binary, passthrough, { stdio: 'inherit', shell: false }); if (result.error) { console.error(`fmode-ffmpeg: failed to launch ${binary}: ${result.error.message}`); process.exit(1); } process.exit(result.status == null ? 1 : result.status); } // Everything after the subcommand, dropping a single leading "--" separator. function passthroughArgs(argv) { const rest = argv.slice(1); if (rest[0] === '--') return rest.slice(1); return rest; } // --------------------------------------------------------------------------- // Skill installer (mirrors fmode-vision) // --------------------------------------------------------------------------- function parseArgs(argv) { const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install'; const args = { command: first, target: GLOBAL_TARGET, smoke: false, force: false, help: false }; if (first === 'workspace' || first === 'install-workspace') { args.command = 'install'; args.target = WORKSPACE_TARGET; } for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) { const token = argv[i]; if (token === '--target' && argv[i + 1]) args.target = argv[++i]; else if (token.startsWith('--target=')) args.target = token.slice('--target='.length); else if (token === '--workspace') args.target = WORKSPACE_TARGET; else if (token === '--global') args.target = GLOBAL_TARGET; else if (token === '--smoke') args.smoke = true; else if (token === '--force') args.force = true; else if (token === '--help' || token === '-h') args.help = true; } args.target = path.resolve(expandHome(args.target)); return args; } function printHelp() { console.log([ 'fmode-ffmpeg — bundled ffmpeg/ffprobe runner + Claude Code skill installer', '', 'Run ffmpeg / ffprobe (no system install needed):', ' npx fmode-ffmpeg@latest which # print bundled ffmpeg binary path', ' npx fmode-ffmpeg@latest which-ffprobe # print bundled ffprobe binary path', ' npx fmode-ffmpeg@latest exec -- -i in.mp4 out.mp3 # run ffmpeg with passthrough args', ' npx fmode-ffmpeg@latest probe -- -show_format in.mp4 # run ffprobe with passthrough args', ' npx fmode-ffmpeg@latest version # print ffmpeg -version', '', 'Install the Claude Code skill:', ' npx fmode-ffmpeg@latest workspace [--smoke] # install into ./.claude/skills/fmode-ffmpeg', ' npx fmode-ffmpeg@latest install [--smoke] # install into ~/.claude/skills/fmode-ffmpeg', ' npx fmode-ffmpeg@latest install --target