#!/usr/bin/env bun import { Command, CommanderError } from 'commander'; import packageJson from '../../package.json'; import { closeDb, initDb } from '../db'; import { registerArticleCommands } from './commands/article'; import { registerDbCommands } from './commands/db'; import { registerFeedCommands } from './commands/feed'; import { registerFetchCommands } from './commands/fetch'; import { registerImportExportCommands } from './commands/import-export'; import { registerTuiCommand } from './commands/tui'; import { CliError, isVerbose, setVerbose } from './error-handler'; import { createOutput } from './output'; const program = new Command(); const BOOLEAN_GLOBAL_SHORT_FLAGS = new Set(['V', 'h', 'j', 'q', 'v']); function hasShortFlag(arg: string, flag: string): boolean { if (!arg.startsWith('-') || arg.startsWith('--')) return false; const cluster = arg.slice(1); return cluster.includes(flag) && [...cluster].every((candidate) => BOOLEAN_GLOBAL_SHORT_FLAGS.has(candidate)); } function hasGlobalFlag(flag: '--json' | '-j' | '--quiet' | '-q'): boolean { return process.argv .slice(2) .some((arg) => flag.startsWith('--') ? arg === flag || arg.startsWith(`${flag}=`) : hasShortFlag(arg, flag.slice(1)), ); } function hasVersionFlag(): boolean { return process.argv.slice(2).some((arg) => arg === '--version' || hasShortFlag(arg, 'V')); } function writeCommanderMetaOutput(text: string): void { if (!hasGlobalFlag('--json') && !hasGlobalFlag('-j')) { process.stdout.write(text); return; } // Help and version are commands in their own right. Keep their machine // output parseable just like every other CLI command, while retaining the // complete human help text as data rather than inventing a lossy schema. const payload = hasVersionFlag() ? { version: text.trim() } : { help: text.trimEnd() }; console.log(JSON.stringify(payload, null, 2)); } process.on('exit', closeDb); program .name(packageJson.name) .description('A powerful CLI RSS reader with OPML support, smart caching, and more') .version(packageJson.version) .option('-d, --database ', 'Path to SQLite database', undefined) .option('-j, --json', 'Output as JSON', false) .option('-q, --quiet', 'Suppress non-essential output', false) .option('-v, --verbose', 'Show full error stack traces', false) .hook('preAction', (thisCommand, actionCommand) => { const opts = thisCommand.opts(); const dbPath = opts.database; setVerbose(opts.verbose); // The dynamically imported TUI owns initialization so direct `launchTui` // consumers and CLI callers share the same custom-path contract without a // duplicate close/migrate/open cycle. if (actionCommand.name() !== 'tui') initDb(dbPath); }); // Commander writes parse errors before it exits by default. Suppress that // direct output and throw instead so the single top-level formatter below owns // every error path, including unknown commands and missing values. program.configureOutput({ outputError: () => {}, writeOut: writeCommanderMetaOutput }).exitOverride(); // Register all command groups registerFeedCommands(program); registerFetchCommands(program); registerArticleCommands(program); registerImportExportCommands(program); registerDbCommands(program); registerTuiCommand(program); program.parseAsync().catch((error) => { // Errors thrown outside runCommand (e.g. in the preAction hook for an invalid // --database path) still need to honor --json/--quiet and emit a formatted // message instead of a raw stack dump. if (error instanceof CommanderError && error.exitCode === 0) { process.exit(0); } const opts = program.opts() as { json?: boolean; quiet?: boolean }; const output = createOutput({ json: Boolean(opts.json) || hasGlobalFlag('--json') || hasGlobalFlag('-j'), quiet: Boolean(opts.quiet) || hasGlobalFlag('--quiet') || hasGlobalFlag('-q'), }); const message = error instanceof Error ? error.message : String(error); const stack = isVerbose() && error instanceof Error ? error.stack : undefined; const code = error instanceof CommanderError || error instanceof CliError ? error.code : undefined; output.error(message, { code, stack }); process.exit(error instanceof CommanderError ? error.exitCode : 1); });