A power-user CLI for airfare discovery. Wraps ITA Matrix's undocumented backend for full routing-language and extension-code support, hands off to Google Flights for booking, with on-disk caching and golden-file regression tests against captured wire bodies.
Not affiliated with Google, ITA Software, or ITA Matrix. Uses Matrix's public-API-key endpoint the same way the web UI does.
git clone https://github.com/ak2k/flight-cli
cd flight-cli
uv venv && uv pip install -e .Requires Python 3.11+.
flight --install-completion adds tab completion to the current shell (bash,
zsh, fish or PowerShell); flight --show-completion prints the script instead.
Tab then offers the values of --cabin, --sort, --backend, --format,
--gf-transport and the time-of-day flags. Airport codes, --routing and
--extension are free text and do not complete.
# specific-date search — auto-picks the backend.
# Plain cash search → Google Flights (fast, broad coverage).
# Airport sets and metro codes (JFK,EWR or NYC) stay there too; a leg over
# Google's 11 airports a page is asked as several pages (up to 8) and merged.
flight search JFK LHR --dep 2026-08-15 --return 2026-08-22
# A carrier or alliance, a maximum duration, a layover bound, one time-of-day
# window, -REDEYES/-OVERNIGHTS, a +CABIN naming the --cabin asked, a child or an
# infant stays on Google Flights. Every row is also checked against the carrier,
# duration, layover, time window, night flights and cabin; rows over the stop
# ceiling are dropped and counted on stderr. An infant's empty Google board goes
# to Matrix.
flight search MIA PAR --dep 2026-06-15 \
--routing "LH+" --ext "MAXCONNECT 2:00"
# A price cap in the search's currency: Google is asked for it in USD and every
# row is checked; a Matrix answer is cut to it. --bags prices fares with one checked
# bag (1,1 adds a carry-on) and says per row whether the price includes them;
# it is Google-only, so a search only Matrix could answer is refused.
flight search JFK LAX --dep 2026-10-20 --max-price 250
flight search JFK LAX --dep 2026-10-20 --bags 1
# Beside several cabins, each cabin is held to the cap and each price ends in
# ✓ (includes the bags), ✗ (does not) or ? (Google does not say).
flight search JFK LAX --dep 2026-10-20 --cabin economy,premium --bags 1 --max-price 900
# Arrival windows and economy without basic fares are Google-only too. Every
# row lands inside the window, to the minute; no row shows whether its fare is
# basic, so every --exclude-basic run says the rows cannot be checked.
flight search JFK LAX --dep 2026-10-20 --arrive-times 18:00-21:30
flight search JFK LAX --dep 2026-10-20 --exclude-basic
# What Google can't serve auto-flips to ITA Matrix, naming why on stderr:
# ordered routing, fare construction, multi-city slices, time-of-day buckets
# with a gap between them, a flexible date and an arrival date.
flight search MIA PAR --dep 2026-06-15 --routing "LH UA" --ext "-REDEYES"
# Matrix's date options: a day either side of the date (--flex 1; before,
# after, or 2 for two days either side), or arrive on a date rather than leave
# on it (--arrive in place of --dep). --return-flex and --return-arrive do the
# same for the return. Beside --arrive, --arrive-times is the window Matrix
# holds the arrival to.
flight search JFK LHR --dep 2026-10-20 --flex 1
flight search JFK LHR --arrive 2026-10-21 --arrive-times evening
# Force a backend explicitly:
flight search JFK LHR --dep 2026-08-15 --backend matrix
flight search JFK LHR --dep 2026-08-15 --backend gflight
# When the Google table is wider than the output (80 columns when no std
# stream is a terminal), its legs print one per line and the CO2 column may
# be left out with a note; --format json carries every value.
# lowest-fare calendar across a date window (one Matrix call per airport
# pair, PAR split into CDG, ORY and BVA, plus the query as typed on a round
# trip; returns 30 days × N durations)
flight calendar MIA PAR --start 2026-06-07 -d 5-7 \
--routing "LH+" --ext "MAXCONNECT 2:00" --depart-times morning
# Without --fast, a table calendar prints Google Flights' price graph under
# Matrix's grid, and --format json or envelope carries it when given
# --gf-transport browser or auto. Matrix lists fares it priced; Google gives one
# price per date pair with no itinerary behind it, so their lows can differ.
# When they do, one stderr line names both lows with their date pairs, says
# what both asked, and gives the search on each date pair that shows which fare
# is bookable. A departure date Matrix priced no fare on is named on stderr too.
flight calendar NYC PAR --start 2026-10-20 --end 2026-11-19 -d 5-7
# phase-2 of the calendar flow: full itineraries for a picked date. Give it
# the calendar's filters (routing, codes, --depart-times/--return-times,
# --include-unavailable) so it prices the grid's question, and the airport
# pair that priced the picked cell: a split calendar shows it in the route
# column (MIA→CDG) or beside a trip length another pair priced, and as
# origin/destination in --json. A calendar of one airport pair has no route
# column; give detail its codes.
flight detail MIA CDG --dep 2026-06-10 --return 2026-06-16 --duration 5-7 \
--routing "LH+" --ext "MAXCONNECT 2:00" --depart-times morning
# IATA autocomplete
flight airport LON
# Award overlay (PointsPath, seats.aero) is implicit on BOTH backends once
# you've logged in (`flight auth pp login`). --cash-only skips it;
# --awards-only shows only the award table.
flight search JFK LHR --dep 2026-08-15flight fare and flight gflight are deprecated aliases for flight search --backend matrix and flight search --backend gflight respectively. They
still work for one release; --help marks them deprecated.
Every result-printing command supports:
--matrix-url— print a deep-link that opens the same search in ITA Matrix's web UI--google-url— print a structured Google Flights URL (tfs=protobuf) that opens directly to the search--pick N— pin itinerary #N (1-based, as shown in the table) in the--matrix-url/--google-urldeep links instead of the cheapest;--awards-onlyprints no table, so a pick there names no row and the links are unpinned--currency EUR— price in that currency on both backends (search,calendar,detail); a non-USD calendar is Matrix's alone, without the USD-only Google Flights price graph--fare-rules(search) — after the table, print itinerary--pick N's fare basis, booking codes and fare rules (penalties, changes, refunds) from Matrix--verify(search, Google Flights) — after the table, ask Matrix for itinerary--pick Nas exactly that itinerary (its flights by number, each on its own day and minute, between its airports) and print Matrix's price beside Google's with the fare basis, booking codes and fare rules; or say why Matrix does not price it: those flights only on another itinerary, no fare, or that none of the trips Matrix returned for that route and day names its carrier. For a party, Matrix's price is the party's total, as Google's is, or one passenger's marked per traveler where Matrix states no total. With--format jsonthe document is{"search": [...], "verify": {...}}, and--format envelopecarries the same object underverify;verify.matrix.priceandverify.delta(Google's price minus Matrix's) are the party's, andverify.matrix.per_traveleris one passenger's--no-separate-tickets(search, Google Flights) — hide the itineraries Google sells as separate tickets, which every search that shows Google rows otherwise adds from Google's Cheapest tab, marked†(‡for a self transfer, where bags are rechecked between flights) on the Google, merged and multi-cabin tables orseparate_tickets: true, a round trip as its outbound alone at Google's round-trip total, never priced against Matrix (cross-check reasonseparate_tickets) and skipped, with a reason, by awards,--sellers,--verifyand pinned links; on a multi-city--slicesearch, no one-way tickets are asked for--format envelope(search,calendar) — for agents and scripts: one JSON object with the same keys whichever path answered (version,command,backend,currency,complete,notes,results,awards,insight,price_history,facets,price_graph,verify,cross_check,split_ticket). It is written at exit 0 and 1; a usage error (exit 2) writes no document.completeis false when the answer is narrower than asked, such as a cabin, a calendar sub-query, a departure date Matrix priced no fare on, an award provider or Google's Cheapest tab lost, andnotescarries what stderr said. A calendar's result rows carry theirdepartureandreturndates besiderow. A Google search'sfacetsare what Google's filters offer for it (fare, trip-length and layover ranges, airlines, alliances, connecting airports), to choose a carrier, alliance,--max-price,MAXDURorMAXCONNECTwithout a second search. A calendar'sprice_graphis Google's estimate per date pair, read under--gf-transport browserorauto. Schema:docs/envelope.schema.json--format json— the answering path's own document: Google Flights rows, Matrix's raw response (a calendar's withgoogle_price_graphbeside it under--gf-transport browserorauto),{cabin: …}for several cabins, or the award document when awards run.--jsonis a deprecated alias for it--no-cache— bypass the on-disk response cache (~/.cache/flight-cli/)
- Routing language (
--routing):LH+(every flight marketed by Lufthansa),BA AA(a BA flight, then an AA flight),F* X:LHR F*(connects at LHR). Each slice reads its routing from its own origin:--routing-retgives a round trip's return its own (''for none), and unset, the return gets--routingonly when it reads the same both ways.BA AAon a round trip without--routing-retis refused, naming the reversed orderAA BA. Beside--slice,--routingand--extensionapply to every slice with nor=/e=of its own. More codes → - Extension codes (
--extension):MAXCONNECT 5:00,MAXSTOPS 1,MINMILES 3000,-REDEYES,-OVERNIGHTS,ALLIANCE oneworld. A round trip copies them onto the return unless--ext-retgives its own. - Multi-airport:
flight calendar MIA VIE,PAR,FCO,MAD --start ...— search across N European cities at once, one Matrix query per airport pair, plus the query as typed on a round trip, merged into one grid in one currency whose every day names the pair that priced it. - Time-of-day filters (
--depart-times,--return-times):morning,morning,middayetc. Buckets that make one window stay on Google Flights;morning,eveninggoes to Matrix.searchalso takes one window to the minute (--depart-times 9:30-13:45): Google is asked for its whole hours and every row is checked to the minute, and Matrix takes it as it is.--arrive-times 18:00-21:30and--return-arrive-timeshold when each direction lands: beside--dep(--return) on Google Flights only, since Matrix takes no arrival time beside a departure date; beside--arrive(--return-arrive) Matrix holds the arrival to them. - Flexible and arrival dates (
search, Matrix only):--flex before|after|1|2also searches the day before, the day after, a day either side or two days either side of--dep(Matrix's "Or day before", "Or day after", "+/- 1 day", "+/- 2 days");--arrive DATEin place of--depasks for flights that arrive on that date.--return-flexand--return-arrivedo the same for the return, and a--slicetakes them asf=andd=arrive(JFK-LHR:2026-10-20:f=1:d=arrive). Google takes neither, so each sends the search to Matrix with the reason named,--backend gflightrefuses it, the Matrix link opens the same choice, and a Google link beside the rows says it searches the typed date as a departure date. Award providers are asked for departures on the typed date only. - Economy without basic fares (
--exclude-basic): Google Flights only, economy only. Google is asked to leave basic fares out, but no row says whether its fare is basic, and Google served basic fares on JFK-LHR anyway, so every run says the rows cannot be checked.--sellersand--verifyare refused beside it: neither the booking page nor Matrix is asked to leave basic fares out. - Stop limits (
--stops N): at most N stops per direction, on every backend.0= nonstop only,1= up to one stop, … - Calendar-mode duration ranges (
-d 5-7): one search returns prices for 5-, 6-, and 7-night trips at every starting day.calendar --fast -d 5-7shows Google's price graph alone, one column per trip length, within 8 page loads. - Cheap days (
calendar, Matrix's table): a day'sminprice prints green when it is at least 20% under the median of the window's priced days in the table's currency; with fewer than 5 such days nothing is colored. - Split tickets (
search --split): on a Google Flights round trip, also prices one-way tickets each way and prints the cheapest pair of one-ticket one-ways whose return leaves the airport the outbound lands at, after it lands, with their total, on one line under the round-trip table. - Multi-city on separate tickets (two or more
--slicethat are not a round trip: an open jaw, or a longer trip): Matrix prices the trip as one ticket, and before its tablesearchalso prints the cheapest combinations of one Google Flights one-way per slice (flight search --slice JFK-LHR:2026-10-20 --slice CDG-JFK:2026-10-27), each total the sum of its tickets in one currency and marked†, because a missed flight on one ticket is not protected on the next. Each ticket leaves after the one before it lands, on a later day when it leaves from another airport.--format envelopecarries them undersplit_ticket;--format jsonwrites them beside Matrix's document only with--split.--backend gflightshows the combinations alone and asks Matrix nothing; its--format jsonis{"search": [], "split_ticket": {…}}. - Several cabins (
--cabin economy,business): one table with a price column per cabin, every cabin priced on the--sortcabin's itineraries. A cabin whose own cheapest fare is on no row gets a line under the table naming it and the--sortthat lists it first, for a party at the party's total, or one traveler's price where Matrix states none, and the line says which;--format jsonand--format envelopecarry every fare the table and that line print. - Google vs Matrix cross-check (the default table): every price is for the whole party and a row ranks on the lowest one it prints; a row both sides price for the same trip shows
delta(Google − Matrix), every other row sayswhyit has none, and the caption says how much of Matrix's answer was read. Where Google's cheapest Google-only row is under every fare in Matrix's answer, Matrix is asked for that row's exact flights (60 s at most), and the line under the table gives Matrix's price for them beside Google's, or why Matrix does not price them, or that it did not answer.flight search JFK LAX --dep 2026-10-20 --format json --enrich --cash-onlywrites the same comparison as{"search": …, "cross_check": …}, that answer ascross_check.low_check, and--format envelopecarries the comparison undercross_check. - Party prices (
--adults 2and the like): every itinerary price is the party's total, on Matrix as on Google, under a header that says so; Matrix's carrier x stops grid and its cheapest line stay per traveler. The multi-cabin table (several--cabinvalues) prints each cabin's party total too, starred as per traveler where Matrix states no total. - Sellers and explore (Chrome, the
browserextra):flight search JFK LAX --dep 2026-10-20 --sellers --pick 2lists every seller of row 2 with its price and fare name, cheapest first, then each seller's bag fees and booking link on a line of its own;flight explore JFK --month 2026-11 --days 5-7 --max-price 300lists where JFK flies that month and the cheapest round trip to each. - Watches (
flight watch add JFK LHR --below 400,flight watch list,flight watch rm 1): saves a route, an optional--depdate or--from/--towindow, a--belowceiling, a--cabinand--awardtowatches.jsonin the config directory (mode 0600). Nothing polls, searches or notifies from them yet.
flight doctor prints pass, FAIL or skip for each backend, transport and
credential; --format json gives the same checks as a document.
| Check | What it checks |
|---|---|
config |
config.toml parses, if there is one, and the rps setting is a number of at least 5.6e-309 |
matrix-key |
which Matrix key a search would send (FLIGHT_API_KEY, the cache and its age, or none), without fetching one |
cache |
the response cache opens |
google-cookies |
the saved Google session cookie: its age and NID count |
matrix-spa-key |
the key Matrix's page serves, and whether it is the one in use; nothing is cached |
matrix-search |
one live Matrix search, JFK-LAX 30 days out; passes only on a priced solution |
google-http, google-browser |
the same search on Google Flights' page over http and in Chrome; passes only on a priced row. Chrome is skipped when patchright or Chrome is missing |
pointspath, seats-aero |
one authenticated request each when credentials are stored, skipped otherwise. The seats.aero check spends one unit of its daily quota |
It exits 0 when nothing failed, 75 when every failure is a throttle, brownout
or outage worth retrying, and 1 otherwise. Each failure names its cause; a
shape failure means a parser no longer reads what Google or Matrix sends
(docs/memories/doctor.md). Credentials appear only
as sha256: fingerprints.
When Matrix answers with a body the response models cannot read, a search
stops with a line naming the field, the issue tracker and a saved copy of the
body under ~/.cache/flight-cli/shape-changes/ (MATRIX_CACHE_DIR moves it).
Attach that file to the report; -vv adds every field the parse refused.
When you've logged in (flight auth pp login), flight search automatically
overlays award availability onto each cash itinerary it returns — on both
backends. Each row shows the airline-native miles cost, taxes, the banks whose
points transfer to that program, cents-per-mile valuation, and a stops marker
so a nonstop award is distinguishable from a connection at a glance.
Round-trips render one table per leg.
Award data comes from a provider registry behind a common AwardProvider
interface. Two providers ship today:
- PointsPath — transferable-points award pricing (requires a paid subscription; see Setup below).
- seats.aero — award availability across programs (requires an API key).
Each configured provider auto-enables and fans out per leg; the cash↔award matcher and renderers are provider-blind.
The providers take one airport per end, so an airport set (JFK,EWR) or a
metro code (NYC, asked as JFK, LGA and EWR) is asked pair by pair, and an
award attaches only to cash rows on its own airports. Each pair costs a
PointsPath request per cabin and airline and one seats.aero quota unit, so a
search asks at most 8 pairs, those its cash rows fly first, and every leg at
least one. A leg with pairs left out gets one stderr line naming them, in
every output format, and its JSON entry lists them as pairs_not_asked:
Awards for outbound NYC→LON 2026-11-04: asked 4 of 18 airport pairs (at most 8 a search); not asked: JFK→LTN, ...
# implicit overlay — any search adds the award table when a provider is configured
flight search JFK LHR --dep 2026-08-15
# skip the overlay even when configured (cash only)
flight search JFK LHR --dep 2026-08-15 --cash-only
# award-only listing (skip the cash table render)
flight search JFK LHR --dep 2026-08-15 --awards-only
# restrict to specific providers
flight search JFK LHR --dep 2026-08-15 --providers pp
# limit the cabin set (default: Economy + Business)
flight search JFK LHR --dep 2026-08-15 --cabin Economy
# per-provider override (e.g. PointsPath airline set); repeatable
flight search JFK LHR --dep 2026-08-15 --provider-opt 'pp.airlines=United,Delta,American'PointsPath requires a paid subscription (free tier is the browser extension only). Three login modes:
1. Headed browser login (default, recommended). Opens a Patchright Chrome so you can sign in normally; the CLI captures the resulting session into ~/.config/flight-cli/pp.json. Independent of any Chrome PP session you have open elsewhere — different server-side Supabase session, so the refresh chains never race.
We use Patchright (a drop-in Playwright fork that patches the CDP Runtime.enable leak and the navigator.webdriver flag) because pointspath.com is behind Cloudflare's bot fingerprint check, which stock Playwright fails. The browser profile is persisted at ~/.cache/flight-cli/browser-profile/ so the Cloudflare cf_clearance cookie survives across login sessions — you usually only have to clear the human-check once.
# One-time: download real Chrome (~150MB) into Patchright's cache.
# `channel="chrome"` uses the real Chrome binary because its TLS
# fingerprint matches real Chrome traffic — bundled Chromium doesn't.
uvx --from patchright patchright install chrome
# Then log in. `--with patchright` adds the Python package ephemerally
# for this one invocation — no need to mutate flight-cli's venv.
uv run --with patchright flight auth pp login
flight auth pp whoami # confirmIf you'd rather make patchright a permanent venv resident (skip --with every time), there's an optional install extra: uv pip install -e '.[browser-login]'. Most users don't need this.
2. --from-chrome (cookie import). Reads Supabase cookies from your local Chrome profile via rookiepy. Quicker than headed login since you don't sign in again — but the CLI then shares Chrome's refresh-token chain. Supabase rotates refresh tokens single-use, so a refresh on one side will eventually invalidate the other. Use this when you don't mind re-importing periodically.
flight auth pp login --from-chrome3. --tokens-file PATH (JSON import). Bring your own session JSON. Useful when you've captured tokens with another tool (CDP cookie sniff, browser DevTools, etc.).
flight auth pp login --tokens-file ~/Downloads/pp_tokens.json
# Expected file shape:
# {"access_token": "...", "refresh_token": "...", "user": {"email": "..."}}Once tokens are saved, refresh is automatic for the lifetime of the refresh-token chain (~indefinite, modulo the rotation race in mode 2).
On each award overlay (cached for 24h / 7d respectively):
GET /api/pricing-info— universe of supported airlines + their transfer-partner banksGET /api/extension-config— your account's enabled feature flags- The airlines fanned out are: pricing-info entries minus those with
enable<Airline>=0in the feature flags. Always-on airlines (American, Delta, United, JetBlue, Alaska) have no toggle and are always included.
Pass --provider-opt 'pp.airlines=United,Delta,...' to skip discovery and call only the named set.
Browser-based login(now the default — see Setup above)- Award overlay on
calendar(lowest-fare-calendar) — fan-out is N days × M airlines; deserves its own design - Ask more than 8 airport pairs in one search — a set or metro search past that names the pairs it left out (stderr, JSON
pairs_not_asked), and the cap has no flag - Match against airlines we don't yet support (the few in pricing-info but not enabled for your tier are silently skipped)
The codebase is a small pydantic discriminated union with match-based adapters — adding a new search mode or a new backend is mechanical and type-checked.
src/flight_cli/
domain.py SpecificDateSearch | CalendarSearch | CalendarFollowup
+ SearchOptions + Leg + TimeOfDay
wire.py to_wire(search) → typed WireBody (Matrix API request)
links.py matrix_deep_link / matrix_itinerary_url, google_flights_url
+ pinned (--pick N) deep-link encoders
client.py MatrixClient.execute(search)
fli_bridge.py Google Flights handoff via the `flights` (fli) pypi package
_gflight_ids.py gflight query wrapper: captures opaque flight ids;
persists the session NID cookie (TTL'd) so each run starts
warm, and retries cold-session empties as a fallback
cli.py typer commands (search / calendar / detail / airport + auth)
models.py response models
_http.py httpx + curl_cffi + aiolimiter + stamina
providers/ award-provider registry behind a common AwardProvider protocol
base.py AwardFlight / AwardProvider / LegQuery
registry.py gather_awards: construct enabled providers, fan out per leg
pointspath/ PointsPath provider
seats_aero/ seats.aero provider
pp/ PointsPath client + cash↔award matcher + `auth pp` subapp
auth.py Supabase JWT store + refresh
client.py airline-search / pricing-info / extension-config (cached)
match.py cash↔award join by (flight#, date) / (route, time) / matched id
cli.py auth subapp + award overlay wired into `search`
models.py PointsPath response shapes
tests/
fixtures/ captured SPA wire bodies (golden files)
test_wire_round_trip.py
pp/ PointsPath model + match + helper unit tests
seats_aero/ seats.aero provider unit tests
Run tests with pytest tests/.
ITA Matrix is dramatically more powerful than consumer flight-search sites — routing language, extension codes, lowest-fare calendars — but the web UI is clunky and there's no published API. This CLI captures everything Matrix can do behind a fluent command-line interface, plus hands off to Google Flights for the actual booking flow.
- AWeirdDev/fast-flights — Google Flights
tfs=protobuf encoder - punitarani/fli — Google Flights API client (
flightson PyPI) - adamhwang/ita-matrix-powertools — userscript that documented several Matrix internals
MIT