felo-ai 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ ---
2
+ name: felo-x-search
3
+ description: "Search X (Twitter) data using Felo X Search API. Use when users ask about X/Twitter users, tweets, trending topics on X, tweet replies, or when explicit commands like /felo-x-search are used. Supports user lookup, user search, user tweets, tweet search, and tweet replies."
4
+ ---
5
+
6
+ # Felo X Search Skill
7
+
8
+ ## When to Use
9
+
10
+ Trigger this skill when the user wants to:
11
+
12
+ - Look up X (Twitter) user profiles by username
13
+ - Search for X users by keyword
14
+ - Get tweets from a specific X user
15
+ - Search tweets by keyword or advanced query
16
+ - Get replies to specific tweets
17
+
18
+ Trigger keywords (examples):
19
+
20
+ - English: twitter, tweet, X user, X search, tweets from, replies to, trending on X
21
+ - 简体中文: 推特, 推文, X用户, X搜索, 推文回复
22
+ - 日本語: ツイッター, ツイート, Xユーザー, X検索
23
+
24
+ Explicit commands: `/felo-x-search`, "search X", "search twitter"
25
+
26
+ Do NOT use for:
27
+
28
+ - General web search (use `felo-search`)
29
+ - Webpage extraction (use `felo-web-extract`)
30
+ - Generating slides (use `felo-slides`)
31
+
32
+ ## Setup
33
+
34
+ ### 1. Get API key
35
+
36
+ 1. Visit [felo.ai](https://felo.ai)
37
+ 2. Open Settings -> API Keys
38
+ 3. Create and copy your API key
39
+
40
+ ### 2. Configure environment variable
41
+
42
+ Linux/macOS:
43
+
44
+ ```bash
45
+ export FELO_API_KEY="your-api-key-here"
46
+ ```
47
+
48
+ Windows PowerShell:
49
+
50
+ ```powershell
51
+ $env:FELO_API_KEY="your-api-key-here"
52
+ ```
53
+
54
+ ## How to Execute
55
+
56
+ ### Option A: Use the bundled script or packaged CLI
57
+
58
+ **Packaged CLI** (after `npm install -g felo-ai`):
59
+
60
+ ```bash
61
+ felo x [query] [options]
62
+ ```
63
+
64
+ **Script** (from repo):
65
+
66
+ ```bash
67
+ node felo-x-search/scripts/run_x_search.mjs [query] [options]
68
+ ```
69
+
70
+ ### Usage Pattern
71
+
72
+ The `x` command uses parameter combinations to infer intent — no subcommands needed.
73
+
74
+ **With query (search mode):**
75
+
76
+ | Usage | Behavior |
77
+ |-------|----------|
78
+ | `felo x "AI news"` | Search tweets (default) |
79
+ | `felo x -q "AI news"` | Same as above |
80
+ | `felo x "OpenAI" --user` | Search users |
81
+
82
+ **With --id (lookup mode):**
83
+
84
+ | Usage | Behavior |
85
+ |-------|----------|
86
+ | `felo x --id "1234567890"` | Get tweet replies (default) |
87
+ | `felo x --id "elonmusk" --user` | Get user info |
88
+ | `felo x --id "elonmusk" --user --tweets` | Get user tweets |
89
+
90
+ ### Options
91
+
92
+ | Option | Description |
93
+ |--------|-------------|
94
+ | `[query]` | Positional arg, search keyword (equivalent to -q) |
95
+ | `-q, --query <text>` | Search keyword |
96
+ | `--id <values>` | Tweet IDs or usernames (comma-separated) |
97
+ | `--user` | Switch to user mode |
98
+ | `--tweets` | Get user tweets (with --id --user) |
99
+ | `-l, --limit <n>` | Number of results |
100
+ | `--cursor <str>` | Pagination cursor |
101
+ | `--include-replies` | Include replies (with --tweets) |
102
+ | `--query-type <type>` | Query type filter (tweet search) |
103
+ | `--since-time <val>` | Start time filter |
104
+ | `--until-time <val>` | End time filter |
105
+ | `-j, --json` | Output raw JSON |
106
+ | `-t, --timeout <seconds>` | Timeout in seconds (default: 30) |
107
+
108
+ ### Option B: Call API with curl
109
+
110
+ ```bash
111
+ # Search tweets
112
+ curl -X POST "https://openapi.felo.ai/x/tweet/search" \
113
+ -H "Authorization: Bearer $FELO_API_KEY" \
114
+ -H "Content-Type: application/json" \
115
+ -d '{"query": "AI news", "limit": 20}'
116
+
117
+ # Search users
118
+ curl -X POST "https://openapi.felo.ai/x/user/search" \
119
+ -H "Authorization: Bearer $FELO_API_KEY" \
120
+ -H "Content-Type: application/json" \
121
+ -d '{"query": "artificial intelligence"}'
122
+
123
+ # Get user info
124
+ curl -X POST "https://openapi.felo.ai/x/user/info" \
125
+ -H "Authorization: Bearer $FELO_API_KEY" \
126
+ -H "Content-Type: application/json" \
127
+ -d '{"usernames": ["elonmusk", "OpenAI"]}'
128
+
129
+ # Get user tweets
130
+ curl -X POST "https://openapi.felo.ai/x/user/tweets" \
131
+ -H "Authorization: Bearer $FELO_API_KEY" \
132
+ -H "Content-Type: application/json" \
133
+ -d '{"username": "elonmusk", "limit": 20}'
134
+
135
+ # Get tweet replies
136
+ curl -X POST "https://openapi.felo.ai/x/tweet/replies" \
137
+ -H "Authorization: Bearer $FELO_API_KEY" \
138
+ -H "Content-Type: application/json" \
139
+ -d '{"tweet_ids": ["1234567890"]}'
140
+ ```
141
+
142
+ ## Output Format
143
+
144
+ ### User Info (default, non-JSON)
145
+
146
+ ```markdown
147
+ ## @elonmusk (Elon Musk 🔵)
148
+ - User ID: `44196397`
149
+ - Followers: 100.0M
150
+ - Following: 500
151
+ - Total Tweets: 30.0K
152
+ - Bio: ...
153
+ - Location: Mars
154
+ - Account Created: 2009-06-02T00:00:00Z
155
+
156
+ ---
157
+ ```
158
+
159
+ ### Tweet (default, non-JSON)
160
+
161
+ ```markdown
162
+ ### @elonmusk(Elon Musk ✓)
163
+ - Posted: 2026-03-09T12:00:00Z | Tweet ID: `1234567890`
164
+
165
+ Tweet content here...
166
+
167
+ Engagement: 5.0K likes | 1.0K retweets | 200 replies
168
+
169
+ ---
170
+ ```
171
+
172
+ ## Error Handling
173
+
174
+ ### Common Error Codes
175
+
176
+ - `INVALID_API_KEY` — API Key is invalid or revoked
177
+ - `X_SEARCH_FAILED` — X search request failed (check parameters or downstream error)
178
+
179
+ ### Missing API Key
180
+
181
+ If `FELO_API_KEY` is not set, display setup instructions and stop.
182
+
183
+ ## API Reference (summary)
184
+
185
+ - **Base URL**: `https://openapi.felo.ai`. Override with `FELO_API_BASE` env if needed.
186
+ - **Auth**: `Authorization: Bearer YOUR_API_KEY`
187
+ - **Endpoints**:
188
+ - `POST /x/user/info` — Batch get user profiles
189
+ - `POST /x/user/search` — Search users
190
+ - `POST /x/user/tweets` — Get user tweets
191
+ - `POST /x/tweet/search` — Search tweets
192
+ - `POST /x/tweet/replies` — Get tweet replies
193
+
194
+ ## Important Notes
195
+
196
+ - Always check `FELO_API_KEY` before calling; if missing, return setup instructions.
197
+ - Format output as readable Markdown by default; use `--json` for raw API response.
198
+ - Use pagination cursors (`next_cursor`) for fetching more results.
199
+ - For tweet search, advanced query syntax is supported (same as X advanced search).
200
+
201
+ ## References
202
+
203
+ - [Felo X Search API](https://openapi.felo.ai/docs/api-reference/v2/x-search.html)
204
+ - [Felo Open Platform](https://openapi.felo.ai/docs/)
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+
3
+ const DEFAULT_API_BASE = 'https://openapi.felo.ai';
4
+ const DEFAULT_TIMEOUT_MS = 30_000;
5
+ const MAX_RETRIES = 3;
6
+ const RETRY_BASE_MS = 1000;
7
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+ const SPINNER_INTERVAL_MS = 80;
9
+ const STATUS_PAD = 56;
10
+
11
+ function startSpinner(message) {
12
+ const start = Date.now();
13
+ let i = 0;
14
+ const id = setInterval(() => {
15
+ const elapsed = Math.floor((Date.now() - start) / 1000);
16
+ const line = `${message} ${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${elapsed}s`;
17
+ process.stderr.write(`\r${line.padEnd(STATUS_PAD, ' ')}`);
18
+ i += 1;
19
+ }, SPINNER_INTERVAL_MS);
20
+ return id;
21
+ }
22
+
23
+ function stopSpinner(id) {
24
+ if (id != null) clearInterval(id);
25
+ process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
26
+ }
27
+
28
+ function sleep(ms) {
29
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+
32
+ function getMessage(payload) {
33
+ return payload?.message || payload?.error || payload?.msg || payload?.code || 'Unknown error';
34
+ }
35
+
36
+ async function fetchWithRetry(url, init, timeoutMs) {
37
+ let lastError;
38
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
39
+ const controller = new AbortController();
40
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
41
+ try {
42
+ const res = await fetch(url, { ...init, signal: controller.signal });
43
+ if (res.status >= 500 && attempt < MAX_RETRIES) {
44
+ await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
45
+ continue;
46
+ }
47
+ return res;
48
+ } catch (err) {
49
+ lastError = err;
50
+ if (err.name === 'AbortError') throw new Error(`Request timed out after ${timeoutMs / 1000}s`);
51
+ if (attempt < MAX_RETRIES) {
52
+ await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
53
+ continue;
54
+ }
55
+ throw lastError;
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+ throw lastError;
61
+ }
62
+
63
+ async function postApi(apiBase, apiKey, path, body, timeoutMs) {
64
+ const res = await fetchWithRetry(
65
+ `${apiBase}${path}`,
66
+ {
67
+ method: 'POST',
68
+ headers: {
69
+ Accept: 'application/json',
70
+ Authorization: `Bearer ${apiKey}`,
71
+ 'Content-Type': 'application/json',
72
+ },
73
+ body: JSON.stringify(body),
74
+ },
75
+ timeoutMs,
76
+ );
77
+ let data = {};
78
+ try { data = await res.json(); } catch { data = {}; }
79
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
80
+ if (data.status === 'error') throw new Error(getMessage(data));
81
+ return data;
82
+ }
83
+
84
+ // ── Formatting ──
85
+
86
+ function formatNumber(num) {
87
+ if (num == null) return '0';
88
+ if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
89
+ if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
90
+ if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
91
+ return String(num);
92
+ }
93
+
94
+ function formatUser(u, headerLevel = 2) {
95
+ if (!u) return '';
96
+ const h = '#'.repeat(Math.min(6, headerLevel));
97
+ let badge = '';
98
+ if (u.blue_verified) badge = ' 🔵';
99
+ else if (u.verified) badge = ' ✓';
100
+
101
+ let out = `${h} @${u.username} (${u.display_name || u.username}${badge})\n`;
102
+ out += `- User ID: \`${u.user_id}\`\n`;
103
+ if (u.verified_type) out += `- Verification Type: ${u.verified_type}\n`;
104
+ const status = [];
105
+ if (u.protected) status.push('Protected (Private)');
106
+ if (u.is_automated) status.push('Automated');
107
+ if (status.length) out += `- Account Status: ${status.join(' | ')}\n`;
108
+ out += `- Followers: ${formatNumber(u.followers_count)}\n`;
109
+ out += `- Following: ${formatNumber(u.following_count)}\n`;
110
+ out += `- Total Tweets: ${formatNumber(u.tweet_count)}\n`;
111
+ if (u.favorites_count > 0) out += `- Likes Given: ${formatNumber(u.favorites_count)}\n`;
112
+ if (u.media_count > 0) out += `- Media Count: ${formatNumber(u.media_count)}\n`;
113
+ if (u.description) out += `- Bio: ${u.description}\n`;
114
+ if (u.location) out += `- Location: ${u.location}\n`;
115
+ if (u.url) out += `- Website: ${u.url}\n`;
116
+ if (u.can_dm) out += `- Direct Messages: Open\n`;
117
+ if (u.profile_image_url) out += `- Profile Image: ${u.profile_image_url}\n`;
118
+ if (u.cover_image_url) out += `- Cover Image: ${u.cover_image_url}\n`;
119
+ if (u.pinned_tweet_ids?.length) out += `- Pinned Tweets: ${u.pinned_tweet_ids.length} tweet(s)\n`;
120
+ if (u.created_at) out += `- Account Created: ${u.created_at}\n`;
121
+ out += '\n---\n\n';
122
+ return out;
123
+ }
124
+
125
+ function formatTweet(t, indent = '', headerLevel = 3) {
126
+ if (!t) return '';
127
+ const h = '#'.repeat(Math.min(6, headerLevel));
128
+ const author = t.author || {};
129
+ const verified = author.verified ? ' ✓' : '';
130
+
131
+ let out = `${indent}${h} @${author.username || 'unknown'}(${author.display_name || author.username || 'unknown'}${verified})\n`;
132
+ const meta = [`Posted: ${t.created_at || 'unknown'}`, `Tweet ID: \`${t.id}\``];
133
+ if (t.conversation_id) meta.push(`Conversation: \`${t.conversation_id}\``);
134
+ if (t.is_reply && t.in_reply_to_username) meta.push(`Reply to @${t.in_reply_to_username}`);
135
+ out += `${indent}- ${meta.join(' | ')}\n\n`;
136
+
137
+ for (const line of (t.content || '').split('\n')) {
138
+ out += `${indent}${line}\n`;
139
+ }
140
+ out += '\n';
141
+
142
+ const metrics = t.metrics || {};
143
+ const parts = [];
144
+ if (metrics.favorite_count) parts.push(`${formatNumber(metrics.favorite_count)} likes`);
145
+ if (metrics.retweet_count) parts.push(`${formatNumber(metrics.retweet_count)} retweets`);
146
+ if (metrics.reply_count) parts.push(`${formatNumber(metrics.reply_count)} replies`);
147
+ if (parts.length) out += `${indent}Engagement: ${parts.join(' | ')}\n`;
148
+
149
+ if (Array.isArray(t.media_urls) && t.media_urls.length) {
150
+ out += `${indent}Media (${t.media_urls.length}):\n`;
151
+ for (const media of t.media_urls) {
152
+ const type = media.type || 'photo';
153
+ if (type === 'video') {
154
+ out += `${indent} • [video] ${media.url || ''}\n`;
155
+ } else {
156
+ out += `${indent} • [photo] ${media.thumbnail || ''}\n`;
157
+ }
158
+ }
159
+ }
160
+
161
+ out += `\n${indent}---\n\n`;
162
+ return out;
163
+ }
164
+
165
+ // ── CLI ──
166
+
167
+ function usage() {
168
+ console.error(
169
+ [
170
+ 'Usage:',
171
+ ' node felo-x-search/scripts/run_x_search.mjs [query] [options]',
172
+ '',
173
+ 'Examples:',
174
+ ' run_x_search.mjs "AI news" Search tweets (default)',
175
+ ' run_x_search.mjs "OpenAI" --user Search users',
176
+ ' run_x_search.mjs --id "1234567890" Get tweet replies',
177
+ ' run_x_search.mjs --id "elonmusk" --user Get user info',
178
+ ' run_x_search.mjs --id "elonmusk" --user --tweets Get user tweets',
179
+ '',
180
+ 'Options:',
181
+ ' -q, --query <text> Search keyword (same as positional arg)',
182
+ ' --id <values> Tweet IDs or usernames (comma-separated)',
183
+ ' --user Switch to user mode',
184
+ ' --tweets Get user tweets (with --id --user)',
185
+ ' -l, --limit <n> Number of results',
186
+ ' --cursor <str> Pagination cursor',
187
+ ' --include-replies Include replies (with --tweets)',
188
+ ' --query-type <type> Query type filter',
189
+ ' --since-time <val> Start time filter',
190
+ ' --until-time <val> End time filter',
191
+ ' -j, --json Output raw JSON',
192
+ ' -t, --timeout <ms> Request timeout in ms (default: 30000)',
193
+ ' --help Show this help',
194
+ ].join('\n'),
195
+ );
196
+ }
197
+
198
+ function parseArgs(argv) {
199
+ const out = {
200
+ query: '',
201
+ ids: [],
202
+ user: false,
203
+ tweets: false,
204
+ limit: 0,
205
+ cursor: '',
206
+ includeReplies: false,
207
+ queryType: '',
208
+ sinceTime: null,
209
+ untilTime: null,
210
+ json: false,
211
+ timeoutMs: DEFAULT_TIMEOUT_MS,
212
+ help: false,
213
+ };
214
+
215
+ const positional = [];
216
+
217
+ for (let i = 0; i < argv.length; i++) {
218
+ const a = argv[i];
219
+ if (a === '--help' || a === '-h') { out.help = true; }
220
+ else if (a === '--json' || a === '-j') { out.json = true; }
221
+ else if (a === '--user') { out.user = true; }
222
+ else if (a === '--tweets') { out.tweets = true; }
223
+ else if (a === '--include-replies') { out.includeReplies = true; }
224
+ else if (a === '-q' || a === '--query') { out.query = (argv[++i] || '').trim(); }
225
+ else if (a === '--id') { out.ids = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); }
226
+ else if (a === '-l' || a === '--limit') { out.limit = parseInt(argv[++i] || '', 10) || 0; }
227
+ else if (a === '--cursor') { out.cursor = (argv[++i] || '').trim(); }
228
+ else if (a === '--query-type') { out.queryType = (argv[++i] || '').trim(); }
229
+ else if (a === '--since-time') { out.sinceTime = argv[++i]; }
230
+ else if (a === '--until-time') { out.untilTime = argv[++i]; }
231
+ else if (a === '-t' || a === '--timeout') {
232
+ const n = parseInt(argv[++i] || '', 10);
233
+ if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
234
+ }
235
+ else if (!a.startsWith('-')) { positional.push(a); }
236
+ }
237
+
238
+ // positional arg as query
239
+ if (!out.query && positional.length) {
240
+ out.query = positional.join(' ').trim();
241
+ }
242
+
243
+ return out;
244
+ }
245
+
246
+ async function main() {
247
+ const args = parseArgs(process.argv.slice(2));
248
+ if (args.help) { usage(); process.exit(0); }
249
+
250
+ const apiKey = process.env.FELO_API_KEY?.trim();
251
+ if (!apiKey) {
252
+ console.error('ERROR: FELO_API_KEY not set');
253
+ process.exit(1);
254
+ }
255
+
256
+ const apiBase = (process.env.FELO_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/$/, '');
257
+ const { query, ids, json, timeoutMs } = args;
258
+
259
+ if (!query && !ids.length) {
260
+ usage();
261
+ process.exit(1);
262
+ }
263
+
264
+ let code = 1;
265
+ try {
266
+ if (query) {
267
+ if (args.user) {
268
+ // search users
269
+ const spinnerId = startSpinner(`Searching users: ${query}`);
270
+ try {
271
+ const body = { query };
272
+ if (args.cursor) body.cursor = args.cursor;
273
+ const payload = await postApi(apiBase, apiKey, '/x/user/search', body, timeoutMs);
274
+ if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
275
+ else {
276
+ const data = payload?.data || {};
277
+ const users = data.users || [];
278
+ if (!users.length) { process.stderr.write('No users found.\n'); }
279
+ else {
280
+ process.stdout.write(`Found ${data.total || users.length} user(s)\n\n`);
281
+ for (const u of users) process.stdout.write(formatUser(u));
282
+ if (data.has_next && data.next_cursor) process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}"\n`);
283
+ code = 0;
284
+ }
285
+ }
286
+ } finally { stopSpinner(spinnerId); }
287
+ } else {
288
+ // search tweets (default)
289
+ const spinnerId = startSpinner(`Searching tweets: ${query}`);
290
+ try {
291
+ const body = { query };
292
+ if (args.queryType) body.query_type = args.queryType;
293
+ if (args.sinceTime) body.since_time = args.sinceTime;
294
+ if (args.untilTime) body.until_time = args.untilTime;
295
+ if (args.limit) body.limit = args.limit;
296
+ if (args.cursor) body.cursor = args.cursor;
297
+ const payload = await postApi(apiBase, apiKey, '/x/tweet/search', body, timeoutMs);
298
+ if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
299
+ else {
300
+ const data = payload?.data || {};
301
+ const tweets = data.tweets || [];
302
+ if (!tweets.length) { process.stderr.write('No tweets found.\n'); }
303
+ else {
304
+ process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
305
+ for (const t of tweets) process.stdout.write(formatTweet(t));
306
+ if (data.has_next && data.next_cursor) process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}"\n`);
307
+ code = 0;
308
+ }
309
+ }
310
+ } finally { stopSpinner(spinnerId); }
311
+ }
312
+ } else if (ids.length) {
313
+ if (args.user) {
314
+ if (args.tweets) {
315
+ // get user tweets
316
+ const label = ids[0];
317
+ const spinnerId = startSpinner(`Fetching tweets from ${label}`);
318
+ try {
319
+ const body = { username: ids[0] };
320
+ if (args.limit) body.limit = args.limit;
321
+ if (args.cursor) body.cursor = args.cursor;
322
+ if (args.includeReplies) body.include_replies = true;
323
+ const payload = await postApi(apiBase, apiKey, '/x/user/tweets', body, timeoutMs);
324
+ if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
325
+ else {
326
+ const data = payload?.data || {};
327
+ const tweets = data.tweets || [];
328
+ if (!tweets.length) { process.stderr.write('No tweets found.\n'); }
329
+ else {
330
+ process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
331
+ for (const t of tweets) process.stdout.write(formatTweet(t));
332
+ if (data.has_next && data.next_cursor) process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}"\n`);
333
+ code = 0;
334
+ }
335
+ }
336
+ } finally { stopSpinner(spinnerId); }
337
+ } else {
338
+ // get user info
339
+ const spinnerId = startSpinner('Fetching user info');
340
+ try {
341
+ const payload = await postApi(apiBase, apiKey, '/x/user/info', { usernames: ids }, timeoutMs);
342
+ if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
343
+ else {
344
+ const users = payload?.data?.users || [];
345
+ if (!users.length) { process.stderr.write('No users found.\n'); }
346
+ else { for (const u of users) process.stdout.write(formatUser(u)); code = 0; }
347
+ }
348
+ } finally { stopSpinner(spinnerId); }
349
+ }
350
+ } else {
351
+ // get tweet replies (default for --id)
352
+ const spinnerId = startSpinner(`Fetching replies for ${ids.length} tweet(s)`);
353
+ try {
354
+ const body = { tweet_ids: ids };
355
+ if (args.cursor) body.cursor = args.cursor;
356
+ if (args.sinceTime) body.since_time = args.sinceTime;
357
+ if (args.untilTime) body.until_time = args.untilTime;
358
+ const payload = await postApi(apiBase, apiKey, '/x/tweet/replies', body, timeoutMs);
359
+ if (json) { console.log(JSON.stringify(payload, null, 2)); code = 0; }
360
+ else {
361
+ const results = payload?.data?.results || [];
362
+ if (!results.length) { process.stderr.write('No replies found.\n'); }
363
+ else {
364
+ for (const r of results) {
365
+ process.stdout.write(`## Replies to tweet \`${r.tweet_id}\` (${r.total || 0} total)\n\n`);
366
+ for (const t of (r.replies || [])) process.stdout.write(formatTweet(t));
367
+ if (r.has_next && r.next_cursor) process.stderr.write(`More replies for ${r.tweet_id}. Use --cursor "${r.next_cursor}"\n`);
368
+ }
369
+ code = 0;
370
+ }
371
+ }
372
+ } finally { stopSpinner(spinnerId); }
373
+ }
374
+ }
375
+ } catch (err) {
376
+ process.stderr.write(`Error: ${err?.message || err}\n`);
377
+ }
378
+
379
+ process.exit(code);
380
+ }
381
+
382
+ main().catch((err) => {
383
+ process.stderr.write(`Fatal: ${err?.message || err}\n`);
384
+ process.exit(1);
385
+ });