@tks/wayfinder 0.3.0 → 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
@@ -118,6 +124,12 @@ Search nearby coffee spots:
118
124
  wayfinder places --near "Shinjuku, Tokyo" --type coffee --limit 5
119
125
  ```
120
126
 
127
+ Narrow results to walking-distance intent:
128
+
129
+ ```bash
130
+ wayfinder places --near "Domino Park, Brooklyn, NY" --range walk
131
+ ```
132
+
121
133
  Structured places output for scripting:
122
134
 
123
135
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tks/wayfinder",
3
- "version": "0.3.0",
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.0 travel search
25
+ const HELP_TEXT = `wayfinder v0.4.0 travel search
26
26
 
27
27
  Usage:
28
28
  wayfinder setup [--reset]
@@ -30,7 +30,7 @@ Usage:
30
30
  wayfinder flights one-way --from SFO --to JFK --date 2026-03-21 [filters]
31
31
  wayfinder flights booking --from SFO --to JFK --date 2026-03-21 --token <BOOKING_TOKEN> [--token <BOOKING_TOKEN>] [--json]
32
32
  wayfinder hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
33
- wayfinder places --near "Shinjuku, Tokyo" [--type restaurant|coffee] [--limit N] [--json]
33
+ wayfinder places --near "Shinjuku, Tokyo" [--type restaurant|coffee] [--range walk] [--limit N] [--json]
34
34
 
35
35
  Setup:
36
36
  Runs interactive key setup and stores your SerpApi key in local config.
@@ -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
@@ -72,6 +72,7 @@ Places required:
72
72
 
73
73
  Places optional filters:
74
74
  --type <restaurant|coffee> Place type (default restaurant)
75
+ --range <walk> Heuristic nearby scope; "walk" narrows to walking-distance intent
75
76
  --limit <N> Maximum number of results (default 10)
76
77
 
77
78
  Output:
@@ -134,26 +135,50 @@ export async function runWayfinder(
134
135
 
135
136
  const apiKey = resolveApiKey(env, homeDir);
136
137
  if (parsed.mode === "flights") {
137
- 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);
138
141
 
139
- if (flights.options.length === 0) {
140
- throw new CliError("No flights found for the selected query", ExitCode.NoResults);
141
- }
142
+ if (nonEmptyDateResults.length === 0) {
143
+ throw new CliError("No flights found for the selected query", ExitCode.NoResults);
144
+ }
142
145
 
143
- if (parsed.outputJson) {
144
- output.stdout(
145
- JSON.stringify(
146
- {
147
- query: parsed.query,
148
- googleFlightsUrl: flights.googleFlightsUrl,
149
- results: flights.options,
150
- },
151
- null,
152
- 2,
153
- ),
154
- );
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
+ }
155
160
  } else {
156
- 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
+ }
157
182
  }
