Skill: kris-hansen/google-flights (version 2.0.0)

--- name: google-flights description: Search Google Flights for prices, availability, and deals. Use when user asks about flight prices, searching for flights, comparing airfares, finding the cheapest travel dates, or monitoring prices on regular routes. Supports flexible date search, filters (nonstop, price cap, departure/arrival times), connection quality scoring, and price tracking with alerts. --- # Google Flights Flight search with flexible dates, smart filters, connection scoring, and price tracking. ## Quick Start ```bash cd ~/clawd/skills/google-flights source .venv/bin/activate # Basic search ./scripts/search.py LAX JFK tomorrow # Flexible dates — find cheapest day ./scripts/search.py LAX JFK "apr 15" --flex 3 # Nonstop under $500 ./scripts/search.py SFO ORD "next friday" --nonstop --max-price 500 # Watch a route for price drops ./scripts/watch-route.py add LAX JFK --alert-below 350 ./scripts/watch-route.py watch ``` ## Search ### Basic Usage ```bash ./scripts/search.py <from> <to> <date> [options] ``` ### Date Formats Natural language supported: - `tomorrow`, `today` - `next friday`, `next week` - `mar 15`, `March 15`, `3/15` - `2026-04-15` (ISO format) ### Filters | Flag | Short | Description | |------|-------|-------------| | `--flex N` | `-f` | Search ±N days around date | | `--nonstop` | `-n` | Nonstop flights only | | `--max-price` | `-m` | Maximum price | | `--depart-after` | | Depart after time (8am, 14:00) | | `--arrive-before` | | Arrive before time (6pm, 18:00) | | `--seat` | `-s` | economy, premium-economy, business, first | | `--adults` | `-a` | Number of adults (default: 1) | | `--children` | `-c` | Number of children | | `--return` | `-r` | Return date for round-trip | ### Output | Flag | Description | |------|-------------| | `--top N` | Show top N results (default: 5) | | `--sort` | Sort by: price (default), score, duration | | `--show-scores` | Show connection quality breakdown | | `--json` | JSON output | ### Examples ```bash # Find cheapest day in a week window ./scripts/search.py LAX JFK "apr 10" --flex 7 --nonstop # Morning departure, business class ./scripts/search.py SFO LHR "may 1" --seat business --depart-after 8am # Family trip sorted by connection quality ./scripts/search.py DEN MCO "jun 15" -a 2 -c 2 --sort score --show-scores # Round-trip under $800 ./scripts/search.py SEA LAX "apr 1" --return "apr 8" --max-price 800 ``` ## Price Tracking Track specific flights and get alerts on price changes. ```bash # Track a specific flight ./scripts/track.py add LAX JFK "2026-05-15" --alert-below 400 # Track round-trip ./scripts/track.py add LAX JFK "may 1" --return "may 8" -a 350 # Check all tracked flights ./scripts/track.py check # View price history ./scripts/track.py history LAX-JFK-2026-05-15 # List / remove ./scripts/track.py list ./scripts/track.py remove LAX-JFK-2026-05-15 ``` ## Route Watching Monitor regular routes (e.g., commute between two cities). ```bash # Add a route to watch ./scripts/watch-route.py add LAX JFK --alert-below 400 # Check all watched routes ./scripts/watch-route.py watch # List watched routes ./scripts/watch-route.py list # Remove a route ./scripts/watch-route.py remove LAX-JFK ``` ### Cron Integration Set up daily price checks: ```bash openclaw cron add \ --name "Flight Price Watch" \ --cron "0 9 * * *" \ --tz "America/New_York" \ --session isolated \ --message "cd ~/clawd/skills/google-flights && source .venv/bin/activate && ./scripts/watch-route.py watch. Alert user only if prices drop below threshold." ``` ## Connection Quality Scoring Flights scored 0-100 based on: | Factor | Impact | |--------|--------| | Nonstop flight | +15 | | Preferred airline | +10 | | Tight connection (<45min) | -30 | | Long layover (>4hr) | -5 to -25 | | Problematic connection airport | -10 to -20 | | Winter weather risk (ORD, EWR, etc.) | -15 | | Red-eye (depart after 10pm) | -15 | | Early departure (<6am) | -10 | | Avoided airline | -25 | Use `--show-scores` to see breakdown or `--sort score` to prioritize quality. ## Configuration Copy `config.example.json` to `config.json` and customize: ```json { "preferred_airlines": ["United", "Delta"], "avoid_airlines": ["Spirit"], "prefer_nonstop": true, "max_layover_hours": 4, "min_layover_minutes": 45, "home_airports": ["LAX", "JFK"], "loyalty_programs": { "united_mileageplus": "gold" } } ``` ## Setup ```bash cd ~/clawd/skills/google-flights uv venv && source .venv/bin/activate && uv pip install fast-flights chmod +x scripts/*.py cp config.example.json config.json # then edit ``` ## Data Files - `~/clawd/memory/flight-tracking.json` — Tracked flights - `~/clawd/memory/flight-prices.jsonl` — Price history - `~/clawd/memory/route-watch-state.json` — Watched routes

path: config.example.json
content: {
  "preferred_airlines": [],
  "avoid_airlines": [],
  "prefer_nonstop": false,
  "max_layover_hours": 4,
  "min_layover_minutes": 45,
  "home_airports": [],
  "loyalty_programs": {}
}


path: scripts/search.py
content: #!/usr/bin/env python3
"""
Flight search CLI using fast-flights (Google Flights scraper).
Features: flexible dates, filters, connection scoring, user preferences.
"""

