@tks/wayfinder 0.2.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
@@ -59,6 +59,12 @@ Structured output for scripting:
59
59
  wayfinder flights --from SFO --to JFK --date 2026-04-10 --json | jq '.results[] | {price,airline,stops}'
60
60
  ```
61
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
+
62
68
  Search hotels:
63
69
 
64
70
  ```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.1",
4
4
  "description": "Travel search for your terminal and your AI agents",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@ import { resolveApiKey } 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
7
 
8
8
  interface Output {
@@ -22,6 +22,7 @@ const HELP_TEXT = `wayfinder v0.2.0 travel search
22
22
  Usage:
23
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]
25
26
  wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
26
27
 
27
28
  Flights required:
@@ -37,6 +38,13 @@ Flights optional filters:
37
38
  --depart-before <HH:MM> End of departure window
38
39
  --exclude-basic Exclude basic economy fares
39
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
+
40
48
  Hotels required:
41
49
  --where <QUERY> Destination or hotel search query
42
50
  --check-in <YYYY-MM-DD> Check-in date
@@ -71,7 +79,7 @@ export async function runWayfinder(
71
79
  if (parsed.mode === "flights") {
72
80
  const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
73
81
 
74
- if (flights.length === 0) {
82
+ if (flights.options.length === 0) {
75
83
  throw new CliError("No flights found for the selected query", ExitCode.NoResults);
76
84
  }
77
85
 
@@ -80,14 +88,50 @@ export async function runWayfinder(
80
88
  JSON.stringify(
81
89
  {
82
90
  query: parsed.query,
83
- results: flights,
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,
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,
84
128
  },
85
129
  null,
86
130
  2,
87
131
  ),
88
132
  );
89
133
  } else {
90
- output.stdout(renderFlightTable(flights));
134
+ output.stdout(renderFlightBookingText(flightLinks));
91
135
  }
92
136
  } else {
93
137
  const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
@@ -128,3 +172,17 @@ if (import.meta.main) {
128
172
  const code = await runWayfinder(process.argv.slice(2));
129
173
  process.exitCode = code;
130
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/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";
30
39
 
31
40
  const HELP_FLAGS = new Set(["-h", "--help"]);
32
41
 
@@ -44,6 +53,10 @@ 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
+
47
60
  return parseFlightsArgs(args);
48
61
  }
49
62
 
@@ -197,6 +210,73 @@ function parseHotelsArgs(args: string[]): ParsedArgs {
197
210
  };
198
211
  }
199
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
+
200
280
  function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
201
281
  if (!raw.from || !raw.to || !raw.date) {
202
282
  throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
@@ -284,6 +364,31 @@ function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
284
364
  };
285
365
  }
286
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
+
287
392
  function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
288
393
  const args = [...argv];
289
394
  if (args[0] === "hotels") {
@@ -293,6 +398,11 @@ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] }
293
398
 
294
399
  if (args[0] === "flights") {
295
400
  args.shift();
401
+ if (args[0] === "booking") {
402
+ args.shift();
403
+ return { mode: "flight-booking", args };
404
+ }
405
+
296
406
  if (args[0] === "one-way") {
297
407
  args.shift();
298
408
  }
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,25 @@ 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 type ParsedArgs =
67
+ | ParsedArgsHelp
68
+ | ParsedArgsFlights
69
+ | ParsedArgsHotels
70
+ | ParsedArgsFlightBooking;
53
71
 
54
72
  export interface FlightOption {
55
73
  price: number;
@@ -58,6 +76,7 @@ export interface FlightOption {
58
76
  arrivalTime: string;
59
77
  durationMinutes: number;
60
78
  stops: number;
79
+ bookingToken?: string;
61
80
  }
62
81
 
63
82
  export interface HotelOption {
@@ -69,3 +88,20 @@ export interface HotelOption {
69
88
  location: string;
70
89
  link?: string;
71
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[];
107
+ }