felo-ai 0.2.6 → 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.
Files changed (39) hide show
  1. package/.github/workflows/publish-npm.yml +39 -0
  2. package/CHANGELOG.md +30 -0
  3. package/CONTRIBUTING.md +346 -346
  4. package/README.en.md +129 -129
  5. package/README.md +435 -408
  6. package/docs/EXAMPLES.md +632 -632
  7. package/docs/FAQ.md +479 -479
  8. package/felo-search/LICENSE +21 -21
  9. package/felo-search/README.md +440 -440
  10. package/felo-search/SKILL.md +291 -291
  11. package/felo-slides/LICENSE +21 -21
  12. package/felo-slides/README.md +87 -87
  13. package/felo-slides/SKILL.md +166 -166
  14. package/felo-slides/scripts/run_ppt_task.mjs +251 -251
  15. package/felo-superAgent/LICENSE +21 -0
  16. package/felo-superAgent/README.md +125 -0
  17. package/felo-superAgent/SKILL.md +165 -0
  18. package/felo-web-fetch/README.md +127 -0
  19. package/felo-web-fetch/SKILL.md +204 -0
  20. package/felo-web-fetch/scripts/run_web_fetch.mjs +316 -0
  21. package/felo-x-search/SKILL.md +204 -0
  22. package/felo-x-search/scripts/run_x_search.mjs +385 -0
  23. package/felo-youtube-subtitling/README.md +59 -59
  24. package/felo-youtube-subtitling/SKILL.md +161 -161
  25. package/felo-youtube-subtitling/scripts/run_youtube_subtitling.mjs +239 -239
  26. package/package.json +37 -35
  27. package/src/cli.js +370 -252
  28. package/src/config.js +66 -66
  29. package/src/search.js +142 -142
  30. package/src/slides.js +332 -332
  31. package/src/superAgent.js +609 -0
  32. package/src/{webExtract.js → webFetch.js} +148 -148
  33. package/src/xSearch.js +366 -0
  34. package/src/youtubeSubtitling.js +179 -179
  35. package/tests/config.test.js +78 -78
  36. package/tests/search.test.js +100 -100
  37. package/felo-web-extract/README.md +0 -78
  38. package/felo-web-extract/SKILL.md +0 -200
  39. package/felo-web-extract/scripts/run_web_extract.mjs +0 -232