158
183
  } else if (parsed.mode === "flight-booking") {
159
184
  const bookingResults = await searchFlightBookingOptions(
@@ -339,3 +364,7 @@ async function promptWithFallback(
339
364
  rl.close();
340
365
  }
341
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,17 +2,18 @@ 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,
9
+ PlaceRange,
9
10
  PlaceType,
10
11
  } from "./types";
11
12
 
12
13
  interface FlightRawOptions {
13
14
  from?: string;
14
15
  to?: string;
15
- date?: string;
16
+ dates: string[];
16
17
  airline?: string;
17
18
  maxStops?: string;
18
19
  maxPrice?: string;
@@ -47,6 +48,7 @@ interface PlaceRawOptions {
47
48
  near?: string;
48
49
  type?: string;
49
50
  limit?: string;
51
+ range?: string;
50
52
  outputJson: boolean;
51
53
  help: boolean;
52
54
  }
@@ -86,6 +88,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
86
88
 
87
89
  function parseFlightsArgs(args: string[]): ParsedArgs {
88
90
  const raw: FlightRawOptions = {
91
+ dates: [],
89
92
  excludeBasic: false,
90
93
  outputJson: false,
91
94
  help: false,
@@ -126,7 +129,7 @@ function parseFlightsArgs(args: string[]): ParsedArgs {
126
129
  raw.to = value;
127
130
  break;
128
131
  case "--date":
129
- raw.date = value;
132
+ raw.dates.push(value);
130
133
  break;
131
134
  case "--airline":
132
135
  raw.airline = value;
@@ -339,6 +342,9 @@ function parsePlacesArgs(args: string[]): ParsedArgs {
339
342
  case "--limit":
340
343
  raw.limit = value;
341
344
  break;
345
+ case "--range":
346
+ raw.range = value;
347
+ break;
342
348
  default:
343
349
  throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
344
350
  }
@@ -377,8 +383,8 @@ function parseSetupArgs(args: string[]): ParsedArgs {
377
383
  };
378
384
  }
379
385
 
380
- function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
381
- if (!raw.from || !raw.to || !raw.date) {
386
+ function buildFlightQuery(raw: FlightRawOptions): FlightsQuery {
387
+ if (!raw.from || !raw.to || raw.dates.length === 0) {
382
388
  throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
383
389
  }
384
390
 
@@ -389,7 +395,14 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
389
395
  throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
390
396
  }
391
397
 
392
- 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
+
393
406
  const airlineCode = raw.airline ? normalizeAirlineCode(raw.airline) : undefined;
394
407
  const maxStops = raw.maxStops ? normalizeMaxStops(raw.maxStops) : undefined;
395
408
  const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
@@ -419,10 +432,9 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
419
432
  }
420
433
  }
421
434
 
422
- return {
435
+ const shared = {
423
436
  origin,
424
437
  destination,
425
- departureDate,
426
438
  airlineCode,
427
439
  maxStops,
428
440
  maxPrice,
@@ -430,6 +442,18 @@ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
430
442
  departureBeforeMinutes,
431
443
  excludeBasic: raw.excludeBasic || undefined,
432
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
+ };
433
457
  }
434
458
 
435
459
  function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
@@ -497,11 +521,13 @@ function buildPlaceQuery(raw: PlaceRawOptions): PlaceQuery {
497
521
  const near = normalizeLocation(raw.near);
498
522
  const type = raw.type ? normalizePlaceType(raw.type) : "restaurant";
499
523
  const limit = raw.limit ? normalizeLimit(raw.limit, "--limit") : 10;
524
+ const range = raw.range ? normalizePlaceRange(raw.range) : undefined;
500
525
 
501
526
  return {
502
527
  near,
503
528
  type,
504
529
  limit,
530
+ range,
505
531
  };
506
532
  }
507
533
 
@@ -658,6 +684,15 @@ function normalizePlaceType(value: string): PlaceType {
658
684
  return normalized;
659
685
  }
660
686
 
687
+ function normalizePlaceRange(value: string): PlaceRange {
688
+ const normalized = value.trim().toLowerCase();
689
+ if (normalized !== "walk") {
690
+ throw new CliError("--range must be: walk", ExitCode.InvalidInput);
691
+ }
692
+
693
+ return normalized;
694
+ }
695
+
661
696
  function normalizeTime(value: string, flagName: string): number {
662
697
  const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
663
698
  if (!match) {
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,
@@ -387,10 +422,11 @@ function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
387
422
  function buildPlaceRequestUrl(query: PlaceQuery, apiKey: string): string {
388
423
  const url = new URL("https://serpapi.com/search.json");
389
424
  const searchTerm = query.type === "coffee" ? "coffee" : "restaurants";
425
+ const rangePrefix = query.range === "walk" ? "within walking distance " : "";
390
426
 
391
427
  url.searchParams.set("engine", "google_maps");
392
428
  url.searchParams.set("type", "search");
393
- url.searchParams.set("q", `${searchTerm} near ${query.near}`);
429
+ url.searchParams.set("q", `${rangePrefix}${searchTerm} near ${query.near}`);
394
430
  url.searchParams.set("api_key", apiKey);
395
431
  return url.toString();
396
432
  }
@@ -684,3 +720,33 @@ function collectBookingLinks(
684
720
  collectBookingLinks(child, links);
685
721
  }
686
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;
@@ -31,11 +45,13 @@ export interface HotelQuery {
31
45
  }
32
46
 
33
47
  export type PlaceType = "restaurant" | "coffee";
48
+ export type PlaceRange = "walk";
34
49
 
35
50
  export interface PlaceQuery {
36
51
  near: string;
37
52
  type: PlaceType;
38
53
  limit: number;
54
+ range?: PlaceRange;
39
55
  }
40
56
 
41
57
  export interface ParsedArgsHelp {
@@ -47,7 +63,7 @@ export interface ParsedArgsFlights {
47
63
  help: false;
48
64
  mode: "flights";
49
65
  outputJson: boolean;
50
- query: FlightQuery;
66
+ query: FlightsQuery;
51
67
  }
52
68
 
53
69
  export interface ParsedArgsHotels {
@@ -131,6 +147,16 @@ export interface FlightSearchResult {
131
147
  googleFlightsUrl?: string;
132
148
  }
133
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
+
134
160
  export interface FlightBookingLink {
135
161
  url: string;
136
162
  source?: string;