diff --git a/src/index.ts b/src/index.ts index 8145378..94fe2da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ 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 { extractTweetId } from './lib/extract-tweet-id.js'; import { SweetisticsClient } from './lib/sweetistics-client.js'; @@ -50,6 +51,7 @@ type BirdConfig = { sweetisticsBaseUrl?: string; allowChrome?: boolean; allowFirefox?: boolean; + timeoutMs?: number; }; function readConfigFile(path: string): Partial { @@ -78,6 +80,18 @@ function loadConfig(): BirdConfig { const config = loadConfig(); +const KNOWN_COMMANDS = new Set([ + 'tweet', + 'reply', + 'read', + 'replies', + 'thread', + 'search', + 'mentions', + 'whoami', + 'check', +]); + program.addHelpText( 'beforeAll', () => `${colors.banner('bird CLI')} ${colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`, @@ -117,7 +131,8 @@ program '--engine ', 'Engine: graphql | sweetistics | auto', process.env.BIRD_ENGINE || config.engine || 'graphql', - ); + ) + .option('--timeout ', 'Request timeout in milliseconds'); type EngineMode = 'graphql' | 'sweetistics' | 'auto'; @@ -146,6 +161,19 @@ function shouldUseSweetistics(engine: EngineMode, hasApiKey: boolean): boolean { return hasApiKey; // auto } +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'; @@ -208,6 +236,7 @@ program .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 ?? [] }); @@ -231,6 +260,7 @@ program const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, + timeoutMs, }); let mediaIds: string[] | undefined; if (media.length > 0) { @@ -265,7 +295,9 @@ program } if (media.length > 0) { - console.error('❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.'); + console.error( + '❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.', + ); process.exit(1); } @@ -291,7 +323,7 @@ program console.error(`📍 Using credentials from: ${cookies.source}`); } - const client = new TwitterClient({ cookies }); + const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.tweet(text); if (result.success) { @@ -302,6 +334,7 @@ program const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, + timeoutMs, }).tweet(text); if (fallback.success) { console.log('✅ Tweet posted via Sweetistics (fallback)!'); @@ -326,6 +359,7 @@ program .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 ?? [] }); @@ -350,6 +384,7 @@ program const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, + timeoutMs, }); let mediaIds: string[] | undefined; if (media.length > 0) { @@ -384,7 +419,9 @@ program } if (media.length > 0) { - console.error('❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.'); + console.error( + '❌ Media uploads are only supported via Sweetistics. Provide SWEETISTICS_API_KEY or --engine sweetistics.', + ); process.exit(1); } @@ -412,7 +449,7 @@ program console.error(`📝 Replying to tweet: ${tweetId}`); - const client = new TwitterClient({ cookies }); + const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.reply(text, tweetId); if (result.success) { @@ -423,6 +460,7 @@ program const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, + timeoutMs, }).tweet(text, tweetId); if (fallback.success) { console.log('✅ Reply posted via Sweetistics (fallback)!'); @@ -447,6 +485,7 @@ program .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, @@ -460,7 +499,11 @@ program console.error('❌ Sweetistics engine selected but no API key provided.'); process.exit(1); } - const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + 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) { @@ -499,7 +542,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies }); + const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.getTweet(tweetId); if (result.success && result.tweet) { @@ -517,9 +560,11 @@ program } } else if (sweetistics.apiKey) { console.error(`⚠️ GraphQL read failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).read( - tweetId, - ); + const fallback = await new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + timeoutMs, + }).read(tweetId); if (fallback.success && fallback.tweet) { if (cmdOpts.json) { console.log(JSON.stringify(fallback.tweet, null, 2)); @@ -551,6 +596,7 @@ program .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, @@ -563,7 +609,11 @@ program console.error('❌ Sweetistics engine selected but no API key provided.'); process.exit(1); } - const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + 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.' }); @@ -589,7 +639,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies }); + const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.getReplies(tweetId); if (result.success && result.tweets) { @@ -599,6 +649,7 @@ program const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, + timeoutMs, }).replies(tweetId); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); @@ -620,6 +671,7 @@ program .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, @@ -632,7 +684,11 @@ program console.error('❌ Sweetistics engine selected but no API key provided.'); process.exit(1); } - const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + 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.' }); @@ -658,16 +714,18 @@ program process.exit(1); } - const client = new TwitterClient({ cookies }); + 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 (sweetistics.apiKey) { console.error(`⚠️ GraphQL thread failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).thread( - tweetId, - ); + const fallback = await new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + timeoutMs, + }).thread(tweetId); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); } else { @@ -689,6 +747,7 @@ program .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, @@ -702,7 +761,11 @@ program console.error('❌ Sweetistics engine selected but no API key provided.'); process.exit(1); } - const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + 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.' }); @@ -728,17 +791,18 @@ program process.exit(1); } - const client = new TwitterClient({ cookies }); + 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 (sweetistics.apiKey) { console.error(`⚠️ GraphQL search failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).search( - query, - count, - ); + const fallback = await new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + timeoutMs, + }).search(query, count); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); } else { @@ -759,6 +823,7 @@ program .option('--json', 'Output as JSON') .action(async (cmdOpts: { 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); @@ -769,7 +834,11 @@ program console.error('❌ Sweetistics engine selected but no API key provided.'); process.exit(1); } - const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + const client = new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + timeoutMs, + }); const result = await client.search('@clawdbot', count); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); @@ -794,17 +863,18 @@ program process.exit(1); } - const client = new TwitterClient({ cookies }); + const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.search('@clawdbot', count); if (result.success && result.tweets) { printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); } else if (sweetistics.apiKey) { console.error(`⚠️ GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`); - const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).search( - '@clawdbot', - count, - ); + const fallback = await new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + timeoutMs, + }).search('@clawdbot', count); if (fallback.success && fallback.tweets) { printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); } else { @@ -823,6 +893,7 @@ program .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, @@ -836,7 +907,11 @@ program process.exit(1); } - const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + const client = new SweetisticsClient({ + baseUrl: sweetistics.baseUrl, + apiKey: sweetistics.apiKey, + timeoutMs, + }); const result = await client.getCurrentUser(); if (result.success && result.user) { @@ -878,7 +953,7 @@ program console.error(`📍 Using credentials from: ${cookies.source}`); } - const client = new TwitterClient({ cookies }); + const client = new TwitterClient({ cookies, timeoutMs }); const result = await client.getCurrentUser(); const credentialSource = cookies.source ?? 'env/auto-detected cookies'; @@ -894,6 +969,7 @@ program const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey, + timeoutMs, }).getCurrentUser(); if (fallback.success && fallback.user) { const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)'; @@ -962,10 +1038,16 @@ program } }); -// Show help when invoked without any subcommand -if (process.argv.length <= 2) { +const rawArgs = process.argv.slice(2); +const { argv, showHelp } = resolveCliInvocation(rawArgs, KNOWN_COMMANDS); + +if (showHelp) { program.outputHelp(); process.exit(0); } -program.parse(); +if (argv) { + program.parse(argv); +} else { + program.parse(); +} diff --git a/src/lib/cli-args.ts b/src/lib/cli-args.ts new file mode 100644 index 0000000..1b7c8ef --- /dev/null +++ b/src/lib/cli-args.ts @@ -0,0 +1,32 @@ +export type CliInvocation = { + argv: string[] | null; + showHelp: boolean; +}; + +const TWEET_URL_REGEX = /^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/[^/]+\/status\/\d+/i; +const TWEET_ID_REGEX = /^\d{8,}$/; + +export function looksLikeTweetInput(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed); +} + +export function resolveCliInvocation(rawArgs: string[], knownCommands: Set): CliInvocation { + if (rawArgs.length === 0) { + return { argv: null, showHelp: true }; + } + + const hasKnownCommand = rawArgs.some((arg) => knownCommands.has(arg)); + + if (!hasKnownCommand) { + const tweetArgIndex = rawArgs.findIndex(looksLikeTweetInput); + if (tweetArgIndex >= 0) { + const rewrittenArgs = [...rawArgs]; + rewrittenArgs.splice(tweetArgIndex, 0, 'read'); + return { argv: ['node', 'bird', ...rewrittenArgs], showHelp: false }; + } + } + + return { argv: null, showHelp: false }; +} diff --git a/src/lib/sweetistics-client.ts b/src/lib/sweetistics-client.ts index 8170c8b..3d89fc9 100644 --- a/src/lib/sweetistics-client.ts +++ b/src/lib/sweetistics-client.ts @@ -4,6 +4,7 @@ export interface SweetisticsClientOptions { baseUrl: string; apiKey: string; userAgent?: string; + timeoutMs?: number; } export interface SweetisticsTweetResult { @@ -63,13 +64,15 @@ export class SweetisticsClient { private readonly baseUrl: string; private readonly apiKey: string; private readonly userAgent?: string; + private readonly timeoutMs: number; // Prevent hanging requests; keep Sweetistics calls snappy for CLI users. - private static readonly REQUEST_TIMEOUT_MS = 15_000; + private static readonly DEFAULT_REQUEST_TIMEOUT_MS = 15_000; constructor(options: SweetisticsClientOptions) { this.baseUrl = normalizeBaseUrl(options.baseUrl); this.apiKey = options.apiKey.trim(); this.userAgent = options.userAgent; + this.timeoutMs = options.timeoutMs ?? SweetisticsClient.DEFAULT_REQUEST_TIMEOUT_MS; if (!this.apiKey) { throw new Error('Sweetistics API key is required'); } @@ -310,7 +313,7 @@ export class SweetisticsClient { { method: 'GET', headers, - } + }, ); } catch (error) { return { success: false, error: this.normalizeError(error) }; @@ -466,7 +469,11 @@ export class SweetisticsClient { return { success: true, tweets }; } - async uploadMedia(input: { data: string; mimeType: string; alt?: string }): Promise<{ success: boolean; mediaId?: string; error?: string }> { + async uploadMedia(input: { + data: string; + mimeType: string; + alt?: string; + }): Promise<{ success: boolean; mediaId?: string; error?: string }> { try { const response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/media/upload`, { method: 'POST', @@ -504,8 +511,8 @@ export class SweetisticsClient { private async fetchWithTimeout(url: string, init?: RequestInit): Promise { const controller = new AbortController(); const timeoutId = setTimeout( - () => controller.abort(new Error(`Request timed out after ${SweetisticsClient.REQUEST_TIMEOUT_MS}ms`)), - SweetisticsClient.REQUEST_TIMEOUT_MS, + () => controller.abort(new Error(`Request timed out after ${this.timeoutMs}ms`)), + this.timeoutMs, ); try { diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts new file mode 100644 index 0000000..52da447 --- /dev/null +++ b/tests/cli-args.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { looksLikeTweetInput, resolveCliInvocation } from '../src/lib/cli-args.js'; + +describe('cli-args', () => { + const known = new Set(['tweet', 'reply', 'read', 'replies', 'thread', 'search', 'mentions', 'whoami', 'check']); + + it('detects tweet URLs', () => { + expect(looksLikeTweetInput('https://x.com/user/status/1234567890')).toBe(true); + expect(looksLikeTweetInput('http://twitter.com/user/status/1234567890')).toBe(true); + expect(looksLikeTweetInput('x.com/user/status/1234567890')).toBe(true); + }); + + it('detects numeric tweet ids', () => { + expect(looksLikeTweetInput('1234567890')).toBe(true); + expect(looksLikeTweetInput('123')).toBe(false); + }); + + it('returns help for empty args', () => { + const result = resolveCliInvocation([], known); + expect(result.showHelp).toBe(true); + expect(result.argv).toBeNull(); + }); + + it('rewrites bare tweet url to read command', () => { + const result = resolveCliInvocation(['https://x.com/user/status/1234567890'], known); + expect(result.showHelp).toBe(false); + expect(result.argv).toEqual(['node', 'bird', 'read', 'https://x.com/user/status/1234567890']); + }); + + it('rewrites bare tweet id to read command', () => { + const result = resolveCliInvocation(['1234567890123456789'], known); + expect(result.argv).toEqual(['node', 'bird', 'read', '1234567890123456789']); + }); + + it('preserves leading options before inferred read command', () => { + const result = resolveCliInvocation(['--engine', 'sweetistics', 'https://x.com/user/status/1234567890'], known); + expect(result.argv).toEqual([ + 'node', + 'bird', + '--engine', + 'sweetistics', + 'read', + 'https://x.com/user/status/1234567890', + ]); + }); + + it('does not rewrite when a known command is provided', () => { + const result = resolveCliInvocation(['read', 'https://x.com/user/status/1234567890'], known); + expect(result.argv).toBeNull(); + }); + + it('does not rewrite unknown commands', () => { + const result = resolveCliInvocation(['https://example.com'], known); + expect(result.argv).toBeNull(); + expect(result.showHelp).toBe(false); + }); +});