find-flight 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 find-flight contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # find-flight
2
+
3
+ **Google Flights search for AI agents.** A [pi](https://github.com/earendil-works/pi) package that bundles:
4
+
5
+ - **a `search_flights` extension tool** — deterministic, structured flight searches the agent calls directly
6
+ - **a portable skill** — recipes and reference that work in pi, Claude Code, and Codex
7
+
8
+ Powered by the [`fast-flights`](https://github.com/AWeirdDev/flights) Google Flights scraper. Read-only — it fetches real availability and prices, but does **not** book anything.
9
+
10
+ ## ✨ What it does
11
+
12
+ - Search flights between airports on given dates (one-way or round-trip)
13
+ - Real prices, airlines, per-leg times, duration, plane type, stop counts, carbon emissions
14
+ - Filters: seat class, passengers, currency, language, max stops, max price, exclude basic economy
15
+ - Returns **structured** results — correct per-direction stop counts, no ad-hoc scripts left behind
16
+
17
+ ## 📦 Install
18
+
19
+ Requires **Python 3.10+** with fast-flights, and live network access:
20
+
21
+ ```bash
22
+ pip install fast-flights
23
+ ```
24
+
25
+ ### As a pi package (<img src="https://img.shields.io/badge/pi-package-5B8DEF)" width="60px" align="center">)
26
+
27
+ ```bash
28
+ pi install git:github.com/<your-user>/find-flight
29
+ # or from npm if published:
30
+ pi install npm:find-flight
31
+ ```
32
+
33
+ Or install only for the current run:
34
+
35
+ ```bash
36
+ pi -e git:github.com/<your-user>/find-flight
37
+ ```
38
+
39
+ After install, reload pi (`/reload`) — the agent gains a `search_flights` tool automatically.
40
+
41
+ ### Manually (local dev / other harnesses)
42
+
43
+ ```bash
44
+ # pi extension
45
+ ln -s "$(pwd)/extensions/flight-search.ts" ~/.pi/agent/extensions/flight-search.ts
46
+
47
+ # skill (pi)
48
+ ln -s "$(pwd)/skills/find-flight" ~/.pi/agent/skills/find-flight
49
+ # skill (Claude Code / Codex)
50
+ ln -s "$(pwd)/skills/find-flight" ~/.agents/skills/find-flight
51
+ ln -s "$(pwd)/skills/find-flight" ~/.claude/skills/find-flight
52
+ ```
53
+
54
+ ## 🚀 Usage
55
+
56
+ Just ask in natural language:
57
+
58
+ > *"Find one-way flights from IST to ECN on 12 September, economy, show cheapest 3"*
59
+ >
60
+ > *"Cheapest round-trip Istanbul to Paris, out 23 Oct return 25 Oct"*
61
+
62
+ The agent calls `search_flights(from_airport, to_airport, outbound_date, trip, return_date?, seat, currency, ...)` and gets structured JSON.
63
+
64
+ ### Python executor (standalone)
65
+
66
+ `flight_runner.py` reads a JSON query on stdin and writes structured JSON on stdout — useful for testing or scripting without the agent:
67
+
68
+ ```bash
69
+ echo '{"from_airport":"IST","to_airport":"ECN","outbound_date":"2026-09-12","trip":"one-way","currency":"EUR"}' \
70
+ | python3 flight_runner.py
71
+ ```
72
+
73
+ ### CLI example
74
+
75
+ ```bash
76
+ python examples/search_flights.py --from IST --to ECN --date 2026-09-12 --currency EUR --sort price --limit 3
77
+ ```
78
+
79
+ ## 📁 Layout
80
+
81
+ ```
82
+ find-flight/
83
+ ├── package.json # pi package manifest (extensions + skills)
84
+ ├── extensions/
85
+ │ └── flight-search.ts # registerTool: search_flights
86
+ ├── skills/
87
+ │ └── find-flight/SKILL.md # portable agent skill
88
+ ├── flight_runner.py # Python executor (structured JSON)
89
+ ├── examples/
90
+ │ └── search_flights.py # CLI example
91
+ └── README.md
92
+ ```
93
+
94
+ ## ⚙️ Notes / limitations
95
+
96
+ - **Round-trip data (verified):** parsed results contain only the **outbound** legs; `price` is the **two-way total**. The tool reports this explicitly.
97
+ - `multi-city` trips are not supported by the upstream library.
98
+ - Scraping Google is subject to rate limits / IP blocks. For heavy use, plug a proxy (BrightData) or richer source (SearchApi) integration into `fast-flights`.
99
+ - Prices are quoted in the `currency` you pass; if omitted, Google picks a default (often the local currency).
100
+ - Config: point the runner elsewhere with `FLIGHT_RUNNER=/path/to/flight_runner.py`, or choose a different interpreter with `FLIGHT_PYTHON=...`.
101
+
102
+ ## 🔒 Security
103
+
104
+ This package executes Python (`flight_runner.py`) that makes live network requests to Google. Review the source before installing.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Search Google Flights with fast-flights from the command line.
3
+
4
+ Requires: pip install fast-flights
5
+
6
+ Examples:
7
+ python search_flights.py --from TPE --to MYJ --date 2025-01-01
8
+ python search_flights.py --from TPE --to MYJ --date 2025-01-01 --trip round-trip --return-date 2025-01-08 --seat business
9
+ python search_flights.py --from TPE --to MYJ --date 2025-01-01 --max-stops 1 --max-price 500 --currency USD --sort price --limit 5
10
+ echo "Flights from TPE to MYJ on 2025-01-01 one way economy" | python search_flights.py --text
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from datetime import date as Date
17
+
18
+ from fast_flights import (
19
+ FlightsNotFound,
20
+ FlightQuery,
21
+ Passengers,
22
+ ResultList,
23
+ create_query,
24
+ get_flights,
25
+ )
26
+
27
+ TRIPS = ("one-way", "round-trip")
28
+ SEATS = ("economy", "premium-economy", "business", "first")
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ p = argparse.ArgumentParser(description="Search Google Flights with fast-flights.")
33
+ p.add_argument("--text", action="store_true",
34
+ help="Read a natural-language query from stdin instead of structured flags.")
35
+ p.add_argument("--from", dest="from_airport", required=False,
36
+ help="3-letter IATA departure airport.")
37
+ p.add_argument("--to", dest="to_airport", required=False,
38
+ help="3-letter IATA arrival airport.")
39
+ p.add_argument("--date", type=Date.fromisoformat,
40
+ help="Departure date, YYYY-MM-DD.")
41
+ p.add_argument("--return-date", type=Date.fromisoformat,
42
+ help="Return date for round-trip, YYYY-MM-DD.")
43
+ p.add_argument("--trip", choices=TRIPS, default="one-way")
44
+ p.add_argument("--seat", choices=SEATS, default="economy")
45
+ p.add_argument("--adults", type=int, default=1)
46
+ p.add_argument("--children", type=int, default=0)
47
+ p.add_argument("--currency", default="")
48
+ p.add_argument("--language", default="")
49
+ p.add_argument("--max-stops", type=int)
50
+ p.add_argument("--max-price", type=int)
51
+ p.add_argument("--sort", choices=("price", "duration"), default=None,
52
+ help="Sort results by price (ascending) or total duration (ascending).")
53
+ p.add_argument("--limit", type=int, default=0,
54
+ help="Show only the top N itineraries after sorting (0 = all).")
55
+ return p
56
+
57
+
58
+ def leg(date: Date, src: str, dst: str, max_stops: int | None) -> FlightQuery:
59
+ src, dst = src.strip().upper(), dst.strip().upper()
60
+ for code in (src, dst):
61
+ if not code.isalpha() or len(code) != 3:
62
+ sys.exit(f"airport codes must be 3 letters, got {code!r}")
63
+ return FlightQuery(
64
+ date=date.strftime("%Y-%m-%d"),
65
+ from_airport=src,
66
+ to_airport=dst,
67
+ max_stops=max_stops,
68
+ )
69
+
70
+
71
+ def build_query(args: argparse.Namespace):
72
+ flights = [leg(args.date, args.from_airport, args.to_airport, args.max_stops)]
73
+ if args.trip == "round-trip":
74
+ if not args.return_date:
75
+ sys.exit("--return-date is required for --trip round-trip")
76
+ flights.append(leg(args.return_date, args.to_airport, args.from_airport, args.max_stops))
77
+
78
+ return create_query(
79
+ flights=flights,
80
+ trip=args.trip,
81
+ seat=args.seat,
82
+ passengers=Passengers(adults=args.adults, children=args.children),
83
+ currency=args.currency,
84
+ language=args.language,
85
+ max_price=args.max_price,
86
+ )
87
+
88
+
89
+ def total_duration(flight) -> int:
90
+ return sum(seg.duration for seg in flight.flights)
91
+
92
+
93
+ def print_result(result: ResultList, currency: str = "") -> None:
94
+ if not result:
95
+ print("No flights found.")
96
+ return
97
+ sym = f"{currency} " if currency else "$"
98
+ for flight in result:
99
+ print(f"{sym}{flight.price} {', '.join(flight.airlines)} ({flight.type})")
100
+ for seg in flight.flights:
101
+ dep = f"{seg.departure.date[0]}-{seg.departure.date[1]:02d}-{seg.departure.date[2]:02d} {seg.departure.time[0]:02d}:{seg.departure.time[1]:02d}"
102
+ arr = f"{seg.arrival.date[0]}-{seg.arrival.date[1]:02d}-{seg.arrival.date[2]:02d} {seg.arrival.time[0]:02d}:{seg.arrival.time[1]:02d}"
103
+ print(f" {seg.from_airport.code} {dep} -> {seg.to_airport.code} {arr} "
104
+ f"({seg.duration} min, {seg.plane_type})")
105
+ print(f"\n{len(result)} itinerary/ies")
106
+
107
+
108
+ def main() -> int:
109
+ args = build_parser().parse_args()
110
+
111
+ if args.text:
112
+ query = sys.stdin.read().strip()
113
+ if not query:
114
+ sys.exit("no text on stdin")
115
+ result = get_flights(query)
116
+ else:
117
+ query = build_query(args)
118
+ result = get_flights(query)
119
+
120
+ if args.sort == "price":
121
+ result.sort(key=lambda f: f.price)
122
+ elif args.sort == "duration":
123
+ result.sort(key=total_duration)
124
+ if args.limit:
125
+ result = result[: args.limit]
126
+
127
+ print_result(result, currency=args.currency)
128
+ return 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ try:
133
+ raise SystemExit(main())
134
+ except FlightsNotFound:
135
+ print("No flights found (Google returned an error/no results).")
136
+ raise SystemExit(1)
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Flight Search — a pi extension that registers a `search_flights` tool.
3
+ *
4
+ * The tool delegates to `flight_runner.py` (a small Python wrapper around the
5
+ * `fast-flights` Google Flights scraper) for deterministic, structured results:
6
+ * correct per-direction stop counts, no scratch scripts, no ad-hoc formatting.
7
+ *
8
+ * Install:
9
+ * - python3 with `fast-flights` installed (the runner reports a clear error if not)
10
+ * - symlink this file into your pi extensions dir so it auto-loads on /reload:
11
+ * ln -s "$(pwd)/extensions/flight-search.ts" ~/.pi/agent/extensions/flight-search.ts
12
+ *
13
+ * The runner path is resolved in this order:
14
+ * 1. `FLIGHT_RUNNER` env var
15
+ * 2. `flight_runner.py` next to this file
16
+ * 3. default OSS folder: <repo>/flight_runner.py
17
+ */
18
+
19
+ import { execFile } from "node:child_process";
20
+ import { accessSync, realpathSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+
23
+ import { StringEnum } from "@earendil-works/pi-ai";
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { Type } from "typebox";
26
+
27
+ export function findRunner(): string {
28
+ const env = process.env.FLIGHT_RUNNER;
29
+ if (env) return env;
30
+
31
+ // __dirname is the dir the extension was LOADED from. If the extension is
32
+ // symlinked into ~/.pi/agent/extensions/, __dirname is that symlink dir, so
33
+ // resolve the real path of this module before looking for its neighbours.
34
+ let base = __dirname;
35
+ try {
36
+ base = dirname(realpathSync(__filename));
37
+ } catch {
38
+ /* fall back to __dirname */
39
+ }
40
+
41
+ // 1) flight_runner.py next to this file
42
+ const local = join(base, "flight_runner.py");
43
+ if (fileExists(local)) return local;
44
+ // 2) OSS project layout: <project>/flight_runner.py
45
+ const project = join(dirname(base), "flight_runner.py");
46
+ if (fileExists(project)) return project;
47
+ // 3) last resort: current working dir
48
+ return join(process.cwd(), "flight_runner.py");
49
+ }
50
+
51
+ function fileExists(p: string): boolean {
52
+ try {
53
+ accessSync(p);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function runPython(runner: string, python: string, payload: unknown, signal?: AbortSignal): Promise<string> {
61
+ return new Promise((resolvePromise, reject) => {
62
+ const child = execFile(
63
+ python,
64
+ [runner],
65
+ {
66
+ timeout: 120_000,
67
+ maxBuffer: 10 * 1024 * 1024,
68
+ signal,
69
+ },
70
+ (error, stdout, stderr) => {
71
+ if (error) {
72
+ reject(
73
+ new Error(
74
+ `flight search failed (${error.message})${stderr ? `: ${stderr.trim()}` : ""}`,
75
+ ),
76
+ );
77
+ return;
78
+ }
79
+ resolvePromise(stdout);
80
+ },
81
+ );
82
+ child.stdin?.on("error", () => {}); // EPIPE if python exits early
83
+ child.stdin?.end(JSON.stringify(payload));
84
+ });
85
+ }
86
+
87
+ const search_flights = {
88
+ name: "search_flights",
89
+ label: "Search Flights",
90
+ description:
91
+ "Search real Google Flights availability between two airports on given dates via fast-flights. Returns structured JSON: price (currency), airline(s), number of stops, and each flight leg with airports, times, duration and plane type. For round-trips, price is the two-way total and only the outbound legs are returned. Use for finding flights, checking prices/availability, comparing routes, dates, seats, or airlines.",
92
+ promptSnippet: "Search live Google Flights for price/availability between airports",
93
+ promptGuidelines: [
94
+ "Use search_flights for real flight prices and availability instead of guessing or writing ad-hoc scraping scripts.",
95
+ "Before calling search_flights, verify airport codes (from_airport/to_airport must be 3-letter IATA codes) and resolve dates to YYYY-MM-DD; ask the user if the code, trip type, or dates are unclear.",
96
+ "search_flights prices carry the currency you pass; pass a currency (e.g. 'EUR', 'TRY', 'USD') unless the user prefers Google's default.",
97
+ ],
98
+ parameters: Type.Object({
99
+ from_airport: Type.String({ description: "3-letter IATA departure airport code (e.g. 'IST')" }),
100
+ to_airport: Type.String({ description: "3-letter IATA arrival airport code (e.g. 'ECN')" }),
101
+ outbound_date: Type.String({ description: "Outbound departure date, YYYY-MM-DD" }),
102
+ trip: StringEnum(["one-way", "round-trip"] as const),
103
+ return_date: Type.Optional(
104
+ Type.String({ description: "Return date, YYYY-MM-DD — required when trip is round-trip" }),
105
+ ),
106
+ seat: StringEnum(["economy", "premium-economy", "business", "first"] as const),
107
+ currency: Type.Optional(
108
+ Type.String({ description: "ISO currency code, e.g. EUR, USD, TRY. Omit for Google default." }),
109
+ ),
110
+ language: Type.Optional(
111
+ Type.String({ description: "IETF language tag, e.g. en-US. Omit for Google default." }),
112
+ ),
113
+ adults: Type.Optional(Type.Integer({ minimum: 0, maximum: 9 })),
114
+ children: Type.Optional(Type.Integer({ minimum: 0, maximum: 9 })),
115
+ max_stops: Type.Optional(Type.Integer({ minimum: 0 })),
116
+ max_price: Type.Optional(Type.Integer({ minimum: 0 })),
117
+ exclude_basic_economy: Type.Optional(Type.Boolean()),
118
+ }),
119
+
120
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
121
+ onUpdate?.({ content: [{ type: "text", text: "Searching flights…" }] });
122
+
123
+ if (params.trip === "round-trip" && !params.return_date) {
124
+ throw new Error("return_date is required when trip is 'round-trip'");
125
+ }
126
+
127
+ const runner = findRunner();
128
+ const python = process.env.FLIGHT_PYTHON ?? "python3";
129
+ const stdout = await runPython(runner, python, params, signal);
130
+
131
+ let data: unknown;
132
+ try {
133
+ data = JSON.parse(stdout);
134
+ } catch {
135
+ throw new Error(`flight search returned invalid JSON: ${stdout.slice(0, 300)}`);
136
+ }
137
+ const obj = data as Record<string, unknown>;
138
+ if (obj.error) throw new Error(`flight search error: ${obj.error}`);
139
+
140
+ const flights = (obj.flights ?? []) as Array<Record<string, unknown>>;
141
+ if (flights.length === 0) {
142
+ return {
143
+ content: [{ type: "text", text: "No flights found for this search." }],
144
+ details: { currency: obj.currency, count: 0 },
145
+ };
146
+ }
147
+ flights.sort((a, b) => Number(a.price) - Number(b.price));
148
+ const top = flights.slice(0, 10);
149
+
150
+ const lines = top.map((f, i) => {
151
+ const legs = (f.legs as Array<Record<string, string>>) ?? [];
152
+ const seg = legs.map(
153
+ (l) =>
154
+ `${l.from} ${l.departure} → ${l.to} ${l.arrival} ${l.date} (${Math.round(
155
+ (Number(l.duration_minutes) || 0) / 60,
156
+ )}h, ${l.plane})`,
157
+ ).join(", ");
158
+ const rt = f.is_round_trip ? " (round-trip total)" : "";
159
+ const airline = (f.airlines as string[] | undefined)?.join("/") ?? "";
160
+ const stops = Number(f.stops) || 0;
161
+ return `${i + 1}. ${f.currency} ${f.price}${rt} · ${airline} · ${stops === 0 ? "direct" : `${stops} stop`}\n ${seg}`;
162
+ });
163
+ const summary = `${lines.join("\n")}\n\n${flights.length} itinerary/ies (showing cheapest ${top.length})`;
164
+
165
+ return {
166
+ content: [{ type: "text", text: summary }],
167
+ details: { currency: obj.currency, flights },
168
+ };
169
+ },
170
+ };
171
+
172
+ export default function (pi: ExtensionAPI) {
173
+ pi.registerTool(search_flights);
174
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """Search Google Flights via fast-flights and emit structured JSON.
3
+
4
+ This is the deterministic executor behind the pi `search_flights` extension
5
+ tool (and usable standalone). It reads a JSON query object on stdin and writes
6
+ a JSON result object on stdout.
7
+
8
+ Query keys (all optional unless noted):
9
+ from_airport (required), to_airport (required)
10
+ outbound_date (required): "YYYY-MM-DD"
11
+ return_date: "YYYY-MM-DD" for round-trip
12
+ trip: "one-way" | "round-trip"
13
+ seat: economy | premium-economy | business | first
14
+ adults, children, infants_in_seat, infants_on_lap: int
15
+ currency: ISO code or "" (Google default)
16
+ language: IETF tag or ""
17
+ max_stops, max_price: int
18
+ carry_on_bags, checked_bags, hide_separate_and_self_transfer,
19
+ exclude_basic_economy: int/bool
20
+
21
+ Errors are reported as JSON with {"error": "..."} and a non-zero exit code, so
22
+ the caller can distinguish "no flights" (valid empty result) from a failure.
23
+
24
+ Output shape (ResultList -> list):
25
+ [{ "price": int, "currency": str, "is_round_trip": bool, "airlines": [..],
26
+ "type": str, "carbon": {"typical_on_route": int, "emission": int},
27
+ "stops": int, "legs": [leg...] }]
28
+
29
+ IMPORTANT (fast-flights behavior, verified): `flight.flights` contains ONLY the
30
+ OUTBOUND legs. For a round-trip, `price` is the TOTAL round-trip fare, but no
31
+ return-leg detail is present in the parsed data. So the output describes the
32
+ outbound itinerary; set `is_round_trip` when return was requested so the caller
33
+ knows `price` is the two-way total.
34
+
35
+ Each leg: { "from": code, "from_name": str, "to": code, "to_name": str,
36
+ "date": "YYYY-MM-DD", "departure": "HH:MM", "arrival": "HH:MM",
37
+ "duration_minutes": int, "plane": str }
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import sys
43
+
44
+ from fast_flights import (
45
+ FlightsNotFound,
46
+ FlightQuery,
47
+ Passengers,
48
+ create_query,
49
+ get_flights,
50
+ )
51
+
52
+ SEATS = ("economy", "premium-economy", "business", "first")
53
+ TRIPS = ("one-way", "round-trip")
54
+
55
+
56
+ def _hhmm(t) -> str:
57
+ return f"{t[0]:02d}:{t[1]:02d}"
58
+
59
+
60
+ def _leg(seg) -> dict:
61
+ return {
62
+ "from": seg.from_airport.code,
63
+ "from_name": seg.from_airport.name,
64
+ "to": seg.to_airport.code,
65
+ "to_name": seg.to_airport.name,
66
+ "date": "%04d-%02d-%02d" % seg.departure.date,
67
+ "departure": _hhmm(seg.departure.time),
68
+ "arrival": _hhmm(seg.arrival.time),
69
+ "duration_minutes": seg.duration,
70
+ "plane": seg.plane_type,
71
+ }
72
+
73
+
74
+ def run(q: dict) -> dict:
75
+ seat = q.get("seat", "economy")
76
+ trip = q.get("trip", "one-way")
77
+ if seat not in SEATS:
78
+ raise ValueError(f"seat must be one of {SEATS}")
79
+ if trip not in TRIPS:
80
+ raise ValueError(f"trip must be one of {TRIPS}")
81
+
82
+ outbound_date = q["outbound_date"]
83
+ src = q["from_airport"].strip().upper()
84
+ dst = q["to_airport"].strip().upper()
85
+ for code in (src, dst):
86
+ if not (code.isalpha() and len(code) == 3):
87
+ raise ValueError(f"airport code must be 3 letters, got {code!r}")
88
+
89
+ legs = [FlightQuery(date=outbound_date, from_airport=src, to_airport=dst)]
90
+ return_date = q.get("return_date")
91
+ if trip == "round-trip":
92
+ if not return_date:
93
+ raise ValueError("return_date is required for round-trip")
94
+ legs.append(FlightQuery(date=return_date, from_airport=dst, to_airport=src))
95
+
96
+ query = create_query(
97
+ flights=legs,
98
+ trip=trip,
99
+ seat=seat,
100
+ passengers=Passengers(
101
+ adults=int(q.get("adults", 1)),
102
+ children=int(q.get("children", 0)),
103
+ infants_in_seat=int(q.get("infants_in_seat", 0)),
104
+ infants_on_lap=int(q.get("infants_on_lap", 0)),
105
+ ),
106
+ currency=q.get("currency", ""),
107
+ language=q.get("language", ""),
108
+ max_stops=q.get("max_stops"),
109
+ max_price=q.get("max_price"),
110
+ carry_on_bags=int(q.get("carry_on_bags", 0)),
111
+ checked_bags=int(q.get("checked_bags", 0)),
112
+ hide_separate_and_self_transfer=bool(q.get("hide_separate_and_self_transfer", False)),
113
+ exclude_basic_economy=bool(q.get("exclude_basic_economy", False)),
114
+ )
115
+
116
+ try:
117
+ result = get_flights(query)
118
+ except FlightsNotFound:
119
+ return {"currency": q.get("currency", "") or "default", "flights": []}
120
+
121
+ currency = q.get("currency", "") or "default"
122
+ out = [
123
+ {
124
+ "price": f.price,
125
+ "currency": currency,
126
+ "is_round_trip": trip == "round-trip",
127
+ "airlines": f.airlines,
128
+ "type": f.type,
129
+ "carbon": {
130
+ "typical_on_route": f.carbon.typical_on_route,
131
+ "emission": f.carbon.emission,
132
+ },
133
+ "stops": len(f.flights) - 1,
134
+ "legs": [_leg(x) for x in f.flights],
135
+ }
136
+ for f in result
137
+ ]
138
+ return {"currency": currency, "flights": out}
139
+
140
+
141
+ def main() -> int:
142
+ try:
143
+ q = json.load(sys.stdin)
144
+ payload = run(q)
145
+ except Exception as e: # noqa: BLE001 - report any failure to the caller
146
+ print(json.dumps({"error": str(e)}))
147
+ return 1
148
+ print(json.dumps(payload))
149
+ return 0
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "find-flight",
3
+ "version": "0.1.0",
4
+ "description": "Google Flights search for agents — a pi extension (search_flights tool) + portable skill, powered by the fast-flights scraper.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "flights",
8
+ "google-flights",
9
+ "travel",
10
+ "flight-search",
11
+ "fast-flights",
12
+ "trip"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/fcan-dev/find-flight.git"
19
+ },
20
+ "homepage": "https://github.com/fcan-dev/find-flight#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/fcan-dev/find-flight/issues"
23
+ },
24
+ "files": [
25
+ "extensions/",
26
+ "skills/",
27
+ "flight_runner.py",
28
+ "examples/",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "verify": "python3 -c \"import json,sys; from pathlib import Path; Path('flight_runner.py')\" && python3 -m py_compile flight_runner.py && echo \"runner OK\"",
34
+ "prepublishOnly": "npm run verify"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "pi": {
40
+ "extensions": ["./extensions"],
41
+ "skills": ["./skills"]
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-ai": "*",
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "typebox": "*"
47
+ }
48
+ }
@@ -0,0 +1,205 @@
1
+ ---
2
+ name: find-flight
3
+ description: Use when searching for flights, checking flight availability or prices, building flight queries, or booking research. Wraps the fast-flights Python library (Google Flights scraper) — structured queries via create_query/FlightQuery, natural-language queries, or returning ResultList flight data through get_flights.
4
+ ---
5
+
6
+ # Flight Skill
7
+
8
+ > Search Google Flights from Python using the **fast-flights** library. Build a query (structured or natural language), fetch results, and read `ResultList` flight data.
9
+
10
+ ## Overview
11
+
12
+ `fast-flights` is a Python Google Flights scraper. An agent uses it by writing small Python programs: construct a search via `create_query` (or a plain sentence), call `get_flights`, and iterate the returned `ResultList`. Everything is strongly typed.
13
+
14
+ ```bash
15
+ pip install fast-flights
16
+ # deps pulled automatically: primp, protobuf>=5.27, selectolax (Python >= 3.10)
17
+ ```
18
+
19
+ ## When to Use
20
+
21
+ - Find flights/times/prices between airports for a date
22
+ - Compare prices across dates, seats, or filter by stops/airlines/layovers
23
+ - Travel-planning or trip-recommendation tasks that need real availability
24
+ - Getting per-segment legs, durations, plane types, or carbon emissions
25
+
26
+ **When NOT:** for booking/reservation actual purchase (this is read-only search), or non-flight transport.
27
+
28
+ ## Before you query (REQUIRED gate)
29
+
30
+ **Never guess the date, the trip type, or the airport codes.** All three are common model failure points — resolve them *before* building any query:
31
+
32
+ 1. **Get the real current date from the system** — run `date` in a shell (or read the runtime clock), not from memory:
33
+ ```bash
34
+ date "+%Y-%m-%d"
35
+ ```
36
+ Then resolve relative/ambiguous dates ("next Friday", "11-13 September") against this real year. Format every date as `YYYY-MM-DD`.
37
+ 2. **Confirm one-way vs round-trip.** If the user didn't explicitly say, ASK: one-way (1 leg, needs only `date`+`from`+`to`) vs round-trip (2+ legs, needs a `return_date` too). Do not assume. If a stay duration or "until" date is given, that implies round-trip — but still confirm the dates.
38
+ 3. **Verify the airport codes — never guess what a 3-letter IATA code means.** Models routinely map codes to the wrong city (e.g. misreading `ECN` as Edinburgh when it is really Ercan, or inventing a code). Before searching, confirm what `from_airport`/`to_airport` actually are: look the code up in the upstream airport list (`enums/airports.csv` in `AWeirdDev/flights`, ~9.7k rows, by code + name) or against a known route. If you can't verify a code, ask the user instead of assuming.
39
+
40
+ ## Core Recipe — structured query
41
+
42
+ ```python
43
+ from fast_flights import (
44
+ FlightQuery, Passengers, ResultList,
45
+ create_query, get_flights, FlightsNotFound,
46
+ )
47
+
48
+ query = create_query(
49
+ flights=[
50
+ FlightQuery( # one FlightQuery per leg
51
+ date="2025-01-01", # "YYYY-MM-DD" or a datetime
52
+ from_airport="TPE", # 3-letter IATA code
53
+ to_airport="MYJ",
54
+ max_stops=1,
55
+ airlines=["JL", "ONEWORLD"], # IATA codes, or SKYTEAM/STAR_ALLIANCE/ONEWORLD
56
+ earliest_departure_hour=7, # local time, 0-23
57
+ latest_departure_hour=18,
58
+ earliest_arrival_hour=10,
59
+ latest_arrival_hour=23,
60
+ max_duration_minutes=720, # minutes
61
+ connecting_airports=["HND", "NRT"],
62
+ min_layover_minutes=60,
63
+ max_layover_minutes=240,
64
+ less_emissions_only=True,
65
+ ),
66
+ ],
67
+ seat="economy", # economy | premium-economy | business | first
68
+ trip="one-way", # one-way | round-trip (multi-city unimplemented)
69
+ passengers=Passengers(adults=1), # adults/children/infants_in_seat/infants_on_lap
70
+ language="en-US", # "" lets Google decide
71
+ currency="USD", # "" lets Google decide
72
+ max_price=1500, # whole-search filters ↓
73
+ carry_on_bags=0,
74
+ checked_bags=0,
75
+ hide_separate_and_self_transfer=False,
76
+ exclude_basic_economy=False,
77
+ )
78
+
79
+ try:
80
+ result: ResultList = get_flights(query)
81
+ for flight in result:
82
+ print(flight.price, flight.airlines, flight.type)
83
+ except FlightsNotFound:
84
+ print("no flights found")
85
+ ```
86
+
87
+ ### Round-trip example
88
+
89
+ A round-trip is two `FlightQuery` legs in one `create_query` call — outbound then return (reverse airports, return date):
90
+
91
+ ```python
92
+ query = create_query(
93
+ flights=[
94
+ FlightQuery(date="2025-01-01", from_airport="IST", to_airport="ECN"),
95
+ FlightQuery(date="2025-01-08", from_airport="ECN", to_airport="IST"),
96
+ ],
97
+ trip="round-trip",
98
+ seat="economy",
99
+ passengers=Passengers(adults=1),
100
+ currency="EUR",
101
+ )
102
+ ```
103
+
104
+ ## Natural-language path (agent-friendly)
105
+
106
+ `get_flights` also accepts a plain sentence instead of a `Query`:
107
+
108
+ ```python
109
+ from fast_flights import get_flights
110
+ result = get_flights(
111
+ "Flights from TPE to MYJ on 2025-01-01 one way economy class"
112
+ )
113
+ ```
114
+
115
+ Preferred when the request arrives as English text; use the structured recipe when the user gives discrete filters.
116
+
117
+ ## Reading results — the data model
118
+
119
+ `ResultList` is a list of `Flights`, with metadata attached.
120
+
121
+ ```python
122
+ result.metadata # JsMetadata: .airlines[], .alliances[] (code+name)
123
+ for flight in result:
124
+ # flight: Flights
125
+ flight.price # int, in selected currency
126
+ flight.airlines # list[str] of IATA codes
127
+ flight.type # str | "multi"
128
+ flight.carbon # CarbonEmission: .typical_on_route, .emission (grams)
129
+ for leg in flight.flights: # SingleFlight
130
+ leg.from_airport # Airport: .code, .name
131
+ leg.to_airport # Airport: .code, .name
132
+ leg.departure # SimpleDatetime: .date=(Y,M,D), .time=(H,M)
133
+ leg.arrival # SimpleDatetime: .date=(Y,M,D), .time=(H,M)
134
+ leg.duration # int, minutes
135
+ leg.plane_type # str
136
+ ```
137
+
138
+ **Currency is NOT attached to prices.** `flight.price` is a bare `int` with no currency symbol or code. The currency that the price is quoted in is whatever you passed to `create_query(currency=...)` (or Google's default if you passed `""` — often the local currency, e.g. Turkish Lira for a Turkey-originating route). When presenting prices, surface the currency explicitly: track the `currency` you passed (or the user's expected currency) and label every price with it. Never hardcode a currency symbol in your output format — it may not match the price's actual currency.
139
+
140
+ **Round-trip: `price` is the total, but only OUTBOUND legs are returned (verified).** For a `round-trip` query, `flight.flights` contains **only the outbound legs** — the parsed data does not include return-leg detail. `flight.price` is the **two-way total fare**. So: count stops and display legs from `flight.flights` as the outbound itinerary, and tell the user that `price` covers both directions but only outbound flight times are available. Do NOT invent or assume return-leg segments that aren't in the data.
141
+
142
+ `FlightsNotFound` is raised when nothing matches (or Google returns an error status).
143
+
144
+ ### Sorting / narrowing results
145
+
146
+ `ResultList` is a plain list.sort()-able list — sort by price or total duration before presenting to the user:
147
+
148
+ ```python
149
+ def total_duration(flight):
150
+ return sum(seg.duration for seg in flight.flights)
151
+
152
+ result.sort(key=lambda f: f.price) # cheapest first
153
+ result.sort(key=total_duration) # shortest trip first
154
+ best = result[0] # or result[:3] for top-N
155
+ ```
156
+
157
+ ## Options reference
158
+
159
+ | Option | Where | Values / notes |
160
+ |---|---|---|
161
+ | `seat` | create_query | `economy`, `premium-economy`, `business`, `first` |
162
+ | `trip` | create_query | `one-way`, `round-trip`. **round-trip needs 2+ `FlightQuery` legs**; `multi-city` unimplemented |
163
+ | `passengers` | create_query | `Passengers(adults, children, infants_in_seat, infants_on_lap)` |
164
+ | `language` / `currency` | create_query | any supported IETF tag / ISO code; `""` = Google default |
165
+ | `max_price`, `carry_on_bags`, `checked_bags`, `hide_separate_and_self_transfer`, `exclude_basic_economy` | create_query | search-wide |
166
+ | Per-leg filters | FlightQuery | `max_stops`, `airlines`, departure/arrival hour windows, `max_duration_minutes`, `connecting_airports`, `min/max_layover_minutes`, `less_emissions_only` |
167
+
168
+ ## Gotchas
169
+
170
+ - **Hours use local airport time** on a 0–23 clock; durations/layovers are in minutes.
171
+ - **`airlines` on non-first legs is ignored** — Google applies only the first leg's airline filter to the whole search. (The filter also accepts an alliance name.)
172
+ - **`max_stops` can be passed once on `create_query`** as a global, and/or per-leg via `FlightQuery` (per-leg wins).
173
+ - **Round-trip = two+ `FlightQuery` objects**; one way = one.
174
+ - **Passengers rules:** total ≤ 9, and `infants_on_lap ≤ adults` (one lap infant needs one adult). Violations raise `AssertionError`.
175
+ - **Read-only:** this scrapes availability; it cannot book. No guarantee against rate limits / IP blocks — proxy via an integration if scraping at scale.
176
+ - **Live network dependency:** `get_flights` hits Google's servers at call time. For repeatable/offline testing, use `fetch_flights_html` or cached HTML, or an integration (BrightData / SearchApi).
177
+
178
+ ## Integrations (optional)
179
+
180
+ - **BrightData** — proxy the default fetcher to protect your IP (pass `integration=BrightData(api_key=...)`). Only needed if scraping at scale.
181
+ - **SearchApi** — richer data source. Returns `SearchApiResult` (booking options, price insights, amenities) instead of `ResultList`:
182
+ ```python
183
+ from fast_flights.integrations import SearchApi
184
+ result = get_flights(..., integration=SearchApi())
185
+ ```
186
+
187
+ ## Common Mistakes
188
+
189
+ | Mistake | Fix |
190
+ |---|---|
191
+ | Using `get_flights` without try/except | Wrap in `try/except FlightsNotFound` |
192
+ | Round-trip with one leg | Add a second `FlightQuery` (e.g. return leg) |
193
+ | Expecting `multi-city` to work | Unimplemented — treat as not supported today |
194
+ | Passing `airlines` to multiple legs expecting per-leg filtering | Only first leg's `airlines` is applied |
195
+ | Assuming 24h timestamps | Times are `(hour, minute)` on a 0–23 clock |
196
+ | Guessing what a 3-letter airport code means | Verify it against the upstream `enums/airports.csv` (or a known route) before searching |
197
+ | Hardcoding a currency symbol in output | `price` has no currency — track/pass `currency` and label prices with it |
198
+ | Assuming return legs are in the round-trip data | `flights` holds only outbound legs; `price` is the two-way total — label it as such |
199
+
200
+ ## Real-world notes
201
+
202
+ - Version: `fast-flights` 3.x. See upstream repo `AWeirdDev/flights` and docs `aweirddev.github.io/flights`.
203
+ - Run a ready-made CLI: `python examples/search_flights.py --from TPE --to MYJ --date 2025-01-01 --seat economy`.
204
+ - **Round-trip data (verified):** the parsed result contains only outbound legs; `price` is the two-way total. Present it as such.
205
+ - **pi extension:** if the `search_flights` custom tool is active (via `extensions/flight-search.ts`), prefer calling it over writing ad-hoc Python — it returns structured, correctly-labelled results and leaves no scratch files. `flight_runner.py` is the equivalent standalone executor.