@tks/wayfinder 0.3.1 → 0.4.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/README.md CHANGED
@@ -58,6 +58,12 @@ Search one way flights:
58
58
  wayfinder flights --from SFO --to JFK --date 2026-04-10
59
59
  ```
60
60
 
61
+ Search up to 3 departure dates in one command:
62
+
63
+ ```bash
64
+ wayfinder flights --from SFO --to JFK --date 2026-04-10 --date 2026-04-11 --date 2026-04-13
65
+ ```
66
+
61
67
  Search with filters:
62
68
 
63
69
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tks/wayfinder",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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,9 @@
1
1
  import { getConfigPath, readConfigApiKey, resolveApiKey, writeConfigApiKey } from "./config";
2
2
  import { CliError } from "./errors";
3
- import { renderFlightTable, renderHotelTable, renderPlaceTable } from "./format";
3
+ import { renderFlightTable, renderFlightTablesByDate, renderHotelTable, renderPlaceTable } from "./format";
4
4
  import { parseCliArgs } from "./parse";
5
- import { searchFlightBookingOptions, searchFlights, searchHotels, searchPlaces } from "./serpapi";
6
- import { ExitCode } from "./types";
5
+ import { searchFlightBookingOptions, searchFlights, searchFlightsMultiDate, searchHotels, searchPlaces } from "./serpapi";
6
+ import { ExitCode, FlightMultiDateQuery, FlightsQuery } from "./types";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
9
9
  import { existsSync, unlinkSync } from "node:fs";
@@ -22,7 +22,7 @@ interface RunOptions {
22
22
  promptImpl?: (prompt: string) => Promise<string>;
23
23
  }
24
24
 
25
- const HELP_TEXT = `wayfinder v0.3.1 travel search
25
+ const HELP_TEXT = `wayfinder v0.4.0 travel search
26
26
 
27
27
  Usage:
28
28
  wayfinder setup [--reset]
@@ -39,7 +39,7 @@ Setup:
39
39
  Flights required:
40
40
  --from <IATA> Origin airport code
41
41
  --to <IATA> Destination airport code
42
- --date <YYYY-MM-DD> Departure date
42
+ --date <YYYY-MM-DD> Departure date (repeat up to 3 unique dates)
43
43
 
44
44
  Flights optional filters:
45
45
  --airline <IATA> Airline code, example UA
@@ -135,26 +135,50 @@ export async function runWayfinder(
135
135
 
136
136
  const apiKey = resolveApiKey(env, homeDir);
137
137
  if (parsed.mode === "flights") {
138
- const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
138
+ if (isMultiDateFlightQuery(parsed.query)) {
139
+ const multiDateFlights = await searchFlightsMultiDate(parsed.query, apiKey, options.fetchImpl ?? fetch);
140
+ const nonEmptyDateResults = multiDateFlights.resultsByDate.filter((entry) => entry.results.length > 0);
139
141
 
140
- if (flights.options.length === 0) {
141
- throw new CliError("No flights found for the selected query", ExitCode.NoResults);
142
- }
142
+ if (nonEmptyDateResults.length === 0) {
143
+ throw new CliError("No flights found for the selected query", ExitCode.NoResults);
144
+ }
143
145
 
144
- if (parsed.outputJson) {
145
- output.stdout(
146
- JSON.stringify(
147
- {
148
- query: parsed.query,
149
- googleFlightsUrl: flights.googleFlightsUrl,
150
- results: flights.options,
151
- },
152
- null,
153
- 2,
154
- ),
155
- );
146
+ if (parsed.outputJson) {
147
+ output.stdout(
148
+ JSON.stringify(
149
+ {
150
+ query: parsed.query,
151
+ resultsByDate: nonEmptyDateResults,
152
+ },
153
+ null,
154
+ 2,
155
+ ),
156
+ );
157
+ } else {
158
+ output.stdout(renderFlightTablesByDate(nonEmptyDateResults));
159
+ }
156
160
  } else {
157
- output.stdout(renderFlightTable(flights.options));
161
+ const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
162
+
163
+ if (flights.options.length === 0) {
164
+ throw new CliError("No flights found for the selected query", ExitCode.NoResults);
165
+ }
166
+
167
+ if (parsed.outputJson) {
168
+ output.stdout(
169
+ JSON.stringify(
170
+ {
171
+ query: parsed.query,
172
+ googleFlightsUrl: flights.googleFlightsUrl,
173
+ results: flights.options,
174
+ },
175
+ null,
176
+ 2,
177
+ ),
178
+ );
179
+ } else {
180
+ output.stdout(renderFlightTable(flights.options));
181
+ }
158
182
  }
159
183
  } else if (parsed.mode === "flight-booking") {
160
184
  const bookingResults = await searchFlightBookingOptions(
@@ -340,3 +364,7 @@ async function promptWithFallback(
340
364
  rl.close();
341
365
  }
342
366
  }
367
+
368
+ function isMultiDateFlightQuery(query: FlightsQuery): query is FlightMultiDateQuery {
369
+ return "departureDates" in query;
370
+ }
package/src/format.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FlightOption, HotelOption, PlaceOption } from "./types";
1
+ import { FlightDateResult, FlightOption, HotelOption, PlaceOption } from "./types";
2
2
 
3
3
  const currencyFormatter = new Intl.NumberFormat("en-US", {
4
4
  style: "currency",
@@ -74,6 +74,19 @@ export function renderFlightTable(options: FlightOption[]): string {
74
74
  return lines.join("\n");
75
75
  }
76
76
 
77
+ export function renderFlightTablesByDate(resultsByDate: FlightDateResult[]): string {
78
+ const sections = resultsByDate.map((result) => {
79
+ const lines = [`DATE: ${result.date}`];
80
+ if (typeof result.googleFlightsUrl === "string") {
81
+ lines.push(`GOOGLE FLIGHTS: ${result.googleFlightsUrl}`);
82
+ }
83
+ lines.push(renderFlightTable(result.results));
84
+ return lines.join("\n");
85
+ });
86
+
87
+ return sections.join("\n\n");
88
+ }
89
+
77
90
  export function renderHotelTable(options: HotelOption[]): string {
78
91
  const rows = options.map((option) => ({
79
92
  nightly: currencyFormatter.format(option.nightlyPrice),
package/src/parse.ts CHANGED
@@ -2,7 +2,7 @@ import { CliError } from "./errors";
2
2
  import {
3
3
  ExitCode,
4
4
  FlightBookingQuery,
5
- FlightQuery,
5
+ FlightsQuery,
6
6
  HotelQuery,
7
7
  ParsedArgs,
8
8
  PlaceQuery,
@@ -13,7 +13,7 @@ import {
13
13
  interface FlightRawOptions {
14
14
  from?: string;
15
15
  to?: string;
16
- date?: string;
16
+ dates: string[];
17
17
  airline?: string;
18
18
  maxStops?: string;
19
19
  maxPrice?: string;
@@ -88,6 +88,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
88
88
 
89
89
  function parseFlightsArgs(args: string[]): ParsedArgs {
90
90
  const raw: FlightRawOptions = {
91
+ dates: [],
91
92
  excludeBasic: false,
92
93
  outputJson: false,
93
94
  help: false,
@@ -128,7 +129,7 @@ function parseFlightsArgs(args: string[]): ParsedArgs {
128
129
  raw.to = value;
129
130
  break;
130
131
  case "--date":
131
- raw.date = value;
132
+ raw.dates.push(value);
132
133
  break;
133
134
  case "--airline":
134
135
  raw.airline = value;
@@ -382,8 +383,8 @@ function parseSetupArgs(args: string[]): ParsedArgs {
382
383
  };
383
384
  }
384
385
 
385
- function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
386
- if (!raw.from || !raw.to || !raw.date) {
386
+ function buildFlightQuery(raw: FlightRawOptions): FlightsQuery {
387
+ if (!raw.from || !raw.to || raw.dates.length === 0) {
387
388
  throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
388
389
  }
389
390
 
@@ -394,7 +395,14 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
394
395
  throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
395
396
  }
396
397
 
397
- const departureDate = normalizeDate(raw.date, "departure");
398
+ const departureDates = [...new Set(raw.dates.map((date) => normalizeDate(date, "departure")))];
399
+ if (departureDates.length > 3) {
400
+ throw new CliError(
401
+ "Too many dates: maximum 3 unique --date values per search",
402
+ ExitCode.InvalidInput,
403
+ );
404
+ }
405
+
398
406
  const airlineCode = raw.airline ? normalizeAirlineCode(raw.airline) : undefined;
399
407
  const maxStops = raw.maxStops ? normalizeMaxStops(raw.maxStops) : undefined;
400
408
  const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
@@ -424,10 +432,9 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
424
432
  }
425
433
  }
426
434
 
427
- return {
435
+ const shared = {
428
436
  origin,
429
437
  destination,
430
- departureDate,
431
438
  airlineCode,
432
439
  maxStops,
433
440
  maxPrice,
@@ -435,6 +442,18 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
435
442
  departureBeforeMinutes,
436
443
  excludeBasic: raw.excludeBasic || undefined,
437
444
  };
445
+
446
+ if (departureDates.length === 1) {
447
+ return {
448
+ ...shared,
449
+ departureDate: departureDates[0] as string,
450
+ };
451
+ }
452
+
453
+ return {
454
+ ...shared,
455
+ departureDates,
456
+ };
438
457
  }
439
458
 
440
459
  function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
package/src/serpapi.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { CliError } from "./errors";
2
2
  import {
3
3
  ExitCode,
4
+ FlightDateResult,
4
5
  FlightBookingQuery,
5
6
  FlightBookingResult,
7
+ FlightMultiDateQuery,
8
+ FlightMultiDateSearchResult,
6
9
  FlightOption,
7
10
  FlightQuery,
8
11
  FlightSearchResult,
@@ -134,6 +137,38 @@ export async function searchFlights(
134
137
  };
135
138
  }
136
139
 
140
+ export async function searchFlightsMultiDate(
141
+ query: FlightMultiDateQuery,
142
+ apiKey: string,
143
+ fetchImpl: typeof fetch = fetch,
144
+ ): Promise<FlightMultiDateSearchResult> {
145
+ const resultsByDate = await mapWithConcurrency(query.departureDates, 2, async (date) => {
146
+ const singleDateQuery: FlightQuery = {
147
+ origin: query.origin,
148
+ destination: query.destination,
149
+ departureDate: date,
150
+ airlineCode: query.airlineCode,
151
+ maxStops: query.maxStops,
152
+ maxPrice: query.maxPrice,
153
+ departureAfterMinutes: query.departureAfterMinutes,
154
+ departureBeforeMinutes: query.departureBeforeMinutes,
155
+ excludeBasic: query.excludeBasic,
156
+ };
157
+
158
+ const result = await searchFlights(singleDateQuery, apiKey, fetchImpl);
159
+ const grouped: FlightDateResult = {
160
+ date,
161
+ results: result.options,
162
+ googleFlightsUrl: result.googleFlightsUrl,
163
+ };
164
+ return grouped;
165
+ });
166
+
167
+ return {
168
+ resultsByDate,
169
+ };
170
+ }
171
+
137
172
  export async function searchFlightBookingOptions(
138
173
  query: FlightBookingQuery,
139
174
  apiKey: string,
@@ -685,3 +720,33 @@ function collectBookingLinks(
685
720
  collectBookingLinks(child, links);
686
721
  }
687
722
  }
723
+
724
+ async function mapWithConcurrency<TInput, TOutput>(
725
+ items: TInput[],
726
+ concurrency: number,
727
+ worker: (item: TInput, index: number) => Promise<TOutput>,
728
+ ): Promise<TOutput[]> {
729
+ const output: TOutput[] = new Array(items.length);
730
+ let cursor = 0;
731
+
732
+ async function runWorker(): Promise<void> {
733
+ while (true) {
734
+ const index = cursor;
735
+ cursor += 1;
736
+
737
+ if (index >= items.length) {
738
+ return;
739
+ }
740
+
741
+ output[index] = await worker(items[index] as TInput, index);
742
+ }
743
+ }
744
+
745
+ const workers = Array.from(
746
+ { length: Math.min(Math.max(concurrency, 1), items.length) },
747
+ () => runWorker(),
748
+ );
749
+
750
+ await Promise.all(workers);
751
+ return output;
752
+ }
package/src/types.ts CHANGED
@@ -21,6 +21,20 @@ export interface FlightQuery {
21
21
  excludeBasic?: boolean;
22
22
  }
23
23
 
24
+ export interface FlightMultiDateQuery {
25
+ origin: string;
26
+ destination: string;
27
+ departureDates: string[];
28
+ airlineCode?: string;
29
+ maxStops?: number;
30
+ maxPrice?: number;
31
+ departureAfterMinutes?: number;
32
+ departureBeforeMinutes?: number;
33
+ excludeBasic?: boolean;
34
+ }
35
+
36
+ export type FlightsQuery = FlightQuery | FlightMultiDateQuery;
37
+
24
38
  export interface HotelQuery {
25
39
  location: string;
26
40
  checkInDate: string;
@@ -49,7 +63,7 @@ export interface ParsedArgsFlights {
49
63
  help: false;
50
64
  mode: "flights";
51
65
  outputJson: boolean;
52
- query: FlightQuery;
66
+ query: FlightsQuery;
53
67
  }
54
68
 
55
69
  export interface ParsedArgsHotels {
@@ -133,6 +147,16 @@ export interface FlightSearchResult {
133
147
  googleFlightsUrl?: string;
134
148
  }
135
149
 
150
+ export interface FlightDateResult {
151
+ date: string;
152
+ results: FlightOption[];
153
+ googleFlightsUrl?: string;
154
+ }
155
+
156
+ export interface FlightMultiDateSearchResult {
157
+ resultsByDate: FlightDateResult[];
158
+ }
159
+
136
160
  export interface FlightBookingLink {
137
161
  url: string;
138
162
  source?: string;