@tks/wayfinder 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -1,16 +1,22 @@
1
- # wayfinder
2
-
3
- CLI for one way flight search with SerpApi Google Flights.
1
+ # 🛫 wayfinder — Travel search for your terminal and your AI agents.
4
2
 
5
3
  ## Install
6
4
 
5
+ Requires [Bun](https://bun.sh/) runtime.
6
+
7
7
  ```bash
8
+ bun install -g @tks/wayfinder
9
+ ```
10
+
11
+ Or install from source:
12
+
13
+ ```bash
14
+ git clone https://github.com/tksohishi/wayfinder.git
15
+ cd wayfinder
8
16
  bun install
9
17
  bun link
10
18
  ```
11
19
 
12
- `bun link` makes the `wayfinder` command available in your shell.
13
-
14
20
  ## Setup
15
21
 
16
22
  Set API key by environment variable (preferred):
@@ -32,17 +38,47 @@ Or store it in `~/.config/wayfinder/config.json`:
32
38
  Search one way flights:
33
39
 
34
40
  ```bash
35
- wayfinder --from SFO --to JFK --date 2026-04-10
41
+ wayfinder flights --from SFO --to JFK --date 2026-04-10
36
42
  ```
37
43
 
38
44
  Search with filters:
39
45
 
40
46
  ```bash
41
- wayfinder --from LAX --to SEA --date 2026-04-10 --airline AS --max-stops 0 --max-price 250 --depart-after 06:00 --depart-before 12:00
47
+ wayfinder flights --from LAX --to SEA --date 2026-04-10 --airline AS --max-stops 0 --max-price 250 --depart-after 06:00 --depart-before 12:00
48
+ ```
49
+
50
+ Exclude basic economy fares:
51
+
52
+ ```bash
53
+ wayfinder flights --from SFO --to JFK --date 2026-04-10 --exclude-basic
42
54
  ```
43
55
 
44
56
  Structured output for scripting:
45
57
 
46
58
  ```bash
47
- wayfinder --from SFO --to JFK --date 2026-04-10 --json | jq '.results[] | {price,airline,stops}'
59
+ wayfinder flights --from SFO --to JFK --date 2026-04-10 --json | jq '.results[] | {price,airline,stops}'
60
+ ```
61
+
62
+ Get booking links from selected flight tokens:
63
+
64
+ ```bash
65
+ wayfinder flights booking --from LAS --to JFK --date 2026-05-29 --token "<TOKEN_1>" --token "<TOKEN_2>" --json
66
+ ```
67
+
68
+ Search hotels:
69
+
70
+ ```bash
71
+ wayfinder hotels --where "New York, NY" --check-in 2026-04-10 --check-out 2026-04-12
72
+ ```
73
+
74
+ Search hotels with filters:
75
+
76
+ ```bash
77
+ wayfinder hotels --where "Tokyo" --check-in 2026-04-10 --check-out 2026-04-13 --adults 2 --max-price 300 --rating 4
78
+ ```
79
+
80
+ Structured hotel output for scripting:
81
+
82
+ ```bash
83
+ wayfinder hotels --where "Paris" --check-in 2026-04-10 --check-out 2026-04-12 --json | jq '.results[] | {name,nightlyPrice,rating}'
48
84
  ```
package/package.json CHANGED
@@ -1,11 +1,20 @@
1
1
  {
2
2
  "name": "@tks/wayfinder",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
+ "description": "Travel search for your terminal and your AI agents",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/tksohishi/wayfinder.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/tksohishi/wayfinder/issues"
11
+ },
12
+ "homepage": "https://github.com/tksohishi/wayfinder#readme",
4
13
  "license": "MIT",
5
14
  "private": false,
6
15
  "type": "module",
7
16
  "bin": {
8
- "wayfinder": "./bin/wayfinder"
17
+ "wayfinder": "bin/wayfinder"
9
18
  },
10
19
  "files": [
11
20
  "bin",
package/src/cli.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { resolveApiKey } from "./config";
2
2
  import { CliError } from "./errors";
3
- import { renderTable } from "./format";
3
+ import { renderFlightTable, renderHotelTable } from "./format";
4
4
  import { parseCliArgs } from "./parse";
5
- import { searchFlights } from "./serpapi";
5
+ import { searchFlightBookingOptions, searchFlights, searchHotels } from "./serpapi";
6
6
  import { ExitCode } from "./types";
7
7
 
8
8
  interface Output {
@@ -17,23 +17,43 @@ interface RunOptions {
17
17
  output?: Output;
18
18
  }
19
19
 
20
- const HELP_TEXT = `wayfinder v1 flight search
20
+ const HELP_TEXT = `wayfinder v0.2.0 travel search
21
21
 
22
22
  Usage:
23
- wayfinder --from SFO --to JFK --date 2026-03-21 [filters]
23
+ wayfinder flights --from SFO --to JFK --date 2026-03-21 [filters]
24
24
  wayfinder flights one-way --from SFO --to JFK --date 2026-03-21 [filters]
25
+ wayfinder flights booking --from SFO --to JFK --date 2026-03-21 --token <BOOKING_TOKEN> [--token <BOOKING_TOKEN>] [--json]
26
+ wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
25
27
 
26
- Required:
28
+ Flights required:
27
29
  --from <IATA> Origin airport code
28
30
  --to <IATA> Destination airport code
29
31
  --date <YYYY-MM-DD> Departure date
30
32
 
31
- Optional filters:
33
+ Flights optional filters:
32
34
  --airline <IATA> Airline code, example UA
33
35
  --max-stops <0|1|2> Maximum number of stops
34
36
  --max-price <USD> Max price in USD
35
37
  --depart-after <HH:MM> Start of departure window
36
38
  --depart-before <HH:MM> End of departure window
39
+ --exclude-basic Exclude basic economy fares
40
+
41
+ Flights booking required:
42
+ --from <IATA> Origin airport code
43
+ --to <IATA> Destination airport code
44
+ --date <YYYY-MM-DD> Departure date
45
+ --token <BOOKING_TOKEN> Booking token from a flights search result
46
+ (repeat --token to request multiple options)
47
+
48
+ Hotels required:
49
+ --where <QUERY> Destination or hotel search query
50
+ --check-in <YYYY-MM-DD> Check-in date
51
+ --check-out <YYYY-MM-DD> Check-out date
52
+
53
+ Hotels optional filters:
54
+ --adults <N> Number of adults (default 2)
55
+ --max-price <USD> Max nightly rate in USD
56
+ --rating <3.5|4|4.5|5> Minimum guest rating
37
57
 
38
58
  Output:
39
59
  --json Print structured JSON output`;
@@ -56,25 +76,84 @@ export async function runWayfinder(
56
76
  }
57
77
 
58
78
  const apiKey = resolveApiKey(options.env ?? process.env, options.homeDir);
59
- const flights = await searchFlights(parsed.query!, apiKey, options.fetchImpl ?? fetch);
60
-
61
- if (flights.length === 0) {
62
- throw new CliError("No flights found for the selected query", ExitCode.NoResults);
63
- }
64
-
65
- if (parsed.outputJson) {
66
- output.stdout(
67
- JSON.stringify(
68
- {
69
- query: parsed.query,
70
- results: flights,
71
- },
72
- null,
73
- 2,
74
- ),
79
+ if (parsed.mode === "flights") {
80
+ const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
81
+
82
+ if (flights.options.length === 0) {
83
+ throw new CliError("No flights found for the selected query", ExitCode.NoResults);
84
+ }
85
+
86
+ if (parsed.outputJson) {
87
+ output.stdout(
88
+ JSON.stringify(
89
+ {
90
+ query: parsed.query,
91
+ googleFlightsUrl: flights.googleFlightsUrl,
92
+ results: flights.options,
93
+ },
94
+ null,
95
+ 2,
96
+ ),
97
+ );
98
+ } else {
99
+ output.stdout(renderFlightTable(flights.options));
100
+ }
101
+ } else if (parsed.mode === "flight-booking") {
102
+ const bookingResults = await searchFlightBookingOptions(
103
+ parsed.query,
104
+ apiKey,
105
+ options.fetchImpl ?? fetch,
75
106
  );
107
+
108
+ const flightLinks = bookingResults
109
+ .filter((result) => typeof result.googleFlightsUrl === "string")
110
+ .map((result) => ({
111
+ token: result.token,
112
+ googleFlightsUrl: result.googleFlightsUrl as string,
113
+ }));
114
+
115
+ if (flightLinks.length === 0) {
116
+ throw new CliError(
117
+ "No Google Flights links found for the provided token(s)",
118
+ ExitCode.NoResults,
119
+ );
120
+ }
121
+
122
+ if (parsed.outputJson) {
123
+ output.stdout(
124
+ JSON.stringify(
125
+ {
126
+ query: parsed.query,
127
+ results: flightLinks,
128
+ },
129
+ null,
130
+ 2,
131
+ ),
132
+ );
133
+ } else {
134
+ output.stdout(renderFlightBookingText(flightLinks));
135
+ }
76
136
  } else {
77
- output.stdout(renderTable(flights));
137
+ const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
138
+
139
+ if (hotels.length === 0) {
140
+ throw new CliError("No hotels found for the selected query", ExitCode.NoResults);
141
+ }
142
+
143
+ if (parsed.outputJson) {
144
+ output.stdout(
145
+ JSON.stringify(
146
+ {
147
+ query: parsed.query,
148
+ results: hotels,
149
+ },
150
+ null,
151
+ 2,
152
+ ),
153
+ );
154
+ } else {
155
+ output.stdout(renderHotelTable(hotels));
156
+ }
78
157
  }
79
158
 
80
159
  return ExitCode.Success;
@@ -93,3 +172,17 @@ if (import.meta.main) {
93
172
  const code = await runWayfinder(process.argv.slice(2));
94
173
  process.exitCode = code;
95
174
  }
175
+
176
+ function renderFlightBookingText(
177
+ results: Array<{ token: string; googleFlightsUrl: string }>,
178
+ ): string {
179
+ const lines: string[] = [];
180
+
181
+ for (const result of results) {
182
+ lines.push(`TOKEN: ${result.token}`);
183
+ lines.push(`GOOGLE FLIGHTS: ${result.googleFlightsUrl}`);
184
+ lines.push("");
185
+ }
186
+
187
+ return lines.join("\n").trimEnd();
188
+ }
package/src/format.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FlightOption } from "./types";
1
+ import { FlightOption, HotelOption } from "./types";
2
2
 
3
3
  const currencyFormatter = new Intl.NumberFormat("en-US", {
4
4
  style: "currency",
@@ -6,7 +6,7 @@ const currencyFormatter = new Intl.NumberFormat("en-US", {
6
6
  maximumFractionDigits: 0,
7
7
  });
8
8
 
9
- export function renderTable(options: FlightOption[]): string {
9
+ export function renderFlightTable(options: FlightOption[]): string {
10
10
  const rows = options.map((option) => ({
11
11
  price: currencyFormatter.format(option.price),
12
12
  airline: option.airline,
@@ -74,11 +74,75 @@ export function renderTable(options: FlightOption[]): string {
74
74
  return lines.join("\n");
75
75
  }
76
76
 
77
- function maxWidth(
78
- rows: Array<Record<string, string>>,
79
- key: string,
80
- header: string,
81
- ): number {
77
+ export function renderHotelTable(options: HotelOption[]): string {
78
+ const rows = options.map((option) => ({
79
+ nightly: currencyFormatter.format(option.nightlyPrice),
80
+ total: typeof option.totalPrice === "number" ? currencyFormatter.format(option.totalPrice) : "n/a",
81
+ name: option.name,
82
+ rating: typeof option.rating === "number" ? option.rating.toFixed(1) : "n/a",
83
+ reviews: typeof option.reviews === "number" ? String(option.reviews) : "n/a",
84
+ location: option.location,
85
+ }));
86
+
87
+ const headers = {
88
+ nightly: "PRICE/NIGHT",
89
+ total: "TOTAL",
90
+ name: "NAME",
91
+ rating: "RATING",
92
+ reviews: "REVIEWS",
93
+ location: "LOCATION",
94
+ };
95
+
96
+ const widths = {
97
+ nightly: maxWidth(rows, "nightly", headers.nightly),
98
+ total: maxWidth(rows, "total", headers.total),
99
+ name: maxWidth(rows, "name", headers.name),
100
+ rating: maxWidth(rows, "rating", headers.rating),
101
+ reviews: maxWidth(rows, "reviews", headers.reviews),
102
+ location: maxWidth(rows, "location", headers.location),
103
+ };
104
+
105
+ const lines: string[] = [];
106
+
107
+ lines.push(
108
+ [
109
+ headers.nightly.padEnd(widths.nightly),
110
+ headers.total.padEnd(widths.total),
111
+ headers.name.padEnd(widths.name),
112
+ headers.rating.padEnd(widths.rating),
113
+ headers.reviews.padEnd(widths.reviews),
114
+ headers.location.padEnd(widths.location),
115
+ ].join(" "),
116
+ );
117
+
118
+ lines.push(
119
+ [
120
+ "-".repeat(widths.nightly),
121
+ "-".repeat(widths.total),
122
+ "-".repeat(widths.name),
123
+ "-".repeat(widths.rating),
124
+ "-".repeat(widths.reviews),
125
+ "-".repeat(widths.location),
126
+ ].join(" "),
127
+ );
128
+
129
+ for (const row of rows) {
130
+ lines.push(
131
+ [
132
+ row.nightly.padEnd(widths.nightly),
133
+ row.total.padEnd(widths.total),
134
+ row.name.padEnd(widths.name),
135
+ row.rating.padEnd(widths.rating),
136
+ row.reviews.padEnd(widths.reviews),
137
+ row.location.padEnd(widths.location),
138
+ ].join(" "),
139
+ );
140
+ }
141
+
142
+ return lines.join("\n");
143
+ }
144
+
145
+ function maxWidth(rows: Array<Record<string, string>>, key: string, header: string): number {
82
146
  return rows.reduce((width, row) => Math.max(width, row[key].length), header.length);
83
147
  }
84
148
 
package/src/parse.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "./errors";
2
- import { ExitCode, ParsedArgs } from "./types";
2
+ import { ExitCode, FlightBookingQuery, FlightQuery, HotelQuery, ParsedArgs } from "./types";
3
3
 
4
- interface RawOptions {
4
+ interface FlightRawOptions {
5
5
  from?: string;
6
6
  to?: string;
7
7
  date?: string;
@@ -10,15 +10,59 @@ interface RawOptions {
10
10
  maxPrice?: string;
11
11
  departAfter?: string;
12
12
  departBefore?: string;
13
+ excludeBasic: boolean;
13
14
  outputJson: boolean;
14
15
  help: boolean;
15
16
  }
16
17
 
18
+ interface HotelRawOptions {
19
+ where?: string;
20
+ checkIn?: string;
21
+ checkOut?: string;
22
+ adults?: string;
23
+ maxPrice?: string;
24
+ rating?: string;
25
+ outputJson: boolean;
26
+ help: boolean;
27
+ }
28
+
29
+ interface FlightBookingRawOptions {
30
+ from?: string;
31
+ to?: string;
32
+ date?: string;
33
+ tokens: string[];
34
+ outputJson: boolean;
35
+ help: boolean;
36
+ }
37
+
38
+ type SearchMode = "flights" | "hotels" | "flight-booking";
39
+
17
40
  const HELP_FLAGS = new Set(["-h", "--help"]);
18
41
 
19
42
  export function parseCliArgs(argv: string[]): ParsedArgs {
20
- const args = stripSubcommands(argv);
21
- const raw: RawOptions = {
43
+ if (argv.some((token) => HELP_FLAGS.has(token))) {
44
+ return {
45
+ help: true,
46
+ outputJson: argv.includes("--json"),
47
+ };
48
+ }
49
+
50
+ const { mode, args } = stripSubcommands(argv);
51
+
52
+ if (mode === "hotels") {
53
+ return parseHotelsArgs(args);
54
+ }
55
+
56
+ if (mode === "flight-booking") {
57
+ return parseFlightBookingArgs(args);
58
+ }
59
+
60
+ return parseFlightsArgs(args);
61
+ }
62
+
63
+ function parseFlightsArgs(args: string[]): ParsedArgs {
64
+ const raw: FlightRawOptions = {
65
+ excludeBasic: false,
22
66
  outputJson: false,
23
67
  help: false,
24
68
  };
@@ -36,6 +80,11 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
36
80
  continue;
37
81
  }
38
82
 
83
+ if (token === "--exclude-basic") {
84
+ raw.excludeBasic = true;
85
+ continue;
86
+ }
87
+
39
88
  if (!token.startsWith("--")) {
40
89
  throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
41
90
  }
@@ -84,6 +133,151 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
84
133
  };
85
134
  }
86
135
 
136
+ return {
137
+ help: false,
138
+ mode: "flights",
139
+ outputJson: raw.outputJson,
140
+ query: buildFlightQuery(raw),
141
+ };
142
+ }
143
+
144
+ function parseHotelsArgs(args: string[]): ParsedArgs {
145
+ const raw: HotelRawOptions = {
146
+ outputJson: false,
147
+ help: false,
148
+ };
149
+
150
+ for (let i = 0; i < args.length; i += 1) {
151
+ const token = args[i];
152
+
153
+ if (HELP_FLAGS.has(token)) {
154
+ raw.help = true;
155
+ continue;
156
+ }
157
+
158
+ if (token === "--json") {
159
+ raw.outputJson = true;
160
+ continue;
161
+ }
162
+
163
+ if (!token.startsWith("--")) {
164
+ throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
165
+ }
166
+
167
+ const value = args[i + 1];
168
+ if (!value || value.startsWith("--")) {
169
+ throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
170
+ }
171
+
172
+ switch (token) {
173
+ case "--where":
174
+ raw.where = value;
175
+ break;
176
+ case "--check-in":
177
+ raw.checkIn = value;
178
+ break;
179
+ case "--check-out":
180
+ raw.checkOut = value;
181
+ break;
182
+ case "--adults":
183
+ raw.adults = value;
184
+ break;
185
+ case "--max-price":
186
+ raw.maxPrice = value;
187
+ break;
188
+ case "--rating":
189
+ raw.rating = value;
190
+ break;
191
+ default:
192
+ throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
193
+ }
194
+
195
+ i += 1;
196
+ }
197
+
198
+ if (raw.help) {
199
+ return {
200
+ help: true,
201
+ outputJson: raw.outputJson,
202
+ };
203
+ }
204
+
205
+ return {
206
+ help: false,
207
+ mode: "hotels",
208
+ outputJson: raw.outputJson,
209
+ query: buildHotelQuery(raw),
210
+ };
211
+ }
212
+
213
+ function parseFlightBookingArgs(args: string[]): ParsedArgs {
214
+ const raw: FlightBookingRawOptions = {
215
+ tokens: [],
216
+ outputJson: false,
217
+ help: false,
218
+ };
219
+
220
+ for (let i = 0; i < args.length; i += 1) {
221
+ const token = args[i];
222
+
223
+ if (HELP_FLAGS.has(token)) {
224
+ raw.help = true;
225
+ continue;
226
+ }
227
+
228
+ if (token === "--json") {
229
+ raw.outputJson = true;
230
+ continue;
231
+ }
232
+
233
+ if (token === "--token") {
234
+ const value = args[i + 1];
235
+ if (!value || value.startsWith("--")) {
236
+ throw new CliError("Missing value for --token", ExitCode.InvalidInput);
237
+ }
238
+ raw.tokens.push(value.trim());
239
+ i += 1;
240
+ continue;
241
+ }
242
+
243
+ const value = args[i + 1];
244
+ if (!value || value.startsWith("--")) {
245
+ throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
246
+ }
247
+
248
+ switch (token) {
249
+ case "--from":
250
+ raw.from = value;
251
+ break;
252
+ case "--to":
253
+ raw.to = value;
254
+ break;
255
+ case "--date":
256
+ raw.date = value;
257
+ break;
258
+ default:
259
+ throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
260
+ }
261
+
262
+ i += 1;
263
+ }
264
+
265
+ if (raw.help) {
266
+ return {
267
+ help: true,
268
+ outputJson: raw.outputJson,
269
+ };
270
+ }
271
+
272
+ return {
273
+ help: false,
274
+ mode: "flight-booking",
275
+ outputJson: raw.outputJson,
276
+ query: buildFlightBookingQuery(raw),
277
+ };
278
+ }
279
+
280
+ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
87
281
  if (!raw.from || !raw.to || !raw.date) {
88
282
  throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
89
283
  }
@@ -95,7 +289,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
95
289
  throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
96
290
  }
97
291
 
98
- const departureDate = normalizeDate(raw.date);
292
+ const departureDate = normalizeDate(raw.date, "departure");
99
293
  const airlineCode = raw.airline ? normalizeAirlineCode(raw.airline) : undefined;
100
294
  const maxStops = raw.maxStops ? normalizeMaxStops(raw.maxStops) : undefined;
101
295
  const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
@@ -126,32 +320,100 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
126
320
  }
127
321
 
128
322
  return {
129
- help: false,
130
- outputJson: raw.outputJson,
131
- query: {
132
- origin,
133
- destination,
134
- departureDate,
135
- airlineCode,
136
- maxStops,
137
- maxPrice,
138
- departureAfterMinutes,
139
- departureBeforeMinutes,
140
- },
323
+ origin,
324
+ destination,
325
+ departureDate,
326
+ airlineCode,
327
+ maxStops,
328
+ maxPrice,
329
+ departureAfterMinutes,
330
+ departureBeforeMinutes,
331
+ excludeBasic: raw.excludeBasic || undefined,
141
332
  };
142
333
  }
143
334
 
144
- function stripSubcommands(argv: string[]): string[] {
335
+ function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
336
+ if (!raw.where || !raw.checkIn || !raw.checkOut) {
337
+ throw new CliError(
338
+ "Missing required flags: --where, --check-in, --check-out",
339
+ ExitCode.InvalidInput,
340
+ );
341
+ }
342
+
343
+ const location = normalizeLocation(raw.where);
344
+ const checkInDate = normalizeDate(raw.checkIn, "check-in");
345
+ const checkOutDate = normalizeDate(raw.checkOut, "check-out");
346
+ const adults = raw.adults ? normalizeAdults(raw.adults) : 2;
347
+ const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
348
+ const minRating = raw.rating ? normalizeMinRating(raw.rating) : undefined;
349
+
350
+ const checkIn = parseDateOnly(checkInDate);
351
+ const checkOut = parseDateOnly(checkOutDate);
352
+
353
+ if (checkOut <= checkIn) {
354
+ throw new CliError("Check-out date must be after check-in date", ExitCode.InvalidInput);
355
+ }
356
+
357
+ return {
358
+ location,
359
+ checkInDate,
360
+ checkOutDate,
361
+ adults,
362
+ maxPrice,
363
+ minRating,
364
+ };
365
+ }
366
+
367
+ function buildFlightBookingQuery(raw: FlightBookingRawOptions): FlightBookingQuery {
368
+ if (!raw.from || !raw.to || !raw.date) {
369
+ throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
370
+ }
371
+
372
+ const origin = normalizeAirport(raw.from, "origin");
373
+ const destination = normalizeAirport(raw.to, "destination");
374
+ if (origin === destination) {
375
+ throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
376
+ }
377
+
378
+ const departureDate = normalizeDate(raw.date, "departure");
379
+ const tokens = raw.tokens.filter((token) => token.length > 0);
380
+ if (tokens.length === 0) {
381
+ throw new CliError("Missing required flag: --token", ExitCode.InvalidInput);
382
+ }
383
+
384
+ return {
385
+ origin,
386
+ destination,
387
+ departureDate,
388
+ tokens,
389
+ };
390
+ }
391
+
392
+ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
145
393
  const args = [...argv];
394
+ if (args[0] === "hotels") {
395
+ args.shift();
396
+ return { mode: "hotels", args };
397
+ }
146
398
 
147
399
  if (args[0] === "flights") {
148
400
  args.shift();
401
+ if (args[0] === "booking") {
402
+ args.shift();
403
+ return { mode: "flight-booking", args };
404
+ }
405
+
149
406
  if (args[0] === "one-way") {
150
407
  args.shift();
151
408
  }
409
+
410
+ return { mode: "flights", args };
152
411
  }
153
412
 
154
- return args;
413
+ throw new CliError(
414
+ "Missing subcommand: use `flights` or `hotels`",
415
+ ExitCode.InvalidInput,
416
+ );
155
417
  }
156
418
 
157
419
  function normalizeAirport(value: string, fieldName: "origin" | "destination"): string {
@@ -176,27 +438,43 @@ function normalizeAirlineCode(value: string): string {
176
438
  return upper;
177
439
  }
178
440
 
179
- function normalizeDate(value: string): string {
441
+ function normalizeLocation(value: string): string {
442
+ const trimmed = value.trim();
443
+ if (trimmed.length === 0) {
444
+ throw new CliError("Location cannot be empty", ExitCode.InvalidInput);
445
+ }
446
+
447
+ return trimmed;
448
+ }
449
+
450
+ function normalizeDate(value: string, fieldName: "departure" | "check-in" | "check-out"): string {
180
451
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
181
452
  if (!match) {
182
453
  throw new CliError("Invalid date. Expected YYYY-MM-DD", ExitCode.InvalidInput);
183
454
  }
184
455
 
456
+ const date = parseDateOnly(value);
185
457
  const year = Number(match[1]);
186
458
  const month = Number(match[2]);
187
459
  const day = Number(match[3]);
188
460
 
189
- const date = new Date(year, month - 1, day);
190
461
  if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
191
- throw new CliError("Invalid departure date", ExitCode.InvalidInput);
462
+ if (fieldName === "departure") {
463
+ throw new CliError("Invalid departure date", ExitCode.InvalidInput);
464
+ }
465
+
466
+ throw new CliError(`Invalid ${fieldName} date`, ExitCode.InvalidInput);
192
467
  }
193
468
 
194
- const today = new Date();
195
- today.setHours(0, 0, 0, 0);
196
- date.setHours(0, 0, 0, 0);
469
+ const now = new Date();
470
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
197
471
 
198
472
  if (date < today) {
199
- throw new CliError("Departure date cannot be in the past", ExitCode.InvalidInput);
473
+ if (fieldName === "departure") {
474
+ throw new CliError("Departure date cannot be in the past", ExitCode.InvalidInput);
475
+ }
476
+
477
+ throw new CliError(`${capitalize(fieldName)} date cannot be in the past`, ExitCode.InvalidInput);
200
478
  }
201
479
 
202
480
  return value;
@@ -218,6 +496,24 @@ function normalizeMaxPrice(value: string): number {
218
496
  return numeric;
219
497
  }
220
498
 
499
+ function normalizeAdults(value: string): number {
500
+ const numeric = Number.parseInt(value, 10);
501
+ if (!Number.isInteger(numeric) || numeric <= 0) {
502
+ throw new CliError("--adults must be a positive integer", ExitCode.InvalidInput);
503
+ }
504
+
505
+ return numeric;
506
+ }
507
+
508
+ function normalizeMinRating(value: string): 3.5 | 4 | 4.5 | 5 {
509
+ const numeric = Number.parseFloat(value);
510
+ if (numeric !== 3.5 && numeric !== 4 && numeric !== 4.5 && numeric !== 5) {
511
+ throw new CliError("--rating must be one of: 3.5, 4, 4.5, 5", ExitCode.InvalidInput);
512
+ }
513
+
514
+ return numeric;
515
+ }
516
+
221
517
  function normalizeTime(value: string, flagName: string): number {
222
518
  const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
223
519
  if (!match) {
@@ -226,3 +522,12 @@ function normalizeTime(value: string, flagName: string): number {
226
522
 
227
523
  return Number(match[1]) * 60 + Number(match[2]);
228
524
  }
525
+
526
+ function parseDateOnly(value: string): Date {
527
+ const [year, month, day] = value.split("-").map((part) => Number(part));
528
+ return new Date(year, month - 1, day);
529
+ }
530
+
531
+ function capitalize(value: string): string {
532
+ return value.slice(0, 1).toUpperCase() + value.slice(1);
533
+ }
package/src/serpapi.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { CliError } from "./errors";
2
- import { ExitCode, FlightOption, FlightQuery } from "./types";
2
+ import {
3
+ ExitCode,
4
+ FlightBookingQuery,
5
+ FlightBookingResult,
6
+ FlightOption,
7
+ FlightQuery,
8
+ FlightSearchResult,
9
+ HotelOption,
10
+ HotelQuery,
11
+ } from "./types";
3
12
 
4
13
  interface SerpApiAirport {
5
14
  time?: string;
@@ -16,44 +25,70 @@ interface SerpApiItinerary {
16
25
  price?: number;
17
26
  total_duration?: number;
18
27
  flights?: SerpApiSegment[];
28
+ booking_token?: string;
19
29
  }
20
30
 
21
- interface SerpApiResponse {
31
+ interface SerpApiFlightsResponse {
22
32
  error?: string;
33
+ search_metadata?: {
34
+ google_flights_url?: string;
35
+ };
23
36
  best_flights?: SerpApiItinerary[];
24
37
  other_flights?: SerpApiItinerary[];
25
38
  }
26
39
 
27
- export async function searchFlights(
28
- query: FlightQuery,
29
- apiKey: string,
30
- fetchImpl: typeof fetch = fetch,
31
- ): Promise<FlightOption[]> {
32
- const requestUrl = buildRequestUrl(query, apiKey);
40
+ interface SerpApiBookingRequest {
41
+ url?: string;
42
+ post_data?: string;
43
+ }
33
44
 
34
- let response: Response;
35
- try {
36
- response = await fetchImpl(requestUrl, {
37
- signal: AbortSignal.timeout(15_000),
38
- });
39
- } catch (error) {
40
- if (error instanceof Error && error.name === "TimeoutError") {
41
- throw new CliError("SerpApi request timed out", ExitCode.ApiFailure);
42
- }
45
+ interface SerpApiBookingNode {
46
+ source?: string;
47
+ price?: number;
48
+ booking_request?: SerpApiBookingRequest;
49
+ [key: string]: unknown;
50
+ }
43
51
 
44
- throw new CliError("Failed to reach SerpApi", ExitCode.ApiFailure);
45
- }
52
+ interface SerpApiBookingOptionsResponse {
53
+ error?: string;
54
+ search_metadata?: {
55
+ google_flights_url?: string;
56
+ };
57
+ [key: string]: unknown;
58
+ }
46
59
 
47
- if (!response.ok) {
48
- throw new CliError(`SerpApi request failed with status ${response.status}`, ExitCode.ApiFailure);
49
- }
60
+ interface SerpApiPrice {
61
+ lowest?: number;
62
+ extracted_lowest?: number;
63
+ }
50
64
 
51
- let payload: SerpApiResponse;
52
- try {
53
- payload = (await response.json()) as SerpApiResponse;
54
- } catch {
55
- throw new CliError("SerpApi returned invalid JSON", ExitCode.ApiFailure);
56
- }
65
+ interface SerpApiHotelProperty {
66
+ name?: string;
67
+ rate_per_night?: SerpApiPrice;
68
+ total_rate?: SerpApiPrice;
69
+ overall_rating?: number;
70
+ reviews?: number;
71
+ description?: string;
72
+ type?: string;
73
+ link?: string;
74
+ serpapi_property_details_link?: string;
75
+ google_property_details_link?: string;
76
+ }
77
+
78
+ interface SerpApiHotelsResponse {
79
+ error?: string;
80
+ properties?: SerpApiHotelProperty[];
81
+ }
82
+
83
+ export async function searchFlights(
84
+ query: FlightQuery,
85
+ apiKey: string,
86
+ fetchImpl: typeof fetch = fetch,
87
+ ): Promise<FlightSearchResult> {
88
+ const payload = await fetchSerpApiJson<SerpApiFlightsResponse>(
89
+ buildFlightRequestUrl(query, apiKey),
90
+ fetchImpl,
91
+ );
57
92
 
58
93
  if (typeof payload.error === "string" && payload.error.trim() !== "") {
59
94
  throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
@@ -72,11 +107,58 @@ export async function searchFlights(
72
107
  );
73
108
  }
74
109
 
75
- flights.sort((a, b) => a.price - b.price);
76
- return flights;
110
+ flights.sort(compareByDepartureTime);
111
+ return {
112
+ options: flights,
113
+ googleFlightsUrl: payload.search_metadata?.google_flights_url,
114
+ };
115
+ }
116
+
117
+ export async function searchFlightBookingOptions(
118
+ query: FlightBookingQuery,
119
+ apiKey: string,
120
+ fetchImpl: typeof fetch = fetch,
121
+ ): Promise<FlightBookingResult[]> {
122
+ const requests = query.tokens.map(async (token) => {
123
+ const payload = await fetchSerpApiJson<SerpApiBookingOptionsResponse>(
124
+ buildFlightBookingOptionsRequestUrl(query, token, apiKey),
125
+ fetchImpl,
126
+ );
127
+
128
+ if (typeof payload.error === "string" && payload.error.trim() !== "") {
129
+ throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
130
+ }
131
+
132
+ return {
133
+ token,
134
+ googleFlightsUrl: payload.search_metadata?.google_flights_url,
135
+ links: extractBookingLinks(payload),
136
+ };
137
+ });
138
+
139
+ return Promise.all(requests);
140
+ }
141
+
142
+ export async function searchHotels(
143
+ query: HotelQuery,
144
+ apiKey: string,
145
+ fetchImpl: typeof fetch = fetch,
146
+ ): Promise<HotelOption[]> {
147
+ const payload = await fetchSerpApiJson<SerpApiHotelsResponse>(
148
+ buildHotelRequestUrl(query, apiKey),
149
+ fetchImpl,
150
+ );
151
+
152
+ if (typeof payload.error === "string" && payload.error.trim() !== "") {
153
+ throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
154
+ }
155
+
156
+ const hotels = shapeHotelSerpApiResponse(payload);
157
+ hotels.sort((a, b) => a.nightlyPrice - b.nightlyPrice);
158
+ return hotels;
77
159
  }
78
160
 
79
- export function shapeSerpApiResponse(payload: SerpApiResponse): FlightOption[] {
161
+ export function shapeSerpApiResponse(payload: SerpApiFlightsResponse): FlightOption[] {
80
162
  const merged = [...(payload.best_flights ?? []), ...(payload.other_flights ?? [])];
81
163
 
82
164
  return merged
@@ -84,6 +166,12 @@ export function shapeSerpApiResponse(payload: SerpApiResponse): FlightOption[] {
84
166
  .filter((option): option is FlightOption => option !== null);
85
167
  }
86
168
 
169
+ export function shapeHotelSerpApiResponse(payload: SerpApiHotelsResponse): HotelOption[] {
170
+ return (payload.properties ?? [])
171
+ .map((property) => shapeHotelProperty(property))
172
+ .filter((option): option is HotelOption => option !== null);
173
+ }
174
+
87
175
  export function filterByDepartureWindow(
88
176
  options: FlightOption[],
89
177
  minMinutes: number,
@@ -121,7 +209,7 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
121
209
 
122
210
  const uniqueAirlines = [...new Set(segments.map((segment) => segment.airline).filter(Boolean))];
123
211
 
124
- return {
212
+ const option: FlightOption = {
125
213
  price: itinerary.price as number,
126
214
  airline: uniqueAirlines.length > 0 ? uniqueAirlines.join(", ") : "Unknown",
127
215
  departureTime,
@@ -129,6 +217,40 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
129
217
  durationMinutes: inferDurationMinutes(itinerary, segments),
130
218
  stops: Math.max(0, segments.length - 1),
131
219
  };
220
+
221
+ if (typeof itinerary.booking_token === "string" && itinerary.booking_token.trim() !== "") {
222
+ option.bookingToken = itinerary.booking_token.trim();
223
+ }
224
+
225
+ return option;
226
+ }
227
+
228
+ function shapeHotelProperty(property: SerpApiHotelProperty): HotelOption | null {
229
+ if (typeof property.name !== "string" || property.name.trim() === "") {
230
+ return null;
231
+ }
232
+
233
+ const nightlyPrice = extractLowestPrice(property.rate_per_night);
234
+ if (typeof nightlyPrice !== "number") {
235
+ return null;
236
+ }
237
+
238
+ const totalPrice = extractLowestPrice(property.total_rate);
239
+ const rating = Number.isFinite(property.overall_rating) ? property.overall_rating : undefined;
240
+ const reviews = Number.isFinite(property.reviews) ? property.reviews : undefined;
241
+ const location = property.description?.trim() || property.type?.trim() || "n/a";
242
+ const link =
243
+ property.link || property.serpapi_property_details_link || property.google_property_details_link;
244
+
245
+ return {
246
+ name: property.name.trim(),
247
+ nightlyPrice,
248
+ totalPrice,
249
+ rating,
250
+ reviews,
251
+ location,
252
+ link,
253
+ };
132
254
  }
133
255
 
134
256
  function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegment[]): number {
@@ -146,7 +268,7 @@ function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegm
146
268
  return segmentDuration;
147
269
  }
148
270
 
149
- function buildRequestUrl(query: FlightQuery, apiKey: string): string {
271
+ function buildFlightRequestUrl(query: FlightQuery, apiKey: string): string {
150
272
  const url = new URL("https://serpapi.com/search.json");
151
273
 
152
274
  url.searchParams.set("engine", "google_flights");
@@ -183,6 +305,53 @@ function buildRequestUrl(query: FlightQuery, apiKey: string): string {
183
305
  url.searchParams.set("outbound_times", `${minHour},${maxHour}`);
184
306
  }
185
307
 
308
+ if (query.excludeBasic) {
309
+ url.searchParams.set("exclude_basic", "true");
310
+ url.searchParams.set("travel_class", "1");
311
+ url.searchParams.set("gl", "us");
312
+ }
313
+
314
+ return url.toString();
315
+ }
316
+
317
+ function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
318
+ const url = new URL("https://serpapi.com/search.json");
319
+
320
+ url.searchParams.set("engine", "google_hotels");
321
+ url.searchParams.set("q", query.location);
322
+ url.searchParams.set("check_in_date", query.checkInDate);
323
+ url.searchParams.set("check_out_date", query.checkOutDate);
324
+ url.searchParams.set("adults", String(query.adults));
325
+ url.searchParams.set("currency", "USD");
326
+ url.searchParams.set("api_key", apiKey);
327
+
328
+ if (typeof query.maxPrice === "number") {
329
+ url.searchParams.set("max_price", String(query.maxPrice));
330
+ }
331
+
332
+ if (typeof query.minRating === "number") {
333
+ url.searchParams.set("rating", toSerpApiRating(query.minRating));
334
+ }
335
+
336
+ return url.toString();
337
+ }
338
+
339
+ function buildFlightBookingOptionsRequestUrl(
340
+ query: FlightBookingQuery,
341
+ token: string,
342
+ apiKey: string,
343
+ ): string {
344
+ const url = new URL("https://serpapi.com/search.json");
345
+
346
+ url.searchParams.set("engine", "google_flights");
347
+ url.searchParams.set("type", "2");
348
+ url.searchParams.set("departure_id", query.origin);
349
+ url.searchParams.set("arrival_id", query.destination);
350
+ url.searchParams.set("outbound_date", query.departureDate);
351
+ url.searchParams.set("booking_token", token);
352
+ url.searchParams.set("currency", "USD");
353
+ url.searchParams.set("api_key", apiKey);
354
+
186
355
  return url.toString();
187
356
  }
188
357
 
@@ -198,6 +367,75 @@ function toSerpApiStopsFilter(maxStops: number): string {
198
367
  return "3";
199
368
  }
200
369
 
370
+ function toSerpApiRating(rating: 3.5 | 4 | 4.5 | 5): string {
371
+ if (rating === 3.5) {
372
+ return "7";
373
+ }
374
+
375
+ if (rating === 4) {
376
+ return "8";
377
+ }
378
+
379
+ if (rating === 4.5) {
380
+ return "9";
381
+ }
382
+
383
+ return "10";
384
+ }
385
+
386
+ async function fetchSerpApiJson<T>(
387
+ requestUrl: string,
388
+ fetchImpl: typeof fetch,
389
+ ): Promise<T> {
390
+ let response: Response;
391
+ try {
392
+ response = await fetchImpl(requestUrl, {
393
+ signal: AbortSignal.timeout(15_000),
394
+ });
395
+ } catch (error) {
396
+ if (error instanceof Error && error.name === "TimeoutError") {
397
+ throw new CliError("SerpApi request timed out", ExitCode.ApiFailure);
398
+ }
399
+
400
+ throw new CliError("Failed to reach SerpApi", ExitCode.ApiFailure);
401
+ }
402
+
403
+ if (!response.ok) {
404
+ let details = "";
405
+ try {
406
+ const raw = await response.text();
407
+ if (raw.trim().length > 0) {
408
+ details = `: ${raw.trim()}`;
409
+ }
410
+ } catch {
411
+ details = "";
412
+ }
413
+
414
+ throw new CliError(
415
+ `SerpApi request failed with status ${response.status}${details}`,
416
+ ExitCode.ApiFailure,
417
+ );
418
+ }
419
+
420
+ try {
421
+ return (await response.json()) as T;
422
+ } catch {
423
+ throw new CliError("SerpApi returned invalid JSON", ExitCode.ApiFailure);
424
+ }
425
+ }
426
+
427
+ function extractLowestPrice(value?: SerpApiPrice): number | undefined {
428
+ if (Number.isFinite(value?.lowest)) {
429
+ return value?.lowest;
430
+ }
431
+
432
+ if (Number.isFinite(value?.extracted_lowest)) {
433
+ return value?.extracted_lowest;
434
+ }
435
+
436
+ return undefined;
437
+ }
438
+
201
439
  function extractMinutes(value: string): number | null {
202
440
  const match = /\b([01]?\d|2[0-3]):([0-5]\d)\b/.exec(value);
203
441
  if (!match) {
@@ -206,3 +444,89 @@ function extractMinutes(value: string): number | null {
206
444
 
207
445
  return Number(match[1]) * 60 + Number(match[2]);
208
446
  }
447
+
448
+ function compareByDepartureTime(a: FlightOption, b: FlightOption): number {
449
+ const aKey = departureSortKey(a.departureTime);
450
+ const bKey = departureSortKey(b.departureTime);
451
+
452
+ if (aKey !== bKey) {
453
+ return aKey - bKey;
454
+ }
455
+
456
+ return a.price - b.price;
457
+ }
458
+
459
+ function departureSortKey(value: string): number {
460
+ const match = /^(\d{4})-(\d{2})-(\d{2})\s+([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
461
+ if (!match) {
462
+ return Number.MAX_SAFE_INTEGER;
463
+ }
464
+
465
+ const year = Number(match[1]);
466
+ const month = Number(match[2]);
467
+ const day = Number(match[3]);
468
+ const hour = Number(match[4]);
469
+ const minute = Number(match[5]);
470
+
471
+ return (
472
+ year * 100000000 +
473
+ month * 1000000 +
474
+ day * 10000 +
475
+ hour * 100 +
476
+ minute
477
+ );
478
+ }
479
+
480
+ function extractBookingLinks(payload: SerpApiBookingOptionsResponse): Array<{
481
+ url: string;
482
+ source?: string;
483
+ price?: number;
484
+ }> {
485
+ const links: Array<{ url: string; source?: string; price?: number }> = [];
486
+ collectBookingLinks(payload as SerpApiBookingNode, links);
487
+
488
+ const deduped = new Map<string, { url: string; source?: string; price?: number }>();
489
+ for (const link of links) {
490
+ if (!deduped.has(link.url)) {
491
+ deduped.set(link.url, link);
492
+ }
493
+ }
494
+
495
+ return [...deduped.values()];
496
+ }
497
+
498
+ function collectBookingLinks(
499
+ node: unknown,
500
+ links: Array<{ url: string; source?: string; price?: number }>,
501
+ ): void {
502
+ if (Array.isArray(node)) {
503
+ for (const item of node) {
504
+ collectBookingLinks(item, links);
505
+ }
506
+ return;
507
+ }
508
+
509
+ if (!node || typeof node !== "object") {
510
+ return;
511
+ }
512
+
513
+ const value = node as SerpApiBookingNode;
514
+ const bookingUrl = value.booking_request?.url;
515
+ const bookingPostData = value.booking_request?.post_data;
516
+ if (typeof bookingUrl === "string" && bookingUrl.trim() !== "") {
517
+ const finalUrl =
518
+ typeof bookingPostData === "string" && bookingPostData.trim() !== ""
519
+ ? `${bookingUrl}${bookingUrl.includes("?") ? "&" : "?"}${bookingPostData}`
520
+ : bookingUrl;
521
+
522
+ links.push({
523
+ url: finalUrl,
524
+ source: typeof value.source === "string" ? value.source : undefined,
525
+ price: Number.isFinite(value.price) ? value.price : undefined,
526
+ });
527
+ }
528
+
529
+ for (const child of Object.values(value)) {
530
+ collectBookingLinks(child, links);
531
+ }
532
+ }
package/src/types.ts CHANGED
@@ -18,14 +18,57 @@ export interface FlightQuery {
18
18
  maxPrice?: number;
19
19
  departureAfterMinutes?: number;
20
20
  departureBeforeMinutes?: number;
21
+ excludeBasic?: boolean;
21
22
  }
22
23
 
23
- export interface ParsedArgs {
24
- query?: FlightQuery;
24
+ export interface HotelQuery {
25
+ location: string;
26
+ checkInDate: string;
27
+ checkOutDate: string;
28
+ adults: number;
29
+ maxPrice?: number;
30
+ minRating?: 3.5 | 4 | 4.5 | 5;
31
+ }
32
+
33
+ export interface ParsedArgsHelp {
34
+ help: true;
35
+ outputJson: boolean;
36
+ }
37
+
38
+ export interface ParsedArgsFlights {
39
+ help: false;
40
+ mode: "flights";
41
+ outputJson: boolean;
42
+ query: FlightQuery;
43
+ }
44
+
45
+ export interface ParsedArgsHotels {
46
+ help: false;
47
+ mode: "hotels";
48
+ outputJson: boolean;
49
+ query: HotelQuery;
50
+ }
51
+
52
+ export interface FlightBookingQuery {
53
+ origin: string;
54
+ destination: string;
55
+ departureDate: string;
56
+ tokens: string[];
57
+ }
58
+
59
+ export interface ParsedArgsFlightBooking {
60
+ help: false;
61
+ mode: "flight-booking";
25
62
  outputJson: boolean;
26
- help: boolean;
63
+ query: FlightBookingQuery;
27
64
  }
28
65
 
66
+ export type ParsedArgs =
67
+ | ParsedArgsHelp
68
+ | ParsedArgsFlights
69
+ | ParsedArgsHotels
70
+ | ParsedArgsFlightBooking;
71
+
29
72
  export interface FlightOption {
30
73
  price: number;
31
74
  airline: string;
@@ -33,4 +76,32 @@ export interface FlightOption {
33
76
  arrivalTime: string;
34
77
  durationMinutes: number;
35
78
  stops: number;
79
+ bookingToken?: string;
80
+ }
81
+
82
+ export interface HotelOption {
83
+ name: string;
84
+ nightlyPrice: number;
85
+ totalPrice?: number;
86
+ rating?: number;
87
+ reviews?: number;
88
+ location: string;
89
+ link?: string;
90
+ }
91
+
92
+ export interface FlightSearchResult {
93
+ options: FlightOption[];
94
+ googleFlightsUrl?: string;
95
+ }
96
+
97
+ export interface FlightBookingLink {
98
+ url: string;
99
+ source?: string;
100
+ price?: number;
101
+ }
102
+
103
+ export interface FlightBookingResult {
104
+ token: string;
105
+ googleFlightsUrl?: string;
106
+ links: FlightBookingLink[];
36
107
  }