birdapi 0.0.1__py3-none-any.whl

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.
bird/cli.py ADDED
@@ -0,0 +1,769 @@
1
+ """bird CLI — X/Twitter GraphQL client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import Optional
10
+
11
+ import click
12
+
13
+ # Ensure stdout can handle Unicode on Windows (e.g. cp1252 terminals)
14
+ if sys.stdout and hasattr(sys.stdout, "buffer"):
15
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
16
+
17
+ from .client import TwitterClient
18
+ from ._config import load_credentials, resolve_credentials, save_credentials
19
+ from ._utils import extract_tweet_id, extract_list_id, normalize_handle
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Auth helpers
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _make_client(
27
+ auth_token: Optional[str],
28
+ ct0: Optional[str],
29
+ timeout: Optional[float],
30
+ ) -> TwitterClient:
31
+ tok, csrf = resolve_credentials(auth_token, ct0)
32
+ if not tok or not csrf:
33
+ click.echo(
34
+ "Error: credentials not found.\n"
35
+ "Run bird configure to save your auth_token and ct0.",
36
+ err=True,
37
+ )
38
+ sys.exit(1)
39
+ return TwitterClient(tok, csrf, timeout=timeout)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Global options
44
+ # ---------------------------------------------------------------------------
45
+
46
+ @click.group()
47
+ @click.pass_context
48
+ @click.option("--auth-token", envvar=["AUTH_TOKEN", "TWITTER_AUTH_TOKEN"], hidden=True)
49
+ @click.option("--ct0", envvar=["CT0", "TWITTER_CT0"], hidden=True)
50
+ @click.option("--timeout", type=float, default=None, envvar="BIRD_TIMEOUT_MS",
51
+ help="Request timeout in milliseconds.")
52
+ @click.option("--json", "as_json", is_flag=True)
53
+ @click.option("--quote-depth", type=int, default=1, envvar="BIRD_QUOTE_DEPTH")
54
+ def main(ctx: click.Context, auth_token, ct0, timeout, as_json, quote_depth):
55
+ """bird — fast X/Twitter CLI (cookie auth, no browser extraction)."""
56
+ ctx.ensure_object(dict)
57
+ ctx.obj["auth_token"] = auth_token
58
+ ctx.obj["ct0"] = ct0
59
+ ctx.obj["timeout"] = timeout / 1000 if timeout else None
60
+ ctx.obj["as_json"] = as_json
61
+ ctx.obj["quote_depth"] = quote_depth
62
+
63
+
64
+ def _client(ctx) -> TwitterClient:
65
+ o = ctx.obj
66
+ return _make_client(o["auth_token"], o["ct0"], o["timeout"])
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Pretty-print helpers
71
+ # ---------------------------------------------------------------------------
72
+
73
+ import html as _html
74
+
75
+ _SEPARATOR = "\u2500" * 50 # ──────────────────────────────────────────────────
76
+
77
+
78
+ def _unescape(text: str) -> str:
79
+ return _html.unescape(text)
80
+
81
+
82
+ def _format_tweet(tweet) -> str:
83
+ lines: list[str] = []
84
+
85
+ # Header: @username (Full Name):
86
+ lines.append(f"@{tweet.author.username} ({tweet.author.name}):")
87
+
88
+ # Tweet text
89
+ lines.append(_unescape(tweet.text))
90
+
91
+ # Quoted tweet box
92
+ if tweet.quoted_tweet:
93
+ qt = tweet.quoted_tweet
94
+ lines.append(f"\u250c\u2500 QT @{qt.author.username}:")
95
+ for body_line in _unescape(qt.text).splitlines():
96
+ lines.append(f"\u2502 {body_line}")
97
+ if qt.media:
98
+ for m in qt.media:
99
+ icon = "\U0001f3ac" if m.type in ("video", "animated_gif") else "\U0001f5bc\ufe0f"
100
+ lines.append(f"\u2502 {icon} {m.url}")
101
+ lines.append(f"\u2514\u2500 https://x.com/{qt.author.username}/status/{qt.id}")
102
+
103
+ # Media on the outer tweet
104
+ if tweet.media:
105
+ for m in tweet.media:
106
+ icon = "\U0001f3ac" if m.type in ("video", "animated_gif") else "\U0001f5bc\ufe0f"
107
+ lines.append(f"{icon} {m.url}")
108
+
109
+ # Metadata
110
+ if tweet.created_at:
111
+ lines.append(f"\U0001f4c5 {tweet.created_at}")
112
+ lines.append(f"\U0001f517 https://x.com/{tweet.author.username}/status/{tweet.id}")
113
+ lines.append(_SEPARATOR)
114
+
115
+ return "\n".join(lines)
116
+
117
+
118
+ def _dump_tweet(tweet, as_json: bool) -> None:
119
+ if as_json:
120
+ click.echo(json.dumps(_tweet_to_dict(tweet), ensure_ascii=False, indent=2))
121
+ else:
122
+ click.echo(_format_tweet(tweet))
123
+
124
+
125
+ def _dump_tweets(tweets, as_json: bool) -> None:
126
+ if as_json:
127
+ click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False))
128
+ else:
129
+ for t in tweets:
130
+ click.echo(_format_tweet(t))
131
+
132
+
133
+ def _tweet_to_dict(tweet) -> dict:
134
+ d: dict = {
135
+ "id": tweet.id,
136
+ "text": _unescape(tweet.text),
137
+ "createdAt": tweet.created_at,
138
+ "replyCount": tweet.reply_count,
139
+ "retweetCount": tweet.retweet_count,
140
+ "likeCount": tweet.like_count,
141
+ "conversationId": tweet.conversation_id,
142
+ }
143
+ if tweet.in_reply_to_status_id:
144
+ d["inReplyToStatusId"] = tweet.in_reply_to_status_id
145
+ d["author"] = {"username": tweet.author.username, "name": tweet.author.name}
146
+ d["authorId"] = tweet.author_id
147
+ if tweet.quoted_tweet:
148
+ d["quotedTweet"] = _tweet_to_dict(tweet.quoted_tweet)
149
+ if tweet.media:
150
+ d["media"] = [_media_to_dict(m) for m in tweet.media]
151
+ return d
152
+
153
+
154
+ def _media_to_dict(m) -> dict:
155
+ d: dict = {"type": m.type, "url": m.url}
156
+ if m.width is not None:
157
+ d["width"] = m.width
158
+ if m.height is not None:
159
+ d["height"] = m.height
160
+ if m.preview_url is not None:
161
+ d["previewUrl"] = m.preview_url
162
+ return d
163
+
164
+
165
+ def _user_to_dict(user) -> dict:
166
+ return {
167
+ "id": user.id,
168
+ "username": user.username,
169
+ "name": user.name,
170
+ "description": user.description,
171
+ "followersCount": user.followers_count,
172
+ "followingCount": user.following_count,
173
+ "isBlueVerified": user.is_blue_verified,
174
+ "profileImageUrl": user.profile_image_url,
175
+ "createdAt": user.created_at,
176
+ }
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # read
181
+ # ---------------------------------------------------------------------------
182
+
183
+ @main.command()
184
+ @click.argument("tweet_id_or_url")
185
+ @click.option("--json", "as_json", is_flag=True)
186
+ @click.pass_context
187
+ def read(ctx, tweet_id_or_url, as_json):
188
+ """Fetch and display a tweet by ID or URL."""
189
+ tweet_id = extract_tweet_id(tweet_id_or_url)
190
+ if not tweet_id:
191
+ click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
192
+ sys.exit(1)
193
+ as_json = as_json or ctx.obj.get("as_json")
194
+ with _client(ctx) as client:
195
+ tweet = client.get_tweet(tweet_id)
196
+ if not tweet:
197
+ click.echo("Tweet not found.", err=True)
198
+ sys.exit(1)
199
+ _dump_tweet(tweet, as_json)
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # thread / replies
204
+ # ---------------------------------------------------------------------------
205
+
206
+ @main.command()
207
+ @click.argument("tweet_id_or_url")
208
+ @click.option("--json", "as_json", is_flag=True)
209
+ @click.pass_context
210
+ def thread(ctx, tweet_id_or_url, as_json):
211
+ """Show the full conversation thread for a tweet."""
212
+ tweet_id = extract_tweet_id(tweet_id_or_url)
213
+ if not tweet_id:
214
+ click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
215
+ sys.exit(1)
216
+ as_json = as_json or ctx.obj.get("as_json")
217
+ with _client(ctx) as client:
218
+ tweets = client.get_thread(tweet_id)
219
+ _dump_tweets(tweets, as_json)
220
+
221
+
222
+ @main.command()
223
+ @click.argument("tweet_id_or_url")
224
+ @click.option("-n", "--count", default=20, show_default=True)
225
+ @click.option("--json", "as_json", is_flag=True)
226
+ @click.pass_context
227
+ def replies(ctx, tweet_id_or_url, count, as_json):
228
+ """List replies to a tweet."""
229
+ tweet_id = extract_tweet_id(tweet_id_or_url)
230
+ if not tweet_id:
231
+ click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
232
+ sys.exit(1)
233
+ as_json = as_json or ctx.obj.get("as_json")
234
+ with _client(ctx) as client:
235
+ tweets = client.get_replies(tweet_id)
236
+ _dump_tweets(tweets[:count], as_json)
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # tweet / reply
241
+ # ---------------------------------------------------------------------------
242
+
243
+ @main.command(name="tweet")
244
+ @click.argument("text")
245
+ @click.pass_context
246
+ def post_tweet(ctx, text):
247
+ """Post a new tweet."""
248
+ with _client(ctx) as client:
249
+ tweet_id = client.tweet(text)
250
+ if tweet_id:
251
+ click.echo(f"Posted: https://x.com/i/status/{tweet_id}")
252
+ else:
253
+ click.echo("Failed to post tweet.", err=True)
254
+ sys.exit(1)
255
+
256
+
257
+ @main.command(name="reply")
258
+ @click.argument("tweet_id_or_url")
259
+ @click.argument("text")
260
+ @click.pass_context
261
+ def post_reply(ctx, tweet_id_or_url, text):
262
+ """Reply to a tweet."""
263
+ tweet_id = extract_tweet_id(tweet_id_or_url)
264
+ if not tweet_id:
265
+ click.echo(f"Error: cannot parse tweet ID from {tweet_id_or_url!r}", err=True)
266
+ sys.exit(1)
267
+ with _client(ctx) as client:
268
+ new_id = client.reply(text, tweet_id)
269
+ if new_id:
270
+ click.echo(f"Replied: https://x.com/i/status/{new_id}")
271
+ else:
272
+ click.echo("Failed to post reply.", err=True)
273
+ sys.exit(1)
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # search / mentions
278
+ # ---------------------------------------------------------------------------
279
+
280
+ @main.command()
281
+ @click.argument("query")
282
+ @click.option("-n", "--count", default=20, show_default=True)
283
+ @click.option("--json", "as_json", is_flag=True)
284
+ @click.option("--cursor", default=None)
285
+ @click.option("--max-pages", type=int, default=None)
286
+ @click.pass_context
287
+ def search(ctx, query, count, as_json, cursor, max_pages):
288
+ """Search for tweets matching a query."""
289
+ as_json = as_json or ctx.obj.get("as_json")
290
+ with _client(ctx) as client:
291
+ tweets, next_cursor = client.search(query, count, cursor=cursor, max_pages=max_pages)
292
+ if as_json:
293
+ click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
294
+ else:
295
+ _dump_tweets(tweets, False)
296
+
297
+
298
+ @main.command()
299
+ @click.option("--user", default=None, help="@handle to search mentions for")
300
+ @click.option("-n", "--count", default=20, show_default=True)
301
+ @click.option("--json", "as_json", is_flag=True)
302
+ @click.pass_context
303
+ def mentions(ctx, user, count, as_json):
304
+ """Find tweets mentioning a user (defaults to authenticated user)."""
305
+ as_json = as_json or ctx.obj.get("as_json")
306
+ with _client(ctx) as client:
307
+ tweets, _ = client.get_mentions(user, count)
308
+ _dump_tweets(tweets, as_json)
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # user-tweets
313
+ # ---------------------------------------------------------------------------
314
+
315
+ @main.command("user-tweets")
316
+ @click.argument("handle")
317
+ @click.option("-n", "--count", default=20, show_default=True)
318
+ @click.option("--json", "as_json", is_flag=True)
319
+ @click.option("--cursor", default=None)
320
+ @click.pass_context
321
+ def user_tweets(ctx, handle, count, as_json, cursor):
322
+ """Get tweets from a user's profile timeline."""
323
+ as_json = as_json or ctx.obj.get("as_json")
324
+ norm = normalize_handle(handle)
325
+ if not norm:
326
+ click.echo(f"Invalid handle: {handle!r}", err=True)
327
+ sys.exit(1)
328
+ with _client(ctx) as client:
329
+ user = client.get_user_id_by_username(norm)
330
+ if not user:
331
+ click.echo(f"User @{norm} not found.", err=True)
332
+ sys.exit(1)
333
+ tweets, next_cursor = client.get_user_tweets(user.id, count, cursor=cursor)
334
+ if as_json:
335
+ click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
336
+ else:
337
+ _dump_tweets(tweets, False)
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # bookmarks / unbookmark
342
+ # ---------------------------------------------------------------------------
343
+
344
+ @main.command()
345
+ @click.option("-n", "--count", default=20, show_default=True)
346
+ @click.option("--folder-id", default=None)
347
+ @click.option("--all", "fetch_all", is_flag=True)
348
+ @click.option("--max-pages", type=int, default=None)
349
+ @click.option("--cursor", default=None)
350
+ @click.option("--json", "as_json", is_flag=True)
351
+ @click.pass_context
352
+ def bookmarks(ctx, count, folder_id, fetch_all, max_pages, cursor, as_json):
353
+ """List bookmarked tweets."""
354
+ as_json = as_json or ctx.obj.get("as_json")
355
+ limit = -1 if fetch_all else count
356
+ with _client(ctx) as client:
357
+ tweets, next_cursor = client.get_bookmarks(
358
+ limit, folder_id=folder_id, cursor=cursor, max_pages=max_pages
359
+ )
360
+ if as_json:
361
+ click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
362
+ else:
363
+ _dump_tweets(tweets, False)
364
+
365
+
366
+ @main.command()
367
+ @click.argument("tweet_ids_or_urls", nargs=-1, required=True)
368
+ @click.pass_context
369
+ def unbookmark(ctx, tweet_ids_or_urls):
370
+ """Remove one or more bookmarks by tweet ID or URL."""
371
+ with _client(ctx) as client:
372
+ for val in tweet_ids_or_urls:
373
+ tweet_id = extract_tweet_id(val)
374
+ if not tweet_id:
375
+ click.echo(f"Cannot parse ID from {val!r}", err=True)
376
+ continue
377
+ ok = client.unbookmark(tweet_id)
378
+ status = "Removed" if ok else "Failed to remove"
379
+ click.echo(f"{status}: {tweet_id}")
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # likes
384
+ # ---------------------------------------------------------------------------
385
+
386
+ @main.command()
387
+ @click.option("-n", "--count", default=20, show_default=True)
388
+ @click.option("--json", "as_json", is_flag=True)
389
+ @click.option("--cursor", default=None)
390
+ @click.pass_context
391
+ def likes(ctx, count, as_json, cursor):
392
+ """List liked tweets."""
393
+ as_json = as_json or ctx.obj.get("as_json")
394
+ with _client(ctx) as client:
395
+ tweets, next_cursor = client.get_likes(count, cursor=cursor)
396
+ if as_json:
397
+ click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
398
+ else:
399
+ _dump_tweets(tweets, False)
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # home
404
+ # ---------------------------------------------------------------------------
405
+
406
+ @main.command()
407
+ @click.option("-n", "--count", default=20, show_default=True)
408
+ @click.option("--following", is_flag=True, help="Show Following (chronological) feed")
409
+ @click.option("--json", "as_json", is_flag=True)
410
+ @click.pass_context
411
+ def home(ctx, count, following, as_json):
412
+ """Fetch home timeline (For You or Following feed)."""
413
+ as_json = as_json or ctx.obj.get("as_json")
414
+ with _client(ctx) as client:
415
+ if following:
416
+ tweets = client.get_home_latest_timeline(count)
417
+ else:
418
+ tweets = client.get_home_timeline(count)
419
+ _dump_tweets(tweets, as_json)
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # following / followers
424
+ # ---------------------------------------------------------------------------
425
+
426
+ @main.command()
427
+ @click.option("--user", default=None, help="User ID to look up (defaults to self)")
428
+ @click.option("-n", "--count", default=20, show_default=True)
429
+ @click.option("--json", "as_json", is_flag=True)
430
+ @click.option("--cursor", default=None)
431
+ @click.pass_context
432
+ def following(ctx, user, count, as_json, cursor):
433
+ """List users the authenticated user (or --user) follows."""
434
+ as_json = as_json or ctx.obj.get("as_json")
435
+ with _client(ctx) as client:
436
+ if user:
437
+ uid = user
438
+ else:
439
+ me = client.get_current_user()
440
+ if not me:
441
+ click.echo("Could not determine current user.", err=True)
442
+ sys.exit(1)
443
+ uid = me.id
444
+ users, next_cursor = client.get_following(uid, count, cursor=cursor)
445
+ if as_json:
446
+ click.echo(json.dumps({"users": [_user_to_dict(u) for u in users], "nextCursor": next_cursor}, ensure_ascii=False))
447
+ else:
448
+ for u in users:
449
+ click.echo(f"@{u.username} — {u.name}")
450
+
451
+
452
+ @main.command()
453
+ @click.option("--user", default=None, help="User ID to look up (defaults to self)")
454
+ @click.option("-n", "--count", default=20, show_default=True)
455
+ @click.option("--json", "as_json", is_flag=True)
456
+ @click.option("--cursor", default=None)
457
+ @click.pass_context
458
+ def followers(ctx, user, count, as_json, cursor):
459
+ """List users that follow the authenticated user (or --user)."""
460
+ as_json = as_json or ctx.obj.get("as_json")
461
+ with _client(ctx) as client:
462
+ if user:
463
+ uid = user
464
+ else:
465
+ me = client.get_current_user()
466
+ if not me:
467
+ click.echo("Could not determine current user.", err=True)
468
+ sys.exit(1)
469
+ uid = me.id
470
+ users, next_cursor = client.get_followers(uid, count, cursor=cursor)
471
+ if as_json:
472
+ click.echo(json.dumps({"users": [_user_to_dict(u) for u in users], "nextCursor": next_cursor}, ensure_ascii=False))
473
+ else:
474
+ for u in users:
475
+ click.echo(f"@{u.username} — {u.name}")
476
+
477
+
478
+ # ---------------------------------------------------------------------------
479
+ # lists / list-timeline
480
+ # ---------------------------------------------------------------------------
481
+
482
+ @main.command("lists")
483
+ @click.option("--member-of", is_flag=True, help="Show lists you're a member of")
484
+ @click.option("-n", "--count", default=100, show_default=True)
485
+ @click.option("--json", "as_json", is_flag=True)
486
+ @click.pass_context
487
+ def list_lists(ctx, member_of, count, as_json):
488
+ """List your owned lists or memberships."""
489
+ as_json = as_json or ctx.obj.get("as_json")
490
+ with _client(ctx) as client:
491
+ if member_of:
492
+ lst = client.get_list_memberships(count)
493
+ else:
494
+ lst = client.get_owned_lists(count)
495
+ if as_json:
496
+ click.echo(json.dumps([
497
+ {"id": l.id, "name": l.name, "memberCount": l.member_count, "isPrivate": l.is_private}
498
+ for l in lst
499
+ ], ensure_ascii=False))
500
+ else:
501
+ for l in lst:
502
+ priv = " (private)" if l.is_private else ""
503
+ click.echo(f"[{l.id}] {l.name}{priv} — {l.member_count or '?'} members")
504
+
505
+
506
+ @main.command("list-timeline")
507
+ @click.argument("list_id_or_url")
508
+ @click.option("-n", "--count", default=20, show_default=True)
509
+ @click.option("--json", "as_json", is_flag=True)
510
+ @click.option("--cursor", default=None)
511
+ @click.option("--max-pages", type=int, default=None)
512
+ @click.pass_context
513
+ def list_timeline(ctx, list_id_or_url, count, as_json, cursor, max_pages):
514
+ """Get tweets from a list timeline."""
515
+ list_id = extract_list_id(list_id_or_url)
516
+ if not list_id:
517
+ click.echo(f"Cannot parse list ID from {list_id_or_url!r}", err=True)
518
+ sys.exit(1)
519
+ as_json = as_json or ctx.obj.get("as_json")
520
+ with _client(ctx) as client:
521
+ tweets, next_cursor = client.get_list_timeline(list_id, count, cursor=cursor, max_pages=max_pages)
522
+ if as_json:
523
+ click.echo(json.dumps([_tweet_to_dict(t) for t in tweets], ensure_ascii=False, indent=2))
524
+ else:
525
+ _dump_tweets(tweets, False)
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # news / trending
530
+ # ---------------------------------------------------------------------------
531
+
532
+ @main.command()
533
+ @click.option("-n", "--count", default=10, show_default=True)
534
+ @click.option("--ai-only", is_flag=True)
535
+ @click.option("--with-tweets", is_flag=True)
536
+ @click.option("--tweets-per-item", type=int, default=5, show_default=True)
537
+ @click.option("--for-you", "tab_for_you", is_flag=True)
538
+ @click.option("--news-only", "tab_news", is_flag=True)
539
+ @click.option("--sports", "tab_sports", is_flag=True)
540
+ @click.option("--entertainment", "tab_entertainment", is_flag=True)
541
+ @click.option("--trending-only", "tab_trending", is_flag=True)
542
+ @click.option("--json", "as_json", is_flag=True)
543
+ @click.pass_context
544
+ def news(ctx, count, ai_only, with_tweets, tweets_per_item,
545
+ tab_for_you, tab_news, tab_sports, tab_entertainment, tab_trending, as_json):
546
+ """Fetch news and trending topics from X's Explore tabs."""
547
+ as_json = as_json or ctx.obj.get("as_json")
548
+ tabs: list[str] = []
549
+ if tab_for_you:
550
+ tabs.append("forYou")
551
+ if tab_news:
552
+ tabs.append("news")
553
+ if tab_sports:
554
+ tabs.append("sports")
555
+ if tab_entertainment:
556
+ tabs.append("entertainment")
557
+ if tab_trending:
558
+ tabs.append("trending")
559
+ if not tabs:
560
+ tabs = ["forYou", "news", "sports", "entertainment"]
561
+ with _client(ctx) as client:
562
+ items = client.get_news(
563
+ count,
564
+ ai_only=ai_only,
565
+ with_tweets=with_tweets,
566
+ tweets_per_item=tweets_per_item,
567
+ tabs=tabs,
568
+ )
569
+ if as_json:
570
+ def _item_dict(item):
571
+ d = {
572
+ "id": item.id,
573
+ "headline": item.headline,
574
+ "category": item.category,
575
+ "timeAgo": item.time_ago,
576
+ "postCount": item.post_count,
577
+ "description": item.description,
578
+ "url": item.url,
579
+ }
580
+ if item.tweets:
581
+ d["tweets"] = [_tweet_to_dict(t) for t in item.tweets]
582
+ return d
583
+ click.echo(json.dumps([_item_dict(i) for i in items], ensure_ascii=False))
584
+ else:
585
+ for item in items:
586
+ parts = [item.headline]
587
+ if item.category:
588
+ parts.append(f"[{item.category}]")
589
+ if item.time_ago:
590
+ parts.append(item.time_ago)
591
+ if item.post_count:
592
+ parts.append(f"{item.post_count:,} posts")
593
+ click.echo(" ".join(parts))
594
+
595
+
596
+ @main.command()
597
+ @click.option("-n", "--count", default=10, show_default=True)
598
+ @click.option("--json", "as_json", is_flag=True)
599
+ @click.pass_context
600
+ def trending(ctx, count, as_json):
601
+ """Alias for news --trending-only."""
602
+ ctx.invoke(news, count=count, ai_only=False, with_tweets=False, tweets_per_item=5,
603
+ tab_for_you=False, tab_news=False, tab_sports=False,
604
+ tab_entertainment=False, tab_trending=True, as_json=as_json)
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # about / whoami / check
609
+ # ---------------------------------------------------------------------------
610
+
611
+ @main.command()
612
+ @click.argument("handle")
613
+ @click.option("--json", "as_json", is_flag=True)
614
+ @click.pass_context
615
+ def about(ctx, handle, as_json):
616
+ """Get 'About this account' information for a user."""
617
+ as_json = as_json or ctx.obj.get("as_json")
618
+ with _client(ctx) as client:
619
+ profile = client.get_user_about_account(handle)
620
+ if not profile:
621
+ click.echo("No about information found.", err=True)
622
+ sys.exit(1)
623
+ if as_json:
624
+ click.echo(json.dumps({
625
+ "accountBasedIn": profile.account_based_in,
626
+ "source": profile.source,
627
+ "createdCountryAccurate": profile.created_country_accurate,
628
+ "locationAccurate": profile.location_accurate,
629
+ "learnMoreUrl": profile.learn_more_url,
630
+ }, ensure_ascii=False))
631
+ else:
632
+ if profile.account_based_in:
633
+ click.echo(f"Based in: {profile.account_based_in}")
634
+ if profile.source:
635
+ click.echo(f"Source: {profile.source}")
636
+ if profile.created_country_accurate:
637
+ click.echo(f"Created in: {profile.created_country_accurate}")
638
+
639
+
640
+ @main.command()
641
+ @click.option("--json", "as_json", is_flag=True)
642
+ @click.pass_context
643
+ def whoami(ctx, as_json):
644
+ """Print which X account your cookies belong to."""
645
+ as_json = as_json or ctx.obj.get("as_json")
646
+ with _client(ctx) as client:
647
+ user = client.get_current_user()
648
+ if not user:
649
+ click.echo("Could not determine current user.", err=True)
650
+ sys.exit(1)
651
+ if as_json:
652
+ click.echo(json.dumps(_user_to_dict(user), ensure_ascii=False))
653
+ else:
654
+ click.echo(f"@{user.username} ({user.name}) — id: {user.id}")
655
+
656
+
657
+ @main.command()
658
+ @click.pass_context
659
+ def check(ctx):
660
+ """Show which credentials are available and where they came from."""
661
+ import os as _os
662
+ o = ctx.obj
663
+ saved = load_credentials()
664
+
665
+ sources: dict[str, str] = {}
666
+ for key, flag_val, env_keys, saved_key in [
667
+ ("auth_token", o.get("auth_token"), ["AUTH_TOKEN", "TWITTER_AUTH_TOKEN"], "auth_token"),
668
+ ("ct0", o.get("ct0"), ["CT0", "TWITTER_CT0"], "ct0"),
669
+ ]:
670
+ if flag_val:
671
+ sources[key] = "flag"
672
+ elif any(_os.environ.get(e) for e in env_keys):
673
+ sources[key] = "env"
674
+ elif saved.get(saved_key):
675
+ sources[key] = "credentials file"
676
+ else:
677
+ sources[key] = "NOT SET"
678
+
679
+ for key, source in sources.items():
680
+ click.echo(f"{key:<12} {source}")
681
+
682
+
683
+ @main.command()
684
+ def configure():
685
+ """Interactively save X/Twitter credentials (auth_token and ct0).
686
+
687
+ Credentials are stored in ~/.config/bird/credentials.json and loaded
688
+ automatically by all commands.
689
+
690
+ \b
691
+ Where to find these values:
692
+ 1. Log in to x.com in your browser
693
+ 2. Open DevTools -> Application -> Cookies -> https://x.com
694
+ 3. Copy the values of auth_token and ct0
695
+ """
696
+ import sys
697
+
698
+ saved = load_credentials()
699
+
700
+ print("Configure bird credentials\n", flush=True)
701
+ print("Where to find these: x.com DevTools -> Application -> Cookies -> https://x.com\n", flush=True)
702
+
703
+ def _read(label: str, saved_key: str) -> str:
704
+ current = saved.get(saved_key, "")
705
+ hint = f" [{current[:8]}...] (Enter to keep)" if current else ""
706
+ print(f"{label}{hint}: ", end="", flush=True)
707
+ try:
708
+ value = sys.stdin.readline().strip()
709
+ except (EOFError, KeyboardInterrupt):
710
+ print()
711
+ sys.exit(0)
712
+ return value or current
713
+
714
+ auth_token = _read("auth_token", "auth_token")
715
+ ct0 = _read("ct0", "ct0")
716
+
717
+ if not auth_token or not ct0:
718
+ print("Aborted — both values are required.", flush=True)
719
+ sys.exit(1)
720
+
721
+ print("\nValidating credentials...", flush=True)
722
+ try:
723
+ client = TwitterClient(auth_token, ct0, timeout=15)
724
+ user = client.get_current_user()
725
+ client.close()
726
+ except Exception as exc:
727
+ print(f"Error connecting to X: {exc}", flush=True)
728
+ sys.exit(1)
729
+
730
+ if not user:
731
+ print("Could not verify credentials — check your auth_token and ct0.", flush=True)
732
+ sys.exit(1)
733
+
734
+ path = save_credentials(auth_token, ct0)
735
+ print(f"Authenticated as @{user.username} ({user.name})", flush=True)
736
+ print(f"Credentials saved to {path}", flush=True)
737
+
738
+
739
+ # ---------------------------------------------------------------------------
740
+ # query-ids
741
+ # ---------------------------------------------------------------------------
742
+
743
+ @main.command("query-ids")
744
+ @click.option("--fresh", is_flag=True, help="Force-refresh the cache from x.com bundles")
745
+ @click.option("--json", "as_json", is_flag=True)
746
+ @click.pass_context
747
+ def query_ids_cmd(ctx, fresh, as_json):
748
+ """Inspect or refresh the cached GraphQL query IDs."""
749
+ from ._query_ids import query_id_store
750
+ if fresh:
751
+ from ._constants import FALLBACK_QUERY_IDS
752
+ query_id_store.refresh(list(FALLBACK_QUERY_IDS.keys()), force=True)
753
+ click.echo("Query IDs refreshed.")
754
+ info = query_id_store.info()
755
+ if as_json:
756
+ click.echo(json.dumps(info, ensure_ascii=False))
757
+ else:
758
+ cached = info.get("cached", False)
759
+ click.echo(f"Cache path: {info['cachePath']}")
760
+ click.echo(f"Cached: {cached}")
761
+ if cached:
762
+ click.echo(f"Age: {info.get('ageSeconds', '?')}s (TTL {info.get('ttl', '?')}s)")
763
+ click.echo(f"Fresh: {info.get('fresh', '?')}")
764
+ ids = info.get("ids") or {}
765
+ click.echo(f"Operations: {len(ids)} cached")
766
+
767
+
768
+ if __name__ == "__main__":
769
+ main()