#!/usr/bin/env node /** * bird - CLI tool for posting tweets and replies * * Usage: * bird tweet "Hello world!" * bird reply "This is a reply" * bird reply "This is a reply" * bird read */ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { Command } from 'commander'; import JSON5 from 'json5'; import kleur from 'kleur'; import { resolveCliInvocation } from './lib/cli-args.js'; import { resolveCredentials } from './lib/cookies.js'; import { type EngineMode, resolveEngineMode, shouldAllowSweetisticsFallback, shouldUseSweetistics, } from './lib/engine.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { formatStatsLine, labelPrefix, type OutputConfig, resolveOutputConfigFromArgv, resolveOutputConfigFromCommander, statusPrefix, } from './lib/output.js'; import { runtimeQueryIds } from './lib/runtime-query-ids.js'; import { SweetisticsClient } from './lib/sweetistics-client.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { getCliVersion } from './lib/version.js'; const program = new Command(); const rawArgs = process.argv.slice(2); const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; const isTty = process.stdout.isTTY; let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty); kleur.enabled = output.color; const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text); const collect = (value: string, previous: string[] = []) => { previous.push(value); return previous; }; const p = (kind: Parameters[0]) => statusPrefix(kind, output); const l = (kind: Parameters[0]) => labelPrefix(kind, output); function requireSweetisticsApiKey(apiKey: string | null): string { if (!apiKey) { console.error(`${p('err')}Sweetistics API key missing. Set SWEETISTICS_API_KEY or pass --sweetistics-api-key.`); process.exit(1); } return apiKey; } function applyOutputFromCommand(command: Command) { const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean }; output = resolveOutputConfigFromCommander(opts, process.env, isTty); kleur.enabled = output.color; } 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)), }; type BirdConfig = { engine?: EngineMode; chromeProfile?: string; firefoxProfile?: string; sweetisticsApiKey?: string; sweetisticsBaseUrl?: string; allowChrome?: boolean; allowFirefox?: boolean; timeoutMs?: number; }; function readConfigFile(path: string): Partial { if (!existsSync(path)) return {}; try { const raw = readFileSync(path, 'utf8'); const parsed = JSON5.parse(raw) as Partial; return parsed ?? {}; } catch (error) { console.error( colors.muted( `${p('warn')}Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`, ), ); return {}; } } function loadConfig(): BirdConfig { const globalPath = join(homedir(), '.config', 'bird', 'config.json5'); const localPath = join(process.cwd(), '.birdrc.json5'); return { ...readConfigFile(globalPath), ...readConfigFile(localPath), }; } const config = loadConfig(); const KNOWN_COMMANDS = new Set([ 'tweet', 'reply', 'query-ids', 'read', 'replies', 'thread', 'search', 'mentions', 'help', 'whoami', 'check', ]); program.addHelpText( 'beforeAll', () => `${colors.banner('bird CLI')} ${colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`, ); program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion()); const formatExample = (command: string, description: string) => `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`; program.addHelpText( 'afterAll', () => `\n${colors.section('Examples')}\n${[ formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'), formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'), formatExample('bird tweet "hello from bird"', 'Send a tweet'), formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'), ].join('\n\n')}`, ); // Global options for authentication program .option('--auth-token ', 'Twitter auth_token cookie') .option('--ct0 ', 'Twitter ct0 cookie') .option('--chrome-profile ', 'Chrome profile name for cookie extraction', config.chromeProfile) .option('--firefox-profile ', 'Firefox profile name for cookie extraction', config.firefoxProfile) .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--sweetistics-api-key ', 'Sweetistics API key (or set SWEETISTICS_API_KEY)') .option( '--sweetistics-base-url ', 'Sweetistics base URL', config.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com', ) .option( '--engine ', 'Engine: graphql | sweetistics | auto', process.env.BIRD_ENGINE || config.engine || 'auto', ) .option('--timeout ', 'Request timeout in milliseconds') .option('--plain', 'Plain output (stable, no emoji, no color)') .option('--no-emoji', 'Disable emoji output') .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); program.hook('preAction', (_thisCommand, actionCommand) => { applyOutputFromCommand(actionCommand); }); type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer }; function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) { const apiKey = options.sweetisticsApiKey || process.env.SWEETISTICS_API_KEY || process.env.SWEETISTICS_LOCALHOST_API_KEY || null; const baseUrl = options.sweetisticsBaseUrl || process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com'; return { apiKey, baseUrl }; } 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 resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined { return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS); } 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 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; } for (const tweet of tweets) { console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); console.log(tweet.text); if (tweet.createdAt) { console.log(`${l('date')}${tweet.createdAt}`); } console.log(`${l('url')}https://x.com/${tweet.author.username}/status/${tweet.id}`); if (opts.showSeparator ?? true) { console.log('─'.repeat(50)); } } } program .command('help [command]') .description('Show help for a command') .action((commandName?: string) => { if (!commandName) { program.outputHelp(); return; } const cmd = program.commands.find((c) => c.name() === commandName); if (!cmd) { console.error(`${p('err')}Unknown command: ${commandName}`); process.exitCode = 2; return; } cmd.outputHelp(); }); program .command('query-ids') .description('Show or refresh cached Twitter GraphQL query IDs') .option('--json', 'Output as JSON') .option('--fresh', 'Force refresh (downloads X client bundles)', false) .action(async (cmdOpts: { json?: boolean; fresh?: boolean }) => { const operations = [ 'CreateTweet', 'CreateRetweet', 'FavoriteTweet', 'TweetDetail', 'SearchTimeline', 'UserArticlesTweets', ]; if (cmdOpts.fresh) { console.error(`${p('info')}Refreshing GraphQL query IDs…`); await runtimeQueryIds.refresh(operations, { force: true }); } const info = await runtimeQueryIds.getSnapshotInfo(); if (!info) { if (cmdOpts.json) { console.log(JSON.stringify({ cached: false, cachePath: runtimeQueryIds.cachePath }, null, 2)); return; } console.log(`${p('warn')}No cached query IDs yet.`); console.log(`${p('info')}Run: bird query-ids --fresh`); return; } if (cmdOpts.json) { console.log( JSON.stringify( { cached: true, cachePath: info.cachePath, fetchedAt: info.snapshot.fetchedAt, isFresh: info.isFresh, ageMs: info.ageMs, ids: info.snapshot.ids, discovery: info.snapshot.discovery, }, null, 2, ), ); return; } console.log(`${p('ok')}GraphQL query IDs cached`); console.log(`path: ${info.cachePath}`); console.log(`fetched_at: ${info.snapshot.fetchedAt}`); console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`); console.log(`ops: ${Object.keys(info.snapshot.ids).length}`); }); // Tweet command program .command('tweet') .description('Post a new tweet') .argument('', 'Tweet text') .action(async (text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); } catch (error) { console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`); process.exit(1); } const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } try { const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; for (const item of media) { const res = await client.uploadMedia({ data: item.buffer.toString('base64'), mimeType: item.mime, alt: item.alt, }); if (!res.success || !res.mediaId) { throw new Error(res.error ?? 'Media upload failed'); } uploaded.push(res.mediaId); } mediaIds = uploaded; } const result = await client.tweet(text, undefined, mediaIds); if (result.success) { console.log(`${p('ok')}Tweet posted via Sweetistics!`); if (result.tweetId) { console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); } return; } console.error(`${p('err')}Sweetistics post failed: ${result.error ?? 'Unknown error'}`); process.exit(1); } catch (error) { console.error(`${p('err')}Sweetistics error: ${error instanceof Error ? error.message : String(error)}`); if (error instanceof Error && /(^|\\b)INTERNAL_ERROR(\\b|$)|An unexpected error occurred/.test(error.message)) { console.error(`${p('hint')}Sweetistics media upload likely needs Twitter OAuth with \`media.write\` scope.`); } process.exit(1); } } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } if (cookies.source) { console.error(`${l('source')}${cookies.source}`); } const client = new TwitterClient({ cookies, timeoutMs }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; for (const item of media) { const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt }); if (!res.success || !res.mediaId) { console.error(`${p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`); process.exit(1); } uploaded.push(res.mediaId); } mediaIds = uploaded; } const result = await client.tweet(text, mediaIds); if (result.success) { console.log(`${p('ok')}Tweet posted successfully!`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error(`${p('warn')}GraphQL tweet failed (${result.error}); trying Sweetistics fallback...`); const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).tweet(text); if (fallback.success) { console.log(`${p('ok')}Tweet posted via Sweetistics (fallback)!`); if (fallback.tweetId) { console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`); } } else { console.error(`${p('err')}Failed to post tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`); process.exit(1); } } else { console.error(`${p('err')}Failed to post tweet: ${result.error}`); process.exit(1); } }); // Reply command program .command('reply') .description('Reply to an existing tweet') .argument('', 'Tweet ID or URL to reply to') .argument('', 'Reply text') .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); } catch (error) { console.error(`${p('err')}${error instanceof Error ? error.message : String(error)}`); process.exit(1); } const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } try { const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; for (const item of media) { const res = await client.uploadMedia({ data: item.buffer.toString('base64'), mimeType: item.mime, alt: item.alt, }); if (!res.success || !res.mediaId) { throw new Error(res.error ?? 'Media upload failed'); } uploaded.push(res.mediaId); } mediaIds = uploaded; } const result = await client.tweet(text, tweetId, mediaIds); if (result.success) { console.log(`${p('ok')}Reply posted via Sweetistics!`); if (result.tweetId) { console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); } return; } console.error(`${p('err')}Sweetistics reply failed: ${result.error ?? 'Unknown error'}`); process.exit(1); } catch (error) { console.error(`${p('err')}Sweetistics error: ${error instanceof Error ? error.message : String(error)}`); if (error instanceof Error && /(^|\\b)INTERNAL_ERROR(\\b|$)|An unexpected error occurred/.test(error.message)) { console.error(`${p('hint')}Sweetistics media upload likely needs Twitter OAuth with \`media.write\` scope.`); } process.exit(1); } } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } if (cookies.source) { console.error(`${l('source')}${cookies.source}`); } console.error(`${p('info')}Replying to tweet: ${tweetId}`); const client = new TwitterClient({ cookies, timeoutMs }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; for (const item of media) { const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt }); if (!res.success || !res.mediaId) { console.error(`${p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`); process.exit(1); } uploaded.push(res.mediaId); } mediaIds = uploaded; } const result = await client.reply(text, tweetId, mediaIds); if (result.success) { console.log(`${p('ok')}Reply posted successfully!`); console.log(`${l('url')}https://x.com/i/status/${result.tweetId}`); } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error(`${p('warn')}GraphQL reply failed (${result.error}); trying Sweetistics fallback...`); const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).tweet(text, tweetId); if (fallback.success) { console.log(`${p('ok')}Reply posted via Sweetistics (fallback)!`); if (fallback.tweetId) { console.log(`${l('url')}https://x.com/i/status/${fallback.tweetId}`); } } else { console.error(`${p('err')}Failed to post reply: ${result.error} | Sweetistics fallback: ${fallback.error}`); process.exit(1); } } else { console.error(`${p('err')}Failed to post reply: ${result.error}`); process.exit(1); } }); // Read command - fetch tweet content program .command('read') .description('Read/fetch a tweet by ID or URL') .argument('', 'Tweet ID or URL to read') .option('--json', 'Output as JSON') .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); const result = await client.read(tweetId); if (result.success && result.tweet) { if (cmdOpts.json) { console.log(JSON.stringify(result.tweet, null, 2)); } else { console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); console.log(result.tweet.text); if (result.tweet.createdAt) { console.log(`\n${l('date')}${result.tweet.createdAt}`); } console.log(formatStatsLine(result.tweet, output)); } return; } console.error(`${p('err')}Failed to read tweet via Sweetistics: ${result.error ?? 'Unknown error'}`); process.exit(1); } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.getTweet(tweetId); if (result.success && result.tweet) { if (cmdOpts.json) { console.log(JSON.stringify(result.tweet, null, 2)); } else { console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); console.log(result.tweet.text); if (result.tweet.createdAt) { console.log(`\n${l('date')}${result.tweet.createdAt}`); } console.log(formatStatsLine(result.tweet, output)); } } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error(`${p('warn')}GraphQL read failed (${result.error}); trying Sweetistics fallback...`); const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).read(tweetId); if (fallback.success && fallback.tweet) { if (cmdOpts.json) { console.log(JSON.stringify(fallback.tweet, null, 2)); } else { console.log(`@${fallback.tweet.author.username} (${fallback.tweet.author.name}):`); console.log(fallback.tweet.text); if (fallback.tweet.createdAt) { console.log(`\n${l('date')}${fallback.tweet.createdAt}`); } console.log(formatStatsLine(fallback.tweet, output)); } } else { console.error(`${p('err')}Failed to read tweet: ${result.error} | Sweetistics fallback: ${fallback.error}`); process.exit(1); } } else { console.error(`${p('err')}Failed to read tweet: ${result.error}`); process.exit(1); } }); // Replies command - list replies to a tweet program .command('replies') .description('List replies to a tweet (by ID or URL)') .argument('', 'Tweet ID or URL') .option('--json', 'Output as JSON') .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); const result = await client.replies(tweetId); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); return; } console.error(`${p('err')}Failed to fetch replies via Sweetistics: ${result.error}`); process.exit(1); } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.getReplies(tweetId); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error(`${p('warn')}GraphQL replies failed (${result.error}); trying Sweetistics fallback...`); const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).replies(tweetId); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); } else { console.error(`${p('err')}Failed to fetch replies: ${result.error} | Sweetistics fallback: ${fallback.error}`); process.exit(1); } } else { console.error(`${p('err')}Failed to fetch replies: ${result.error}`); process.exit(1); } }); // Thread command - show full conversation thread program .command('thread') .description('Show the full conversation thread containing the tweet') .argument('', 'Tweet ID or URL') .option('--json', 'Output as JSON') .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); const result = await client.thread(tweetId); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); return; } console.error(`${p('err')}Failed to fetch thread via Sweetistics: ${result.error}`); process.exit(1); } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.getThread(tweetId); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error(`${p('warn')}GraphQL thread failed (${result.error}); trying Sweetistics fallback...`); const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).thread(tweetId); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); } else { console.error(`${p('err')}Failed to fetch thread: ${result.error} | Sweetistics fallback: ${fallback.error}`); process.exit(1); } } else { console.error(`${p('err')}Failed to fetch thread: ${result.error}`); process.exit(1); } }); // Search command - find tweets program .command('search') .description('Search for tweets') .argument('', 'Search query (e.g., "@clawdbot" or "from:clawdbot")') .option('-n, --count ', 'Number of tweets to fetch', '10') .option('--json', 'Output as JSON') .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); const result = await client.search(query, count); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); return; } console.error(`${p('err')}Search failed via Sweetistics: ${result.error}`); process.exit(1); } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.search(query, count); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error(`${p('warn')}GraphQL search failed (${result.error}); trying Sweetistics fallback...`); const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).search(query, count); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); } else { console.error(`${p('err')}Search failed: ${result.error} | Sweetistics fallback: ${fallback.error}`); process.exit(1); } } else { console.error(`${p('err')}Search failed: ${result.error}`); process.exit(1); } }); // Mentions command - shortcut to search for @username mentions program .command('mentions') .description('Find tweets mentioning a user (defaults to current user)') .option('-u, --user ', 'User handle (e.g. @steipete)') .option('-n, --count ', 'Number of tweets to fetch', '10') .option('--json', 'Output as JSON') .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const sweetistics = resolveSweetisticsConfig(opts); const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); if (fromUserOpt.error) { console.error(`${p('err')}${fromUserOpt.error}`); process.exit(2); } let query: string | null = fromUserOpt.query; if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); if (!query) { const who = await client.getCurrentUser(); const handle = normalizeHandle(who.user?.username); if (!handle) { console.error( `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user .`, ); process.exit(1); } query = `@${handle}`; } const result = await client.search(query, count); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); return; } console.error(`${p('err')}Failed to fetch mentions via Sweetistics: ${result.error}`); process.exit(1); } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } const client = new TwitterClient({ cookies, timeoutMs }); if (!query) { const who = await client.getCurrentUser(); const handle = normalizeHandle(who.user?.username); if (!handle) { if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error( `${p('warn')}Could not determine current user (${who.error ?? 'Unknown error'}); trying Sweetistics fallback...`, ); const fallbackWho = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).getCurrentUser(); const fallbackHandle = normalizeHandle(fallbackWho.user?.username); if (!fallbackHandle) { console.error(`${p('err')}Could not determine current user. Use --user .`); process.exit(1); } query = `@${fallbackHandle}`; } else { console.error( `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user .`, ); process.exit(1); } } else { query = `@${handle}`; } } const result = await client.search(query, count); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); } else if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { console.error(`${p('warn')}GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`); const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).search(query, count); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); } else { console.error(`${p('err')}Failed to fetch mentions: ${result.error} | Sweetistics fallback: ${fallback.error}`); process.exit(1); } } else { console.error(`${p('err')}Failed to fetch mentions: ${result.error}`); process.exit(1); } }); // Whoami command - show the logged-in account program .command('whoami') .description('Show which Twitter account the current credentials belong to') .action(async () => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const sweetistics = resolveSweetisticsConfig({ sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); const engine = resolveEngineMode(opts.engine || config.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const resolvedEngine = useSweetistics ? 'sweetistics' : 'graphql'; if (useSweetistics) { if (!sweetistics.apiKey) { console.error(`${p('err')}Sweetistics engine selected but no API key provided.`); process.exit(1); } const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, timeoutMs, }); const result = await client.getCurrentUser(); if (result.success && result.user) { const handle = result.user.username ? `@${result.user.username}` : '(no handle)'; const name = result.user.name || handle; console.log(`${l('user')}${handle} (${name})`); console.log(`${l('userId')}${result.user.id}`); if (result.user.email) { console.log(`${l('email')}${result.user.email}`); } console.log(`${l('engine')}${resolvedEngine}`); console.log(`${l('credentials')}Sweetistics API key`); return; } console.error(`${p('err')}Failed to determine Sweetistics user: ${result.error ?? 'Unknown error'}`); process.exit(1); } const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { console.error(`${p('warn')}${warning}`); } if (!cookies.authToken || !cookies.ct0) { console.error(`${p('err')}Missing required credentials`); process.exit(1); } if (cookies.source) { console.error(`${l('source')}${cookies.source}`); } const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.getCurrentUser(); const credentialSource = cookies.source ?? 'env/auto-detected cookies'; if (result.success && result.user) { console.log(`${l('user')}@${result.user.username} (${result.user.name})`); console.log(`${l('userId')}${result.user.id}`); console.log(`${l('engine')}${resolvedEngine}`); console.log(`${l('credentials')}${credentialSource}`); } else { // Fallback: try Sweetistics if available if (shouldAllowSweetisticsFallback(engine, Boolean(sweetistics.apiKey))) { const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: requireSweetisticsApiKey(sweetistics.apiKey), timeoutMs, }).getCurrentUser(); if (fallback.success && fallback.user) { const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)'; const name = fallback.user.name || handle; console.log(`${l('user')}${handle} (${name})`); console.log(`${l('userId')}${fallback.user.id}`); if (fallback.user.email) { console.log(`${l('email')}${fallback.user.email}`); } console.log(`${l('engine')}sweetistics (fallback)`); console.log(`${l('credentials')}Sweetistics API key`); return; } } console.error(`${p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`); process.exit(1); } }); // Check command - verify credentials program .command('check') .description('Check credential availability') .action(async () => { const opts = program.opts(); const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, chromeProfile: opts.chromeProfile, }); console.log(`${p('info')}Credential check`); console.log('─'.repeat(40)); if (cookies.authToken) { console.log(`${p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`); } else { console.log(`${p('err')}auth_token: not found`); } if (cookies.ct0) { console.log(`${p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`); } else { console.log(`${p('err')}ct0: not found`); } if (cookies.source) { console.log(`${l('source')}${cookies.source}`); } if (warnings.length > 0) { console.log(`\n${p('warn')}Warnings:`); for (const warning of warnings) { console.log(` - ${warning}`); } } if (cookies.authToken && cookies.ct0) { console.log(`\n${p('ok')}Ready to tweet!`); } else { console.log(`\n${p('err')}Missing credentials. Options:`); console.log(' 1. Login to x.com in Chrome'); console.log(' 2. Set AUTH_TOKEN and CT0 environment variables'); console.log(' 3. Use --auth-token and --ct0 flags'); process.exit(1); } }); const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS); if (showHelp) { program.outputHelp(); process.exit(0); } if (argv) { program.parse(argv); } else { program.parse(['node', 'bird', ...normalizedArgs]); }