feat(cli): add shorthand read and timeouts

This commit is contained in:
Peter Steinberger
2025-12-20 11:29:17 +01:00
parent bc7139dff9
commit 8e4c5c8bd8
4 changed files with 217 additions and 39 deletions
+116 -34
View File
@@ -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<BirdConfig> {
@@ -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>',
'Engine: graphql | sweetistics | auto',
process.env.BIRD_ENGINE || config.engine || 'graphql',
);
)
.option('--timeout <ms>', '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<string | number | undefined | null>): 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('<text>', '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('<text>', '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();
}
+32
View File
@@ -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<string>): 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 };
}
+12 -5
View File
@@ -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<Response> {
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 {
+57
View File
@@ -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);
});
});