diff --git a/package.json b/package.json index 0ff8fd2..0179337 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "build:dist": "tsc && node scripts/copy-dist-assets.js", "build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/cli.ts --outfile bird", "dev": "tsx src/index.ts", - "bird": "pnpm run build:dist && node dist/index.js", + "bird": "pnpm run build:dist && node dist/cli.js", "test": "vitest run", "test:watch": "vitest", "lint": "pnpm run lint:biome && pnpm run lint:oxlint", diff --git a/src/cli.ts b/src/cli.ts index f694996..b4d84d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node + /** * bird - CLI tool for posting tweets and replies * @@ -9,102 +10,16 @@ * bird read */ -import { Command } from 'commander'; -import { collectCookieSource, createCliContext } from './cli/shared.js'; -import { registerBookmarksCommand } from './commands/bookmarks.js'; -import { registerCheckCommand } from './commands/check.js'; -import { registerHelpCommand } from './commands/help.js'; -import { registerPostCommands } from './commands/post.js'; -import { registerQueryIdsCommand } from './commands/query-ids.js'; -import { registerReadCommands } from './commands/read.js'; -import { registerSearchCommands } from './commands/search.js'; -import { registerUserCommands } from './commands/users.js'; +import { createProgram, KNOWN_COMMANDS } from './cli/program.js'; +import { createCliContext } from './cli/shared.js'; import { resolveCliInvocation } from './lib/cli-args.js'; -import { getCliVersion } from './lib/version.js'; - -const program: Command = new Command(); const rawArgs: string[] = process.argv.slice(2); const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; const ctx = createCliContext(normalizedArgs); -const collect = (value: string, previous: string[] = []): string[] => { - previous.push(value); - return previous; -}; - -const KNOWN_COMMANDS = new Set([ - 'tweet', - 'reply', - 'query-ids', - 'read', - 'replies', - 'thread', - 'search', - 'mentions', - 'bookmarks', - 'following', - 'followers', - 'likes', - 'help', - 'whoami', - 'check', -]); - -program.addHelpText( - 'beforeAll', - () => `${ctx.colors.banner('bird CLI')} ${ctx.colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`, -); - -program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion()); - -const formatExample = (command: string, description: string): string => - `${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`; - -program.addHelpText( - 'afterAll', - () => - `\n${ctx.colors.section('Examples')}\n${[ - formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'), - formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'), - formatExample('bird tweet "hello from bird"', 'Send a tweet'), - formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'), - ].join('\n\n')}`, -); - -program - .option('--auth-token ', 'Twitter auth_token cookie') - .option('--ct0 ', 'Twitter ct0 cookie') - .option('--chrome-profile ', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile) - .option('--firefox-profile ', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile) - .option('--cookie-timeout ', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)') - .option( - '--cookie-source ', - 'Cookie source for browser cookie extraction (repeatable)', - collectCookieSource, - [], - ) - .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) - .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) - .option('--timeout ', 'Request timeout in milliseconds') - .option('--quote-depth ', 'Max quoted tweet depth (default: 1; 0 disables)') - .option('--plain', 'Plain output (stable, no emoji, no color)') - .option('--no-emoji', 'Disable emoji output') - .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); - -program.hook('preAction', (_thisCommand, actionCommand) => { - ctx.applyOutputFromCommand(actionCommand); -}); - -registerHelpCommand(program, ctx); -registerQueryIdsCommand(program, ctx); -registerPostCommands(program, ctx); -registerReadCommands(program, ctx); -registerSearchCommands(program, ctx); -registerBookmarksCommand(program, ctx); -registerUserCommands(program, ctx); -registerCheckCommand(program, ctx); +const program = createProgram(ctx); const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS); diff --git a/src/cli/program.ts b/src/cli/program.ts new file mode 100644 index 0000000..45ae729 --- /dev/null +++ b/src/cli/program.ts @@ -0,0 +1,115 @@ +import { Command } from 'commander'; +import { registerBookmarksCommand } from '../commands/bookmarks.js'; +import { registerCheckCommand } from '../commands/check.js'; +import { registerHelpCommand } from '../commands/help.js'; +import { registerPostCommands } from '../commands/post.js'; +import { registerQueryIdsCommand } from '../commands/query-ids.js'; +import { registerReadCommands } from '../commands/read.js'; +import { registerSearchCommands } from '../commands/search.js'; +import { registerUserCommands } from '../commands/users.js'; +import { getCliVersion } from '../lib/version.js'; +import { type CliContext, collectCookieSource } from './shared.js'; + +export const KNOWN_COMMANDS = new Set([ + 'tweet', + 'reply', + 'query-ids', + 'read', + 'replies', + 'thread', + 'search', + 'mentions', + 'bookmarks', + 'following', + 'followers', + 'likes', + 'help', + 'whoami', + 'check', +]); + +export function createProgram(ctx: CliContext): Command { + const program: Command = new Command(); + + program.configureHelp({ + showGlobalOptions: true, + }); + + const collect = (value: string, previous: string[] = []): string[] => { + previous.push(value); + return previous; + }; + + program.addHelpText( + 'beforeAll', + () => + `${ctx.colors.banner('bird')} ${ctx.colors.muted(getCliVersion())} ${ctx.colors.subtitle( + '— fast X CLI for tweeting, replying, and reading', + )}`, + ); + + program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion()); + + const formatExample = (command: string, description: string): string => + `${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`; + + program.addHelpText( + 'afterAll', + () => + `\n${ctx.colors.section('Examples')}\n${[ + formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'), + formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'), + formatExample('bird tweet "hello from bird"', 'Send a tweet'), + formatExample( + 'bird 1234567890123456789 --json', + 'Read a tweet (ID or URL shorthand for `read`) and print JSON', + ), + ].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[ + formatExample('bird [--json]', 'Shorthand for `bird read `'), + ].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted( + ` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, query-ids`, + )}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird --help')} to see per-command flags.)`)}`, + ); + + program.addHelpText( + 'afterAll', + () => + `\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted( + ` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`, + )}\n${ctx.colors.muted( + ` Supports: chromeProfile, firefoxProfile, cookieSource, cookieTimeoutMs, timeoutMs, quoteDepth`, + )}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted( + ` ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_COOKIE_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`, + )}`, + ); + + program + .option('--auth-token ', 'Twitter auth_token cookie') + .option('--ct0 ', 'Twitter ct0 cookie') + .option('--chrome-profile ', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile) + .option('--firefox-profile ', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile) + .option('--cookie-timeout ', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)') + .option('--cookie-source ', 'Cookie source for browser cookie extraction (repeatable)', collectCookieSource) + .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect) + .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect) + .option('--timeout ', 'Request timeout in milliseconds') + .option('--quote-depth ', 'Max quoted tweet depth (default: 1; 0 disables)') + .option('--plain', 'Plain output (stable, no emoji, no color)') + .option('--no-emoji', 'Disable emoji output') + .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); + + program.hook('preAction', (_thisCommand, actionCommand) => { + ctx.applyOutputFromCommand(actionCommand); + }); + + registerHelpCommand(program, ctx); + registerQueryIdsCommand(program, ctx); + registerPostCommands(program, ctx); + registerReadCommands(program, ctx); + registerSearchCommands(program, ctx); + registerBookmarksCommand(program, ctx); + registerUserCommands(program, ctx); + registerCheckCommand(program, ctx); + + return program; +}