import { Command } from 'commander'; export interface JellyfinInvocation { action?: string; discovery?: boolean; play?: boolean; login?: boolean; logout?: boolean; setup?: boolean; server?: string; username?: string; password?: string; passwordStore?: string; logLevel?: string; } export interface YtInvocation { target?: string; mode?: string; outDir?: string; keepTemp?: boolean; whisperBin?: string; whisperModel?: string; ytSubgenAudioFormat?: string; logLevel?: string; } export interface CommandActionInvocation { action: string; logLevel?: string; } export interface CliInvocations { jellyfinInvocation: JellyfinInvocation | null; ytInvocation: YtInvocation | null; configInvocation: CommandActionInvocation | null; mpvInvocation: CommandActionInvocation | null; appInvocation: { appArgs: string[] } | null; doctorTriggered: boolean; doctorLogLevel: string | null; texthookerTriggered: boolean; texthookerLogLevel: string | null; } function applyRootOptions(program: Command): void { program .option('-b, --backend ', 'Display backend') .option('-d, --directory ', 'Directory to browse') .option('-r, --recursive', 'Search directories recursively') .option('-p, --profile ', 'MPV profile') .option('--start', 'Explicitly start overlay') .option('--log-level ', 'Log level') .option('-R, --rofi', 'Use rofi picker') .option('-S, --start-overlay', 'Auto-start overlay') .option('-T, --no-texthooker', 'Disable texthooker-ui server'); } function buildSubcommandHelpText(program: Command): string { const subcommands = program.commands .filter((command) => command.name() !== 'help') .map((command) => { const aliases = command.aliases(); const term = aliases.length > 0 ? `${command.name()}|${aliases[0]}` : command.name(); return { term, description: command.description() }; }); if (subcommands.length === 0) return ''; const longestTerm = Math.max(...subcommands.map((entry) => entry.term.length)); const lines = subcommands.map((entry) => ` ${entry.term.padEnd(longestTerm)} ${entry.description || ''}`.trimEnd(), ); return `\nCommands:\n${lines.join('\n')}\n`; } function getTopLevelCommand(argv: string[]): { name: string; index: number } | null { const commandNames = new Set([ 'jellyfin', 'jf', 'yt', 'youtube', 'doctor', 'config', 'mpv', 'texthooker', 'app', 'bin', 'help', ]); const optionsWithValue = new Set([ '-b', '--backend', '-d', '--directory', '-p', '--profile', '--log-level', ]); for (let i = 0; i < argv.length; i += 1) { const token = argv[i] || ''; if (token === '--') return null; if (token.startsWith('-')) { if (optionsWithValue.has(token)) i += 1; continue; } return commandNames.has(token) ? { name: token, index: i } : null; } return null; } function hasTopLevelCommand(argv: string[]): boolean { return getTopLevelCommand(argv) !== null; } export function resolveTopLevelCommand(argv: string[]): { name: string; index: number } | null { return getTopLevelCommand(argv); } export function parseCliPrograms( argv: string[], scriptName: string, ): { options: Record; rootTarget: unknown; invocations: CliInvocations; } { let jellyfinInvocation: JellyfinInvocation | null = null; let ytInvocation: YtInvocation | null = null; let configInvocation: CommandActionInvocation | null = null; let mpvInvocation: CommandActionInvocation | null = null; let appInvocation: { appArgs: string[] } | null = null; let doctorLogLevel: string | null = null; let texthookerLogLevel: string | null = null; let doctorTriggered = false; let texthookerTriggered = false; const commandProgram = new Command(); commandProgram .name(scriptName) .description('Launch MPV with SubMiner sentence mining overlay') .showHelpAfterError(true) .enablePositionalOptions() .allowExcessArguments(false) .allowUnknownOption(false) .exitOverride(); applyRootOptions(commandProgram); const rootProgram = new Command(); rootProgram .name(scriptName) .description('Launch MPV with SubMiner sentence mining overlay') .usage('[options] [command] [target]') .showHelpAfterError(true) .allowExcessArguments(false) .allowUnknownOption(false) .exitOverride() .argument('[target]', 'file, directory, or URL'); applyRootOptions(rootProgram); commandProgram .command('jellyfin') .alias('jf') .description('Jellyfin workflows') .argument('[action]', 'setup|discovery|play|login|logout') .option('-d, --discovery', 'Cast discovery mode') .option('-p, --play', 'Interactive play picker') .option('-l, --login', 'Login flow') .option('--logout', 'Clear token/session') .option('--setup', 'Open setup window') .option('-s, --server ', 'Jellyfin server URL') .option('-u, --username ', 'Jellyfin username') .option('-w, --password ', 'Jellyfin password') .option('--password-store ', 'Pass through Electron safeStorage backend') .option('--log-level ', 'Log level') .action((action: string | undefined, options: Record) => { jellyfinInvocation = { action, discovery: options.discovery === true, play: options.play === true, login: options.login === true, logout: options.logout === true, setup: options.setup === true, server: typeof options.server === 'string' ? options.server : undefined, username: typeof options.username === 'string' ? options.username : undefined, password: typeof options.password === 'string' ? options.password : undefined, passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined, logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, }; }); commandProgram .command('yt') .alias('youtube') .description('YouTube workflows') .argument('[target]', 'YouTube URL or ytsearch: query') .option('-m, --mode ', 'Subtitle generation mode') .option('-o, --out-dir ', 'Subtitle output dir') .option('--keep-temp', 'Keep temp files') .option('--whisper-bin ', 'whisper.cpp CLI path') .option('--whisper-model ', 'whisper model path') .option('--yt-subgen-audio-format ', 'Audio extraction format') .option('--log-level ', 'Log level') .action((target: string | undefined, options: Record) => { ytInvocation = { target, mode: typeof options.mode === 'string' ? options.mode : undefined, outDir: typeof options.outDir === 'string' ? options.outDir : undefined, keepTemp: options.keepTemp === true, whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined, whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined, ytSubgenAudioFormat: typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined, logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, }; }); commandProgram .command('doctor') .description('Run dependency and environment checks') .option('--log-level ', 'Log level') .action((options: Record) => { doctorTriggered = true; doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; }); commandProgram .command('config') .description('Config helpers') .argument('[action]', 'path|show', 'path') .option('--log-level ', 'Log level') .action((action: string, options: Record) => { configInvocation = { action, logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, }; }); commandProgram .command('mpv') .description('MPV helpers') .argument('[action]', 'status|socket|idle', 'status') .option('--log-level ', 'Log level') .action((action: string, options: Record) => { mpvInvocation = { action, logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, }; }); commandProgram .command('texthooker') .description('Launch texthooker-only mode') .option('--log-level ', 'Log level') .action((options: Record) => { texthookerTriggered = true; texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; }); commandProgram .command('app') .alias('bin') .description('Pass arguments directly to SubMiner binary') .allowUnknownOption(true) .allowExcessArguments(true) .argument('[appArgs...]', 'Arguments forwarded to SubMiner app binary') .action((appArgs: string[] | undefined) => { appInvocation = { appArgs: Array.isArray(appArgs) ? appArgs : [] }; }); rootProgram.addHelpText('after', buildSubcommandHelpText(commandProgram)); const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram; try { selectedProgram.parse(['node', scriptName, ...argv]); } catch (error) { const commanderError = error as { code?: string; message?: string }; if (commanderError?.code === 'commander.helpDisplayed') { process.exit(0); } throw new Error(commanderError?.message || String(error)); } return { options: selectedProgram.opts>(), rootTarget: rootProgram.processedArgs[0], invocations: { jellyfinInvocation, ytInvocation, configInvocation, mpvInvocation, appInvocation, doctorTriggered, doctorLogLevel, texthookerTriggered, texthookerLogLevel, }, }; }