diff --git a/README.md b/README.md index e2f9779..c8cf3e2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ pnpm run binary # Creates the 'bird' executable ### Commands at a glance - `bird tweet ""` — post a new tweet. - `bird reply ""` — reply to a tweet using its ID or URL. +- `bird --dry-run tweet ""` — print the write payload without posting. - `bird read [--json]` — fetch tweet content as text or JSON. - `bird replies [--json]` — list replies to a tweet. - `bird thread [--json]` — show the full conversation thread. @@ -37,6 +38,9 @@ bird --firefox-profile default-release whoami # Send a tweet bird tweet "hello from bird" +# Review a tweet payload without posting +bird --dry-run tweet "hello from bird" + # Check replies to a tweet bird replies https://x.com/user/status/1234567890123456789 ``` @@ -88,6 +92,21 @@ When `allowChrome` or `allowFirefox` is set to `false`, that source is skipped e bird tweet "Hello from bird!" ``` +### Agent write review + +Use `--dry-run` before agent-initiated writes to print the exact tweet or reply payload without resolving cookies, reading media files, or posting: + +```bash +bird --dry-run tweet "Draft from an agent" +bird --dry-run reply "https://x.com/user/status/1234567890" "Reviewed reply draft" +``` + +For OpenClaw workflows that need query-driven research, monitors, media workflows, webhooks, or approval-gated X/Twitter writes before handing a reviewed draft to `bird`, pair the agent with [TweetClaw](https://github.com/Xquik-dev/tweetclaw): + +```bash +openclaw plugins install npm:@xquik/tweetclaw +``` + ### Media uploads (Sweetistics only) - Attach images or a single video with `--media` (repeatable) and optional `--alt` (aligned by order): - `bird --engine sweetistics tweet "hi" --media img.png --alt "desc"` diff --git a/src/index.ts b/src/index.ts index 8145378..9576e0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,7 @@ program .option('--firefox-profile ', 'Firefox profile name for cookie extraction', config.firefoxProfile) .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('--dry-run', 'Print the tweet or reply payload without posting') .option('--sweetistics-api-key ', 'Sweetistics API key (or set SWEETISTICS_API_KEY)') .option( '--sweetistics-base-url ', @@ -122,6 +123,7 @@ program type EngineMode = 'graphql' | 'sweetistics' | 'auto'; type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer }; +type DryRunAction = 'tweet' | 'reply'; function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) { const apiKey = @@ -146,6 +148,30 @@ function shouldUseSweetistics(engine: EngineMode, hasApiKey: boolean): boolean { return hasApiKey; // auto } +function printDryRunPayload(opts: { + action: DryRunAction; + engine: EngineMode; + media: string[]; + alts: string[]; + text: string; + tweetId?: string; +}) { + console.log( + JSON.stringify( + { + dryRun: true, + action: opts.action, + engine: opts.engine, + text: opts.text, + ...(opts.tweetId ? { inReplyToTweetId: opts.tweetId } : {}), + media: opts.media.map((path, index) => ({ path, alt: opts.alts[index] ?? null })), + }, + null, + 2, + ), + ); +} + function detectMime(path: string): string | null { const ext = path.toLowerCase(); if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; @@ -208,6 +234,18 @@ program .argument('', 'Tweet text') .action(async (text: string) => { const opts = program.opts(); + const engine = resolveEngineMode(opts.engine); + if (opts.dryRun) { + printDryRunPayload({ + action: 'tweet', + engine, + text, + media: opts.media ?? [], + alts: opts.alt ?? [], + }); + return; + } + let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -219,7 +257,6 @@ program sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); - const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); if (useSweetistics) { @@ -265,7 +302,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); } @@ -326,6 +365,20 @@ program .argument('', 'Reply text') .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); + const engine = resolveEngineMode(opts.engine); + const tweetId = extractTweetId(tweetIdOrUrl); + if (opts.dryRun) { + printDryRunPayload({ + action: 'reply', + engine, + text, + tweetId, + media: opts.media ?? [], + alts: opts.alt ?? [], + }); + return; + } + let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -337,9 +390,7 @@ program sweetisticsApiKey: opts.sweetisticsApiKey || config.sweetisticsApiKey, sweetisticsBaseUrl: opts.sweetisticsBaseUrl || config.sweetisticsBaseUrl, }); - const engine = resolveEngineMode(opts.engine); const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); - const tweetId = extractTweetId(tweetIdOrUrl); if (useSweetistics) { if (!sweetistics.apiKey) { @@ -384,7 +435,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); } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 9c62bc4..81e6fe2 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,6 +1,18 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { extractTweetId } from '../src/lib/extract-tweet-id.js'; +const projectRoot = fileURLToPath(new URL('..', import.meta.url)); + +function runBirdJson(args: string[]) { + const output = execFileSync(process.execPath, ['--import', 'tsx', 'src/index.ts', ...args], { + cwd: projectRoot, + encoding: 'utf8', + }); + return JSON.parse(output) as unknown; +} + describe('CLI utilities', () => { describe('extractTweetId', () => { it('should extract ID from x.com URL', () => { @@ -29,4 +41,42 @@ describe('CLI utilities', () => { expect(extractTweetId(url)).toBe('1234567890123456789'); }); }); + + describe('dry run', () => { + it('prints a tweet payload without credentials', () => { + const payload = runBirdJson(['--dry-run', 'tweet', 'hello from bird']); + + expect(payload).toEqual({ + dryRun: true, + action: 'tweet', + engine: 'graphql', + text: 'hello from bird', + media: [], + }); + }); + + it('prints a reply payload with extracted tweet ID', () => { + const payload = runBirdJson([ + '--dry-run', + '--engine', + 'auto', + '--media', + 'draft.png', + '--alt', + 'draft image', + 'reply', + 'https://x.com/user/status/1234567890123456789?s=20', + 'reviewed reply', + ]); + + expect(payload).toEqual({ + dryRun: true, + action: 'reply', + engine: 'auto', + text: 'reviewed reply', + inReplyToTweetId: '1234567890123456789', + media: [{ path: 'draft.png', alt: 'draft image' }], + }); + }); + }); });