import argparse
import json
import sys
import warnings
import os
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any

# Suppress date parsing warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)

# Add venv to path
script_dir = os.path.dirname(os.path.abspath(__file__))
venv_site = os.path.join(script_dir, '..', '.venv', 'lib')
if os.path.exists(venv_site):
    for d in os.listdir(venv_site):
        if d.startswith('python'):
            sys.path.insert(0, os.path.join(venv_site, d, 'site-packages'))
            break

from fast_flights import FlightData, Passengers, get_flights

# Airport connection quality ratings (1-5, higher is better)
AIRPORT_QUALITY = {
    # Major hubs - generally good
    "LAX": 3, "JFK": 3, "ORD": 2, "DFW": 4, "DEN": 4, "ATL": 4,
    "SFO": 4, "SEA": 4, "BOS": 3, "MIA": 3, "PHX": 4, "LAS": 4,
    # Canadian
    "YYZ": 3, "YVR": 4, "YYC": 4, "YUL": 3, "YOW": 4,
    # International
    "LHR": 3, "CDG": 2, "FRA": 4, "AMS": 4, "HND": 5, "NRT": 4,
    # Problematic for connections
    "EWR": 2, "LGA": 2, "MDW": 2,
}

# Risky winter connection airports
WINTER_RISK_AIRPORTS = {"ORD", "EWR", "JFK", "BOS", "DEN", "MSP", "DTW"}


def load_preferences() -> Dict[str, Any]:
    """Load user preferences from TOOLS.md or config."""
    prefs = {
        "preferred_airlines": [],
        "avoid_airlines": [],
        "prefer_nonstop": False,
        "max_layover_hours": 4,
        "min_layover_minutes": 45,
    }
    
    # Try to load from config file
    config_path = os.path.expanduser("~/clawd/skills/google-flights/config.json")
    if os.path.exists(config_path):
        try:
            with open(config_path) as f:
                user_prefs = json.load(f)
                prefs.update(user_prefs)
        except:
            pass
    
    return prefs