@@ -1,148 +1,148 @@
1
- import { getApiKey, NO_KEY_MESSAGE } from './search.js';
2
- import * as config from './config.js';
3
-
4
- const DEFAULT_API_BASE = 'https://openapi.felo.ai';
5
- const DEFAULT_TIMEOUT_MS = 60_000;
6
- const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
- const SPINNER_INTERVAL_MS = 80;
8
- const STATUS_PAD = 56;
9
-
10
- function startSpinner(message) {
11
- const start = Date.now();
12
- let i = 0;
13
- const id = setInterval(() => {
14
- const elapsed = Math.floor((Date.now() - start) / 1000);
15
- const line = `${message} ${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${elapsed}s`;
16
- process.stderr.write(`\r${line.padEnd(STATUS_PAD, ' ')}`);
17
- i += 1;
18
- }, SPINNER_INTERVAL_MS);
19
- return id;
20
- }
21
-
22
- function stopSpinner(id) {
23
- if (id != null) clearInterval(id);
24
- process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
- }
26
-
27
- async function getApiBase() {
28
- let base = process.env.FELO_API_BASE?.trim();
29
- if (!base) {
30
- const v = await config.getConfigValue('FELO_API_BASE');
31
- base = typeof v === 'string' ? v.trim() : '';
32
- }
33
- return (base || DEFAULT_API_BASE).replace(/\/$/, '');
34
- }
35
-
36
- function getMessage(payload) {
37
- return payload?.message || payload?.error || payload?.msg || payload?.code || 'Unknown error';
38
- }
39
-
40
- function stringifyContent(content) {
41
- if (content == null) return '';
42
- if (typeof content === 'string') return content;
43
- if (typeof content === 'object') {
44
- if (content.markdown) return content.markdown;
45
- if (content.text) return content.text;
46
- if (content.html) return content.html;
47
- return JSON.stringify(content, null, 2);
48
- }
49
- return String(content);
50
- }
51
-
52
- async function fetchExtract(apiBase, apiKey, body, timeoutMs) {
53
- const controller = new AbortController();
54
- const timer = setTimeout(() => controller.abort(), timeoutMs);
55
- try {
56
- const res = await fetch(`${apiBase}/v2/web/extract`, {
57
- method: 'POST',
58
- headers: {
59
- Accept: 'application/json',
60
- Authorization: `Bearer ${apiKey}`,
61
- 'Content-Type': 'application/json',
62
- },
63
- body: JSON.stringify(body),
64
- signal: controller.signal,
65
- });
66
- let data = {};
67
- try {
68
- data = await res.json();
69
- } catch {
70
- data = {};
71
- }
72
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
73
- const code = data.code;
74
- const hasData = data?.data != null;
75
- const successCodes = [0, 200];
76
- const ok =
77
- successCodes.includes(Number(code)) ||
78
- code === undefined ||
79
- code === null ||
80
- (hasData && res.ok);
81
- if (!ok) throw new Error(getMessage(data));
82
- return data;
83
- } finally {
84
- clearTimeout(timer);
85
- }
86
- }
87
-
88
- /**
89
- * Run web extract and print result. Returns exit code (0 or 1).
90
- * @param {Object} opts - { url, format, targetSelector, waitForSelector, readability, timeoutMs, json }
91
- */
92
- export async function webExtract(opts) {
93
- const apiKey = await getApiKey();
94
- if (!apiKey) {
95
- console.error(NO_KEY_MESSAGE.trim());
96
- return 1;
97
- }
98
- if (!opts?.url || typeof opts.url !== 'string' || !opts.url.trim()) {
99
- process.stderr.write('ERROR: URL is required and must be a non-empty string.\n');
100
- return 1;
101
- }
102
-
103
- const apiBase = await getApiBase();
104
- const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
105
- ? opts.timeoutMs
106
- : DEFAULT_TIMEOUT_MS;
107
-
108
- const shortUrl = opts.url.length > 45 ? opts.url.slice(0, 42) + '...' : opts.url;
109
- const spinnerId = startSpinner(`Fetching ${shortUrl}`);
110
-
111
- const body = {
112
- url: opts.url,
113
- output_format: opts.format || 'markdown',
114
- crawl_mode: opts.crawlMode || 'fast',
115
- with_readability: Boolean(opts.readability),
116
- timeout: timeoutMs,
117
- };
118
- if (opts.targetSelector) body.target_selector = opts.targetSelector;
119
- if (opts.waitForSelector) body.wait_for_selector = opts.waitForSelector;
120
-
121
- try {
122
- const payload = await fetchExtract(apiBase, apiKey, body, timeoutMs);
123
- const content = payload?.data?.content;
124
-
125
- if (opts.json) {
126
- console.log(JSON.stringify(payload, null, 2));
127
- return 0;
128
- }
129
-
130
- const out = stringifyContent(content);
131
- const isEmpty = out == null || String(out).trim() === '';
132
- if (isEmpty) {
133
- process.stderr.write(
134
- `No content extracted from ${opts.url}. The page may be empty, blocked, or the selector did not match.\n`
135
- );
136
- return 1;
137
- }
138
- console.log(out);
139
- return 0;
140
- } catch (err) {
141
- process.stderr.write(
142
- `Web extract failed for ${opts.url}: ${err?.message || err}\n`
143
- );
144
- return 1;
145
- } finally {
146
- stopSpinner(spinnerId);
147
- }
148
- }
1
+ import { getApiKey, NO_KEY_MESSAGE } from './search.js';
2
+ import * as config from './config.js';
3
+
4
+ const DEFAULT_API_BASE = 'https://openapi.felo.ai';
5
+ const DEFAULT_TIMEOUT_MS = 60_000;
6
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ const SPINNER_INTERVAL_MS = 80;
8
+ const STATUS_PAD = 56;
9
+
10
+ function startSpinner(message) {
11
+ const start = Date.now();
12
+ let i = 0;
13
+ const id = setInterval(() => {
14
+ const elapsed = Math.floor((Date.now() - start) / 1000);
15
+ const line = `${message} ${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${elapsed}s`;
16
+ process.stderr.write(`\r${line.padEnd(STATUS_PAD, ' ')}`);
17
+ i += 1;
18
+ }, SPINNER_INTERVAL_MS);
19
+ return id;
20
+ }
21
+
22
+ function stopSpinner(id) {
23
+ if (id != null) clearInterval(id);
24
+ process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
+ }
26
+
27
+ async function getApiBase() {
28
+ let base = process.env.FELO_API_BASE?.trim();
29
+ if (!base) {
30
+ const v = await config.getConfigValue('FELO_API_BASE');
31
+ base = typeof v === 'string' ? v.trim() : '';
32
+ }
33
+ return (base || DEFAULT_API_BASE).replace(/\/$/, '');
34
+ }
35
+
36
+ function getMessage(payload) {
37
+ return payload?.message || payload?.error || payload?.msg || payload?.code || 'Unknown error';
38
+ }
39
+
40
+ function stringifyContent(content) {
41
+ if (content == null) return '';
42
+ if (typeof content === 'string') return content;
43
+ if (typeof content === 'object') {
44
+ if (content.markdown) return content.markdown;
45
+ if (content.text) return content.text;
46
+ if (content.html) return content.html;
47
+ return JSON.stringify(content, null, 2);
48
+ }
49
+ return String(content);
50
+ }
51
+
52
+ async function fetchContent(apiBase, apiKey, body, timeoutMs) {
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
55
+ try {
56
+ const res = await fetch(`${apiBase}/v2/web/extract`, {
57
+ method: 'POST',
58
+ headers: {
59
+ Accept: 'application/json',
60
+ Authorization: `Bearer ${apiKey}`,
61
+ 'Content-Type': 'application/json',
62
+ },
63
+ body: JSON.stringify(body),
64
+ signal: controller.signal,
65
+ });
66
+ let data = {};
67
+ try {
68
+ data = await res.json();
69
+ } catch {
70
+ data = {};
71
+ }
72
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
73
+ const code = data.code;
74
+ const hasData = data?.data != null;
75
+ const successCodes = [0, 200];
76
+ const ok =
77
+ successCodes.includes(Number(code)) ||
78
+ code === undefined ||
79
+ code === null ||
80
+ (hasData && res.ok);
81
+ if (!ok) throw new Error(getMessage(data));
82
+ return data;
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Run web fetch and print result. Returns exit code (0 or 1).
90
+ * @param {Object} opts - { url, format, targetSelector, waitForSelector, readability, timeoutMs, json }
91
+ */
92
+ export async function webFetch(opts) {
93
+ const apiKey = await getApiKey();
94
+ if (!apiKey) {
95
+ console.error(NO_KEY_MESSAGE.trim());
96
+ return 1;
97
+ }
98
+ if (!opts?.url || typeof opts.url !== 'string' || !opts.url.trim()) {
99
+ process.stderr.write('ERROR: URL is required and must be a non-empty string.\n');
100
+ return 1;
101
+ }
102
+
103
+ const apiBase = await getApiBase();
104
+ const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
105
+ ? opts.timeoutMs
106
+ : DEFAULT_TIMEOUT_MS;
107
+
108
+ const shortUrl = opts.url.length > 45 ? opts.url.slice(0, 42) + '...' : opts.url;
109
+ const spinnerId = startSpinner(`Fetching ${shortUrl}`);
110
+
111
+ const body = {
112
+ url: opts.url,
113
+ output_format: opts.format || 'markdown',
114
+ crawl_mode: opts.crawlMode || 'fast',
115
+ with_readability: Boolean(opts.readability),
116
+ timeout: timeoutMs,
117
+ };
118
+ if (opts.targetSelector) body.target_selector = opts.targetSelector;
119
+ if (opts.waitForSelector) body.wait_for_selector = opts.waitForSelector;
120
+
121
+ try {
122
+ const payload = await fetchContent(apiBase, apiKey, body, timeoutMs);
123
+ const content = payload?.data?.content;
124
+
125
+ if (opts.json) {
126
+ console.log(JSON.stringify(payload, null, 2));
127
+ return 0;
128
+ }
129
+
130
+ const out = stringifyContent(content);
131
+ const isEmpty = out == null || String(out).trim() === '';
132
+ if (isEmpty) {
133
+ process.stderr.write(
134
+ `No content fetched from ${opts.url}. The page may be empty, blocked, or the selector did not match.\n`
135
+ );
136
+ return 1;
137
+ }
138
+ console.log(out);
139
+ return 0;
140
+ } catch (err) {
141
+ process.stderr.write(
142
+ `Web fetch failed for ${opts.url}: ${err?.message || err}\n`
143
+ );
144
+ return 1;
145
+ } finally {
146
+ stopSpinner(spinnerId);
147
+ }
148
+ }
package/src/xSearch.js ADDED
@@ -0,0 +1,366 @@
1
+ import { getApiKey, fetchWithTimeoutAndRetry, NO_KEY_MESSAGE } from './search.js';
2
+ import * as config from './config.js';
3
+
4
+ const DEFAULT_API_BASE = 'https://openapi.felo.ai';
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ const SPINNER_INTERVAL_MS = 80;
8
+ const STATUS_PAD = 56;
9
+
10
+ function startSpinner(message) {
11
+ const start = Date.now();
12
+ let i = 0;
13
+ const id = setInterval(() => {
14
+ const elapsed = Math.floor((Date.now() - start) / 1000);
15
+ const line = `${message} ${SPINNER_FRAMES[i % SPINNER_FRAMES.length]} ${elapsed}s`;
16
+ process.stderr.write(`\r${line.padEnd(STATUS_PAD, ' ')}`);
17
+ i += 1;
18
+ }, SPINNER_INTERVAL_MS);
19
+ return id;
20
+ }
21
+
22
+ function stopSpinner(id) {
23
+ if (id != null) clearInterval(id);
24
+ process.stderr.write(`\r${' '.repeat(STATUS_PAD)}\r`);
25
+ }
26
+
27
+ async function getApiBase() {
28
+ let base = process.env.FELO_API_BASE?.trim();
29
+ if (!base) {
30
+ const v = await config.getConfigValue('FELO_API_BASE');
31
+ base = typeof v === 'string' ? v.trim() : '';
32
+ }
33
+ return (base || DEFAULT_API_BASE).replace(/\/$/, '');
34
+ }
35
+
36
+ function getMessage(payload) {
37
+ return payload?.message || payload?.error || payload?.msg || payload?.code || 'Unknown error';
38
+ }
39
+
40
+ async function postApi(apiBase, apiKey, path, body, timeoutMs) {
41
+ const res = await fetchWithTimeoutAndRetry(
42
+ `${apiBase}/v2${path}`,
43
+ {
44
+ method: 'POST',
45
+ headers: {
46
+ Accept: 'application/json',
47
+ Authorization: `Bearer ${apiKey}`,
48
+ 'Content-Type': 'application/json',
49
+ },
50
+ body: JSON.stringify(body),
51
+ },
52
+ timeoutMs,
53
+ );
54
+ let data = {};
55
+ try {
56
+ data = await res.json();
57
+ } catch {
58
+ data = {};
59
+ }
60
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${getMessage(data)}`);
61
+ if (data.status === 'error') throw new Error(getMessage(data));
62
+ return data;
63
+ }
64
+
65
+ // ── Formatting helpers ──
66
+
67
+ function formatNumber(num) {
68
+ if (num == null) return '0';
69
+ if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
70
+ if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
71
+ if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
72
+ return String(num);
73
+ }
74
+
75
+ function formatUser(u, headerLevel = 2) {
76
+ if (!u) return '';
77
+ const h = '#'.repeat(Math.min(6, headerLevel));
78
+
79
+ let badge = '';
80
+ if (u.blue_verified) badge = ' 🔵';
81
+ else if (u.verified) badge = ' ✓';
82
+
83
+ let out = `${h} @${u.username} (${u.display_name || u.username}${badge})\n`;
84
+ out += `- User ID: \`${u.user_id}\`\n`;
85
+
86
+ if (u.verified_type) out += `- Verification Type: ${u.verified_type}\n`;
87
+
88
+ const status = [];
89
+ if (u.protected) status.push('Protected (Private)');
90
+ if (u.is_automated) status.push('Automated');
91
+ if (status.length) out += `- Account Status: ${status.join(' | ')}\n`;
92
+
93
+ out += `- Followers: ${formatNumber(u.followers_count)}\n`;
94
+ out += `- Following: ${formatNumber(u.following_count)}\n`;
95
+ out += `- Total Tweets: ${formatNumber(u.tweet_count)}\n`;
96
+
97
+ if (u.favorites_count > 0) out += `- Likes Given: ${formatNumber(u.favorites_count)}\n`;
98
+ if (u.media_count > 0) out += `- Media Count: ${formatNumber(u.media_count)}\n`;
99
+ if (u.description) out += `- Bio: ${u.description}\n`;
100
+ if (u.location) out += `- Location: ${u.location}\n`;
101
+ if (u.url) out += `- Website: ${u.url}\n`;
102
+ if (u.can_dm) out += `- Direct Messages: Open\n`;
103
+ if (u.profile_image_url) out += `- Profile Image: ${u.profile_image_url}\n`;
104
+ if (u.cover_image_url) out += `- Cover Image: ${u.cover_image_url}\n`;
105
+ if (u.pinned_tweet_ids?.length) out += `- Pinned Tweets: ${u.pinned_tweet_ids.length} tweet(s)\n`;
106
+ if (u.created_at) out += `- Account Created: ${u.created_at}\n`;
107
+
108
+ out += '\n---\n\n';
109
+ return out;
110
+ }
111
+
112
+ function formatTweet(t, indent = '', headerLevel = 3) {
113
+ if (!t) return '';
114
+ const h = '#'.repeat(Math.min(6, headerLevel));
115
+ const author = t.author || {};
116
+ const verified = author.verified ? ' ✓' : '';
117
+
118
+ let out = `${indent}${h} @${author.username || 'unknown'}(${author.display_name || author.username || 'unknown'}${verified})\n`;
119
+
120
+ const meta = [`Posted: ${t.created_at || 'unknown'}`, `Tweet ID: \`${t.id}\``];
121
+ if (t.conversation_id) meta.push(`Conversation: \`${t.conversation_id}\``);
122
+ if (t.is_reply && t.in_reply_to_username) meta.push(`Reply to @${t.in_reply_to_username}`);
123
+ out += `${indent}- ${meta.join(' | ')}\n\n`;
124
+
125
+ const content = t.content || '';
126
+ for (const line of content.split('\n')) {
127
+ out += `${indent}${line}\n`;
128
+ }
129
+ out += '\n';
130
+
131
+ const metrics = t.metrics || {};
132
+ const parts = [];
133
+ if (metrics.favorite_count) parts.push(`${formatNumber(metrics.favorite_count)} likes`);
134
+ if (metrics.retweet_count) parts.push(`${formatNumber(metrics.retweet_count)} retweets`);
135
+ if (metrics.reply_count) parts.push(`${formatNumber(metrics.reply_count)} replies`);
136
+ if (parts.length) out += `${indent}Engagement: ${parts.join(' | ')}\n`;
137
+
138
+ if (Array.isArray(t.media_urls) && t.media_urls.length) {
139
+ out += `${indent}Media (${t.media_urls.length}):\n`;
140
+ for (const media of t.media_urls) {
141
+ const type = media.type || 'photo';
142
+ if (type === 'video') {
143
+ out += `${indent} • [video] ${media.url || ''}\n`;
144
+ } else {
145
+ out += `${indent} • [photo] ${media.thumbnail || ''}\n`;
146
+ }
147
+ }
148
+ }
149
+
150
+ out += `\n${indent}---\n\n`;
151
+ return out;
152
+ }
153
+
154
+ // ── Subcommands ──
155
+
156
+ async function userInfo(usernames, opts) {
157
+ const apiKey = await getApiKey();
158
+ if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
159
+ if (!usernames || !usernames.length) {
160
+ process.stderr.write('ERROR: --usernames is required.\n');
161
+ return 1;
162
+ }
163
+
164
+ const apiBase = await getApiBase();
165
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
166
+ const spinnerId = startSpinner('Fetching user info');
167
+
168
+ try {
169
+ const payload = await postApi(apiBase, apiKey, '/x/user/info', { usernames }, timeoutMs);
170
+ if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
171
+
172
+ const users = payload?.data?.users || [];
173
+ if (!users.length) {
174
+ process.stderr.write('No users found.\n');
175
+ return 1;
176
+ }
177
+ for (const u of users) {
178
+ process.stdout.write(formatUser(u));
179
+ }
180
+ return 0;
181
+ } catch (err) {
182
+ process.stderr.write(`Failed to get user info: ${err?.message || err}\n`);
183
+ return 1;
184
+ } finally {
185
+ stopSpinner(spinnerId);
186
+ }
187
+ }
188
+
189
+ async function userSearch(query, opts) {
190
+ const apiKey = await getApiKey();
191
+ if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
192
+ if (!query) {
193
+ process.stderr.write('ERROR: --query is required.\n');
194
+ return 1;
195
+ }
196
+
197
+ const apiBase = await getApiBase();
198
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
199
+ const spinnerId = startSpinner(`Searching users: ${query}`);
200
+
201
+ try {
202
+ const body = { query };
203
+ if (opts.cursor) body.cursor = opts.cursor;
204
+ const payload = await postApi(apiBase, apiKey, '/x/user/search', body, timeoutMs);
205
+ if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
206
+
207
+ const data = payload?.data || {};
208
+ const users = data.users || [];
209
+ if (!users.length) {
210
+ process.stderr.write('No users found.\n');
211
+ return 1;
212
+ }
213
+ process.stdout.write(`Found ${data.total || users.length} user(s)\n\n`);
214
+ for (const u of users) {
215
+ process.stdout.write(formatUser(u));
216
+ }
217
+ if (data.has_next && data.next_cursor) {
218
+ process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}" to fetch next page.\n`);
219
+ }
220
+ return 0;
221
+ } catch (err) {
222
+ process.stderr.write(`Failed to search users: ${err?.message || err}\n`);
223
+ return 1;
224
+ } finally {
225
+ stopSpinner(spinnerId);
226
+ }
227
+ }
228
+
229
+ async function userTweets(opts) {
230
+ const apiKey = await getApiKey();
231
+ if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
232
+ if (!opts.username && !opts.xUserId) {
233
+ process.stderr.write('ERROR: --username or --x-user-id is required.\n');
234
+ return 1;
235
+ }
236
+
237
+ const apiBase = await getApiBase();
238
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
239
+ const label = opts.username || opts.xUserId;
240
+ const spinnerId = startSpinner(`Fetching tweets from ${label}`);
241
+
242
+ try {
243
+ const body = {};
244
+ if (opts.username) body.username = opts.username;
245
+ if (opts.xUserId) body.x_user_id = opts.xUserId;
246
+ if (opts.limit) body.limit = opts.limit;
247
+ if (opts.cursor) body.cursor = opts.cursor;
248
+ if (opts.includeReplies) body.include_replies = true;
249
+
250
+ const payload = await postApi(apiBase, apiKey, '/x/user/tweets', body, timeoutMs);
251
+ if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
252
+
253
+ const data = payload?.data || {};
254
+ const tweets = data.tweets || [];
255
+ if (!tweets.length) {
256
+ process.stderr.write('No tweets found.\n');
257
+ return 1;
258
+ }
259
+ process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
260
+ for (const t of tweets) {
261
+ process.stdout.write(formatTweet(t));
262
+ }
263
+ if (data.has_next && data.next_cursor) {
264
+ process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}" to fetch next page.\n`);
265
+ }
266
+ return 0;
267
+ } catch (err) {
268
+ process.stderr.write(`Failed to get user tweets: ${err?.message || err}\n`);
269
+ return 1;
270
+ } finally {
271
+ stopSpinner(spinnerId);
272
+ }
273
+ }
274
+
275
+ async function tweetSearch(query, opts) {
276
+ const apiKey = await getApiKey();
277
+ if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
278
+ if (!query) {
279
+ process.stderr.write('ERROR: --query is required.\n');
280
+ return 1;
281
+ }
282
+
283
+ const apiBase = await getApiBase();
284
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
285
+ const spinnerId = startSpinner(`Searching tweets: ${query}`);
286
+
287
+ try {
288
+ const body = { query };
289
+ if (opts.queryType) body.query_type = opts.queryType;
290
+ if (opts.sinceTime) body.since_time = opts.sinceTime;
291
+ if (opts.untilTime) body.until_time = opts.untilTime;
292
+ if (opts.limit) body.limit = opts.limit;
293
+ if (opts.cursor) body.cursor = opts.cursor;
294
+
295
+ const payload = await postApi(apiBase, apiKey, '/x/tweet/search', body, timeoutMs);
296
+ if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
297
+
298
+ const data = payload?.data || {};
299
+ const tweets = data.tweets || [];
300
+ if (!tweets.length) {
301
+ process.stderr.write('No tweets found.\n');
302
+ return 1;
303
+ }
304
+ process.stdout.write(`Found ${data.total || tweets.length} tweet(s)\n\n`);
305
+ for (const t of tweets) {
306
+ process.stdout.write(formatTweet(t));
307
+ }
308
+ if (data.has_next && data.next_cursor) {
309
+ process.stderr.write(`\nMore results available. Use --cursor "${data.next_cursor}" to fetch next page.\n`);
310
+ }
311
+ return 0;
312
+ } catch (err) {
313
+ process.stderr.write(`Failed to search tweets: ${err?.message || err}\n`);
314
+ return 1;
315
+ } finally {
316
+ stopSpinner(spinnerId);
317
+ }
318
+ }
319
+
320
+ async function tweetReplies(tweetIds, opts) {
321
+ const apiKey = await getApiKey();
322
+ if (!apiKey) { console.error(NO_KEY_MESSAGE.trim()); return 1; }
323
+ if (!tweetIds || !tweetIds.length) {
324
+ process.stderr.write('ERROR: --tweet-ids is required.\n');
325
+ return 1;
326
+ }
327
+
328
+ const apiBase = await getApiBase();
329
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
330
+ const spinnerId = startSpinner(`Fetching replies for ${tweetIds.length} tweet(s)`);
331
+
332
+ try {
333
+ const body = { tweet_ids: tweetIds };
334
+ if (opts.cursor) body.cursor = opts.cursor;
335
+ if (opts.sinceTime) body.since_time = opts.sinceTime;
336
+ if (opts.untilTime) body.until_time = opts.untilTime;
337
+
338
+ const payload = await postApi(apiBase, apiKey, '/x/tweet/replies', body, timeoutMs);
339
+ if (opts.json) { console.log(JSON.stringify(payload, null, 2)); return 0; }
340
+
341
+ const results = payload?.data?.results || [];
342
+ if (!results.length) {
343
+ process.stderr.write('No replies found.\n');
344
+ return 1;
345
+ }
346
+ for (const r of results) {
347
+ process.stdout.write(`## Replies to tweet \`${r.tweet_id}\` (${r.total || 0} total)\n\n`);
348
+ const replies = r.replies || [];
349
+ for (const t of replies) {
350
+ process.stdout.write(formatTweet(t));
351
+ }
352
+ if (r.has_next && r.next_cursor) {
353
+ process.stderr.write(`More replies available for ${r.tweet_id}. Use --cursor "${r.next_cursor}" to fetch next page.\n`);
354
+ }
355
+ }
356
+ return 0;
357
+ } catch (err) {
358
+ process.stderr.write(`Failed to get tweet replies: ${err?.message || err}\n`);
359
+ return 1;
360
+ } finally {
361
+ stopSpinner(spinnerId);
362
+ }
363
+ }
364
+
365
+ export { userInfo, userSearch, userTweets, tweetSearch, tweetReplies };
366
+ export { formatNumber, formatUser, formatTweet };