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