def parse_date(date_str: str) -> str:
    """Parse flexible date input to YYYY-MM-DD format."""
    date_str = date_str.lower().strip()
    today = datetime.now()
    
    if date_str == "today":
        return today.strftime("%Y-%m-%d")
    elif date_str == "tomorrow":
        return (today + timedelta(days=1)).strftime("%Y-%m-%d")
    elif date_str.startswith("in ") and "day" in date_str:
        days = int(''.join(filter(str.isdigit, date_str)))
        return (today + timedelta(days=days)).strftime("%Y-%m-%d")
    elif date_str.startswith("next "):
        weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
        for i, day in enumerate(weekdays):
            if day in date_str:
                days_ahead = i - today.weekday()
                if days_ahead <= 0:
                    days_ahead += 7
                return (today + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
        if "week" in date_str:
            return (today + timedelta(days=7)).strftime("%Y-%m-%d")
    
    # Try to parse with year first
    if len(date_str) >= 8 and date_str[4] == '-':
        return date_str
    
    for fmt in ["%Y-%m-%d", "%m/%d/%Y", "%B %d, %Y", "%b %d, %Y", "%B %d %Y", "%b %d %Y"]:
        try:
            return datetime.strptime(date_str, fmt).strftime("%Y-%m-%d")
        except ValueError:
            continue
    
    for fmt in ["%m/%d", "%d %b", "%b %d", "%B %d"]:
        try:
            parsed = datetime.strptime(date_str, fmt)
            parsed = parsed.replace(year=today.year)
            if parsed.date() < today.date():
                parsed = parsed.replace(year=today.year + 1)
            return parsed.strftime("%Y-%m-%d")
        except ValueError:
            continue
    
    return date_str


def parse_time(time_str: str) -> Optional[int]:
    """Parse time string to minutes from midnight."""
    if not time_str:
        return None
    time_str = time_str.lower().strip()
    
    # Handle 24h format
    if ":" in time_str:
        parts = time_str.replace("am", "").replace("pm", "").strip().split(":")
        hour = int(parts[0])
        minute = int(parts[1]) if len(parts) > 1 else 0
        if "pm" in time_str.lower() and hour < 12:
            hour += 12
        elif "am" in time_str.lower() and hour == 12:
            hour = 0
        return hour * 60 + minute
    
    # Handle simple hour
    hour = int(''.join(filter(str.isdigit, time_str)))
    if "pm" in time_str and hour < 12:
        hour += 12
    elif "am" in time_str and hour == 12:
        hour = 0
    return hour * 60


def generate_flex_dates(base_date: str, flex_days: int) -> List[str]:
    """Generate list of dates around base date."""
    base = datetime.strptime(base_date, "%Y-%m-%d")
    dates = []
    for delta in range(-flex_days, flex_days + 1):
        d = base + timedelta(days=delta)
        if d.date() >= datetime.now().date():
            dates.append(d.strftime("%Y-%m-%d"))
    return dates


def build_google_flights_url(from_apt: str, to_apt: str, dep_date: str, ret_date: str = None) -> str:
    """Build a direct Google Flights search URL."""
    if ret_date:
        return f"https://www.google.com/travel/flights?q=Flights%20from%20{from_apt}%20to%20{to_apt}%20on%20{dep_date}%20returning%20{ret_date}&hl=en"
    else:
        return f"https://www.google.com/travel/flights?q=Flights%20from%20{from_apt}%20to%20{to_apt}%20on%20{dep_date}%20one%20way&hl=en"


def parse_flight_time(time_str: str) -> Optional[int]:
    """Parse flight time like '6:25 AM' to minutes from midnight."""
    if not time_str:
        return None
    try:
        # Handle various formats
        time_str = time_str.strip().upper()
        if "AM" in time_str or "PM" in time_str:
            is_pm = "PM" in time_str
            time_str = time_str.replace("AM", "").replace("PM", "").strip()
            parts = time_str.split(":")
            hour = int(parts[0])
            minute = int(parts[1]) if len(parts) > 1 else 0
            if is_pm and hour < 12:
                hour += 12
            elif not is_pm and hour == 12:
                hour = 0
            return hour * 60 + minute
    except:
        pass
    return None


def parse_duration(duration_str: str) -> Optional[int]:
    """Parse duration like '6h 23m' or '6 hr 23 min' to minutes."""
    if not duration_str:
        return None
    try:
        hours = 0
        minutes = 0
        duration_str = duration_str.lower()
        
        # Extract hours
        if "h" in duration_str:
            h_part = duration_str.split("h")[0].strip().split()[-1]
            hours = int(h_part)
        
        # Extract minutes
        if "m" in duration_str:
            m_part = duration_str.split("h")[-1] if "h" in duration_str else duration_str
            m_part = ''.join(filter(str.isdigit, m_part.split("m")[0]))
            if m_part:
                minutes = int(m_part)
        
        return hours * 60 + minutes
    except:
        return None


def score_connection(flight, prefs: Dict[str, Any], is_winter: bool = False) -> Dict[str, Any]:
    """
    Score a flight connection based on multiple factors.
    Returns dict with score (0-100) and breakdown.
    """
    score = 100
    factors = []
    
    # Check if flight has stops info
    stops = getattr(flight, 'stops', 0)
    if isinstance(stops, str):
        stops = 0 if 'nonstop' in stops.lower() else int(''.join(filter(str.isdigit, stops)) or 1)
    
    # Nonstop bonus
    if stops == 0:
        score += 15
        factors.append(("Nonstop", "+15"))
    else:
        # Layover penalties
        layover_info = getattr(flight, 'layover', None)
        if layover_info:
            # Parse layover duration
            layover_mins = parse_duration(str(layover_info))
            if layover_mins:
                if layover_mins < prefs.get('min_layover_minutes', 45):
                    score -= 30
                    factors.append(("Tight connection", "-30"))
                elif layover_mins > prefs.get('max_layover_hours', 4) * 60:
                    penalty = min(25, (layover_mins - prefs['max_layover_hours'] * 60) // 30 * 5)
                    score -= penalty
                    factors.append((f"Long layover ({layover_mins}min)", f"-{penalty}"))
        
        # Connection airport quality
        # Try to extract connection airport from flight info
        flight_str = str(flight)
        for airport in AIRPORT_QUALITY:
            if airport in flight_str and airport not in [getattr(flight, 'from_airport', ''), getattr(flight, 'to_airport', '')]:
                quality = AIRPORT_QUALITY.get(airport, 3)
                if quality < 3:
                    score -= (3 - quality) * 10
                    factors.append((f"Connection at {airport}", f"-{(3-quality)*10}"))
                
                # Winter risk
                if is_winter and airport in WINTER_RISK_AIRPORTS:
                    score -= 15
                    factors.append((f"Winter risk at {airport}", "-15"))
                break
    
    # Red-eye penalty
    dep_time = getattr(flight, 'departure', None)
    if dep_time:
        dep_mins = parse_flight_time(str(dep_time))
        if dep_mins is not None:
            if dep_mins < 360:  # Before 6am
                score -= 10
                factors.append(("Early departure", "-10"))
            elif dep_mins >= 1320:  # After 10pm
                score -= 15
                factors.append(("Red-eye", "-15"))
    
    # Airline preferences
    airline = getattr(flight, 'airline', '') or ''
    airline_name = str(airline).lower()
    
    for pref in prefs.get('preferred_airlines', []):
        if pref.lower() in airline_name:
            score += 10
            factors.append((f"Preferred: {pref}", "+10"))
            break
    
    for avoid in prefs.get('avoid_airlines', []):
        if avoid.lower() in airline_name:
            score -= 25
            factors.append((f"Avoid: {avoid}", "-25"))
            break
    
    return {
        "score": max(0, min(100, score)),
        "factors": factors
    }


def extract_flights_with_details(flights, prefs: Dict[str, Any]) -> List[Dict]:
    """Extract flight details with scoring."""
    is_winter = datetime.now().month in [11, 12, 1, 2, 3]
    results = []
    
    for f in flights:
        price_str = getattr(f, 'price', None)
        if not price_str:
            continue
        
        # Parse price to numeric
        numeric_price = int(''.join(filter(str.isdigit, str(price_str))) or 0)
        
        # Get connection score
        conn_score = score_connection(f, prefs, is_winter)
        
        flight_info = {
            "airline": getattr(f, 'name', None) or getattr(f, 'airline', 'Unknown'),
            "price": price_str,
            "price_numeric": numeric_price,
            "departure": getattr(f, 'departure', None),
            "arrival": getattr(f, 'arrival', None),
            "duration": getattr(f, 'duration', None),
            "stops": getattr(f, 'stops', None),
            "is_best": getattr(f, 'is_best', False),
            "connection_score": conn_score["score"],
            "score_factors": conn_score["factors"],
        }
        results.append(flight_info)
    
    return results


def filter_flights(flights: List[Dict], args) -> List[Dict]:
    """Apply user filters to flight list."""
    filtered = []
    
    for f in flights:
        # Nonstop filter
        if args.nonstop:
            stops = f.get('stops', '')
            if stops and 'nonstop' not in str(stops).lower():
                if '0' not in str(stops):
                    continue
        
        # Max price filter
        if args.max_price:
            if f['price_numeric'] > args.max_price:
                continue
        
        # Departure time filter
        if args.depart_after:
            dep_mins = parse_flight_time(str(f.get('departure', '')))
            after_mins = parse_time(args.depart_after)
            if dep_mins is not None and after_mins is not None:
                if dep_mins < after_mins:
                    continue
        
        # Arrival time filter
        if args.arrive_before:
            arr_mins = parse_flight_time(str(f.get('arrival', '')))
            before_mins = parse_time(args.arrive_before)
            if arr_mins is not None and before_mins is not None:
                if arr_mins > before_mins:
                    continue
        
        filtered.append(f)
    
    return filtered


def search_flights(from_apt: str, to_apt: str, dep_date: str, ret_date: str = None,
                   seat: str = "economy", adults: int = 1, children: int = 0) -> Any:
    """Execute flight search."""
    import io
    from contextlib import redirect_stderr
    
    flight_data = [
        FlightData(
            date=dep_date,
            from_airport=from_apt,
            to_airport=to_apt
        )
    ]
    
    trip = "one-way"
    if ret_date:
        trip = "round-trip"
        flight_data.append(
            FlightData(
                date=ret_date,
                from_airport=to_apt,
                to_airport=from_apt
            )
        )
    
    with redirect_stderr(io.StringIO()):
        result = get_flights(
            flight_data=flight_data,
            trip=trip,
            seat=seat,
            passengers=Passengers(
                adults=adults,
                children=children,
                infants_in_seat=0,
                infants_on_lap=0
            ),
            fetch_mode="fallback"
        )
    
    return result


def main():
    parser = argparse.ArgumentParser(description="Search Google Flights with smart features")
    parser.add_argument("from_airport", help="Departure airport code (e.g., LAX, YYC)")
    parser.add_argument("to_airport", help="Arrival airport code (e.g., JFK, LHR)")
    parser.add_argument("date", help="Departure date (YYYY-MM-DD, 'tomorrow', 'next monday', etc.)")
    parser.add_argument("--return", "-r", dest="return_date", help="Return date for round-trip")
    parser.add_argument("--adults", "-a", type=int, default=1, help="Number of adults")
    parser.add_argument("--children", "-c", type=int, default=0, help="Number of children")
    parser.add_argument("--seat", "-s", choices=["economy", "premium-economy", "business", "first"], 
                        default="economy", help="Seat class")
    
    # New filters
    parser.add_argument("--flex", "-f", type=int, default=0, 
                        help="Flexible dates: search ±N days around date")
    parser.add_argument("--nonstop", "-n", action="store_true", help="Nonstop flights only")
    parser.add_argument("--max-price", "-m", type=int, help="Maximum price filter")
    parser.add_argument("--depart-after", help="Depart after time (e.g., 8am, 14:00)")
    parser.add_argument("--arrive-before", help="Arrive before time (e.g., 6pm, 18:00)")
    parser.add_argument("--sort", choices=["price", "score", "duration"], default="price",
                        help="Sort results by (default: price)")
    
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument("--top", "-t", type=int, default=5, help="Show top N results")
    parser.add_argument("--show-scores", action="store_true", help="Show connection quality scores")
    
    args = parser.parse_args()
    
    prefs = load_preferences()
    dep_date = parse_date(args.date)
    ret_date = parse_date(args.return_date) if args.return_date else None
    
    # Handle flexible dates
    if args.flex > 0:
        search_dates = generate_flex_dates(dep_date, args.flex)
    else:
        search_dates = [dep_date]
    
    all_results = []
    
    for search_date in search_dates:
        try:
            result = search_flights(
                args.from_airport.upper(),
                args.to_airport.upper(),
                search_date,
                ret_date,
                args.seat,
                args.adults,
                args.children
            )
            
            flights = extract_flights_with_details(result.flights, prefs)
            flights = filter_flights(flights, args)
            
            for f in flights:
                f['date'] = search_date
                f['price_trend'] = result.current_price
            
            all_results.extend(flights)
            
        except Exception as e:
            if not args.json:
                print(f"⚠️  Error searching {search_date}: {e}", file=sys.stderr)
    
    if not all_results:
        if args.json:
            print(json.dumps({"error": "No flights found", "filters": vars(args)}))
        else:
            print("❌ No flights found matching your criteria")
        sys.exit(1)
    
    # Sort results
    if args.sort == "price":
        all_results.sort(key=lambda x: x['price_numeric'])
    elif args.sort == "score":
        all_results.sort(key=lambda x: -x['connection_score'])
    elif args.sort == "duration":
        all_results.sort(key=lambda x: parse_duration(str(x.get('duration', ''))) or 9999)
    
    # Deduplicate by price+airline+time
    seen = set()
    unique_results = []
    for f in all_results:
        key = (f['price'], f['airline'], f.get('departure'), f.get('date'))
        if key not in seen:
            seen.add(key)
            unique_results.append(f)
    
    top_results = unique_results[:args.top]
    
    google_url = build_google_flights_url(
        args.from_airport.upper(),
        args.to_airport.upper(),
        dep_date,
        ret_date
    )
    
    if args.json:
        output = {
            "query": {
                "from": args.from_airport.upper(),
                "to": args.to_airport.upper(),
                "date": dep_date,
                "return_date": ret_date,
                "flex_days": args.flex,
                "seat": args.seat,
                "passengers": {"adults": args.adults, "children": args.children},
                "filters": {
                    "nonstop": args.nonstop,
                    "max_price": args.max_price,
                    "depart_after": args.depart_after,
                    "arrive_before": args.arrive_before,
                }
            },
            "results_count": len(unique_results),
            "flights": top_results,
            "google_flights_url": google_url
        }
        print(json.dumps(output, indent=2, default=str))
    else:
        print(f"✈️  {args.from_airport.upper()} → {args.to_airport.upper()}")
        
        if args.flex > 0:
            print(f"📅 {dep_date} (±{args.flex} days)" + (f" ↔ {ret_date}" if ret_date else " (one-way)"))
        else:
            print(f"📅 {dep_date}" + (f" ↔ {ret_date}" if ret_date else " (one-way)"))
        
        print(f"👥 {args.adults} adult(s)" + (f", {args.children} child(ren)" if args.children else ""))
        print(f"💺 {args.seat.replace('-', ' ').title()}")
        
        filters_applied = []
        if args.nonstop:
            filters_applied.append("nonstop only")
        if args.max_price:
            filters_applied.append(f"max ${args.max_price}")
        if args.depart_after:
            filters_applied.append(f"depart after {args.depart_after}")
        if args.arrive_before:
            filters_applied.append(f"arrive before {args.arrive_before}")
        
        if filters_applied:
            print(f"🔍 Filters: {', '.join(filters_applied)}")
        
        print()
        print(f"🔢 {len(unique_results)} options found (showing top {len(top_results)})")
        print()
        
        for i, f in enumerate(top_results, 1):
            stops_str = str(f.get('stops', '?'))
            if 'nonstop' in stops_str.lower():
                stops_str = "Nonstop ⭐"
            
            date_str = f" [{f['date']}]" if args.flex > 0 else ""
            best_marker = " 🏆" if f.get('is_best') else ""
            
            print(f"{i}. {f['airline']} | {f.get('departure', '?')} → {f.get('arrival', '?')} | {f.get('duration', '?')} | {stops_str} | {f['price']}{best_marker}{date_str}")
            
            if args.show_scores and f.get('score_factors'):
                score = f['connection_score']
                factors_str = ", ".join([f"{name}({val})" for name, val in f['score_factors']])
                print(f"   📊 Score: {score}/100 — {factors_str}")
        
        print()
        print(f"🔗 {google_url}")


if __name__ == "__main__":
    main()


path: scripts/track.py
content: #!/usr/bin/env python3
"""
Flight price tracker with alerts.
Stores price history and notifies on price drops.
"""

import argparse
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path

# Add venv to path
script_dir = os.path.dirname(os.path.abspath(__file__))
venv_site = os.path.join(script_dir, '..', '.venv', 'lib')
if os.path.exists(venv_site):
    for d in os.listdir(venv_site):
        if d.startswith('python'):
            sys.path.insert(0, os.path.join(venv_site, d, 'site-packages'))
            break

# Import search functionality
sys.path.insert(0, script_dir)
from search import search_flights, parse_date, extract_flights_with_details, load_preferences

# Default tracking file
TRACKING_FILE = os.path.expanduser("~/clawd/memory/flight-tracking.json")
PRICE_HISTORY_FILE = os.path.expanduser("~/clawd/memory/flight-prices.jsonl")


def ensure_tracking_dir():
    """Ensure memory directory exists."""
    Path(TRACKING_FILE).parent.mkdir(parents=True, exist_ok=True)


def load_tracked_flights() -> dict:
    """Load tracked flights from JSON file."""
    if os.path.exists(TRACKING_FILE):
        try:
            with open(TRACKING_FILE) as f:
                return json.load(f)
        except:
            pass
    return {"flights": [], "last_check": None}


def save_tracked_flights(data: dict):
    """Save tracked flights to JSON file."""
    ensure_tracking_dir()
    with open(TRACKING_FILE, 'w') as f:
        json.dump(data, f, indent=2)


def append_price_history(entry: dict):
    """Append a price check to history."""
    ensure_tracking_dir()
    with open(PRICE_HISTORY_FILE, 'a') as f:
        f.write(json.dumps(entry) + "\n")


def get_price_history(flight_key: str, limit: int = 30) -> list:
    """Get price history for a specific flight."""
    history = []
    if os.path.exists(PRICE_HISTORY_FILE):
        with open(PRICE_HISTORY_FILE) as f:
            for line in f:
                try:
                    entry = json.loads(line)
                    if entry.get('key') == flight_key:
                        history.append(entry)
                except:
                    continue
    return history[-limit:]


def generate_flight_key(from_apt: str, to_apt: str, date: str, ret_date: str = None) -> str:
    """Generate unique key for a flight route."""
    key = f"{from_apt}-{to_apt}-{date}"
    if ret_date:
        key += f"-{ret_date}"
    return key


def check_flight_price(flight_config: dict) -> dict:
    """Check current price for a tracked flight."""
    prefs = load_preferences()
    
    try:
        result = search_flights(
            flight_config['from'],
            flight_config['to'],
            flight_config['date'],
            flight_config.get('return_date'),
            flight_config.get('seat', 'economy'),
            flight_config.get('adults', 1),
            flight_config.get('children', 0)
        )
        
        flights = extract_flights_with_details(result.flights, prefs)
        
        if not flights:
            return {"error": "No flights found"}
        
        # Get lowest price
        lowest = min(flights, key=lambda x: x['price_numeric'])
        
        return {
            "lowest_price": lowest['price'],
            "lowest_price_numeric": lowest['price_numeric'],
            "airline": lowest['airline'],
            "price_trend": result.current_price,
            "options_count": len(flights),
            "checked_at": datetime.now().isoformat()
        }
        
    except Exception as e:
        return {"error": str(e)}


def add_tracking(args) -> str:
    """Add a new flight to track."""
    data = load_tracked_flights()
    
    date = parse_date(args.date)
    ret_date = parse_date(args.return_date) if args.return_date else None
    key = generate_flight_key(args.from_airport.upper(), args.to_airport.upper(), date, ret_date)
    
    # Check if already tracking
    for f in data['flights']:
        if f['key'] == key:
            return f"⚠️  Already tracking {key}"
    
    flight_config = {
        "key": key,
        "from": args.from_airport.upper(),
        "to": args.to_airport.upper(),
        "date": date,
        "return_date": ret_date,
        "seat": args.seat,
        "adults": args.adults,
        "children": args.children,
        "alert_below": args.alert_below,
        "added_at": datetime.now().isoformat(),
        "last_price": None,
        "lowest_seen": None,
    }
    
    # Do initial price check
    result = check_flight_price(flight_config)
    if 'lowest_price_numeric' in result:
        flight_config['last_price'] = result['lowest_price_numeric']
        flight_config['lowest_seen'] = result['lowest_price_numeric']
        flight_config['last_check'] = result['checked_at']
        
        # Log to history
        append_price_history({
            "key": key,
            "price": result['lowest_price_numeric'],
            "price_str": result['lowest_price'],
            "trend": result.get('price_trend'),
            "timestamp": result['checked_at']
        })
    
    data['flights'].append(flight_config)
    save_tracked_flights(data)
    
    price_str = result.get('lowest_price', 'N/A')
    alert_str = f" (alert if < ${args.alert_below})" if args.alert_below else ""
    return f"✅ Now tracking {key}\n   Current lowest: {price_str}{alert_str}"


def list_tracking(args) -> str:
    """List all tracked flights."""
    data = load_tracked_flights()
    
    if not data['flights']:
        return "📭 No flights being tracked.\nUse: track.py add YYC LAX 2026-04-15"
    
    lines = ["📋 **Tracked Flights**\n"]
    
    for f in data['flights']:
        trip_type = "↔" if f.get('return_date') else "→"
        route = f"{f['from']} {trip_type} {f['to']}"
        dates = f['date'] + (f" - {f['return_date']}" if f.get('return_date') else "")
        
        price_info = ""
        if f.get('last_price'):
            price_info = f"${f['last_price']}"
            if f.get('lowest_seen') and f['lowest_seen'] < f['last_price']:
                price_info += f" (lowest: ${f['lowest_seen']})"
        
        alert_info = ""
        if f.get('alert_below'):
            alert_info = f" 🔔<${f['alert_below']}"
        
        lines.append(f"• {route} | {dates} | {price_info}{alert_info}")
    
    if data.get('last_check'):
        lines.append(f"\nLast check: {data['last_check']}")
    
    return "\n".join(lines)


def check_all(args) -> str:
    """Check prices for all tracked flights."""
    data = load_tracked_flights()
    
    if not data['flights']:
        return "📭 No flights being tracked."
    
    alerts = []
    updates = []
    
    for f in data['flights']:
        # Skip expired flights
        flight_date = datetime.strptime(f['date'], "%Y-%m-%d")
        if flight_date.date() < datetime.now().date():
            continue
        
        result = check_flight_price(f)
        
        if 'error' in result:
            updates.append(f"⚠️  {f['key']}: {result['error']}")
            continue
        
        current_price = result['lowest_price_numeric']
        price_str = result['lowest_price']
        prev_price = f.get('last_price')
        
        # Update tracking
        f['last_price'] = current_price
        f['last_check'] = result['checked_at']
        
        if f.get('lowest_seen') is None or current_price < f['lowest_seen']:
            f['lowest_seen'] = current_price
        
        # Log to history
        append_price_history({
            "key": f['key'],
            "price": current_price,
            "price_str": price_str,
            "trend": result.get('price_trend'),
            "timestamp": result['checked_at']
        })
        
        # Check for alerts
        if f.get('alert_below') and current_price < f['alert_below']:
            alerts.append(f"🚨 **PRICE DROP** {f['key']}: {price_str} (target: <${f['alert_below']})")
        elif prev_price and current_price < prev_price:
            diff = prev_price - current_price
            pct = (diff / prev_price) * 100
            updates.append(f"📉 {f['key']}: {price_str} (down ${diff}, {pct:.1f}%)")
        elif prev_price and current_price > prev_price:
            diff = current_price - prev_price
            updates.append(f"📈 {f['key']}: {price_str} (up ${diff})")
        else:
            updates.append(f"➡️  {f['key']}: {price_str}")
    
    data['last_check'] = datetime.now().isoformat()
    save_tracked_flights(data)
    
    output = []
    if alerts:
        output.extend(alerts)
        output.append("")
    output.extend(updates)
    
    return "\n".join(output) if output else "No updates."


def remove_tracking(args) -> str:
    """Remove a flight from tracking."""
    data = load_tracked_flights()
    
    key = args.key.upper()
    initial_count = len(data['flights'])
    data['flights'] = [f for f in data['flights'] if f['key'] != key]
    
    if len(data['flights']) < initial_count:
        save_tracked_flights(data)
        return f"✅ Removed {key} from tracking"
    else:
        return f"⚠️  {key} not found in tracked flights"


def show_history(args) -> str:
    """Show price history for a flight."""
    history = get_price_history(args.key.upper(), args.limit)
    
    if not history:
        return f"No price history for {args.key}"
    
    lines = [f"📊 Price history for {args.key} (last {len(history)} checks)\n"]
    
    for entry in history:
        ts = entry.get('timestamp', '?')[:16]
        price = entry.get('price_str', f"${entry.get('price', '?')}")
        trend = entry.get('trend', '')
        lines.append(f"  {ts} | {price} | {trend}")
    
    # Simple trend analysis
    prices = [e['price'] for e in history if 'price' in e]
    if len(prices) >= 2:
        first = prices[0]
        last = prices[-1]
        lowest = min(prices)
        highest = max(prices)
        
        lines.append(f"\n📈 Range: ${lowest} - ${highest}")
        if last < first:
            lines.append(f"📉 Trending down ({((first-last)/first)*100:.1f}% since first check)")
        elif last > first:
            lines.append(f"📈 Trending up ({((last-first)/first)*100:.1f}% since first check)")
    
    return "\n".join(lines)


def main():
    parser = argparse.ArgumentParser(description="Track flight prices and get alerts")
    subparsers = parser.add_subparsers(dest="command", help="Commands")
    
    # Add command
    add_parser = subparsers.add_parser("add", help="Add a flight to track")
    add_parser.add_argument("from_airport", help="Departure airport")
    add_parser.add_argument("to_airport", help="Arrival airport")
    add_parser.add_argument("date", help="Departure date")
    add_parser.add_argument("--return", "-r", dest="return_date", help="Return date")
    add_parser.add_argument("--alert-below", "-a", type=int, help="Alert when price drops below this")
    add_parser.add_argument("--seat", "-s", default="economy", help="Seat class")
    add_parser.add_argument("--adults", type=int, default=1)
    add_parser.add_argument("--children", type=int, default=0)
    
    # List command
    list_parser = subparsers.add_parser("list", help="List tracked flights")
    
    # Check command
    check_parser = subparsers.add_parser("check", help="Check prices for all tracked flights")
    
    # Remove command
    remove_parser = subparsers.add_parser("remove", help="Remove a flight from tracking")
    remove_parser.add_argument("key", help="Flight key (e.g., YYC-LAX-2026-04-15)")
    
    # History command
    history_parser = subparsers.add_parser("history", help="Show price history")
    history_parser.add_argument("key", help="Flight key")
    history_parser.add_argument("--limit", "-l", type=int, default=30, help="Number of entries")
    
    args = parser.parse_args()
    
    if args.command == "add":
        print(add_tracking(args))
    elif args.command == "list":
        print(list_tracking(args))
    elif args.command == "check":
        print(check_all(args))
    elif args.command == "remove":
        print(remove_tracking(args))
    elif args.command == "history":
        print(show_history(args))
    else:
        parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/watch-route.py
content: #!/usr/bin/env python3
"""
Route price watcher for regular travel patterns.
Monitors prices between home bases and alerts on good deals.
"""

import argparse
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path

# Add venv to path
script_dir = os.path.dirname(os.path.abspath(__file__))
venv_site = os.path.join(script_dir, '..', '.venv', 'lib')
if os.path.exists(venv_site):
    for d in os.listdir(venv_site):
        if d.startswith('python'):
            sys.path.insert(0, os.path.join(venv_site, d, 'site-packages'))
            break

sys.path.insert(0, script_dir)
from search import search_flights, parse_date, extract_flights_with_details, load_preferences

# State file - customize path as needed
STATE_FILE = os.path.expanduser("~/clawd/memory/route-watch-state.json")


def ensure_dir():
    Path(STATE_FILE).parent.mkdir(parents=True, exist_ok=True)


def load_state() -> dict:
    """Load route watch state."""
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE) as f:
                return json.load(f)
        except:
            pass
    return {
        "routes": [],
        "current_location": None,
        "last_check": None,
        "price_history": []
    }


def save_state(state: dict):
    ensure_dir()
    with open(STATE_FILE, 'w') as f:
        json.dump(state, f, indent=2)


def check_route_prices(from_apt: str, to_apt: str, days_out: int = 28) -> list:
    """Check flight prices for upcoming dates on a route."""
    prefs = load_preferences()
    results = []
    
    # Check weekly intervals
    check_days = [7, 14, 21, 28]
    
    for days in check_days:
        if days > days_out:
            break
        
        target_date = (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
        
        try:
            result = search_flights(from_apt, to_apt, target_date, seat='economy')
            flights = extract_flights_with_details(result.flights, prefs)
            
            if flights:
                nonstops = [f for f in flights if 'nonstop' in str(f.get('stops', '')).lower() or f.get('stops') == 0]
                best_overall = min(flights, key=lambda x: x['price_numeric'])
                best_nonstop = min(nonstops, key=lambda x: x['price_numeric']) if nonstops else None
                
                results.append({
                    'date': target_date,
                    'days_out': days,
                    'best_price': best_overall['price'],
                    'best_price_numeric': best_overall['price_numeric'],
                    'best_airline': best_overall['airline'],
                    'best_nonstop_price': best_nonstop['price'] if best_nonstop else None,
                    'best_nonstop_numeric': best_nonstop['price_numeric'] if best_nonstop else None,
                    'trend': result.current_price,
                    'options': len(flights)
                })
        except Exception as e:
            pass
    
    return results


def add_route(args) -> str:
    """Add a route to watch."""
    state = load_state()
    
    from_apt = args.from_airport.upper()
    to_apt = args.to_airport.upper()
    route_key = f"{from_apt}-{to_apt}"
    
    # Check if already watching
    for r in state['routes']:
        if r['key'] == route_key:
            return f"⚠️  Already watching {route_key}"
    
    route = {
        "key": route_key,
        "from": from_apt,
        "to": to_apt,
        "alert_below": args.alert_below or 400,
        "added_at": datetime.now().isoformat(),
        "last_price": None
    }
    
    state['routes'].append(route)
    save_state(state)
    
    return f"✅ Now watching {route_key} (alert if < ${route['alert_below']})"


def list_routes(args) -> str:
    """List watched routes."""
    state = load_state()
    
    if not state['routes']:
        return "📭 No routes being watched.\nUse: watch-route.py add YYC LAX --alert-below 400"
    
    lines = ["📋 **Watched Routes**\n"]
    
    for r in state['routes']:
        route = f"{r['from']} → {r['to']}"
        price_info = f"Last: ${r['last_price']}" if r.get('last_price') else "Not checked"
        alert_info = f" 🔔<${r['alert_below']}" if r.get('alert_below') else ""
        lines.append(f"• {route} | {price_info}{alert_info}")
    
    if state.get('last_check'):
        lines.append(f"\nLast check: {state['last_check'][:16]}")
    
    return "\n".join(lines)


def watch(args) -> str:
    """Check prices for all watched routes."""
    state = load_state()
    
    if not state['routes']:
        return "📭 No routes to watch. Add one first:\nwatch-route.py add YYC LAX --alert-below 400"
    
    alerts = []
    updates = []
    
    for route in state['routes']:
        from_apt = route['from']
        to_apt = route['to']
        threshold = route.get('alert_below', 400)
        
        results = check_route_prices(from_apt, to_apt, days_out=args.days)
        
        if not results:
            updates.append(f"⚠️  {route['key']}: Couldn't fetch prices")
            continue
        
        # Find best price across all dates
        best = min(results, key=lambda x: x['best_price_numeric'])
        price = best['best_price_numeric']
        price_str = best['best_price']
        best_date = datetime.strptime(best['date'], '%Y-%m-%d').strftime('%b %d')
        
        prev_price = route.get('last_price')
        route['last_price'] = price
        route['last_check'] = datetime.now().isoformat()
        
        # Check for alerts
        if price < threshold:
            alerts.append(f"🔥 **{route['key']}** {best_date}: {price_str} (target: <${threshold})")
        elif prev_price and price < prev_price:
            diff = prev_price - price
            updates.append(f"📉 {route['key']}: {price_str} on {best_date} (down ${diff})")
        elif prev_price and price > prev_price:
            diff = price - prev_price
            updates.append(f"📈 {route['key']}: {price_str} on {best_date} (up ${diff})")
        else:
            updates.append(f"➡️  {route['key']}: {price_str} on {best_date}")
    
    state['last_check'] = datetime.now().isoformat()
    save_state(state)
    
    output = []
    if alerts:
        output.extend(alerts)
        output.append("")
    output.extend(updates)
    
    return "\n".join(output) if output else "No updates."


def remove_route(args) -> str:
    """Remove a route from watching."""
    state = load_state()
    
    key = args.key.upper()
    initial_count = len(state['routes'])
    state['routes'] = [r for r in state['routes'] if r['key'] != key]
    
    if len(state['routes']) < initial_count:
        save_state(state)
        return f"✅ Removed {key} from watch list"
    else:
        return f"⚠️  {key} not found"


def main():
    parser = argparse.ArgumentParser(description="Watch flight prices on regular routes")
    subparsers = parser.add_subparsers(dest="command", help="Commands")
    
    # Add route
    add_parser = subparsers.add_parser("add", help="Add a route to watch")
    add_parser.add_argument("from_airport", help="Departure airport")
    add_parser.add_argument("to_airport", help="Arrival airport")
    add_parser.add_argument("--alert-below", "-a", type=int, default=400, help="Alert when price drops below")
    
    # List routes
    subparsers.add_parser("list", help="List watched routes")
    
    # Watch (check prices)
    watch_parser = subparsers.add_parser("watch", help="Check prices for all routes")
    watch_parser.add_argument("--days", "-d", type=int, default=28, help="Days to look ahead")
    
    # Remove route
    remove_parser = subparsers.add_parser("remove", help="Remove a route")
    remove_parser.add_argument("key", help="Route key (e.g., YYC-LAX)")
    
    args = parser.parse_args()
    
    if args.command == "add":
        print(add_route(args))
    elif args.command == "list":
        print(list_routes(args))
    elif args.command == "watch":
        print(watch(args))
    elif args.command == "remove":
        print(remove_route(args))
    else:
        # Default: list then watch
        print(list_routes(args))
        print()
        args.days = 28
        print(watch(args))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)