import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { Command } from 'commander'; import JSON5 from 'json5'; import kleur from 'kleur'; import { extractTweetId } from '../lib/extract-tweet-id.js'; import { hyperlink, labelPrefix, type OutputConfig, resolveOutputConfigFromArgv, resolveOutputConfigFromCommander, statusPrefix, } from '../lib/output.js'; import type { TweetData } from '../lib/twitter-client.js'; export type BirdConfig = { relayBaseUrl?: string; profileName?: string; timeoutMs?: number; quoteDepth?: number; }; export type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer }; export type CliContext = { isTty: boolean; getOutput: () => OutputConfig; colors: { banner: (t: string) => string; subtitle: (t: string) => string; section: (t: string) => string; bullet: (t: string) => string; command: (t: string) => string; option: (t: string) => string; argument: (t: string) => string; description: (t: string) => string; muted: (t: string) => string; accent: (t: string) => string; }; p: (kind: Parameters[0]) => string; l: (kind: Parameters[0]) => string; config: BirdConfig; applyOutputFromCommand: (command: Command) => void; resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined; resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined; resolveProfileNameFromOptions: (opts: ProfileOptions) => string | undefined; resolveRelayBaseUrlFromOptions: (opts: RelayOptions) => string | undefined; loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[]; printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void; printTweetsResult: ( result: { tweets?: TweetData[]; nextCursor?: string; }, opts: { json: boolean; usePagination: boolean; emptyMessage: string; }, ) => void; extractTweetId: (tweetIdOrUrl: string) => string; }; function resolveTimeoutMs(...values: Array): number | undefined { for (const value of values) { if (value === undefined || value === null || value === '') { continue; } const parsed = typeof value === 'number' ? value : Number(value); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } } return undefined; } function resolveQuoteDepth(...values: Array): number | undefined { for (const value of values) { if (value === undefined || value === null || value === '') { continue; } const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10); if (Number.isFinite(parsed) && parsed >= 0) { return Math.floor(parsed); } } return undefined; } function detectMime(path: string): string | null { const ext = path.toLowerCase(); if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) { return 'image/jpeg'; } if (ext.endsWith('.png')) { return 'image/png'; } if (ext.endsWith('.webp')) { return 'image/webp'; } if (ext.endsWith('.gif')) { return 'image/gif'; } if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) { return 'video/mp4'; } if (ext.endsWith('.mov')) { return 'video/quicktime'; } return null; } function readConfigFile(path: string, warn: (message: string) => void): Partial { if (!existsSync(path)) { return {}; } try { const raw = readFileSync(path, 'utf8'); const parsed = JSON5.parse(raw) as Partial; return parsed ?? {}; } catch (error) { warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`); return {}; } } function loadConfig(warn: (message: string) => void): BirdConfig { const globalPath = join(homedir(), '.config', 'bird', 'config.json5'); const localPath = join(process.cwd(), '.birdrc.json5'); return { ...readConfigFile(globalPath, warn), ...readConfigFile(localPath, warn), }; } type RelayOptions = { relayBaseUrl?: string; }; type ProfileOptions = { profileName?: string; }; export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext { const isTty = process.stdout.isTTY; let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, env, isTty); kleur.enabled = output.color; const wrap = (styler: (text: string) => string): ((text: string) => string) => (text: string): string => isTty ? styler(text) : text; const colors = { banner: wrap((t) => kleur.bold().blue(t)), subtitle: wrap((t) => kleur.dim(t)), section: wrap((t) => kleur.bold().white(t)), bullet: wrap((t) => kleur.blue(t)), command: wrap((t) => kleur.bold().cyan(t)), option: wrap((t) => kleur.cyan(t)), argument: wrap((t) => kleur.magenta(t)), description: wrap((t) => kleur.white(t)), muted: wrap((t) => kleur.gray(t)), accent: wrap((t) => kleur.green(t)), }; const p = (kind: Parameters[0]): string => { const prefix = statusPrefix(kind, output); if (output.plain || !output.color) { return prefix; } if (kind === 'ok') { return kleur.green(prefix); } if (kind === 'warn') { return kleur.yellow(prefix); } if (kind === 'err') { return kleur.red(prefix); } if (kind === 'info') { return kleur.cyan(prefix); } return kleur.gray(prefix); }; const l = (kind: Parameters[0]): string => { const prefix = labelPrefix(kind, output); if (output.plain || !output.color) { return prefix; } if (kind === 'url') { return kleur.cyan(prefix); } if (kind === 'date') { return kleur.magenta(prefix); } if (kind === 'source') { return kleur.gray(prefix); } if (kind === 'engine') { return kleur.blue(prefix); } if (kind === 'credentials') { return kleur.yellow(prefix); } if (kind === 'user') { return kleur.cyan(prefix); } if (kind === 'userId') { return kleur.magenta(prefix); } if (kind === 'email') { return kleur.green(prefix); } return kleur.gray(prefix); }; const config = loadConfig((message) => { console.error(colors.muted(`${p('warn')}${message}`)); }); function applyOutputFromCommand(command: Command): void { const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean }; output = resolveOutputConfigFromCommander(opts, env, isTty); kleur.enabled = output.color; } function resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined { return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS); } function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined { return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH); } function resolveRelayBaseUrlFromOptions(opts: RelayOptions): string | undefined { return opts.relayBaseUrl || config.relayBaseUrl || env.TWITTER_RELAY_BASE_URL; } function resolveProfileNameFromOptions(opts: ProfileOptions): string | undefined { return opts.profileName || env.BIRD_PROFILE_NAME || config.profileName; } function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { if (opts.media.length === 0) { return []; } const specs: MediaSpec[] = []; for (const [index, path] of opts.media.entries()) { const mime = detectMime(path); if (!mime) { throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`); } const buffer = readFileSync(path); specs.push({ path, mime, buffer, alt: opts.alts[index] }); } const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length; if (videoCount > 1) { throw new Error('Only one video can be attached'); } if (videoCount === 1 && specs.length > 1) { throw new Error('Video cannot be combined with other media'); } if (specs.length > 4) { throw new Error('Maximum 4 media attachments'); } return specs; } function printTweets( tweets: TweetData[], opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {}, ) { if (opts.json) { console.log(JSON.stringify(tweets, null, 2)); return; } if (tweets.length === 0) { console.log(opts.emptyMessage ?? 'No tweets found.'); return; } const useEmoji = output.emoji && !output.plain; const articleLabel = useEmoji ? '📰' : 'Article:'; const mediaLabel = (type: 'photo' | 'video' | 'animated_gif'): string => { if (useEmoji) { return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️'; } return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:'; }; const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' }; for (const tweet of tweets) { console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); // Display tweet text, with article indicator if present if (tweet.article) { // Full body mode: text starts with article title (from extractArticleText) // Preview mode: text is short tweet intro that doesn't start with title const hasFullBody = tweet.text.startsWith(tweet.article.title); if (hasFullBody) { console.log(`${articleLabel} ${tweet.text}`); } else { console.log(`${articleLabel} ${tweet.article.title}`); if (tweet.article.previewText) { console.log(` ${tweet.article.previewText}`); } } } else { console.log(tweet.text); } // Display media attachments if (tweet.media && tweet.media.length > 0) { for (const m of tweet.media) { console.log(`${mediaLabel(m.type)} ${m.url}`); } } // Display quoted tweet if (tweet.quotedTweet) { console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`); const qtText = tweet.quotedTweet.article ? `${articleLabel} ${tweet.quotedTweet.article.title}` : tweet.quotedTweet.text; // Indent and truncate quoted tweet text const maxLen = 280; const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText; for (const line of truncated.split('\n').slice(0, 4)) { console.log(`${quotePrefix.mid}${line}`); } // Display quoted tweet media if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) { for (const m of tweet.quotedTweet.media) { console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`); } } console.log( `${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`, ); } if (tweet.createdAt) { console.log(`${l('date')}${tweet.createdAt}`); } const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`; console.log(`${l('url')}${hyperlink(tweetUrl, tweetUrl, output)}`); if (opts.showSeparator ?? true) { console.log('─'.repeat(50)); } } } function printTweetsResult( result: { tweets?: TweetData[]; nextCursor?: string }, opts: { json: boolean; usePagination: boolean; emptyMessage: string }, ) { const tweets = result.tweets ?? []; if (opts.json && opts.usePagination) { console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2)); return; } printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage }); } return { isTty, getOutput: () => output, colors, p, l, config, applyOutputFromCommand, resolveTimeoutFromOptions, resolveQuoteDepthFromOptions, resolveProfileNameFromOptions, resolveRelayBaseUrlFromOptions, loadMedia, printTweets, printTweetsResult, extractTweetId, }; }