Files
last30days-skill/docs/plans/2026-02-03-bird-cli-implementation.md
Matt Van Horn 8ae00ea214 docs: Add Bird CLI implementation plan
12-task implementation plan for integrating Bird CLI as free X/Twitter
search alternative. Includes detection, interactive install, search
dispatch, and main flow integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:53:03 -08:00

25 KiB

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

"""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

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():

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

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:

# Depth configurations: number of results to request
DEPTH_CONFIG = {
    "quick": 12,
    "default": 30,
    "deep": 60,
}

Add after get_bird_status():

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

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:

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

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):

# 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):

    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

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:

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

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:

# 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

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):

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:

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

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:

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:

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:

            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

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:

    # Load config
    config = env.get_config()

    # Check available sources
    available = env.get_available_sources(config)

Replace with:

    # 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:

    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

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

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