@tks/wayfinder 0.2.0 → 0.2.3

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
@@ -2,13 +2,19 @@
2
2
 
3
3
  ## Install
4
4
 
5
- Requires [Bun](https://bun.sh/) runtime.
5
+ Primary install method:
6
+
7
+ ```bash
8
+ brew install tksohishi/tap/wayfinder
9
+ ```
10
+
11
+ Secondary fallback method (requires [Bun](https://bun.sh/) runtime):
6
12
 
7
13
  ```bash
8
14
  bun install -g @tks/wayfinder
9
15
  ```
10
16
 
11
- Or install from source:
17
+ Install from source:
12
18
 
13
19
  ```bash
14
20
  git clone https://github.com/tksohishi/wayfinder.git
@@ -19,7 +25,18 @@ bun link
19
25
 
20
26
  ## Setup
21
27
 
22
- Set API key by environment variable (preferred):
28
+ First run:
29
+
30
+ ```bash
31
+ wayfinder setup
32
+ ```
33
+
34
+ This interactive command explains why the key is needed, where to get it, and saves it to your local config.
35
+ SerpApi is the API provider wayfinder uses to fetch Google Flights and Google Hotels results.
36
+
37
+ Advanced alternatives:
38
+
39
+ Set API key by environment variable:
23
40
 
24
41
  ```bash
25
42
  export SERPAPI_API_KEY="your_key"
@@ -59,6 +76,12 @@ Structured output for scripting:
59
76
  wayfinder flights --from SFO --to JFK --date 2026-04-10 --json | jq '.results[] | {price,airline,stops}'
60
77
  ```
61
78
 
79
+ Get booking links from selected flight tokens:
80
+
81
+ ```bash
82
+ wayfinder flights booking --from LAS --to JFK --date 2026-05-29 --token "<TOKEN_1>" --token "<TOKEN_2>" --json
83
+ ```
84
+
62
85
  Search hotels:
63
86
 
64
87
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tks/wayfinder",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "Travel search for your terminal and your AI agents",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.ts CHANGED
@@ -1,9 +1,12 @@
1
- import { resolveApiKey } from "./config";
1
+ import { getConfigPath, readConfigApiKey, resolveApiKey, writeConfigApiKey } from "./config";
2
2
  import { CliError } from "./errors";
3
3
  import { renderFlightTable, renderHotelTable } from "./format";
4
4
  import { parseCliArgs } from "./parse";
5
- import { searchFlights, searchHotels } from "./serpapi";
5
+ import { searchFlightBookingOptions, searchFlights, searchHotels } from "./serpapi";
6
6
  import { ExitCode } from "./types";
7
+ import { createInterface } from "node:readline/promises";
8
+ import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
9
+ import { existsSync, unlinkSync } from "node:fs";
7
10
 
8
11
  interface Output {
9
12
  stdout: (message: string) => void;
@@ -15,15 +18,23 @@ interface RunOptions {
15
18
  homeDir?: string;
16
19
  fetchImpl?: typeof fetch;
17
20
  output?: Output;
21
+ isInteractive?: boolean;
22
+ promptImpl?: (prompt: string) => Promise<string>;
18
23
  }
19
24
 
20
- const HELP_TEXT = `wayfinder v0.2.0 travel search
25
+ const HELP_TEXT = `wayfinder v0.2.1 travel search
21
26
 
22
27
  Usage:
28
+ wayfinder setup [--reset]
23
29
  wayfinder flights --from SFO --to JFK --date 2026-03-21 [filters]
24
30
  wayfinder flights one-way --from SFO --to JFK --date 2026-03-21 [filters]
31
+ wayfinder flights booking --from SFO --to JFK --date 2026-03-21 --token <BOOKING_TOKEN> [--token <BOOKING_TOKEN>] [--json]
25
32
  wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
26
33
 
34
+ Setup:
35
+ Runs interactive key setup and stores your SerpApi key in local config.
36
+ --reset Remove existing local config and reconfigure
37
+
27
38
  Flights required:
28
39
  --from <IATA> Origin airport code
29
40
  --to <IATA> Destination airport code
@@ -37,6 +48,13 @@ Flights optional filters:
37
48
  --depart-before <HH:MM> End of departure window
38
49
  --exclude-basic Exclude basic economy fares
39
50
 
51
+ Flights booking required:
52
+ --from <IATA> Origin airport code
53
+ --to <IATA> Destination airport code
54
+ --date <YYYY-MM-DD> Departure date
55
+ --token <BOOKING_TOKEN> Booking token from a flights search result
56
+ (repeat --token to request multiple options)
57
+
40
58
  Hotels required:
41
59
  --where <QUERY> Destination or hotel search query
42
60
  --check-in <YYYY-MM-DD> Check-in date
@@ -54,12 +72,40 @@ export async function runWayfinder(
54
72
  argv: string[] = process.argv.slice(2),
55
73
  options: RunOptions = {},
56
74
  ): Promise<number> {
75
+ const env = options.env ?? process.env;
76
+ const homeDir = options.homeDir;
77
+ const isInteractive = options.isInteractive ?? Boolean(defaultStdin.isTTY && defaultStdout.isTTY);
57
78
  const output = options.output ?? {
58
79
  stdout: (message: string) => console.log(message),
59
80
  stderr: (message: string) => console.error(message),
60
81
  };
61
82
 
62
83
  try {
84
+ if (argv.length === 0) {
85
+ try {
86
+ resolveApiKey(env, homeDir);
87
+ output.stdout(HELP_TEXT);
88
+ return ExitCode.Success;
89
+ } catch (error) {
90
+ if (error instanceof CliError && error.exitCode === ExitCode.MissingApiKey) {
91
+ if (!isInteractive) {
92
+ output.stderr(error.message);
93
+ return error.exitCode;
94
+ }
95
+
96
+ return await runSetupFlow(output, homeDir, options.promptImpl, isInteractive, false);
97
+ }
98
+
99
+ if (error instanceof CliError) {
100
+ output.stderr(error.message);
101
+ return error.exitCode;
102
+ }
103
+
104
+ output.stderr("Unexpected internal error");
105
+ return ExitCode.InternalError;
106
+ }
107
+ }
108
+
63
109
  const parsed = parseCliArgs(argv);
64
110
 
65
111
  if (parsed.help) {
@@ -67,11 +113,21 @@ export async function runWayfinder(
67
113
  return ExitCode.Success;
68
114
  }
69
115
 
70
- const apiKey = resolveApiKey(options.env ?? process.env, options.homeDir);
116
+ if (parsed.mode === "setup") {
117
+ return await runSetupFlow(
118
+ output,
119
+ homeDir,
120
+ options.promptImpl,
121
+ isInteractive,
122
+ parsed.reset,
123
+ );
124
+ }
125
+
126
+ const apiKey = resolveApiKey(env, homeDir);
71
127
  if (parsed.mode === "flights") {
72
128
  const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
73
129
 
74
- if (flights.length === 0) {
130
+ if (flights.options.length === 0) {
75
131
  throw new CliError("No flights found for the selected query", ExitCode.NoResults);
76
132
  }
77
133
 
@@ -80,14 +136,50 @@ export async function runWayfinder(
80
136
  JSON.stringify(
81
137
  {
82
138
  query: parsed.query,
83
- results: flights,
139
+ googleFlightsUrl: flights.googleFlightsUrl,
140
+ results: flights.options,
141
+ },
142
+ null,
143
+ 2,
144
+ ),
145
+ );
146
+ } else {
147
+ output.stdout(renderFlightTable(flights.options));
148
+ }
149
+ } else if (parsed.mode === "flight-booking") {
150
+ const bookingResults = await searchFlightBookingOptions(
151
+ parsed.query,
152
+ apiKey,
153
+ options.fetchImpl ?? fetch,
154
+ );
155
+
156
+ const flightLinks = bookingResults
157
+ .filter((result) => typeof result.googleFlightsUrl === "string")
158
+ .map((result) => ({
159
+ token: result.token,
160
+ googleFlightsUrl: result.googleFlightsUrl as string,
161
+ }));
162
+
163
+ if (flightLinks.length === 0) {
164
+ throw new CliError(
165
+ "No Google Flights links found for the provided token(s)",
166
+ ExitCode.NoResults,
167
+ );
168
+ }
169
+
170
+ if (parsed.outputJson) {
171
+ output.stdout(
172
+ JSON.stringify(
173
+ {
174
+ query: parsed.query,
175
+ results: flightLinks,
84
176
  },
85
177
  null,
86
178
  2,
87
179
  ),
88
180
  );
89
181
  } else {
90
- output.stdout(renderFlightTable(flights));
182
+ output.stdout(renderFlightBookingText(flightLinks));
91
183
  }
92
184
  } else {
93
185
  const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
@@ -128,3 +220,92 @@ if (import.meta.main) {
128
220
  const code = await runWayfinder(process.argv.slice(2));
129
221
  process.exitCode = code;
130
222
  }
223
+
224
+ function renderFlightBookingText(
225
+ results: Array<{ token: string; googleFlightsUrl: string }>,
226
+ ): string {
227
+ const lines: string[] = [];
228
+
229
+ for (const result of results) {
230
+ lines.push(`TOKEN: ${result.token}`);
231
+ lines.push(`GOOGLE FLIGHTS: ${result.googleFlightsUrl}`);
232
+ lines.push("");
233
+ }
234
+
235
+ return lines.join("\n").trimEnd();
236
+ }
237
+
238
+ async function runSetupFlow(
239
+ output: Output,
240
+ homeDir: string | undefined,
241
+ promptImpl: ((prompt: string) => Promise<string>) | undefined,
242
+ isInteractive: boolean,
243
+ forceReset: boolean,
244
+ ): Promise<number> {
245
+ if (!isInteractive) {
246
+ throw new CliError(
247
+ "wayfinder setup requires an interactive terminal. Use SERPAPI_API_KEY or write ~/.config/wayfinder/config.json manually.",
248
+ ExitCode.InvalidInput,
249
+ );
250
+ }
251
+
252
+ output.stdout("Wayfinder needs a SerpApi API key to fetch live flight and hotel data.");
253
+ output.stdout("SerpApi is the API provider that returns Google Flights and Google Hotels search results.");
254
+ output.stdout("Get your key at: https://serpapi.com/manage-api-key");
255
+ output.stdout("");
256
+
257
+ const configPath = getConfigPath(homeDir);
258
+ if (forceReset) {
259
+ if (existsSync(configPath)) {
260
+ unlinkSync(configPath);
261
+ output.stdout("Existing config removed due to --reset.");
262
+ } else {
263
+ output.stdout("No existing config found. Starting fresh setup.");
264
+ }
265
+ }
266
+
267
+ const existingKey = readConfigApiKey(homeDir);
268
+ if (existingKey && !forceReset) {
269
+ const overwrite = await promptWithFallback(promptImpl, "A key already exists. Overwrite? [y/N]: ");
270
+ if (!/^(y|yes)$/i.test(overwrite.trim())) {
271
+ output.stdout("Setup cancelled. Existing key kept.");
272
+ return ExitCode.Success;
273
+ }
274
+ }
275
+
276
+ let apiKey = "";
277
+ while (apiKey.length === 0) {
278
+ apiKey = (await promptWithFallback(promptImpl, "Enter your SerpApi API key: ")).trim();
279
+ if (apiKey.length === 0) {
280
+ output.stdout("API key cannot be empty.");
281
+ }
282
+ }
283
+
284
+ writeConfigApiKey(apiKey, homeDir);
285
+ output.stdout("");
286
+ output.stdout(`Setup complete. Saved key to ${configPath}.`);
287
+ output.stdout("Next step: wayfinder --help");
288
+ output.stdout("Quick start: wayfinder flights --from SFO --to JFK --date 2026-04-10");
289
+
290
+ return ExitCode.Success;
291
+ }
292
+
293
+ async function promptWithFallback(
294
+ promptImpl: ((prompt: string) => Promise<string>) | undefined,
295
+ message: string,
296
+ ): Promise<string> {
297
+ if (promptImpl) {
298
+ return promptImpl(message);
299
+ }
300
+
301
+ const rl = createInterface({
302
+ input: defaultStdin,
303
+ output: defaultStdout,
304
+ });
305
+
306
+ try {
307
+ return await rl.question(message);
308
+ } finally {
309
+ rl.close();
310
+ }
311
+ }
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { CliError } from "./errors";
@@ -14,10 +14,18 @@ export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = os
14
14
  return envKey;
15
15
  }
16
16
 
17
- const configPath = path.join(homeDir, ".config", "wayfinder", "config.json");
17
+ const configKey = readConfigApiKey(homeDir);
18
+ if (!configKey) {
19
+ throw missingKeyError();
20
+ }
21
+
22
+ return configKey;
23
+ }
18
24
 
25
+ export function readConfigApiKey(homeDir = os.homedir()): string | undefined {
26
+ const configPath = getConfigPath(homeDir);
19
27
  if (!existsSync(configPath)) {
20
- throw missingKeyError();
28
+ return undefined;
21
29
  }
22
30
 
23
31
  let parsed: ConfigFile;
@@ -26,21 +34,49 @@ export function resolveApiKey(env: NodeJS.ProcessEnv = process.env, homeDir = os
26
34
  parsed = JSON.parse(raw) as ConfigFile;
27
35
  } catch {
28
36
  throw new CliError(
29
- "Could not read ~/.config/wayfinder/config.json. Ensure it is valid JSON.",
37
+ "Could not read ~/.config/wayfinder/config.json. Ensure it is valid JSON or rerun `wayfinder setup`.",
30
38
  ExitCode.MissingApiKey,
31
39
  );
32
40
  }
33
41
 
34
- if (typeof parsed.serpApiKey !== "string" || parsed.serpApiKey.trim() === "") {
35
- throw missingKeyError();
42
+ if (typeof parsed.serpApiKey !== "string") {
43
+ return undefined;
36
44
  }
37
45
 
38
- return parsed.serpApiKey.trim();
46
+ const key = parsed.serpApiKey.trim();
47
+ return key.length > 0 ? key : undefined;
48
+ }
49
+
50
+ export function writeConfigApiKey(apiKey: string, homeDir = os.homedir()): void {
51
+ const configPath = getConfigPath(homeDir);
52
+ const configDir = path.dirname(configPath);
53
+ mkdirSync(configDir, { recursive: true });
54
+ writeFileSync(
55
+ configPath,
56
+ `${JSON.stringify(
57
+ {
58
+ serpApiKey: apiKey,
59
+ },
60
+ null,
61
+ 2,
62
+ )}\n`,
63
+ "utf8",
64
+ );
65
+ }
66
+
67
+ export function getConfigPath(homeDir = os.homedir()): string {
68
+ return path.join(homeDir, ".config", "wayfinder", "config.json");
39
69
  }
40
70
 
41
71
  function missingKeyError(): CliError {
42
72
  return new CliError(
43
- "Missing SerpApi key. Set SERPAPI_API_KEY or add ~/.config/wayfinder/config.json with {\"serpApiKey\":\"...\"}.",
73
+ [
74
+ "Missing SerpApi API key.",
75
+ "Wayfinder needs a SerpApi key to fetch live flight and hotel results.",
76
+ "SerpApi is the API provider used for Google Flights and Google Hotels data.",
77
+ "Get a key at: https://serpapi.com/manage-api-key",
78
+ "Then run: wayfinder setup",
79
+ ].join("\n"),
44
80
  ExitCode.MissingApiKey,
45
81
  );
46
82
  }
package/src/parse.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "./errors";
2
- import { ExitCode, FlightQuery, HotelQuery, ParsedArgs } from "./types";
2
+ import { ExitCode, FlightBookingQuery, FlightQuery, HotelQuery, ParsedArgs } from "./types";
3
3
 
4
4
  interface FlightRawOptions {
5
5
  from?: string;
@@ -26,7 +26,16 @@ interface HotelRawOptions {
26
26
  help: boolean;
27
27
  }
28
28
 
29
- type SearchMode = "flights" | "hotels";
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" | "setup";
30
39
 
31
40
  const HELP_FLAGS = new Set(["-h", "--help"]);
32
41
 
@@ -44,6 +53,14 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
44
53
  return parseHotelsArgs(args);
45
54
  }
46
55
 
56
+ if (mode === "flight-booking") {
57
+ return parseFlightBookingArgs(args);
58
+ }
59
+
60
+ if (mode === "setup") {
61
+ return parseSetupArgs(args);
62
+ }
63
+
47
64
  return parseFlightsArgs(args);
48
65
  }
49
66
 
@@ -197,6 +214,89 @@ function parseHotelsArgs(args: string[]): ParsedArgs {
197
214
  };
198
215
  }
199
216
 
217
+ function parseFlightBookingArgs(args: string[]): ParsedArgs {
218
+ const raw: FlightBookingRawOptions = {
219
+ tokens: [],
220
+ outputJson: false,
221
+ help: false,
222
+ };
223
+
224
+ for (let i = 0; i < args.length; i += 1) {
225
+ const token = args[i];
226
+
227
+ if (HELP_FLAGS.has(token)) {
228
+ raw.help = true;
229
+ continue;
230
+ }
231
+
232
+ if (token === "--json") {
233
+ raw.outputJson = true;
234
+ continue;
235
+ }
236
+
237
+ if (token === "--token") {
238
+ const value = args[i + 1];
239
+ if (!value || value.startsWith("--")) {
240
+ throw new CliError("Missing value for --token", ExitCode.InvalidInput);
241
+ }
242
+ raw.tokens.push(value.trim());
243
+ i += 1;
244
+ continue;
245
+ }
246
+
247
+ const value = args[i + 1];
248
+ if (!value || value.startsWith("--")) {
249
+ throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
250
+ }
251
+
252
+ switch (token) {
253
+ case "--from":
254
+ raw.from = value;
255
+ break;
256
+ case "--to":
257
+ raw.to = value;
258
+ break;
259
+ case "--date":
260
+ raw.date = value;
261
+ break;
262
+ default:
263
+ throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
264
+ }
265
+
266
+ i += 1;
267
+ }
268
+
269
+ if (raw.help) {
270
+ return {
271
+ help: true,
272
+ outputJson: raw.outputJson,
273
+ };
274
+ }
275
+
276
+ return {
277
+ help: false,
278
+ mode: "flight-booking",
279
+ outputJson: raw.outputJson,
280
+ query: buildFlightBookingQuery(raw),
281
+ };
282
+ }
283
+
284
+ function parseSetupArgs(args: string[]): ParsedArgs {
285
+ const outputJson = args.includes("--json");
286
+ const reset = args.includes("--reset");
287
+ const unsupported = args.filter((token) => token !== "--json" && token !== "--reset");
288
+ if (unsupported.length > 0) {
289
+ throw new CliError(`Unknown argument for setup: ${unsupported[0]}`, ExitCode.InvalidInput);
290
+ }
291
+
292
+ return {
293
+ help: false,
294
+ mode: "setup",
295
+ outputJson,
296
+ reset,
297
+ };
298
+ }
299
+
200
300
  function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
201
301
  if (!raw.from || !raw.to || !raw.date) {
202
302
  throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
@@ -284,6 +384,31 @@ function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
284
384
  };
285
385
  }
286
386
 
387
+ function buildFlightBookingQuery(raw: FlightBookingRawOptions): FlightBookingQuery {
388
+ if (!raw.from || !raw.to || !raw.date) {
389
+ throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
390
+ }
391
+
392
+ const origin = normalizeAirport(raw.from, "origin");
393
+ const destination = normalizeAirport(raw.to, "destination");
394
+ if (origin === destination) {
395
+ throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
396
+ }
397
+
398
+ const departureDate = normalizeDate(raw.date, "departure");
399
+ const tokens = raw.tokens.filter((token) => token.length > 0);
400
+ if (tokens.length === 0) {
401
+ throw new CliError("Missing required flag: --token", ExitCode.InvalidInput);
402
+ }
403
+
404
+ return {
405
+ origin,
406
+ destination,
407
+ departureDate,
408
+ tokens,
409
+ };
410
+ }
411
+
287
412
  function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
288
413
  const args = [...argv];
289
414
  if (args[0] === "hotels") {
@@ -293,6 +418,11 @@ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] }
293
418
 
294
419
  if (args[0] === "flights") {
295
420
  args.shift();
421
+ if (args[0] === "booking") {
422
+ args.shift();
423
+ return { mode: "flight-booking", args };
424
+ }
425
+
296
426
  if (args[0] === "one-way") {
297
427
  args.shift();
298
428
  }
@@ -300,8 +430,13 @@ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] }
300
430
  return { mode: "flights", args };
301
431
  }
302
432
 
433
+ if (args[0] === "setup") {
434
+ args.shift();
435
+ return { mode: "setup", args };
436
+ }
437
+
303
438
  throw new CliError(
304
- "Missing subcommand: use `flights` or `hotels`",
439
+ "Missing subcommand: use `setup`, `flights`, or `hotels`",
305
440
  ExitCode.InvalidInput,
306
441
  );
307
442
  }
package/src/serpapi.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { CliError } from "./errors";
2
- import { ExitCode, FlightOption, FlightQuery, HotelOption, HotelQuery } 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,14 +25,38 @@ interface SerpApiItinerary {
16
25
  price?: number;
17
26
  total_duration?: number;
18
27
  flights?: SerpApiSegment[];
28
+ booking_token?: string;
19
29
  }
20
30
 
21
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
 
40
+ interface SerpApiBookingRequest {
41
+ url?: string;
42
+ post_data?: string;
43
+ }
44
+
45
+ interface SerpApiBookingNode {
46
+ source?: string;
47
+ price?: number;
48
+ booking_request?: SerpApiBookingRequest;
49
+ [key: string]: unknown;
50
+ }
51
+
52
+ interface SerpApiBookingOptionsResponse {
53
+ error?: string;
54
+ search_metadata?: {
55
+ google_flights_url?: string;
56
+ };
57
+ [key: string]: unknown;
58
+ }
59
+
27
60
  interface SerpApiPrice {
28
61
  lowest?: number;
29
62
  extracted_lowest?: number;
@@ -51,7 +84,7 @@ export async function searchFlights(
51
84
  query: FlightQuery,
52
85
  apiKey: string,
53
86
  fetchImpl: typeof fetch = fetch,
54
- ): Promise<FlightOption[]> {
87
+ ): Promise<FlightSearchResult> {
55
88
  const payload = await fetchSerpApiJson<SerpApiFlightsResponse>(
56
89
  buildFlightRequestUrl(query, apiKey),
57
90
  fetchImpl,
@@ -74,8 +107,36 @@ export async function searchFlights(
74
107
  );
75
108
  }
76
109
 
77
- flights.sort((a, b) => a.price - b.price);
78
- 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);
79
140
  }
80
141
 
81
142
  export async function searchHotels(
@@ -148,7 +209,7 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
148
209
 
149
210
  const uniqueAirlines = [...new Set(segments.map((segment) => segment.airline).filter(Boolean))];
150
211
 
151
- return {
212
+ const option: FlightOption = {
152
213
  price: itinerary.price as number,
153
214
  airline: uniqueAirlines.length > 0 ? uniqueAirlines.join(", ") : "Unknown",
154
215
  departureTime,
@@ -156,6 +217,12 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
156
217
  durationMinutes: inferDurationMinutes(itinerary, segments),
157
218
  stops: Math.max(0, segments.length - 1),
158
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;
159
226
  }
160
227
 
161
228
  function shapeHotelProperty(property: SerpApiHotelProperty): HotelOption | null {
@@ -269,6 +336,25 @@ function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
269
336
  return url.toString();
270
337
  }
271
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
+
355
+ return url.toString();
356
+ }
357
+
272
358
  function toSerpApiStopsFilter(maxStops: number): string {
273
359
  if (maxStops === 0) {
274
360
  return "1";
@@ -315,7 +401,20 @@ async function fetchSerpApiJson<T>(
315
401
  }
316
402
 
317
403
  if (!response.ok) {
318
- throw new CliError(`SerpApi request failed with status ${response.status}`, ExitCode.ApiFailure);
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
+ );
319
418
  }
320
419
 
321
420
  try {
@@ -345,3 +444,89 @@ function extractMinutes(value: string): number | null {
345
444
 
346
445
  return Number(match[1]) * 60 + Number(match[2]);
347
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
@@ -49,7 +49,33 @@ export interface ParsedArgsHotels {
49
49
  query: HotelQuery;
50
50
  }
51
51
 
52
- export type ParsedArgs = ParsedArgsHelp | ParsedArgsFlights | ParsedArgsHotels;
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";
62
+ outputJson: boolean;
63
+ query: FlightBookingQuery;
64
+ }
65
+
66
+ export interface ParsedArgsSetup {
67
+ help: false;
68
+ mode: "setup";
69
+ outputJson: boolean;
70
+ reset: boolean;
71
+ }
72
+
73
+ export type ParsedArgs =
74
+ | ParsedArgsHelp
75
+ | ParsedArgsFlights
76
+ | ParsedArgsHotels
77
+ | ParsedArgsFlightBooking
78
+ | ParsedArgsSetup;
53
79
 
54
80
  export interface FlightOption {
55
81
  price: number;
@@ -58,6 +84,7 @@ export interface FlightOption {
58
84
  arrivalTime: string;
59
85
  durationMinutes: number;
60
86
  stops: number;
87
+ bookingToken?: string;
61
88
  }
62
89
 
63
90
  export interface HotelOption {
@@ -69,3 +96,20 @@ export interface HotelOption {
69
96
  location: string;
70
97
  link?: string;
71
98
  }
99
+
100
+ export interface FlightSearchResult {
101
+ options: FlightOption[];
102
+ googleFlightsUrl?: string;
103
+ }
104
+
105
+ export interface FlightBookingLink {
106
+ url: string;
107
+ source?: string;
108
+ price?: number;
109
+ }
110
+
111
+ export interface FlightBookingResult {
112
+ token: string;
113
+ googleFlightsUrl?: string;
114
+ links: FlightBookingLink[];
115
+ }