diff --git a/docs/plans/2026-02-03-bird-cli-implementation.md b/docs/plans/2026-02-03-bird-cli-implementation.md new file mode 100644 index 0000000..5aa1e1a --- /dev/null +++ b/docs/plans/2026-02-03-bird-cli-implementation.md @@ -0,0 +1,929 @@ +# Bird CLI Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Bird CLI as a free, zero-config alternative to xAI for X/Twitter searches with interactive installation. + +**Architecture:** New `bird_x.py` module handles Bird detection, installation prompts, and search. Modified `env.py` determines X source priority (Bird → xAI → WebSearch). Main script prompts for Bird install if not found. + +**Tech Stack:** Python 3, subprocess for Bird CLI calls, existing lib modules for normalization/scoring. + +--- + +### Task 1: Create bird_x.py - Detection Functions + +**Files:** +- Create: `scripts/lib/bird_x.py` + +**Step 1: Create the module with detection functions** + +```python +"""Bird CLI client for X (Twitter) search.""" + +import json +import shutil +import subprocess +import sys +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + + +def _log(msg: str): + """Log to stderr.""" + sys.stderr.write(f"[Bird] {msg}\n") + sys.stderr.flush() + + +def is_bird_installed() -> bool: + """Check if Bird CLI is installed.""" + return shutil.which("bird") is not None + + +def is_bird_authenticated() -> Optional[str]: + """Check if Bird is authenticated by running 'bird whoami'. + + Returns: + Username if authenticated, None otherwise. + """ + if not is_bird_installed(): + return None + + try: + result = subprocess.run( + ["bird", "whoami"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + # Output is typically the username + return result.stdout.strip().split('\n')[0] + return None + except (subprocess.TimeoutExpired, FileNotFoundError, Exception): + return None + + +def check_npm_available() -> bool: + """Check if npm is available for installation.""" + return shutil.which("npm") is not None +``` + +**Step 2: Verify the module loads** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print('OK')"` +Expected: `OK` + +**Step 3: Commit** + +```bash +git add scripts/lib/bird_x.py +git commit -m "feat(bird): add detection functions for Bird CLI" +``` + +--- + +### Task 2: Add Bird Installation Functions + +**Files:** +- Modify: `scripts/lib/bird_x.py` + +**Step 1: Add installation function** + +Add after `check_npm_available()`: + +```python +def install_bird() -> Tuple[bool, str]: + """Install Bird CLI via npm. + + Returns: + Tuple of (success, message). + """ + if not check_npm_available(): + return False, "npm not found. Install Node.js first, or install Bird manually: https://github.com/steipete/bird" + + try: + _log("Installing Bird CLI...") + result = subprocess.run( + ["npm", "install", "-g", "@steipete/bird"], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode == 0: + return True, "Bird CLI installed successfully!" + else: + error = result.stderr.strip() or result.stdout.strip() or "Unknown error" + return False, f"Installation failed: {error}" + except subprocess.TimeoutExpired: + return False, "Installation timed out" + except Exception as e: + return False, f"Installation error: {e}" + + +def get_bird_status() -> Dict[str, Any]: + """Get comprehensive Bird status. + + Returns: + Dict with keys: installed, authenticated, username, can_install + """ + installed = is_bird_installed() + username = is_bird_authenticated() if installed else None + + return { + "installed": installed, + "authenticated": username is not None, + "username": username, + "can_install": check_npm_available(), + } +``` + +**Step 2: Verify functions work** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print(bird_x.get_bird_status())"` +Expected: Dict with installed/authenticated status + +**Step 3: Commit** + +```bash +git add scripts/lib/bird_x.py +git commit -m "feat(bird): add installation and status functions" +``` + +--- + +### Task 3: Add Bird Search Function + +**Files:** +- Modify: `scripts/lib/bird_x.py` + +**Step 1: Add depth config and search function** + +Add after imports at top: + +```python +# Depth configurations: number of results to request +DEPTH_CONFIG = { + "quick": 12, + "default": 30, + "deep": 60, +} +``` + +Add after `get_bird_status()`: + +```python +def search_x( + topic: str, + from_date: str, + to_date: str, + depth: str = "default", +) -> Dict[str, Any]: + """Search X using Bird CLI. + + Args: + topic: Search topic + from_date: Start date (YYYY-MM-DD) + to_date: End date (YYYY-MM-DD) + depth: Research depth - "quick", "default", or "deep" + + Returns: + Raw Bird JSON response or error dict. + """ + count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) + + # Build command + cmd = [ + "bird", "search", + topic, + "--since", from_date, + "-n", str(count), + "--json", + ] + + # Adjust timeout based on depth + timeout = 30 if depth == "quick" else 45 if depth == "default" else 60 + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + if result.returncode != 0: + error = result.stderr.strip() or "Bird search failed" + return {"error": error, "items": []} + + # Parse JSON output + output = result.stdout.strip() + if not output: + return {"items": []} + + return json.loads(output) + + except subprocess.TimeoutExpired: + return {"error": "Search timed out", "items": []} + except json.JSONDecodeError as e: + return {"error": f"Invalid JSON response: {e}", "items": []} + except Exception as e: + return {"error": str(e), "items": []} +``` + +**Step 2: Verify search function signature** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; import inspect; print(inspect.signature(bird_x.search_x))"` +Expected: `(topic: str, from_date: str, to_date: str, depth: str = 'default') -> Dict[str, Any]` + +**Step 3: Commit** + +```bash +git add scripts/lib/bird_x.py +git commit -m "feat(bird): add search_x function" +``` + +--- + +### Task 4: Add Bird Response Parser + +**Files:** +- Modify: `scripts/lib/bird_x.py` + +**Step 1: Add parse function** + +Add at end of file: + +```python +def parse_bird_response(response: Dict[str, Any]) -> List[Dict[str, Any]]: + """Parse Bird response to match xai_x output format. + + Args: + response: Raw Bird JSON response + + Returns: + List of normalized item dicts matching xai_x.parse_x_response() format. + """ + items = [] + + # Check for errors + if "error" in response and response["error"]: + _log(f"Bird error: {response['error']}") + return items + + # Bird returns a list of tweets directly or under a key + raw_items = response if isinstance(response, list) else response.get("items", response.get("tweets", [])) + + if not isinstance(raw_items, list): + return items + + for i, tweet in enumerate(raw_items): + if not isinstance(tweet, dict): + continue + + # Extract URL - Bird uses permanent_url or we construct from id + url = tweet.get("permanent_url") or tweet.get("url", "") + if not url and tweet.get("id"): + screen_name = tweet.get("user", {}).get("screen_name", "") + if screen_name: + url = f"https://x.com/{screen_name}/status/{tweet['id']}" + + if not url: + continue + + # Parse date from created_at (e.g., "Wed Jan 15 14:30:00 +0000 2026") + date = None + created_at = tweet.get("created_at", "") + if created_at: + try: + # Try ISO format first + if "T" in created_at: + dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + else: + # Twitter format: "Wed Jan 15 14:30:00 +0000 2026" + dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y") + date = dt.strftime("%Y-%m-%d") + except (ValueError, TypeError): + pass + + # Extract user info + user = tweet.get("user", {}) + author_handle = user.get("screen_name", "") or tweet.get("author_handle", "") + + # Build engagement dict + engagement = { + "likes": tweet.get("like_count") or tweet.get("favorite_count"), + "reposts": tweet.get("retweet_count"), + "replies": tweet.get("reply_count"), + "quotes": tweet.get("quote_count"), + } + # Convert to int where possible + for key in engagement: + if engagement[key] is not None: + try: + engagement[key] = int(engagement[key]) + except (ValueError, TypeError): + engagement[key] = None + + # Build normalized item + item = { + "id": f"X{i+1}", + "text": str(tweet.get("text", tweet.get("full_text", ""))).strip()[:500], + "url": url, + "author_handle": author_handle.lstrip("@"), + "date": date, + "engagement": engagement if any(v is not None for v in engagement.values()) else None, + "why_relevant": "", # Bird doesn't provide relevance explanations + "relevance": 0.7, # Default relevance, let score.py re-rank + } + + items.append(item) + + return items +``` + +**Step 2: Verify parser handles empty input** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print(bird_x.parse_bird_response({}))"` +Expected: `[]` + +**Step 3: Commit** + +```bash +git add scripts/lib/bird_x.py +git commit -m "feat(bird): add response parser matching xai_x format" +``` + +--- + +### Task 5: Add UI Functions for Bird Prompts + +**Files:** +- Modify: `scripts/lib/ui.py` + +**Step 1: Add Bird-related messages and prompts** + +Add after `PROMO_SINGLE_KEY_PLAIN` dict (around line 128): + +```python +# Bird CLI prompts +BIRD_INSTALL_PROMPT = f""" +{Colors.CYAN}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET} +{Colors.CYAN}🐦 FREE X/TWITTER SEARCH AVAILABLE{Colors.RESET} + +Bird CLI provides free X search using your browser session (no API key needed). + +""" + +BIRD_INSTALL_PROMPT_PLAIN = """ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🐦 FREE X/TWITTER SEARCH AVAILABLE + +Bird CLI provides free X search using your browser session (no API key needed). + +""" + +BIRD_AUTH_HELP = f""" +{Colors.YELLOW}Bird authentication failed.{Colors.RESET} + +To fix this: +1. Log into X (twitter.com) in Safari, Chrome, or Firefox +2. Run: {Colors.BOLD}bird check{Colors.RESET} to verify credentials +3. Try again + +For manual setup, see: https://github.com/steipete/bird#authentication +""" + +BIRD_AUTH_HELP_PLAIN = """ +Bird authentication failed. + +To fix this: +1. Log into X (twitter.com) in Safari, Chrome, or Firefox +2. Run: bird check to verify credentials +3. Try again + +For manual setup, see: https://github.com/steipete/bird#authentication +""" +``` + +**Step 2: Add prompt functions to ProgressDisplay class** + +Add these methods to the `ProgressDisplay` class (after `show_promo` method, around line 310): + +```python + def prompt_bird_install(self) -> bool: + """Prompt user to install Bird CLI. + + Returns: + True if user wants to install, False otherwise. + """ + if IS_TTY: + sys.stderr.write(BIRD_INSTALL_PROMPT) + else: + sys.stderr.write(BIRD_INSTALL_PROMPT_PLAIN) + sys.stderr.flush() + + try: + response = input("Install Bird CLI now? (y/n): ").strip().lower() + return response in ('y', 'yes') + except (EOFError, KeyboardInterrupt): + return False + + def show_bird_install_success(self, username: str): + """Show Bird installation success message.""" + msg = f"{Colors.GREEN}✓ Bird installed and authenticated as @{username}{Colors.RESET}\n" if IS_TTY else f"✓ Bird installed and authenticated as @{username}\n" + sys.stderr.write(msg) + sys.stderr.flush() + + def show_bird_install_failed(self, error: str): + """Show Bird installation failure message.""" + msg = f"{Colors.RED}✗ Bird installation failed: {error}{Colors.RESET}\n" if IS_TTY else f"✗ Bird installation failed: {error}\n" + sys.stderr.write(msg) + sys.stderr.flush() + + def show_bird_auth_help(self): + """Show Bird authentication help.""" + if IS_TTY: + sys.stderr.write(BIRD_AUTH_HELP) + else: + sys.stderr.write(BIRD_AUTH_HELP_PLAIN) + sys.stderr.flush() +``` + +**Step 3: Verify new methods exist** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib.ui import ProgressDisplay; p = ProgressDisplay('test', show_banner=False); print(hasattr(p, 'prompt_bird_install'))"` +Expected: `True` + +**Step 4: Commit** + +```bash +git add scripts/lib/ui.py +git commit -m "feat(ui): add Bird CLI install prompts and auth help" +``` + +--- + +### Task 6: Update env.py with X Source Detection + +**Files:** +- Modify: `scripts/lib/env.py` + +**Step 1: Add get_x_source function** + +Add at end of file: + +```python +def get_x_source(config: Dict[str, Any]) -> Optional[str]: + """Determine the best available X/Twitter source. + + Priority: Bird (free) → xAI (paid API) + + Args: + config: Configuration dict from get_config() + + Returns: + 'bird' if Bird is installed and authenticated, + 'xai' if XAI_API_KEY is configured, + None if no X source available. + """ + # Import here to avoid circular dependency + from . import bird_x + + # Check Bird first (free option) + if bird_x.is_bird_installed(): + username = bird_x.is_bird_authenticated() + if username: + return 'bird' + + # Fall back to xAI if key exists + if config.get('XAI_API_KEY'): + return 'xai' + + return None + + +def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]: + """Get detailed X source status for UI decisions. + + Returns: + Dict with keys: source, bird_installed, bird_authenticated, + bird_username, xai_available, can_install_bird + """ + from . import bird_x + + bird_status = bird_x.get_bird_status() + xai_available = bool(config.get('XAI_API_KEY')) + + # Determine active source + if bird_status["authenticated"]: + source = 'bird' + elif xai_available: + source = 'xai' + else: + source = None + + return { + "source": source, + "bird_installed": bird_status["installed"], + "bird_authenticated": bird_status["authenticated"], + "bird_username": bird_status["username"], + "xai_available": xai_available, + "can_install_bird": bird_status["can_install"], + } +``` + +**Step 2: Verify function works** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import env; print(env.get_x_source_status(env.get_config()))"` +Expected: Dict with source status + +**Step 3: Commit** + +```bash +git add scripts/lib/env.py +git commit -m "feat(env): add X source detection with Bird priority" +``` + +--- + +### Task 7: Update __init__.py to Export bird_x + +**Files:** +- Modify: `scripts/lib/__init__.py` + +**Step 1: Add bird_x to imports** + +Replace file contents with: + +```python +# last30days library modules +from . import bird_x +``` + +**Step 2: Verify import works** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import bird_x; print('OK')"` +Expected: `OK` + +**Step 3: Commit** + +```bash +git add scripts/lib/__init__.py +git commit -m "feat(lib): export bird_x module" +``` + +--- + +### Task 8: Integrate Bird into Main Script - Part 1 (Setup Phase) + +**Files:** +- Modify: `scripts/last30days.py` + +**Step 1: Add bird_x import** + +Add `bird_x` to the imports from lib (around line 36): + +```python +from lib import ( + bird_x, + dates, + dedupe, + env, + http, + models, + normalize, + openai_reddit, + reddit_enrich, + render, + schema, + score, + ui, + websearch, + xai_x, +) +``` + +**Step 2: Add Bird setup function** + +Add after the imports, before `load_fixture`: + +```python +def setup_bird_if_needed(progress: ui.ProgressDisplay) -> Optional[str]: + """Check Bird status and offer installation if needed. + + Returns: + 'bird' if Bird is ready to use, + 'declined' if user declined install, + None if Bird not available and couldn't be installed. + """ + status = bird_x.get_bird_status() + + # Already working + if status["authenticated"]: + return 'bird' + + # Installed but not authenticated + if status["installed"]: + progress.show_bird_auth_help() + return None + + # Not installed - offer to install if npm available + if status["can_install"]: + if progress.prompt_bird_install(): + success, message = bird_x.install_bird() + if success: + # Check if auth works now + username = bird_x.is_bird_authenticated() + if username: + progress.show_bird_install_success(username) + return 'bird' + else: + progress.show_bird_auth_help() + return None + else: + progress.show_bird_install_failed(message) + return None + else: + return 'declined' + + return None +``` + +**Step 3: Verify script still loads** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "import scripts.last30days; print('OK')"` +Expected: `OK` + +**Step 4: Commit** + +```bash +git add scripts/last30days.py +git commit -m "feat(main): add Bird setup function" +``` + +--- + +### Task 9: Integrate Bird into Main Script - Part 2 (Search Dispatch) + +**Files:** +- Modify: `scripts/last30days.py` + +**Step 1: Modify _search_x function to support Bird** + +Replace the `_search_x` function (around line 119-159) with: + +```python +def _search_x( + topic: str, + config: dict, + selected_models: dict, + from_date: str, + to_date: str, + depth: str, + mock: bool, + x_source: str = "xai", +) -> tuple: + """Search X via Bird CLI or xAI (runs in thread). + + Args: + x_source: 'bird' or 'xai' - which backend to use + + Returns: + Tuple of (x_items, raw_response, error) + """ + raw_response = None + x_error = None + + if mock: + raw_response = load_fixture("xai_sample.json") + x_items = xai_x.parse_x_response(raw_response or {}) + return x_items, raw_response, x_error + + # Use Bird if specified + if x_source == "bird": + try: + raw_response = bird_x.search_x( + topic, + from_date, + to_date, + depth=depth, + ) + except Exception as e: + raw_response = {"error": str(e)} + x_error = f"{type(e).__name__}: {e}" + + x_items = bird_x.parse_bird_response(raw_response or {}) + + # Check for error in response + if raw_response and raw_response.get("error") and not x_error: + x_error = raw_response["error"] + + return x_items, raw_response, x_error + + # Use xAI (original behavior) + try: + raw_response = xai_x.search_x( + config["XAI_API_KEY"], + selected_models["xai"], + topic, + from_date, + to_date, + depth=depth, + ) + except http.HTTPError as e: + raw_response = {"error": str(e)} + x_error = f"API error: {e}" + except Exception as e: + raw_response = {"error": str(e)} + x_error = f"{type(e).__name__}: {e}" + + x_items = xai_x.parse_x_response(raw_response or {}) + + return x_items, raw_response, x_error +``` + +**Step 2: Update run_research to accept x_source parameter** + +Find the `run_research` function signature (around line 161) and add `x_source` parameter: + +```python +def run_research( + topic: str, + sources: str, + config: dict, + selected_models: dict, + from_date: str, + to_date: str, + depth: str = "default", + mock: bool = False, + progress: ui.ProgressDisplay = None, + x_source: str = "xai", +) -> tuple: +``` + +Then update the `_search_x` call inside (around line 218-222) to pass `x_source`: + +```python + x_future = executor.submit( + _search_x, topic, config, selected_models, + from_date, to_date, depth, mock, x_source + ) +``` + +**Step 3: Verify script syntax is valid** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -m py_compile scripts/last30days.py && echo "OK"` +Expected: `OK` + +**Step 4: Commit** + +```bash +git add scripts/last30days.py +git commit -m "feat(main): dispatch X search to Bird or xAI" +``` + +--- + +### Task 10: Integrate Bird into Main Script - Part 3 (Main Function) + +**Files:** +- Modify: `scripts/last30days.py` + +**Step 1: Update main() to check Bird before research** + +In the `main()` function, after loading config and before checking available sources (around line 345-355), add Bird setup: + +Find this section: +```python + # Load config + config = env.get_config() + + # Check available sources + available = env.get_available_sources(config) +``` + +Replace with: +```python + # Load config + config = env.get_config() + + # Initialize progress display early for Bird prompts + progress = ui.ProgressDisplay(args.topic, show_banner=True) + + # Check Bird availability and offer install if needed + x_source_status = env.get_x_source_status(config) + x_source = x_source_status["source"] + + # If no X source and Bird can be installed, offer it + if x_source is None and x_source_status["can_install_bird"]: + bird_result = setup_bird_if_needed(progress) + if bird_result == 'bird': + x_source = 'bird' + # Refresh status + x_source_status = env.get_x_source_status(config) + + # Check available sources (now accounting for Bird) + available = env.get_available_sources(config) + + # Override available if Bird is ready + if x_source == 'bird': + if available == 'reddit': + available = 'both' # Now have both Reddit + X (via Bird) + elif available == 'web': + available = 'x' # Now have X via Bird +``` + +**Step 2: Remove duplicate progress initialization** + +Find and remove the later `progress = ui.ProgressDisplay(...)` line (around line 371) since we now create it earlier. + +**Step 3: Pass x_source to run_research** + +Find the `run_research` call (around line 413) and add `x_source` parameter: + +```python + reddit_items, x_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error = run_research( + args.topic, + sources, + config, + selected_models, + from_date, + to_date, + depth, + args.mock, + progress, + x_source=x_source or "xai", + ) +``` + +**Step 4: Verify script runs with --help** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 scripts/last30days.py --help` +Expected: Help text displays without errors + +**Step 5: Commit** + +```bash +git add scripts/last30days.py +git commit -m "feat(main): integrate Bird setup into main flow" +``` + +--- + +### Task 11: Test End-to-End with Mock Mode + +**Files:** +- None (testing only) + +**Step 1: Test mock mode still works** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 scripts/last30days.py "Claude Code" --mock --emit=compact 2>&1 | head -20` +Expected: Output showing research results without errors + +**Step 2: Test Bird detection (informational)** + +Run: `cd /Users/mvanhorn/last30days-skill-private && python3 -c "from scripts.lib import env; import json; print(json.dumps(env.get_x_source_status(env.get_config()), indent=2))"` +Expected: JSON showing current Bird/xAI status + +**Step 3: Commit any fixes if needed, then final commit** + +```bash +git add -A +git commit -m "feat(bird): complete Bird CLI integration + +- Add bird_x.py module for Bird CLI detection, install, and search +- Add UI prompts for interactive Bird installation +- Update env.py with X source priority (Bird > xAI) +- Integrate Bird into main research flow +- Bird uses browser cookies (free, no API key needed)" +``` + +--- + +### Task 12: Push to Private Repo + +**Files:** +- None (git only) + +**Step 1: Push all changes** + +Run: `cd /Users/mvanhorn/last30days-skill-private && git push origin main` +Expected: Changes pushed to private repo + +**Step 2: Verify commit history** + +Run: `cd /Users/mvanhorn/last30days-skill-private && git log --oneline -10` +Expected: Shows Bird integration commits + +--- + +## Summary + +After completing all tasks, the skill will: + +1. Check if Bird CLI is installed on startup +2. If not installed but npm available, prompt user to install +3. If installed, verify authentication via `bird whoami` +4. If authenticated, use Bird for all X searches (free) +5. If not, fall back to xAI (if key exists) or WebSearch +6. Output format identical regardless of backend used