Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pnpm run binary # Creates the 'bird' executable
### Commands at a glance
- `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird --dry-run tweet "<text>"` — print the write payload without posting.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
Expand All @@ -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
```
Expand Down Expand Up @@ -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"`
Expand Down
63 changes: 58 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ program
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', config.firefoxProfile)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--dry-run', 'Print the tweet or reply payload without posting')
.option('--sweetistics-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
.option(
'--sweetistics-base-url <url>',
Expand All @@ -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 =
Expand All @@ -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';
Expand Down Expand Up @@ -208,6 +234,18 @@ program
.argument('<text>', '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 ?? [] });
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -326,6 +365,20 @@ program
.argument('<text>', '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 ?? [] });
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down
50 changes: 50 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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' }],
});
});
});
});