返回 last30days-skill
ui.py
1 """Terminal UI utilities for last30days skill."""
2
3 import sys
4 import time
5 import threading
6 import random
7 from typing import Optional
8
9 from .render import _skill_version
10
11 # Check if we're in a real terminal (not captured by Claude Code)
12 IS_TTY = sys.stderr.isatty()
13
14 # ANSI color codes
15 class Colors:
16 PURPLE = '\033[95m'
17 BLUE = '\033[94m'
18 CYAN = '\033[96m'
19 GREEN = '\033[92m'
20 YELLOW = '\033[93m'
21 RED = '\033[91m'
22 BOLD = '\033[1m'
23 DIM = '\033[2m'
24 RESET = '\033[0m'
25
26
27 BANNER = f"""{Colors.PURPLE}{Colors.BOLD}
28 ██╗ █████╗ ███████╗████████╗██████╗ ██████╗ ██████╗ █████╗ ██╗ ██╗███████╗
29 ██║ ██╔══██╗██╔════╝╚══██╔══╝╚════██╗██╔═████╗██╔══██╗██╔══██╗╚██╗ ██╔╝██╔════╝
30 ██║ ███████║███████╗ ██║ █████╔╝██║██╔██║██║ ██║███████║ ╚████╔╝ ███████╗
31 ██║ ██╔══██║╚════██║ ██║ ╚═══██╗████╔╝██║██║ ██║██╔══██║ ╚██╔╝ ╚════██║
32 ███████╗██║ ██║███████║ ██║ ██████╔╝╚██████╔╝██████╔╝██║ ██║ ██║ ███████║
33 ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
34 {Colors.RESET}{Colors.DIM} 30 days of research. 30 seconds of work.{Colors.RESET}
35 """
36
37 MINI_BANNER = f"""{Colors.PURPLE}{Colors.BOLD}/last30days{Colors.RESET} {Colors.DIM}· researching...{Colors.RESET}"""
38
39 # Fun status messages for each phase
40 REDDIT_MESSAGES = [
41 "Diving into Reddit threads...",
42 "Scanning subreddits for gold...",
43 "Reading what Redditors are saying...",
44 "Exploring the front page of the internet...",
45 "Finding the good discussions...",
46 "Upvoting mentally...",
47 "Scrolling through comments...",
48 ]
49
50 X_MESSAGES = [
51 "Checking what X is buzzing about...",
52 "Reading the timeline...",
53 "Finding the hot takes...",
54 "Scanning tweets and threads...",
55 "Discovering trending insights...",
56 "Following the conversation...",
57 "Reading between the posts...",
58 ]
59
60 ENRICHING_MESSAGES = [
61 "Getting the juicy details...",
62 "Fetching engagement metrics...",
63 "Reading top comments...",
64 "Extracting insights...",
65 "Analyzing discussions...",
66 ]
67
68 YOUTUBE_MESSAGES = [
69 "Searching YouTube for videos...",
70 "Finding relevant video content...",
71 "Scanning YouTube channels...",
72 "Discovering video discussions...",
73 "Fetching transcripts...",
74 ]
75
76 TIKTOK_MESSAGES = [
77 "Searching TikTok for trending videos...",
78 "Finding what's viral on TikTok...",
79 "Scanning TikTok for relevant content...",
80 ]
81
82 INSTAGRAM_MESSAGES = [
83 "Searching Instagram Reels...",
84 "Finding what's trending on Instagram...",
85 "Scanning Instagram for relevant reels...",
86 ]
87
88 HN_MESSAGES = [
89 "Searching Hacker News...",
90 "Scanning HN front page stories...",
91 "Finding technical discussions...",
92 "Discovering developer conversations...",
93 ]
94
95 POLYMARKET_MESSAGES = [
96 "Checking prediction markets...",
97 "Finding what people are betting on...",
98 "Scanning Polymarket for odds...",
99 "Discovering prediction markets...",
100 ]
101
102 PROCESSING_MESSAGES = [
103 "Crunching the data...",
104 "Scoring and ranking...",
105 "Finding patterns...",
106 "Removing duplicates...",
107 "Organizing findings...",
108 ]
109
110 WEB_ONLY_MESSAGES = [
111 "Searching the web...",
112 "Finding blogs and docs...",
113 "Crawling news sites...",
114 "Discovering tutorials...",
115 ]
116
117 SOURCE_COMPLETION_ORDER = [
118 "reddit",
119 "x",
120 "youtube",
121 "tiktok",
122 "instagram",
123 "hackernews",
124 "bluesky",
125 "truthsocial",
126 "polymarket",
127 "grounding",
128 "xiaohongshu",
129 "digg",
130 "arxiv",
131 "techmeme",
132 "trustpilot",
133 "amazon",
134 "meta_ads",
135 ]
136
137 SOURCE_COMPLETION_META = {
138 "reddit": ("Reddit", "thread", "threads", Colors.YELLOW),
139 "x": ("X", "post", "posts", Colors.CYAN),
140 "youtube": ("YouTube", "video", "videos", Colors.RED),
141 "tiktok": ("TikTok", "video", "videos", Colors.PURPLE),
142 "instagram": ("Instagram", "reel", "reels", Colors.PURPLE),
143 "hackernews": ("HN", "story", "stories", Colors.YELLOW),
144 "bluesky": ("Bluesky", "post", "posts", Colors.BLUE),
145 "truthsocial": ("Truth Social", "post", "posts", Colors.CYAN),
146 "polymarket": ("Polymarket", "market", "markets", Colors.GREEN),
147 "grounding": ("Web", "result", "results", Colors.GREEN),
148 "xiaohongshu": ("Xiaohongshu", "post", "posts", Colors.RED),
149 "digg": ("Digg", "cluster", "clusters", Colors.YELLOW),
150 "arxiv": ("arXiv", "paper", "papers", Colors.RED),
151 "techmeme": ("Techmeme", "headline", "headlines", Colors.CYAN),
152 "trustpilot": ("Trustpilot", "review", "reviews", Colors.GREEN),
153 "amazon": ("Amazon", "product", "products", Colors.YELLOW),
154 "meta_ads": ("Meta Ads", "creative", "creatives", Colors.BLUE),
155 }
156
157
158 def _completion_sources(source_counts: dict[str, int], display_sources: list[str] | None) -> list[str]:
159 requested = list(dict.fromkeys(display_sources or []))
160 if not requested:
161 requested = [source for source, count in source_counts.items() if count]
162 if not requested and source_counts:
163 requested = list(source_counts)
164
165 candidate_set = set(requested) | set(source_counts)
166 ordered = [source for source in SOURCE_COMPLETION_ORDER if source in candidate_set]
167 for source in requested + list(source_counts):
168 if source in candidate_set and source not in ordered:
169 ordered.append(source)
170 return ordered
171
172
173 def _format_completion_part(source: str, count: int, tty: bool) -> str:
174 label, singular, plural, color = SOURCE_COMPLETION_META.get(
175 source,
176 (source.replace("_", " ").title(), "result", "results", Colors.RESET),
177 )
178 unit = singular if count == 1 else plural
179 if tty:
180 return f"{color}{label}:{Colors.RESET} {count} {unit}"
181 return f"{label}: {count} {unit}"
182
183 def _build_nux_message(diag: dict = None) -> str:
184 """Build conversational NUX message with dynamic source status."""
185 available = set((diag or {}).get("available_sources", []))
186 if diag:
187 reddit = "✓" if "reddit" in available else "✗"
188 x = "✓" if "x" in available else "✗"
189 youtube = "✓" if "youtube" in available else "✗"
190 web = "✓" if "grounding" in available else "✗"
191 status_line = f"Reddit {reddit}, X {x}, YouTube {youtube}, Web {web}"
192 else:
193 status_line = "YouTube ✓, Web ✓, Reddit ✗, X ✗"
194
195 return f"""
196 I just researched that for you. Here's what I've got right now:
197
198 {status_line}
199
200 More sources means better research, but it works fine as-is. You can unlock more for free - log into x.com in your browser for X, and run `brew install yt-dlp` for YouTube transcripts. That gives you Reddit (with comments), X, YouTube, HN, and Polymarket - all free.
201
202 Some examples of what you can do:
203 - "last30 what are people saying about Figma"
204 - "last30 watch my biggest competitor every week"
205 - "last30 watch AI video tools monthly"
206 - "last30 what have you found about AI video?"
207
208 Just start with "last30" and talk to me like normal.
209 """
210
211 # Shorter promo for single missing key
212 PROMO_SINGLE_KEY = {
213 "reddit": "\n💡 Unlock TikTok and Instagram with SCRAPECREATORS_API_KEY - 10,000 free calls, no CC - scrapecreators.com\n",
214 "x": "\n💡 Unlock X: log into x.com in your browser, then re-run. "
215 "Firefox works on all platforms. Safari works on macOS (detected automatically). "
216 "Chrome, Brave, Edge, Arc, Vivaldi, Opera, or Chromium on macOS require "
217 "FROM_BROWSER=auto in .env (Keychain dialog). On Windows only Firefox is supported. "
218 "Or add AUTH_TOKEN/CT0 or XAI_API_KEY.\n",
219 "web": "\n💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY.\n",
220 }
221
222 # Bird auth help (for local users with vendored Bird CLI)
223 BIRD_AUTH_HELP = f"""
224 {Colors.YELLOW}Bird authentication failed.{Colors.RESET}
225
226 To fix this:
227 1. Add AUTH_TOKEN and CT0 to ~/.config/last30days/.env, or to trusted .claude/last30days.env with LAST30DAYS_TRUST_PROJECT_CONFIG=1
228 2. Or set XAI_API_KEY for the xAI fallback backend
229 """
230
231 BIRD_AUTH_HELP_PLAIN = """
232 Bird authentication failed.
233
234 To fix this:
235 1. Add AUTH_TOKEN and CT0 to ~/.config/last30days/.env, or to trusted .claude/last30days.env with LAST30DAYS_TRUST_PROJECT_CONFIG=1
236 2. Or set XAI_API_KEY for the xAI fallback backend
237 """
238
239 # Spinner frames
240 SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
241 DOTS_FRAMES = [' ', '. ', '.. ', '...']
242
243
244 class Spinner:
245 """Animated spinner for long-running operations."""
246
247 def __init__(self, message: str = "Working", color: str = Colors.CYAN, quiet: bool = False):
248 self.message = message
249 self.color = color
250 self.running = False
251 self.thread: Optional[threading.Thread] = None
252 self.frame_idx = 0
253 self.shown_static = False
254 self.quiet = quiet # Suppress non-TTY start message (still shows ✓ completion)
255
256 def _spin(self):
257 while self.running:
258 frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]
259 sys.stderr.write(f"\r{self.color}{frame}{Colors.RESET} {self.message} ")
260 sys.stderr.flush()
261 self.frame_idx += 1
262 time.sleep(0.08)
263
264 def start(self):
265 self.running = True
266 if IS_TTY:
267 # Real terminal - animate
268 self.thread = threading.Thread(target=self._spin, daemon=True)
269 self.thread.start()
270 else:
271 # Not a TTY (Claude Code) - just print once
272 if not self.shown_static and not self.quiet:
273 sys.stderr.write(f"⏳ {self.message}\n")
274 sys.stderr.flush()
275 self.shown_static = True
276
277 def update(self, message: str):
278 self.message = message
279 if not IS_TTY and not self.shown_static:
280 # Print update in non-TTY mode
281 sys.stderr.write(f"⏳ {message}\n")
282 sys.stderr.flush()
283
284 def stop(self, final_message: str = ""):
285 self.running = False
286 if self.thread:
287 self.thread.join(timeout=0.2)
288 if IS_TTY:
289 # Clear the line in real terminal
290 sys.stderr.write("\r" + " " * 80 + "\r")
291 if final_message:
292 sys.stderr.write(f"✓ {final_message}\n")
293 sys.stderr.flush()
294
295
296 class ProgressDisplay:
297 """Progress display for research phases."""
298
299 def __init__(self, topic: str, show_banner: bool = True):
300 self.topic = topic
301 self.spinner: Optional[Spinner] = None
302 self.start_time = time.time()
303
304 if show_banner:
305 self._show_banner()
306
307 def _show_banner(self):
308 if IS_TTY:
309 sys.stderr.write(MINI_BANNER + "\n")
310 sys.stderr.write(f"{Colors.DIM}Topic: {Colors.RESET}{Colors.BOLD}{self.topic}{Colors.RESET}\n\n")
311 else:
312 # Simple text for non-TTY
313 sys.stderr.write(f"/last30days · researching: {self.topic}\n")
314 sys.stderr.flush()
315
316 def start_reddit(self):
317 msg = random.choice(REDDIT_MESSAGES)
318 self.spinner = Spinner(f"{Colors.YELLOW}Reddit{Colors.RESET} {msg}", Colors.YELLOW)
319 self.spinner.start()
320
321 def end_reddit(self, count: int):
322 if self.spinner:
323 self.spinner.stop(f"{Colors.YELLOW}Reddit{Colors.RESET} Found {count} threads")
324
325 def start_reddit_enrich(self, current: int, total: int):
326 if self.spinner:
327 self.spinner.stop()
328 msg = random.choice(ENRICHING_MESSAGES)
329 self.spinner = Spinner(f"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}", Colors.YELLOW)
330 self.spinner.start()
331
332 def update_reddit_enrich(self, current: int, total: int):
333 if self.spinner:
334 msg = random.choice(ENRICHING_MESSAGES)
335 self.spinner.update(f"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}")
336
337 def end_reddit_enrich(self):
338 if self.spinner:
339 self.spinner.stop(f"{Colors.YELLOW}Reddit{Colors.RESET} Enriched with engagement data")
340
341 def start_x(self):
342 msg = random.choice(X_MESSAGES)
343 self.spinner = Spinner(f"{Colors.CYAN}X{Colors.RESET} {msg}", Colors.CYAN)
344 self.spinner.start()
345
346 def end_x(self, count: int):
347 if self.spinner:
348 self.spinner.stop(f"{Colors.CYAN}X{Colors.RESET} Found {count} posts")
349
350 def start_youtube(self):
351 msg = random.choice(YOUTUBE_MESSAGES)
352 self.spinner = Spinner(f"{Colors.RED}YouTube{Colors.RESET} {msg}", Colors.RED)
353 self.spinner.start()
354
355 def end_youtube(self, count: int):
356 if self.spinner:
357 self.spinner.stop(f"{Colors.RED}YouTube{Colors.RESET} Found {count} videos")
358
359 def start_tiktok(self):
360 msg = random.choice(TIKTOK_MESSAGES)
361 self.spinner = Spinner(f"{Colors.PURPLE}TikTok{Colors.RESET} {msg}", Colors.PURPLE)
362 self.spinner.start()
363
364 def end_tiktok(self, count: int):
365 if self.spinner:
366 self.spinner.stop(f"{Colors.PURPLE}TikTok{Colors.RESET} Found {count} videos")
367
368 def start_instagram(self):
369 msg = random.choice(INSTAGRAM_MESSAGES)
370 self.spinner = Spinner(f"{Colors.PURPLE}Instagram{Colors.RESET} {msg}", Colors.PURPLE)
371 self.spinner.start()
372
373 def end_instagram(self, count: int):
374 if self.spinner:
375 self.spinner.stop(f"{Colors.PURPLE}Instagram{Colors.RESET} Found {count} reels")
376
377 def start_hackernews(self):
378 msg = random.choice(HN_MESSAGES)
379 self.spinner = Spinner(f"{Colors.YELLOW}HN{Colors.RESET} {msg}", Colors.YELLOW, quiet=True)
380 self.spinner.start()
381
382 def end_hackernews(self, count: int):
383 if self.spinner:
384 self.spinner.stop(f"{Colors.YELLOW}HN{Colors.RESET} Found {count} stories")
385
386 def start_polymarket(self):
387 msg = random.choice(POLYMARKET_MESSAGES)
388 self.spinner = Spinner(f"{Colors.GREEN}Polymarket{Colors.RESET} {msg}", Colors.GREEN, quiet=True)
389 self.spinner.start()
390
391 def end_polymarket(self, count: int):
392 if self.spinner:
393 self.spinner.stop(f"{Colors.GREEN}Polymarket{Colors.RESET} Found {count} markets")
394
395 def start_processing(self):
396 msg = random.choice(PROCESSING_MESSAGES)
397 self.spinner = Spinner(f"{Colors.PURPLE}Processing{Colors.RESET} {msg}", Colors.PURPLE)
398 self.spinner.start()
399
400 def end_processing(self):
401 if self.spinner:
402 self.spinner.stop()
403
404 def show_complete(
405 self,
406 reddit_count: int = 0,
407 x_count: int = 0,
408 youtube_count: int = 0,
409 hn_count: int = 0,
410 pm_count: int = 0,
411 tiktok_count: int = 0,
412 ig_count: int = 0,
413 *,
414 source_counts: dict[str, int] | None = None,
415 display_sources: list[str] | None = None,
416 ):
417 elapsed = time.time() - self.start_time
418 if source_counts is None:
419 source_counts = {
420 "reddit": reddit_count,
421 "x": x_count,
422 "youtube": youtube_count,
423 "tiktok": tiktok_count,
424 "instagram": ig_count,
425 "hackernews": hn_count,
426 "polymarket": pm_count,
427 }
428 if display_sources is None:
429 display_sources = [source for source, count in source_counts.items() if count]
430 if not display_sources:
431 display_sources = ["reddit", "x"]
432
433 ordered_sources = _completion_sources(source_counts, display_sources)
434 parts = [
435 _format_completion_part(source, source_counts.get(source, 0), tty=IS_TTY)
436 for source in ordered_sources
437 ]
438 if IS_TTY:
439 sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ")
440 sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
441 sys.stderr.write(" " + " ".join(parts))
442 sys.stderr.write("\n\n")
443 else:
444 sys.stderr.write(f"✓ Research complete ({elapsed:.1f}s) - {', '.join(parts)}\n")
445 sys.stderr.flush()
446
447 def show_cached(self, age_hours: float = None):
448 if age_hours is not None:
449 age_str = f" ({age_hours:.1f}h old)"
450 else:
451 age_str = ""
452 sys.stderr.write(f"{Colors.GREEN}⚡{Colors.RESET} {Colors.DIM}Using cached results{age_str} - use --refresh for fresh data{Colors.RESET}\n\n")
453 sys.stderr.flush()
454
455 def show_error(self, message: str):
456 sys.stderr.write(f"{Colors.RED}✗ Error:{Colors.RESET} {message}\n")
457 sys.stderr.flush()
458
459 def start_web_only(self):
460 """Show web-only mode indicator."""
461 msg = random.choice(WEB_ONLY_MESSAGES)
462 self.spinner = Spinner(f"{Colors.GREEN}Web{Colors.RESET} {msg}", Colors.GREEN)
463 self.spinner.start()
464
465 def end_web_only(self):
466 """End web-only spinner."""
467 if self.spinner:
468 self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} assistant will search the web")
469
470 def show_web_only_complete(self):
471 """Show completion for web-only mode."""
472 elapsed = time.time() - self.start_time
473 if IS_TTY:
474 sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} ")
475 sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
476 sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} assistant will search blogs, docs & news\n\n")
477 else:
478 sys.stderr.write(f"✓ Ready for web search ({elapsed:.1f}s)\n")
479 sys.stderr.flush()
480
481 def show_promo(self, missing: str = "both", diag: dict = None):
482 """Show NUX / promotional message for missing API keys.
483
484 Args:
485 missing: 'both', 'all', 'reddit', or 'x' - which keys are missing
486 diag: Optional diagnostics dict for dynamic source status
487 """
488 if missing in ("both", "all"):
489 sys.stderr.write(_build_nux_message(diag))
490 elif missing in PROMO_SINGLE_KEY:
491 sys.stderr.write(PROMO_SINGLE_KEY[missing])
492 sys.stderr.flush()
493
494 def show_bird_auth_help(self):
495 """Show Bird authentication help."""
496 if IS_TTY:
497 sys.stderr.write(BIRD_AUTH_HELP)
498 else:
499 sys.stderr.write(BIRD_AUTH_HELP_PLAIN)
500 sys.stderr.flush()
501
502
503 def show_diagnostic_banner(diag: dict):
504 """Show pre-flight source status banner when sources are missing.
505
506 Args:
507 diag: Dict from pipeline.diagnose() with available_sources, x_backend,
508 bird status, provider availability, and native web backend info.
509 """
510 available_sources = set(diag.get("available_sources") or [])
511 has_reddit = "reddit" in available_sources
512 has_scrapecreators = diag.get("has_scrapecreators", False)
513 has_x = "x" in available_sources
514 has_youtube = "youtube" in available_sources
515 has_web = "grounding" in available_sources
516 has_xiaohongshu = "xiaohongshu" in available_sources
517 x_backend = diag.get("x_backend")
518 native_web_backend = diag.get("native_web_backend")
519
520 # If everything is available, no banner needed
521 if has_reddit and has_x and has_youtube and has_web:
522 return
523
524 lines = []
525
526 if IS_TTY:
527 lines.append(f"{Colors.DIM}┌─────────────────────────────────────────────────────┐{Colors.RESET}")
528 _header = f"/last30days v{_skill_version()} - Source Status"
529 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.BOLD}{_header}{Colors.RESET}{' ' * (52 - len(_header))}{Colors.DIM}│{Colors.RESET}")
530 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.DIM}│{Colors.RESET}")
531
532 # Reddit
533 if has_reddit and has_scrapecreators:
534 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — full threads with comments {Colors.DIM}│{Colors.RESET}")
535 elif has_reddit:
536 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — public threads (titles + scores) {Colors.DIM}│{Colors.RESET}")
537 else:
538 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ Reddit{Colors.RESET} — unavailable {Colors.DIM}│{Colors.RESET}")
539
540 # X/Twitter
541 if has_x:
542 username = diag.get("bird_username", "")
543 label = f"Bird ({username})" if x_backend == "bird" and username else str(x_backend or "xai").upper()
544 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ X/Twitter{Colors.RESET} — {label} {Colors.DIM}│{Colors.RESET}")
545 else:
546 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ X/Twitter{Colors.RESET} — No X auth or fallback key {Colors.DIM}│{Colors.RESET}")
547 if diag.get("bird_installed"):
548 lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY {Colors.DIM}│{Colors.RESET}")
549 else:
550 lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Needs Node.js 22+ (Bird is bundled) {Colors.DIM}│{Colors.RESET}")
551
552 # YouTube
553 if has_youtube:
554 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ YouTube{Colors.RESET} — yt-dlp found {Colors.DIM}│{Colors.RESET}")
555 else:
556 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ YouTube{Colors.RESET} — yt-dlp not installed {Colors.DIM}│{Colors.RESET}")
557 lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Fix: brew install yt-dlp (free) {Colors.DIM}│{Colors.RESET}")
558
559 # Xiaohongshu (only show when configured)
560 if has_xiaohongshu:
561 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Xiaohongshu{Colors.RESET} — API connected + logged in {Colors.DIM}│{Colors.RESET}")
562
563 # Web
564 if has_web:
565 backend = native_web_backend or "native"
566 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Web{Colors.RESET} — {backend} API {Colors.DIM}│{Colors.RESET}")
567 else:
568 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.YELLOW}⚡ Web{Colors.RESET} — Add BRAVE_API_KEY or SERPER_API_KEY {Colors.DIM}│{Colors.RESET}")
569
570 lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.DIM}│{Colors.RESET}")
571 lines.append(f"{Colors.DIM}│{Colors.RESET} Config: {Colors.BOLD}~/.config/last30days/.env{Colors.RESET} {Colors.DIM}│{Colors.RESET}")
572 lines.append(f"{Colors.DIM}└─────────────────────────────────────────────────────┘{Colors.RESET}")
573 else:
574 # Plain text for non-TTY (Claude Code / Codex)
575 lines.append("┌─────────────────────────────────────────────────────┐")
576 _header_plain = f"/last30days v{_skill_version()} - Source Status"
577 lines.append(f"│ {_header_plain}{' ' * (52 - len(_header_plain))}│")
578 lines.append("│ │")
579
580 if has_reddit and has_scrapecreators:
581 lines.append("│ ✅ Reddit — full threads with comments │")
582 elif has_reddit:
583 lines.append("│ ✅ Reddit — public threads (titles + scores) │")
584 else:
585 lines.append("│ ❌ Reddit — unavailable │")
586
587 if has_x:
588 lines.append("│ ✅ X/Twitter — available │")
589 else:
590 lines.append("│ ❌ X/Twitter — No X auth or fallback key │")
591 if diag.get("bird_installed"):
592 lines.append("│ └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY │")
593 else:
594 lines.append("│ └─ Needs Node.js 22+ (Bird is bundled) │")
595
596 if has_youtube:
597 lines.append("│ ✅ YouTube — yt-dlp found │")
598 else:
599 lines.append("│ ❌ YouTube — yt-dlp not installed │")
600 lines.append("│ └─ Fix: brew install yt-dlp (free) │")
601
602 if has_xiaohongshu:
603 lines.append("│ ✅ Xiaohongshu — API connected + logged in │")
604
605 if has_web:
606 backend = native_web_backend or "native"
607 lines.append(f"│ ✅ Web — {backend} API available{' ' * max(0, 13 - len(backend))}│")
608 else:
609 lines.append("│ ⚡ Web — Add BRAVE_API_KEY or SERPER_API_KEY │")
610
611 lines.append("│ │")
612 lines.append("│ Config: ~/.config/last30days/.env │")
613 lines.append("└─────────────────────────────────────────────────────┘")
614
615 sys.stderr.write("\n".join(lines) + "\n\n")
616 sys.stderr.flush()
617
618
619 def print_phase(phase: str, message: str):
620 """Print a phase message."""
621 colors = {
622 "reddit": Colors.YELLOW,
623 "x": Colors.CYAN,
624 "process": Colors.PURPLE,
625 "done": Colors.GREEN,
626 "error": Colors.RED,
627 }
628 color = colors.get(phase, Colors.RESET)
629 sys.stderr.write(f"{color}▸{Colors.RESET} {message}\n")
630 sys.stderr.flush()
631
631 lines PYTHON