feat(cli): add shorthand read and timeouts
This commit is contained in:
+116
-34
@@ -15,6 +15,7 @@ import { join } from 'node:path';
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import JSON5 from 'json5';
|
import JSON5 from 'json5';
|
||||||
import kleur from 'kleur';
|
import kleur from 'kleur';
|
||||||
|
import { resolveCliInvocation } from './lib/cli-args.js';
|
||||||
import { resolveCredentials } from './lib/cookies.js';
|
import { resolveCredentials } from './lib/cookies.js';
|
||||||
import { extractTweetId } from './lib/extract-tweet-id.js';
|
import { extractTweetId } from './lib/extract-tweet-id.js';
|
||||||
import { SweetisticsClient } from './lib/sweetistics-client.js';
|
import { SweetisticsClient } from './lib/sweetistics-client.js';
|
||||||
@@ -50,6 +51,7 @@ type BirdConfig = {
|
|||||||
sweetisticsBaseUrl?: string;
|
sweetisticsBaseUrl?: string;
|
||||||
allowChrome?: boolean;
|
allowChrome?: boolean;
|
||||||
allowFirefox?: boolean;
|
allowFirefox?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function readConfigFile(path: string): Partial<BirdConfig> {
|
function readConfigFile(path: string): Partial<BirdConfig> {
|
||||||
@@ -78,6 +80,18 @@ function loadConfig(): BirdConfig {
|
|||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const KNOWN_COMMANDS = new Set([
|
||||||
|
'tweet',
|
||||||
|
'reply',
|
||||||
|
'read',
|
||||||
|
'replies',
|
||||||
|
'thread',
|
||||||
|
'search',
|
||||||
|
'mentions',
|
||||||
|
'whoami',
|
||||||
|
'check',
|
||||||
|
]);
|
||||||
|
|
||||||
program.addHelpText(
|
program.addHelpText(
|
||||||
'beforeAll',
|
'beforeAll',
|
||||||
() => `${colors.banner('bird CLI')} ${colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`,
|
() => `${colors.banner('bird CLI')} ${colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`,
|
||||||
@@ -117,7 +131,8 @@ program
|
|||||||
'--engine <engine>',
|
'--engine <engine>',
|
||||||
'Engine: graphql | sweetistics | auto',
|
'Engine: graphql | sweetistics | auto',
|
||||||
process.env.BIRD_ENGINE || config.engine || 'graphql',
|
process.env.BIRD_ENGINE || config.engine || 'graphql',
|
||||||
);
|
)
|
||||||
|
.option('--timeout <ms>', 'Request timeout in milliseconds');
|
||||||
|
|
||||||
type EngineMode = 'graphql' | 'sweetistics' | 'auto';
|
type EngineMode = 'graphql' | 'sweetistics' | 'auto';
|
||||||
|
|
||||||
@@ -146,6 +161,19 @@ function shouldUseSweetistics(engine: EngineMode, hasApiKey: boolean): boolean {
|
|||||||
return hasApiKey; // auto
|
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 {
|
function detectMime(path: string): string | null {
|
||||||
const ext = path.toLowerCase();
|
const ext = path.toLowerCase();
|
||||||
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
|
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
|
||||||
@@ -208,6 +236,7 @@ program
|
|||||||
.argument('<text>', 'Tweet text')
|
.argument('<text>', 'Tweet text')
|
||||||
.action(async (text: string) => {
|
.action(async (text: string) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
let media: MediaSpec[] = [];
|
let media: MediaSpec[] = [];
|
||||||
try {
|
try {
|
||||||
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||||
@@ -231,6 +260,7 @@ program
|
|||||||
const client = new SweetisticsClient({
|
const client = new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
});
|
});
|
||||||
let mediaIds: string[] | undefined;
|
let mediaIds: string[] | undefined;
|
||||||
if (media.length > 0) {
|
if (media.length > 0) {
|
||||||
@@ -265,7 +295,9 @@ program
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (media.length > 0) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +323,7 @@ program
|
|||||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.tweet(text);
|
const result = await client.tweet(text);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -302,6 +334,7 @@ program
|
|||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
}).tweet(text);
|
}).tweet(text);
|
||||||
if (fallback.success) {
|
if (fallback.success) {
|
||||||
console.log('✅ Tweet posted via Sweetistics (fallback)!');
|
console.log('✅ Tweet posted via Sweetistics (fallback)!');
|
||||||
@@ -326,6 +359,7 @@ program
|
|||||||
.argument('<text>', 'Reply text')
|
.argument('<text>', 'Reply text')
|
||||||
.action(async (tweetIdOrUrl: string, text: string) => {
|
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
let media: MediaSpec[] = [];
|
let media: MediaSpec[] = [];
|
||||||
try {
|
try {
|
||||||
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
|
||||||
@@ -350,6 +384,7 @@ program
|
|||||||
const client = new SweetisticsClient({
|
const client = new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
});
|
});
|
||||||
let mediaIds: string[] | undefined;
|
let mediaIds: string[] | undefined;
|
||||||
if (media.length > 0) {
|
if (media.length > 0) {
|
||||||
@@ -384,7 +419,9 @@ program
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (media.length > 0) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +449,7 @@ program
|
|||||||
|
|
||||||
console.error(`📝 Replying to tweet: ${tweetId}`);
|
console.error(`📝 Replying to tweet: ${tweetId}`);
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.reply(text, tweetId);
|
const result = await client.reply(text, tweetId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -423,6 +460,7 @@ program
|
|||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
}).tweet(text, tweetId);
|
}).tweet(text, tweetId);
|
||||||
if (fallback.success) {
|
if (fallback.success) {
|
||||||
console.log('✅ Reply posted via Sweetistics (fallback)!');
|
console.log('✅ Reply posted via Sweetistics (fallback)!');
|
||||||
@@ -447,6 +485,7 @@ program
|
|||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
const sweetistics = resolveSweetisticsConfig({
|
const sweetistics = resolveSweetisticsConfig({
|
||||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||||
@@ -460,7 +499,11 @@ program
|
|||||||
console.error('❌ Sweetistics engine selected but no API key provided.');
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
process.exit(1);
|
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);
|
const result = await client.read(tweetId);
|
||||||
if (result.success && result.tweet) {
|
if (result.success && result.tweet) {
|
||||||
if (cmdOpts.json) {
|
if (cmdOpts.json) {
|
||||||
@@ -499,7 +542,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.getTweet(tweetId);
|
const result = await client.getTweet(tweetId);
|
||||||
|
|
||||||
if (result.success && result.tweet) {
|
if (result.success && result.tweet) {
|
||||||
@@ -517,9 +560,11 @@ program
|
|||||||
}
|
}
|
||||||
} else if (sweetistics.apiKey) {
|
} else if (sweetistics.apiKey) {
|
||||||
console.error(`⚠️ GraphQL read failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`⚠️ GraphQL read failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).read(
|
const fallback = await new SweetisticsClient({
|
||||||
tweetId,
|
baseUrl: sweetistics.baseUrl,
|
||||||
);
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
|
}).read(tweetId);
|
||||||
if (fallback.success && fallback.tweet) {
|
if (fallback.success && fallback.tweet) {
|
||||||
if (cmdOpts.json) {
|
if (cmdOpts.json) {
|
||||||
console.log(JSON.stringify(fallback.tweet, null, 2));
|
console.log(JSON.stringify(fallback.tweet, null, 2));
|
||||||
@@ -551,6 +596,7 @@ program
|
|||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
const sweetistics = resolveSweetisticsConfig({
|
const sweetistics = resolveSweetisticsConfig({
|
||||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||||
@@ -563,7 +609,11 @@ program
|
|||||||
console.error('❌ Sweetistics engine selected but no API key provided.');
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
process.exit(1);
|
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);
|
const result = await client.replies(tweetId);
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
|
||||||
@@ -589,7 +639,7 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.getReplies(tweetId);
|
const result = await client.getReplies(tweetId);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
@@ -599,6 +649,7 @@ program
|
|||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
}).replies(tweetId);
|
}).replies(tweetId);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
|
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
|
||||||
@@ -620,6 +671,7 @@ program
|
|||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
const sweetistics = resolveSweetisticsConfig({
|
const sweetistics = resolveSweetisticsConfig({
|
||||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||||
@@ -632,7 +684,11 @@ program
|
|||||||
console.error('❌ Sweetistics engine selected but no API key provided.');
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
process.exit(1);
|
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);
|
const result = await client.thread(tweetId);
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
||||||
@@ -658,16 +714,18 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.getThread(tweetId);
|
const result = await client.getThread(tweetId);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
||||||
} else if (sweetistics.apiKey) {
|
} else if (sweetistics.apiKey) {
|
||||||
console.error(`⚠️ GraphQL thread failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`⚠️ GraphQL thread failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).thread(
|
const fallback = await new SweetisticsClient({
|
||||||
tweetId,
|
baseUrl: sweetistics.baseUrl,
|
||||||
);
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
|
}).thread(tweetId);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
||||||
} else {
|
} else {
|
||||||
@@ -689,6 +747,7 @@ program
|
|||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||||
const sweetistics = resolveSweetisticsConfig({
|
const sweetistics = resolveSweetisticsConfig({
|
||||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||||
@@ -702,7 +761,11 @@ program
|
|||||||
console.error('❌ Sweetistics engine selected but no API key provided.');
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
process.exit(1);
|
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);
|
const result = await client.search(query, count);
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
||||||
@@ -728,17 +791,18 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.search(query, count);
|
const result = await client.search(query, count);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
||||||
} else if (sweetistics.apiKey) {
|
} else if (sweetistics.apiKey) {
|
||||||
console.error(`⚠️ GraphQL search failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`⚠️ GraphQL search failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).search(
|
const fallback = await new SweetisticsClient({
|
||||||
query,
|
baseUrl: sweetistics.baseUrl,
|
||||||
count,
|
apiKey: sweetistics.apiKey,
|
||||||
);
|
timeoutMs,
|
||||||
|
}).search(query, count);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
||||||
} else {
|
} else {
|
||||||
@@ -759,6 +823,7 @@ program
|
|||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
const count = Number.parseInt(cmdOpts.count || '10', 10);
|
||||||
const sweetistics = resolveSweetisticsConfig(opts);
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
const engine = resolveEngineMode(opts.engine);
|
const engine = resolveEngineMode(opts.engine);
|
||||||
@@ -769,7 +834,11 @@ program
|
|||||||
console.error('❌ Sweetistics engine selected but no API key provided.');
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
process.exit(1);
|
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);
|
const result = await client.search('@clawdbot', count);
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
||||||
@@ -794,17 +863,18 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.search('@clawdbot', count);
|
const result = await client.search('@clawdbot', count);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
||||||
} else if (sweetistics.apiKey) {
|
} else if (sweetistics.apiKey) {
|
||||||
console.error(`⚠️ GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
|
console.error(`⚠️ GraphQL mentions failed (${result.error}); trying Sweetistics fallback...`);
|
||||||
const fallback = await new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }).search(
|
const fallback = await new SweetisticsClient({
|
||||||
'@clawdbot',
|
baseUrl: sweetistics.baseUrl,
|
||||||
count,
|
apiKey: sweetistics.apiKey,
|
||||||
);
|
timeoutMs,
|
||||||
|
}).search('@clawdbot', count);
|
||||||
if (fallback.success && fallback.tweets) {
|
if (fallback.success && fallback.tweets) {
|
||||||
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
printTweets(fallback.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
||||||
} else {
|
} else {
|
||||||
@@ -823,6 +893,7 @@ program
|
|||||||
.description('Show which Twitter account the current credentials belong to')
|
.description('Show which Twitter account the current credentials belong to')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
const sweetistics = resolveSweetisticsConfig({
|
const sweetistics = resolveSweetisticsConfig({
|
||||||
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey,
|
||||||
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl,
|
||||||
@@ -836,7 +907,11 @@ program
|
|||||||
process.exit(1);
|
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();
|
const result = await client.getCurrentUser();
|
||||||
|
|
||||||
if (result.success && result.user) {
|
if (result.success && result.user) {
|
||||||
@@ -878,7 +953,7 @@ program
|
|||||||
console.error(`📍 Using credentials from: ${cookies.source}`);
|
console.error(`📍 Using credentials from: ${cookies.source}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const result = await client.getCurrentUser();
|
const result = await client.getCurrentUser();
|
||||||
|
|
||||||
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
|
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
|
||||||
@@ -894,6 +969,7 @@ program
|
|||||||
const fallback = await new SweetisticsClient({
|
const fallback = await new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
apiKey: sweetistics.apiKey,
|
apiKey: sweetistics.apiKey,
|
||||||
|
timeoutMs,
|
||||||
}).getCurrentUser();
|
}).getCurrentUser();
|
||||||
if (fallback.success && fallback.user) {
|
if (fallback.success && fallback.user) {
|
||||||
const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)';
|
const handle = fallback.user.username ? `@${fallback.user.username}` : '(no handle)';
|
||||||
@@ -962,10 +1038,16 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show help when invoked without any subcommand
|
const rawArgs = process.argv.slice(2);
|
||||||
if (process.argv.length <= 2) {
|
const { argv, showHelp } = resolveCliInvocation(rawArgs, KNOWN_COMMANDS);
|
||||||
|
|
||||||
|
if (showHelp) {
|
||||||
program.outputHelp();
|
program.outputHelp();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
program.parse();
|
if (argv) {
|
||||||
|
program.parse(argv);
|
||||||
|
} else {
|
||||||
|
program.parse();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export interface SweetisticsClientOptions {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SweetisticsTweetResult {
|
export interface SweetisticsTweetResult {
|
||||||
@@ -63,13 +64,15 @@ export class SweetisticsClient {
|
|||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
private readonly userAgent?: string;
|
private readonly userAgent?: string;
|
||||||
|
private readonly timeoutMs: number;
|
||||||
// Prevent hanging requests; keep Sweetistics calls snappy for CLI users.
|
// 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) {
|
constructor(options: SweetisticsClientOptions) {
|
||||||
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
||||||
this.apiKey = options.apiKey.trim();
|
this.apiKey = options.apiKey.trim();
|
||||||
this.userAgent = options.userAgent;
|
this.userAgent = options.userAgent;
|
||||||
|
this.timeoutMs = options.timeoutMs ?? SweetisticsClient.DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
throw new Error('Sweetistics API key is required');
|
throw new Error('Sweetistics API key is required');
|
||||||
}
|
}
|
||||||
@@ -310,7 +313,7 @@ export class SweetisticsClient {
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: this.normalizeError(error) };
|
return { success: false, error: this.normalizeError(error) };
|
||||||
@@ -466,7 +469,11 @@ export class SweetisticsClient {
|
|||||||
return { success: true, tweets };
|
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 {
|
try {
|
||||||
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/media/upload`, {
|
const response = await this.fetchWithTimeout(`${this.baseUrl}/api/actions/media/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -504,8 +511,8 @@ export class SweetisticsClient {
|
|||||||
private async fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
|
private async fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(
|
const timeoutId = setTimeout(
|
||||||
() => controller.abort(new Error(`Request timed out after ${SweetisticsClient.REQUEST_TIMEOUT_MS}ms`)),
|
() => controller.abort(new Error(`Request timed out after ${this.timeoutMs}ms`)),
|
||||||
SweetisticsClient.REQUEST_TIMEOUT_MS,
|
this.timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user