@tks/wayfinder 0.1.0 → 0.2.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
@@ -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
+ ```bash
8
+ bun install -g @tks/wayfinder
9
+ ```
10
+
11
+ Or install from source:
12
+
7
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,41 @@ 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
+ Search hotels:
63
+
64
+ ```bash
65
+ wayfinder hotels --where "New York, NY" --check-in 2026-04-10 --check-out 2026-04-12
66
+ ```
67
+
68
+ Search hotels with filters:
69
+
70
+ ```bash
71
+ wayfinder hotels --where "Tokyo" --check-in 2026-04-10 --check-out 2026-04-13 --adults 2 --max-price 300 --rating 4
72
+ ```
73
+
74
+ Structured hotel output for scripting:
75
+
76
+ ```bash
77
+ wayfinder hotels --where "Paris" --check-in 2026-04-10 --check-out 2026-04-12 --json | jq '.results[] | {name,nightlyPrice,rating}'
48
78
  ```
package/package.json CHANGED
@@ -1,11 +1,20 @@
1
1
  {
2
2
  "name": "@tks/wayfinder",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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 { searchFlights, searchHotels } from "./serpapi";
6
6
  import { ExitCode } from "./types";
7
7
 
8
8
  interface Output {
@@ -17,23 +17,35 @@ 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 hotels --where "New York, NY" --check-in 2026-03-21 --check-out 2026-03-23 [filters]
25
26
 
26
- Required:
27
+ Flights required:
27
28
  --from <IATA> Origin airport code
28
29
  --to <IATA> Destination airport code
29
30
  --date <YYYY-MM-DD> Departure date
30
31
 
31
- Optional filters:
32
+ Flights optional filters:
32
33
  --airline <IATA> Airline code, example UA
33
34
  --max-stops <0|1|2> Maximum number of stops
34
35
  --max-price <USD> Max price in USD
35
36
  --depart-after <HH:MM> Start of departure window
36
37
  --depart-before <HH:MM> End of departure window
38
+ --exclude-basic Exclude basic economy fares
39
+
40
+ Hotels required:
41
+ --where <QUERY> Destination or hotel search query
42
+ --check-in <YYYY-MM-DD> Check-in date
43
+ --check-out <YYYY-MM-DD> Check-out date
44
+
45
+ Hotels optional filters:
46
+ --adults <N> Number of adults (default 2)
47
+ --max-price <USD> Max nightly rate in USD
48
+ --rating <3.5|4|4.5|5> Minimum guest rating
37
49
 
38
50
  Output:
39
51
  --json Print structured JSON output`;
@@ -56,25 +68,48 @@ export async function runWayfinder(
56
68
  }
57
69
 
58
70
  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
- ),
75
- );
71
+ if (parsed.mode === "flights") {
72
+ const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
73
+
74
+ if (flights.length === 0) {
75
+ throw new CliError("No flights found for the selected query", ExitCode.NoResults);
76
+ }
77
+
78
+ if (parsed.outputJson) {
79
+ output.stdout(
80
+ JSON.stringify(
81
+ {
82
+ query: parsed.query,
83
+ results: flights,
84
+ },
85
+ null,
86
+ 2,
87
+ ),
88
+ );
89
+ } else {
90
+ output.stdout(renderFlightTable(flights));
91
+ }
76
92
  } else {
77
- output.stdout(renderTable(flights));
93
+ const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
94
+
95
+ if (hotels.length === 0) {
96
+ throw new CliError("No hotels found for the selected query", ExitCode.NoResults);
97
+ }
98
+
99
+ if (parsed.outputJson) {
100
+ output.stdout(
101
+ JSON.stringify(
102
+ {
103
+ query: parsed.query,
104
+ results: hotels,
105
+ },
106
+ null,
107
+ 2,
108
+ ),
109
+ );
110
+ } else {
111
+ output.stdout(renderHotelTable(hotels));
112
+ }
78
113
  }
79
114
 
80
115
  return ExitCode.Success;
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, 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,46 @@ 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
+ type SearchMode = "flights" | "hotels";
30
+
17
31
  const HELP_FLAGS = new Set(["-h", "--help"]);
18
32
 
19
33
  export function parseCliArgs(argv: string[]): ParsedArgs {
20
- const args = stripSubcommands(argv);
21
- const raw: RawOptions = {
34
+ if (argv.some((token) => HELP_FLAGS.has(token))) {
35
+ return {
36
+ help: true,
37
+ outputJson: argv.includes("--json"),
38
+ };
39
+ }
40
+
41
+ const { mode, args } = stripSubcommands(argv);
42
+
43
+ if (mode === "hotels") {
44
+ return parseHotelsArgs(args);
45
+ }
46
+
47
+ return parseFlightsArgs(args);
48
+ }
49
+
50
+ function parseFlightsArgs(args: string[]): ParsedArgs {
51
+ const raw: FlightRawOptions = {
52
+ excludeBasic: false,
22
53
  outputJson: false,
23
54
  help: false,
24
55
  };
@@ -36,6 +67,11 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
36
67
  continue;
37
68
  }
38
69
 
70
+ if (token === "--exclude-basic") {
71
+ raw.excludeBasic = true;
72
+ continue;
73
+ }
74
+
39
75
  if (!token.startsWith("--")) {
40
76
  throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
41
77
  }
@@ -84,6 +120,84 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
84
120
  };
85
121
  }
86
122
 
123
+ return {
124
+ help: false,
125
+ mode: "flights",
126
+ outputJson: raw.outputJson,
127
+ query: buildFlightQuery(raw),
128
+ };
129
+ }
130
+
131
+ function parseHotelsArgs(args: string[]): ParsedArgs {
132
+ const raw: HotelRawOptions = {
133
+ outputJson: false,
134
+ help: false,
135
+ };
136
+
137
+ for (let i = 0; i < args.length; i += 1) {
138
+ const token = args[i];
139
+
140
+ if (HELP_FLAGS.has(token)) {
141
+ raw.help = true;
142
+ continue;
143
+ }
144
+
145
+ if (token === "--json") {
146
+ raw.outputJson = true;
147
+ continue;
148
+ }
149
+
150
+ if (!token.startsWith("--")) {
151
+ throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
152
+ }
153
+
154
+ const value = args[i + 1];
155
+ if (!value || value.startsWith("--")) {
156
+ throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
157
+ }
158
+
159
+ switch (token) {
160
+ case "--where":
161
+ raw.where = value;
162
+ break;
163
+ case "--check-in":
164
+ raw.checkIn = value;
165
+ break;
166
+ case "--check-out":
167
+ raw.checkOut = value;
168
+ break;
169
+ case "--adults":
170
+ raw.adults = value;
171
+ break;
172
+ case "--max-price":
173
+ raw.maxPrice = value;
174
+ break;
175
+ case "--rating":
176
+ raw.rating = value;
177
+ break;
178
+ default:
179
+ throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
180
+ }
181
+
182
+ i += 1;
183
+ }
184
+
185
+ if (raw.help) {
186
+ return {
187
+ help: true,
188
+ outputJson: raw.outputJson,
189
+ };
190
+ }
191
+
192
+ return {
193
+ help: false,
194
+ mode: "hotels",
195
+ outputJson: raw.outputJson,
196
+ query: buildHotelQuery(raw),
197
+ };
198
+ }
199
+
200
+ function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
87
201
  if (!raw.from || !raw.to || !raw.date) {
88
202
  throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
89
203
  }
@@ -95,7 +209,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
95
209
  throw new CliError("Origin and destination must be different airports", ExitCode.InvalidInput);
96
210
  }
97
211
 
98
- const departureDate = normalizeDate(raw.date);
212
+ const departureDate = normalizeDate(raw.date, "departure");
99
213
  const airlineCode = raw.airline ? normalizeAirlineCode(raw.airline) : undefined;
100
214
  const maxStops = raw.maxStops ? normalizeMaxStops(raw.maxStops) : undefined;
101
215
  const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
@@ -126,32 +240,70 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
126
240
  }
127
241
 
128
242
  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
- },
243
+ origin,
244
+ destination,
245
+ departureDate,
246
+ airlineCode,
247
+ maxStops,
248
+ maxPrice,
249
+ departureAfterMinutes,
250
+ departureBeforeMinutes,
251
+ excludeBasic: raw.excludeBasic || undefined,
141
252
  };
142
253
  }
143
254
 
144
- function stripSubcommands(argv: string[]): string[] {
255
+ function buildHotelQuery(raw: HotelRawOptions): HotelQuery {
256
+ if (!raw.where || !raw.checkIn || !raw.checkOut) {
257
+ throw new CliError(
258
+ "Missing required flags: --where, --check-in, --check-out",
259
+ ExitCode.InvalidInput,
260
+ );
261
+ }
262
+
263
+ const location = normalizeLocation(raw.where);
264
+ const checkInDate = normalizeDate(raw.checkIn, "check-in");
265
+ const checkOutDate = normalizeDate(raw.checkOut, "check-out");
266
+ const adults = raw.adults ? normalizeAdults(raw.adults) : 2;
267
+ const maxPrice = raw.maxPrice ? normalizeMaxPrice(raw.maxPrice) : undefined;
268
+ const minRating = raw.rating ? normalizeMinRating(raw.rating) : undefined;
269
+
270
+ const checkIn = parseDateOnly(checkInDate);
271
+ const checkOut = parseDateOnly(checkOutDate);
272
+
273
+ if (checkOut <= checkIn) {
274
+ throw new CliError("Check-out date must be after check-in date", ExitCode.InvalidInput);
275
+ }
276
+
277
+ return {
278
+ location,
279
+ checkInDate,
280
+ checkOutDate,
281
+ adults,
282
+ maxPrice,
283
+ minRating,
284
+ };
285
+ }
286
+
287
+ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
145
288
  const args = [...argv];
289
+ if (args[0] === "hotels") {
290
+ args.shift();
291
+ return { mode: "hotels", args };
292
+ }
146
293
 
147
294
  if (args[0] === "flights") {
148
295
  args.shift();
149
296
  if (args[0] === "one-way") {
150
297
  args.shift();
151
298
  }
299
+
300
+ return { mode: "flights", args };
152
301
  }
153
302
 
154
- return args;
303
+ throw new CliError(
304
+ "Missing subcommand: use `flights` or `hotels`",
305
+ ExitCode.InvalidInput,
306
+ );
155
307
  }
156
308
 
157
309
  function normalizeAirport(value: string, fieldName: "origin" | "destination"): string {
@@ -176,27 +328,43 @@ function normalizeAirlineCode(value: string): string {
176
328
  return upper;
177
329
  }
178
330
 
179
- function normalizeDate(value: string): string {
331
+ function normalizeLocation(value: string): string {
332
+ const trimmed = value.trim();
333
+ if (trimmed.length === 0) {
334
+ throw new CliError("Location cannot be empty", ExitCode.InvalidInput);
335
+ }
336
+
337
+ return trimmed;
338
+ }
339
+
340
+ function normalizeDate(value: string, fieldName: "departure" | "check-in" | "check-out"): string {
180
341
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
181
342
  if (!match) {
182
343
  throw new CliError("Invalid date. Expected YYYY-MM-DD", ExitCode.InvalidInput);
183
344
  }
184
345
 
346
+ const date = parseDateOnly(value);
185
347
  const year = Number(match[1]);
186
348
  const month = Number(match[2]);
187
349
  const day = Number(match[3]);
188
350
 
189
- const date = new Date(year, month - 1, day);
190
351
  if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
191
- throw new CliError("Invalid departure date", ExitCode.InvalidInput);
352
+ if (fieldName === "departure") {
353
+ throw new CliError("Invalid departure date", ExitCode.InvalidInput);
354
+ }
355
+
356
+ throw new CliError(`Invalid ${fieldName} date`, ExitCode.InvalidInput);
192
357
  }
193
358
 
194
- const today = new Date();
195
- today.setHours(0, 0, 0, 0);
196
- date.setHours(0, 0, 0, 0);
359
+ const now = new Date();
360
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
197
361
 
198
362
  if (date < today) {
199
- throw new CliError("Departure date cannot be in the past", ExitCode.InvalidInput);
363
+ if (fieldName === "departure") {
364
+ throw new CliError("Departure date cannot be in the past", ExitCode.InvalidInput);
365
+ }
366
+
367
+ throw new CliError(`${capitalize(fieldName)} date cannot be in the past`, ExitCode.InvalidInput);
200
368
  }
201
369
 
202
370
  return value;
@@ -218,6 +386,24 @@ function normalizeMaxPrice(value: string): number {
218
386
  return numeric;
219
387
  }
220
388
 
389
+ function normalizeAdults(value: string): number {
390
+ const numeric = Number.parseInt(value, 10);
391
+ if (!Number.isInteger(numeric) || numeric <= 0) {
392
+ throw new CliError("--adults must be a positive integer", ExitCode.InvalidInput);
393
+ }
394
+
395
+ return numeric;
396
+ }
397
+
398
+ function normalizeMinRating(value: string): 3.5 | 4 | 4.5 | 5 {
399
+ const numeric = Number.parseFloat(value);
400
+ if (numeric !== 3.5 && numeric !== 4 && numeric !== 4.5 && numeric !== 5) {
401
+ throw new CliError("--rating must be one of: 3.5, 4, 4.5, 5", ExitCode.InvalidInput);
402
+ }
403
+
404
+ return numeric;
405
+ }
406
+
221
407
  function normalizeTime(value: string, flagName: string): number {
222
408
  const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
223
409
  if (!match) {
@@ -226,3 +412,12 @@ function normalizeTime(value: string, flagName: string): number {
226
412
 
227
413
  return Number(match[1]) * 60 + Number(match[2]);
228
414
  }
415
+
416
+ function parseDateOnly(value: string): Date {
417
+ const [year, month, day] = value.split("-").map((part) => Number(part));
418
+ return new Date(year, month - 1, day);
419
+ }
420
+
421
+ function capitalize(value: string): string {
422
+ return value.slice(0, 1).toUpperCase() + value.slice(1);
423
+ }
package/src/serpapi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "./errors";
2
- import { ExitCode, FlightOption, FlightQuery } from "./types";
2
+ import { ExitCode, FlightOption, FlightQuery, HotelOption, HotelQuery } from "./types";
3
3
 
4
4
  interface SerpApiAirport {
5
5
  time?: string;
@@ -18,42 +18,44 @@ interface SerpApiItinerary {
18
18
  flights?: SerpApiSegment[];
19
19
  }
20
20
 
21
- interface SerpApiResponse {
21
+ interface SerpApiFlightsResponse {
22
22
  error?: string;
23
23
  best_flights?: SerpApiItinerary[];
24
24
  other_flights?: SerpApiItinerary[];
25
25
  }
26
26
 
27
+ interface SerpApiPrice {
28
+ lowest?: number;
29
+ extracted_lowest?: number;
30
+ }
31
+
32
+ interface SerpApiHotelProperty {
33
+ name?: string;
34
+ rate_per_night?: SerpApiPrice;
35
+ total_rate?: SerpApiPrice;
36
+ overall_rating?: number;
37
+ reviews?: number;
38
+ description?: string;
39
+ type?: string;
40
+ link?: string;
41
+ serpapi_property_details_link?: string;
42
+ google_property_details_link?: string;
43
+ }
44
+
45
+ interface SerpApiHotelsResponse {
46
+ error?: string;
47
+ properties?: SerpApiHotelProperty[];
48
+ }
49
+
27
50
  export async function searchFlights(
28
51
  query: FlightQuery,
29
52
  apiKey: string,
30
53
  fetchImpl: typeof fetch = fetch,
31
54
  ): Promise<FlightOption[]> {
32
- const requestUrl = buildRequestUrl(query, apiKey);
33
-
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
- }
43
-
44
- throw new CliError("Failed to reach SerpApi", ExitCode.ApiFailure);
45
- }
46
-
47
- if (!response.ok) {
48
- throw new CliError(`SerpApi request failed with status ${response.status}`, ExitCode.ApiFailure);
49
- }
50
-
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
- }
55
+ const payload = await fetchSerpApiJson<SerpApiFlightsResponse>(
56
+ buildFlightRequestUrl(query, apiKey),
57
+ fetchImpl,
58
+ );
57
59
 
58
60
  if (typeof payload.error === "string" && payload.error.trim() !== "") {
59
61
  throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
@@ -76,7 +78,26 @@ export async function searchFlights(
76
78
  return flights;
77
79
  }
78
80
 
79
- export function shapeSerpApiResponse(payload: SerpApiResponse): FlightOption[] {
81
+ export async function searchHotels(
82
+ query: HotelQuery,
83
+ apiKey: string,
84
+ fetchImpl: typeof fetch = fetch,
85
+ ): Promise<HotelOption[]> {
86
+ const payload = await fetchSerpApiJson<SerpApiHotelsResponse>(
87
+ buildHotelRequestUrl(query, apiKey),
88
+ fetchImpl,
89
+ );
90
+
91
+ if (typeof payload.error === "string" && payload.error.trim() !== "") {
92
+ throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
93
+ }
94
+
95
+ const hotels = shapeHotelSerpApiResponse(payload);
96
+ hotels.sort((a, b) => a.nightlyPrice - b.nightlyPrice);
97
+ return hotels;
98
+ }
99
+
100
+ export function shapeSerpApiResponse(payload: SerpApiFlightsResponse): FlightOption[] {
80
101
  const merged = [...(payload.best_flights ?? []), ...(payload.other_flights ?? [])];
81
102
 
82
103
  return merged
@@ -84,6 +105,12 @@ export function shapeSerpApiResponse(payload: SerpApiResponse): FlightOption[] {
84
105
  .filter((option): option is FlightOption => option !== null);
85
106
  }
86
107
 
108
+ export function shapeHotelSerpApiResponse(payload: SerpApiHotelsResponse): HotelOption[] {
109
+ return (payload.properties ?? [])
110
+ .map((property) => shapeHotelProperty(property))
111
+ .filter((option): option is HotelOption => option !== null);
112
+ }
113
+
87
114
  export function filterByDepartureWindow(
88
115
  options: FlightOption[],
89
116
  minMinutes: number,
@@ -131,6 +158,34 @@ function shapeItinerary(itinerary: SerpApiItinerary): FlightOption | null {
131
158
  };
132
159
  }
133
160
 
161
+ function shapeHotelProperty(property: SerpApiHotelProperty): HotelOption | null {
162
+ if (typeof property.name !== "string" || property.name.trim() === "") {
163
+ return null;
164
+ }
165
+
166
+ const nightlyPrice = extractLowestPrice(property.rate_per_night);
167
+ if (typeof nightlyPrice !== "number") {
168
+ return null;
169
+ }
170
+
171
+ const totalPrice = extractLowestPrice(property.total_rate);
172
+ const rating = Number.isFinite(property.overall_rating) ? property.overall_rating : undefined;
173
+ const reviews = Number.isFinite(property.reviews) ? property.reviews : undefined;
174
+ const location = property.description?.trim() || property.type?.trim() || "n/a";
175
+ const link =
176
+ property.link || property.serpapi_property_details_link || property.google_property_details_link;
177
+
178
+ return {
179
+ name: property.name.trim(),
180
+ nightlyPrice,
181
+ totalPrice,
182
+ rating,
183
+ reviews,
184
+ location,
185
+ link,
186
+ };
187
+ }
188
+
134
189
  function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegment[]): number {
135
190
  if (Number.isFinite(itinerary.total_duration)) {
136
191
  return itinerary.total_duration as number;
@@ -146,7 +201,7 @@ function inferDurationMinutes(itinerary: SerpApiItinerary, segments: SerpApiSegm
146
201
  return segmentDuration;
147
202
  }
148
203
 
149
- function buildRequestUrl(query: FlightQuery, apiKey: string): string {
204
+ function buildFlightRequestUrl(query: FlightQuery, apiKey: string): string {
150
205
  const url = new URL("https://serpapi.com/search.json");
151
206
 
152
207
  url.searchParams.set("engine", "google_flights");
@@ -183,6 +238,34 @@ function buildRequestUrl(query: FlightQuery, apiKey: string): string {
183
238
  url.searchParams.set("outbound_times", `${minHour},${maxHour}`);
184
239
  }
185
240
 
241
+ if (query.excludeBasic) {
242
+ url.searchParams.set("exclude_basic", "true");
243
+ url.searchParams.set("travel_class", "1");
244
+ url.searchParams.set("gl", "us");
245
+ }
246
+
247
+ return url.toString();
248
+ }
249
+
250
+ function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
251
+ const url = new URL("https://serpapi.com/search.json");
252
+
253
+ url.searchParams.set("engine", "google_hotels");
254
+ url.searchParams.set("q", query.location);
255
+ url.searchParams.set("check_in_date", query.checkInDate);
256
+ url.searchParams.set("check_out_date", query.checkOutDate);
257
+ url.searchParams.set("adults", String(query.adults));
258
+ url.searchParams.set("currency", "USD");
259
+ url.searchParams.set("api_key", apiKey);
260
+
261
+ if (typeof query.maxPrice === "number") {
262
+ url.searchParams.set("max_price", String(query.maxPrice));
263
+ }
264
+
265
+ if (typeof query.minRating === "number") {
266
+ url.searchParams.set("rating", toSerpApiRating(query.minRating));
267
+ }
268
+
186
269
  return url.toString();
187
270
  }
188
271
 
@@ -198,6 +281,62 @@ function toSerpApiStopsFilter(maxStops: number): string {
198
281
  return "3";
199
282
  }
200
283
 
284
+ function toSerpApiRating(rating: 3.5 | 4 | 4.5 | 5): string {
285
+ if (rating === 3.5) {
286
+ return "7";
287
+ }
288
+
289
+ if (rating === 4) {
290
+ return "8";
291
+ }
292
+
293
+ if (rating === 4.5) {
294
+ return "9";
295
+ }
296
+
297
+ return "10";
298
+ }
299
+
300
+ async function fetchSerpApiJson<T>(
301
+ requestUrl: string,
302
+ fetchImpl: typeof fetch,
303
+ ): Promise<T> {
304
+ let response: Response;
305
+ try {
306
+ response = await fetchImpl(requestUrl, {
307
+ signal: AbortSignal.timeout(15_000),
308
+ });
309
+ } catch (error) {
310
+ if (error instanceof Error && error.name === "TimeoutError") {
311
+ throw new CliError("SerpApi request timed out", ExitCode.ApiFailure);
312
+ }
313
+
314
+ throw new CliError("Failed to reach SerpApi", ExitCode.ApiFailure);
315
+ }
316
+
317
+ if (!response.ok) {
318
+ throw new CliError(`SerpApi request failed with status ${response.status}`, ExitCode.ApiFailure);
319
+ }
320
+
321
+ try {
322
+ return (await response.json()) as T;
323
+ } catch {
324
+ throw new CliError("SerpApi returned invalid JSON", ExitCode.ApiFailure);
325
+ }
326
+ }
327
+
328
+ function extractLowestPrice(value?: SerpApiPrice): number | undefined {
329
+ if (Number.isFinite(value?.lowest)) {
330
+ return value?.lowest;
331
+ }
332
+
333
+ if (Number.isFinite(value?.extracted_lowest)) {
334
+ return value?.extracted_lowest;
335
+ }
336
+
337
+ return undefined;
338
+ }
339
+
201
340
  function extractMinutes(value: string): number | null {
202
341
  const match = /\b([01]?\d|2[0-3]):([0-5]\d)\b/.exec(value);
203
342
  if (!match) {
package/src/types.ts CHANGED
@@ -18,14 +18,39 @@ 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";
25
41
  outputJson: boolean;
26
- help: boolean;
42
+ query: FlightQuery;
27
43
  }
28
44
 
45
+ export interface ParsedArgsHotels {
46
+ help: false;
47
+ mode: "hotels";
48
+ outputJson: boolean;
49
+ query: HotelQuery;
50
+ }
51
+
52
+ export type ParsedArgs = ParsedArgsHelp | ParsedArgsFlights | ParsedArgsHotels;
53
+
29
54
  export interface FlightOption {
30
55
  price: number;
31
56
  airline: string;
@@ -34,3 +59,13 @@ export interface FlightOption {
34
59
  durationMinutes: number;
35
60
  stops: number;
36
61
  }
62
+
63
+ export interface HotelOption {
64
+ name: string;
65
+ nightlyPrice: number;
66
+ totalPrice?: number;
67
+ rating?: number;
68
+ reviews?: number;
69
+ location: string;
70
+ link?: string;
71
+ }