refactor(cli): split commands into modules

This commit is contained in:
Peter Steinberger
2026-01-01 13:32:30 +01:00
parent 8aae28315e
commit 51bf79d9ed
10 changed files with 1037 additions and 954 deletions
+25 -954
View File
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { Command } from 'commander';
import JSON5 from 'json5';
import kleur from 'kleur';
import { type CookieSource, resolveCredentials } from '../lib/cookies.js';
import { extractTweetId } from '../lib/extract-tweet-id.js';
import {
labelPrefix,
type OutputConfig,
resolveOutputConfigFromArgv,
resolveOutputConfigFromCommander,
statusPrefix,
} from '../lib/output.js';
import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = {
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: CookieSource | CookieSource[];
cookieTimeoutMs?: number;
timeoutMs?: number;
quoteDepth?: number;
};
export type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
export type CliContext = {
isTty: boolean;
getOutput: () => OutputConfig;
colors: {
banner: (t: string) => string;
subtitle: (t: string) => string;
section: (t: string) => string;
bullet: (t: string) => string;
command: (t: string) => string;
option: (t: string) => string;
argument: (t: string) => string;
description: (t: string) => string;
muted: (t: string) => string;
accent: (t: string) => string;
};
p: (kind: Parameters<typeof statusPrefix>[0]) => string;
l: (kind: Parameters<typeof labelPrefix>[0]) => string;
config: BirdConfig;
applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined;
resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined;
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>;
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void;
extractTweetId: (tweetIdOrUrl: string) => string;
};
const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value: string): CookieSource {
const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
}
export const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
previous.push(parseCookieSource(value));
return previous;
};
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) {
const result: CookieSource[] = [];
for (const entry of input) {
if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry));
}
return result.length > 0 ? result : undefined;
}
return undefined;
}
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 resolveQuoteDepth(...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.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
return undefined;
}
function detectMime(path: string): string | null {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (ext.endsWith('.png')) {
return 'image/png';
}
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null;
}
function readConfigFile(path: string, warn: (message: string) => void): Partial<BirdConfig> {
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
return parsed ?? {};
} catch (error) {
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
function loadConfig(warn: (message: string) => void): BirdConfig {
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
const localPath = join(process.cwd(), '.birdrc.json5');
return {
...readConfigFile(globalPath, warn),
...readConfigFile(localPath, warn),
};
}
type CredentialsOptions = {
authToken?: string;
ct0?: string;
chromeProfile?: string;
firefoxProfile?: string;
cookieSource?: CookieSource[];
cookieTimeout?: string | number;
};
export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext {
const isTty = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
kleur.enabled = output.color;
const wrap =
(styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
const config = loadConfig((message) => {
console.error(colors.muted(`${p('warn')}${message}`));
});
function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, env, isTty);
kleur.enabled = output.color;
}
function resolveTimeoutFromOptions(options: { timeout?: string | number }): number | undefined {
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
}
function resolveCookieTimeoutFromOptions(options: { cookieTimeout?: string | number }): number | undefined {
return resolveTimeoutMs(options.cookieTimeout, config.cookieTimeoutMs, env.BIRD_COOKIE_TIMEOUT_MS);
}
function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
}
function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
const cookieSource = opts.cookieSource?.length
? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
return resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
cookieSource,
chromeProfile: opts.chromeProfile || config.chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
cookieTimeoutMs: resolveCookieTimeoutFromOptions(opts),
});
}
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));
}
}
}
return {
isTty,
getOutput: () => output,
colors,
p,
l,
config,
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions,
resolveCredentialsFromOptions,
loadMedia,
printTweets,
extractTweetId,
};
}
+47
View File
@@ -0,0 +1,47 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerBookmarksCommand(program: Command, ctx: CliContext): void {
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const result = folderId
? await client.getBookmarkFolderTimeline(folderId, count)
: await client.getBookmarks(count);
if (result.success && result.tweets) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
} else {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export function registerCheckCommand(program: Command, ctx: CliContext): void {
program
.command('check')
.description('Check credential availability')
.action(async () => {
const opts = program.opts();
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
console.log(`${ctx.p('info')}Credential check`);
console.log('─'.repeat(40));
if (cookies.authToken) {
console.log(`${ctx.p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`);
} else {
console.log(`${ctx.p('err')}auth_token: not found`);
}
if (cookies.ct0) {
console.log(`${ctx.p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`);
} else {
console.log(`${ctx.p('err')}ct0: not found`);
}
if (cookies.source) {
console.log(`${ctx.l('source')}${cookies.source}`);
}
if (warnings.length > 0) {
console.log(`\n${ctx.p('warn')}Warnings:`);
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
if (cookies.authToken && cookies.ct0) {
console.log(`\n${ctx.p('ok')}Ready to tweet!`);
} else {
console.log(`\n${ctx.p('err')}Missing credentials. Options:`);
console.log(' 1. Login to x.com in Safari/Chrome/Firefox');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags');
process.exit(1);
}
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export function registerHelpCommand(program: Command, ctx: CliContext): void {
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(`${ctx.p('err')}Unknown command: ${commandName}`);
process.exitCode = 2;
return;
}
cmd.outputHelp();
});
}
+119
View File
@@ -0,0 +1,119 @@
import type { Command } from 'commander';
import type { CliContext, MediaSpec } from '../cli/shared.js';
import { formatTweetUrlLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
async function uploadMediaOrExit(
client: TwitterClient,
media: MediaSpec[],
ctx: CliContext,
): Promise<string[] | undefined> {
if (media.length === 0) {
return undefined;
}
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(`${ctx.p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
return uploaded;
}
export function registerPostCommands(program: Command, ctx: CliContext): void {
program
.command('tweet')
.description('Post a new tweet')
.argument('<text>', 'Tweet text')
.action(async (text: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Tweet posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
} else {
console.error(`${ctx.p('err')}Failed to post tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('reply')
.description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
} catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Reply posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
} else {
console.error(`${ctx.p('err')}Failed to post reply: ${result.error}`);
process.exit(1);
}
});
}
+66
View File
@@ -0,0 +1,66 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
export function registerQueryIdsCommand(program: Command, ctx: CliContext): void {
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',
'Bookmarks',
'Following',
'Followers',
'Likes',
];
if (cmdOpts.fresh) {
console.error(`${ctx.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(`${ctx.p('warn')}No cached query IDs yet.`);
console.log(`${ctx.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(`${ctx.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}`);
});
}
+115
View File
@@ -0,0 +1,115 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { formatStatsLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerReadCommands(program: Command, ctx: CliContext): void {
program
.command('read')
.description('Read/fetch a tweet by ID or URL')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
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${ctx.l('date')}${result.tweet.createdAt}`);
}
console.log(formatStatsLine(result.tweet, ctx.getOutput()));
}
} else {
console.error(`${ctx.p('err')}Failed to read tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('replies')
.description('List replies to a tweet (by ID or URL)')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getReplies(tweetId);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1);
}
});
program
.command('thread')
.description('Show the full conversation thread containing the tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getThread(tweetId);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1);
}
});
}
+96
View File
@@ -0,0 +1,96 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerSearchCommands(program: Command, ctx: CliContext): void {
program
.command('search')
.description('Search for tweets')
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
.option('-n, --count <number>', '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 = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.search(query, count);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
} else {
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
process.exit(1);
}
});
program
.command('mentions')
.description('Find tweets mentioning a user (defaults to current user)')
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
.option('-n, --count <number>', '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 = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
if (fromUserOpt.error) {
console.error(`${ctx.p('err')}${fromUserOpt.error}`);
process.exit(2);
}
let query: string | null = fromUserOpt.query;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (handle) {
query = `@${handle}`;
} else {
console.error(
`${ctx.p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
);
process.exit(1);
}
}
const result = await client.search(query, count);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
process.exit(1);
}
});
}
+199
View File
@@ -0,0 +1,199 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUserCommands(program: Command, ctx: CliContext): void {
program
.command('following')
.description('Get users that you (or another user) follow')
.option('--user <userId>', 'User ID to get following for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
let userId = cmdOpts.user;
if (!userId) {
const currentUser = await client.getCurrentUser();
if (!currentUser.success || !currentUser.user?.id) {
console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`);
process.exit(1);
}
userId = currentUser.user.id;
}
const result = await client.getFollowing(userId, count);
if (result.success && result.users) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.users, null, 2));
} else {
if (result.users.length === 0) {
console.log('No users found.');
} else {
for (const user of result.users) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch following: ${result.error}`);
process.exit(1);
}
});
program
.command('followers')
.description('Get users that follow you (or another user)')
.option('--user <userId>', 'User ID to get followers for (defaults to current user)')
.option('-n, --count <number>', 'Number of users to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
let userId = cmdOpts.user;
if (!userId) {
const currentUser = await client.getCurrentUser();
if (!currentUser.success || !currentUser.user?.id) {
console.error(`${ctx.p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`);
process.exit(1);
}
userId = currentUser.user.id;
}
const result = await client.getFollowers(userId, count);
if (result.success && result.users) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.users, null, 2));
} else {
if (result.users.length === 0) {
console.log('No users found.');
} else {
for (const user of result.users) {
console.log(`@${user.username} (${user.name})`);
if (user.description) {
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
}
if (user.followersCount !== undefined) {
console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`);
}
console.log('──────────────────────────────────────────────────');
}
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch followers: ${result.error}`);
process.exit(1);
}
});
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getLikes(count);
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
}
});
program
.command('whoami')
.description('Show which Twitter account the current credentials belong to')
.action(async () => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getCurrentUser();
const credentialSource = cookies.source ?? 'env/auto-detected cookies';
if (result.success && result.user) {
console.log(`${ctx.l('user')}@${result.user.username} (${result.user.name})`);
console.log(`${ctx.l('userId')}${result.user.id}`);
console.log(`${ctx.l('engine')}graphql`);
console.log(`${ctx.l('credentials')}${credentialSource}`);
} else {
console.error(`${ctx.p('err')}Failed to determine current user: ${result.error ?? 'Unknown error'}`);
process.exit(1);
}
});
}