@tks/wayfinder 0.2.1 → 0.3.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
@@ -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"
@@ -82,3 +99,27 @@ Structured hotel output for scripting:
82
99
  ```bash
83
100
  wayfinder hotels --where "Paris" --check-in 2026-04-10 --check-out 2026-04-12 --json | jq '.results[] | {name,nightlyPrice,rating}'
84
101
  ```
102
+
103
+ Search nearby restaurants from a location:
104
+
105
+ ```bash
106
+ wayfinder places --near "Shinjuku, Tokyo"
107
+ ```
108
+
109
+ Use a specific location name for better relevance:
110
+
111
+ ```bash
112
+ wayfinder places --near "Domino Park, Brooklyn, NY"
113
+ ```
114
+
115
+ Search nearby coffee spots:
116
+
117
+ ```bash
118
+ wayfinder places --near "Shinjuku, Tokyo" --type coffee --limit 5
119
+ ```
120
+
121
+ Structured places output for scripting:
122
+
123
+ ```bash
124
+ wayfinder places --near "Shinjuku, Tokyo" --type coffee --json | jq '.results[] | {name,rating,reviews,googleMapsUrl}'
125
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tks/wayfinder",
3
- "version": "0.2.1",
3
+ "version": "0.3.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,12 @@
1
- import { resolveApiKey } from "./config";
1
+ import { getConfigPath, readConfigApiKey, resolveApiKey, writeConfigApiKey } from "./config";
2
2
  import { CliError } from "./errors";
3
- import { renderFlightTable, renderHotelTable } from "./format";
3
+ import { renderFlightTable, renderHotelTable, renderPlaceTable } from "./format";
4
4
  import { parseCliArgs } from "./parse";
5
- import { searchFlightBookingOptions, searchFlights, searchHotels } from "./serpapi";
5
+ import { searchFlightBookingOptions, searchFlights, searchHotels, searchPlaces } 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.3.0 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]
25
31
  wayfinder flights booking --from SFO --to JFK --date 2026-03-21 --token <BOOKING_TOKEN> [--token <BOOKING_TOKEN>] [--json]
26
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]
34
+
35
+ Setup:
36
+ Runs interactive key setup and stores your SerpApi key in local config.
37
+ --reset Remove existing local config and reconfigure
27
38
 
28
39
  Flights required:
29
40
  --from <IATA> Origin airport code
@@ -55,6 +66,14 @@ Hotels optional filters:
55
66
  --max-price <USD> Max nightly rate in USD
56
67
  --rating <3.5|4|4.5|5> Minimum guest rating
57
68
 
69
+ Places required:
70
+ --near <QUERY> Specific location query, example "Domino Park, Brooklyn, NY"
71
+ Broad names can return mixed-city results
72
+
73
+ Places optional filters:
74
+ --type <restaurant|coffee> Place type (default restaurant)
75
+ --limit <N> Maximum number of results (default 10)
76
+
58
77
  Output:
59
78
  --json Print structured JSON output`;
60
79
 
@@ -62,12 +81,40 @@ export async function runWayfinder(
62
81
  argv: string[] = process.argv.slice(2),
63
82
  options: RunOptions = {},
64
83
  ): Promise<number> {
84
+ const env = options.env ?? process.env;
85
+ const homeDir = options.homeDir;
86
+ const isInteractive = options.isInteractive ?? Boolean(defaultStdin.isTTY && defaultStdout.isTTY);
65
87
  const output = options.output ?? {
66
88
  stdout: (message: string) => console.log(message),
67
89
  stderr: (message: string) => console.error(message),
68
90
  };
69
91
 
70
92
  try {
93
+ if (argv.length === 0) {
94
+ try {
95
+ resolveApiKey(env, homeDir);
96
+ output.stdout(HELP_TEXT);
97
+ return ExitCode.Success;
98
+ } catch (error) {
99
+ if (error instanceof CliError && error.exitCode === ExitCode.MissingApiKey) {
100
+ if (!isInteractive) {
101
+ output.stderr(error.message);
102
+ return error.exitCode;
103
+ }
104
+
105
+ return await runSetupFlow(output, homeDir, options.promptImpl, isInteractive, false);
106
+ }
107
+
108
+ if (error instanceof CliError) {
109
+ output.stderr(error.message);
110
+ return error.exitCode;
111
+ }
112
+
113
+ output.stderr("Unexpected internal error");
114
+ return ExitCode.InternalError;
115
+ }
116
+ }
117
+
71
118
  const parsed = parseCliArgs(argv);
72
119
 
73
120
  if (parsed.help) {
@@ -75,7 +122,17 @@ export async function runWayfinder(
75
122
  return ExitCode.Success;
76
123
  }
77
124
 
78
- const apiKey = resolveApiKey(options.env ?? process.env, options.homeDir);
125
+ if (parsed.mode === "setup") {
126
+ return await runSetupFlow(
127
+ output,
128
+ homeDir,
129
+ options.promptImpl,
130
+ isInteractive,
131
+ parsed.reset,
132
+ );
133
+ }
134
+
135
+ const apiKey = resolveApiKey(env, homeDir);
79
136
  if (parsed.mode === "flights") {
80
137
  const flights = await searchFlights(parsed.query, apiKey, options.fetchImpl ?? fetch);
81
138
 
@@ -133,7 +190,7 @@ export async function runWayfinder(
133
190
  } else {
134
191
  output.stdout(renderFlightBookingText(flightLinks));
135
192
  }
136
- } else {
193
+ } else if (parsed.mode === "hotels") {
137
194
  const hotels = await searchHotels(parsed.query, apiKey, options.fetchImpl ?? fetch);
138
195
 
139
196
  if (hotels.length === 0) {
@@ -154,6 +211,27 @@ export async function runWayfinder(
154
211
  } else {
155
212
  output.stdout(renderHotelTable(hotels));
156
213
  }
214
+ } else {
215
+ const places = await searchPlaces(parsed.query, apiKey, options.fetchImpl ?? fetch);
216
+
217
+ if (places.length === 0) {
218
+ throw new CliError("No places found for the selected query", ExitCode.NoResults);
219
+ }
220
+
221
+ if (parsed.outputJson) {
222
+ output.stdout(
223
+ JSON.stringify(
224
+ {
225
+ query: parsed.query,
226
+ results: places,
227
+ },
228
+ null,
229
+ 2,
230
+ ),
231
+ );
232
+ } else {
233
+ output.stdout(renderPlaceTable(places));
234
+ }
157
235
  }
158
236
 
159
237
  return ExitCode.Success;
@@ -186,3 +264,78 @@ function renderFlightBookingText(
186
264
 
187
265
  return lines.join("\n").trimEnd();
188
266
  }
267
+
268
+ async function runSetupFlow(
269
+ output: Output,
270
+ homeDir: string | undefined,
271
+ promptImpl: ((prompt: string) => Promise<string>) | undefined,
272
+ isInteractive: boolean,
273
+ forceReset: boolean,
274
+ ): Promise<number> {
275
+ if (!isInteractive) {
276
+ throw new CliError(
277
+ "wayfinder setup requires an interactive terminal. Use SERPAPI_API_KEY or write ~/.config/wayfinder/config.json manually.",
278
+ ExitCode.InvalidInput,
279
+ );
280
+ }
281
+
282
+ output.stdout("Wayfinder needs a SerpApi API key to fetch live flight and hotel data.");
283
+ output.stdout("SerpApi is the API provider that returns Google Flights and Google Hotels search results.");
284
+ output.stdout("Get your key at: https://serpapi.com/manage-api-key");
285
+ output.stdout("");
286
+
287
+ const configPath = getConfigPath(homeDir);
288
+ if (forceReset) {
289
+ if (existsSync(configPath)) {
290
+ unlinkSync(configPath);
291
+ output.stdout("Existing config removed due to --reset.");
292
+ } else {
293
+ output.stdout("No existing config found. Starting fresh setup.");
294
+ }
295
+ }
296
+
297
+ const existingKey = readConfigApiKey(homeDir);
298
+ if (existingKey && !forceReset) {
299
+ const overwrite = await promptWithFallback(promptImpl, "A key already exists. Overwrite? [y/N]: ");
300
+ if (!/^(y|yes)$/i.test(overwrite.trim())) {
301
+ output.stdout("Setup cancelled. Existing key kept.");
302
+ return ExitCode.Success;
303
+ }
304
+ }
305
+
306
+ let apiKey = "";
307
+ while (apiKey.length === 0) {
308
+ apiKey = (await promptWithFallback(promptImpl, "Enter your SerpApi API key: ")).trim();
309
+ if (apiKey.length === 0) {
310
+ output.stdout("API key cannot be empty.");
311
+ }
312
+ }
313
+
314
+ writeConfigApiKey(apiKey, homeDir);
315
+ output.stdout("");
316
+ output.stdout(`Setup complete. Saved key to ${configPath}.`);
317
+ output.stdout("Next step: wayfinder --help");
318
+ output.stdout("Quick start: wayfinder flights --from SFO --to JFK --date 2026-04-10");
319
+
320
+ return ExitCode.Success;
321
+ }
322
+
323
+ async function promptWithFallback(
324
+ promptImpl: ((prompt: string) => Promise<string>) | undefined,
325
+ message: string,
326
+ ): Promise<string> {
327
+ if (promptImpl) {
328
+ return promptImpl(message);
329
+ }
330
+
331
+ const rl = createInterface({
332
+ input: defaultStdin,
333
+ output: defaultStdout,
334
+ });
335
+
336
+ try {
337
+ return await rl.question(message);
338
+ } finally {
339
+ rl.close();
340
+ }
341
+ }
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/format.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FlightOption, HotelOption } from "./types";
1
+ import { FlightOption, HotelOption, PlaceOption } from "./types";
2
2
 
3
3
  const currencyFormatter = new Intl.NumberFormat("en-US", {
4
4
  style: "currency",
@@ -142,6 +142,74 @@ export function renderHotelTable(options: HotelOption[]): string {
142
142
  return lines.join("\n");
143
143
  }
144
144
 
145
+ export function renderPlaceTable(options: PlaceOption[]): string {
146
+ const rows = options.map((option) => ({
147
+ name: option.name,
148
+ type: option.category,
149
+ rating: typeof option.rating === "number" ? option.rating.toFixed(1) : "n/a",
150
+ reviews: typeof option.reviews === "number" ? String(option.reviews) : "n/a",
151
+ distance: formatDistance(option.distanceMeters),
152
+ address: option.address ?? "n/a",
153
+ }));
154
+
155
+ const headers = {
156
+ name: "NAME",
157
+ type: "TYPE",
158
+ rating: "RATING",
159
+ reviews: "REVIEWS",
160
+ distance: "DISTANCE",
161
+ address: "ADDRESS",
162
+ };
163
+
164
+ const widths = {
165
+ name: maxWidth(rows, "name", headers.name),
166
+ type: maxWidth(rows, "type", headers.type),
167
+ rating: maxWidth(rows, "rating", headers.rating),
168
+ reviews: maxWidth(rows, "reviews", headers.reviews),
169
+ distance: maxWidth(rows, "distance", headers.distance),
170
+ address: maxWidth(rows, "address", headers.address),
171
+ };
172
+
173
+ const lines: string[] = [];
174
+
175
+ lines.push(
176
+ [
177
+ headers.name.padEnd(widths.name),
178
+ headers.type.padEnd(widths.type),
179
+ headers.rating.padEnd(widths.rating),
180
+ headers.reviews.padEnd(widths.reviews),
181
+ headers.distance.padEnd(widths.distance),
182
+ headers.address.padEnd(widths.address),
183
+ ].join(" "),
184
+ );
185
+
186
+ lines.push(
187
+ [
188
+ "-".repeat(widths.name),
189
+ "-".repeat(widths.type),
190
+ "-".repeat(widths.rating),
191
+ "-".repeat(widths.reviews),
192
+ "-".repeat(widths.distance),
193
+ "-".repeat(widths.address),
194
+ ].join(" "),
195
+ );
196
+
197
+ for (const row of rows) {
198
+ lines.push(
199
+ [
200
+ row.name.padEnd(widths.name),
201
+ row.type.padEnd(widths.type),
202
+ row.rating.padEnd(widths.rating),
203
+ row.reviews.padEnd(widths.reviews),
204
+ row.distance.padEnd(widths.distance),
205
+ row.address.padEnd(widths.address),
206
+ ].join(" "),
207
+ );
208
+ }
209
+
210
+ return lines.join("\n");
211
+ }
212
+
145
213
  function maxWidth(rows: Array<Record<string, string>>, key: string, header: string): number {
146
214
  return rows.reduce((width, row) => Math.max(width, row[key].length), header.length);
147
215
  }
@@ -164,3 +232,16 @@ function formatDuration(totalMinutes: number): string {
164
232
 
165
233
  return `${hours}h ${minutes}m`;
166
234
  }
235
+
236
+ function formatDistance(distanceMeters?: number): string {
237
+ if (!Number.isFinite(distanceMeters)) {
238
+ return "n/a";
239
+ }
240
+
241
+ if ((distanceMeters as number) < 1000) {
242
+ return `${Math.round(distanceMeters as number)}m`;
243
+ }
244
+
245
+ const km = (distanceMeters as number) / 1000;
246
+ return `${km.toFixed(1)}km`;
247
+ }
package/src/parse.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { CliError } from "./errors";
2
- import { ExitCode, FlightBookingQuery, FlightQuery, HotelQuery, ParsedArgs } from "./types";
2
+ import {
3
+ ExitCode,
4
+ FlightBookingQuery,
5
+ FlightQuery,
6
+ HotelQuery,
7
+ ParsedArgs,
8
+ PlaceQuery,
9
+ PlaceType,
10
+ } from "./types";
3
11
 
4
12
  interface FlightRawOptions {
5
13
  from?: string;
@@ -35,7 +43,15 @@ interface FlightBookingRawOptions {
35
43
  help: boolean;
36
44
  }
37
45
 
38
- type SearchMode = "flights" | "hotels" | "flight-booking";
46
+ interface PlaceRawOptions {
47
+ near?: string;
48
+ type?: string;
49
+ limit?: string;
50
+ outputJson: boolean;
51
+ help: boolean;
52
+ }
53
+
54
+ type SearchMode = "flights" | "hotels" | "places" | "flight-booking" | "setup";
39
55
 
40
56
  const HELP_FLAGS = new Set(["-h", "--help"]);
41
57
 
@@ -57,6 +73,14 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
57
73
  return parseFlightBookingArgs(args);
58
74
  }
59
75
 
76
+ if (mode === "places") {
77
+ return parsePlacesArgs(args);
78
+ }
79
+
80
+ if (mode === "setup") {
81
+ return parseSetupArgs(args);
82
+ }
83
+
60
84
  return parseFlightsArgs(args);
61
85
  }
62
86
 
@@ -277,6 +301,82 @@ function parseFlightBookingArgs(args: string[]): ParsedArgs {
277
301
  };
278
302
  }
279
303
 
304
+ function parsePlacesArgs(args: string[]): ParsedArgs {
305
+ const raw: PlaceRawOptions = {
306
+ outputJson: false,
307
+ help: false,
308
+ };
309
+
310
+ for (let i = 0; i < args.length; i += 1) {
311
+ const token = args[i];
312
+
313
+ if (HELP_FLAGS.has(token)) {
314
+ raw.help = true;
315
+ continue;
316
+ }
317
+
318
+ if (token === "--json") {
319
+ raw.outputJson = true;
320
+ continue;
321
+ }
322
+
323
+ if (!token.startsWith("--")) {
324
+ throw new CliError(`Unexpected argument: ${token}`, ExitCode.InvalidInput);
325
+ }
326
+
327
+ const value = args[i + 1];
328
+ if (!value || value.startsWith("--")) {
329
+ throw new CliError(`Missing value for ${token}`, ExitCode.InvalidInput);
330
+ }
331
+
332
+ switch (token) {
333
+ case "--near":
334
+ raw.near = value;
335
+ break;
336
+ case "--type":
337
+ raw.type = value;
338
+ break;
339
+ case "--limit":
340
+ raw.limit = value;
341
+ break;
342
+ default:
343
+ throw new CliError(`Unknown flag: ${token}`, ExitCode.InvalidInput);
344
+ }
345
+
346
+ i += 1;
347
+ }
348
+
349
+ if (raw.help) {
350
+ return {
351
+ help: true,
352
+ outputJson: raw.outputJson,
353
+ };
354
+ }
355
+
356
+ return {
357
+ help: false,
358
+ mode: "places",
359
+ outputJson: raw.outputJson,
360
+ query: buildPlaceQuery(raw),
361
+ };
362
+ }
363
+
364
+ function parseSetupArgs(args: string[]): ParsedArgs {
365
+ const outputJson = args.includes("--json");
366
+ const reset = args.includes("--reset");
367
+ const unsupported = args.filter((token) => token !== "--json" && token !== "--reset");
368
+ if (unsupported.length > 0) {
369
+ throw new CliError(`Unknown argument for setup: ${unsupported[0]}`, ExitCode.InvalidInput);
370
+ }
371
+
372
+ return {
373
+ help: false,
374
+ mode: "setup",
375
+ outputJson,
376
+ reset,
377
+ };
378
+ }
379
+
280
380
  function buildFlightQuery(raw: FlightRawOptions): FlightQuery {
281
381
  if (!raw.from || !raw.to || !raw.date) {
282
382
  throw new CliError("Missing required flags: --from, --to, --date", ExitCode.InvalidInput);
@@ -389,6 +489,22 @@ function buildFlightBookingQuery(raw: FlightBookingRawOptions): FlightBookingQue
389
489
  };
390
490
  }
391
491
 
492
+ function buildPlaceQuery(raw: PlaceRawOptions): PlaceQuery {
493
+ if (!raw.near) {
494
+ throw new CliError("Missing required flag: --near", ExitCode.InvalidInput);
495
+ }
496
+
497
+ const near = normalizeLocation(raw.near);
498
+ const type = raw.type ? normalizePlaceType(raw.type) : "restaurant";
499
+ const limit = raw.limit ? normalizeLimit(raw.limit, "--limit") : 10;
500
+
501
+ return {
502
+ near,
503
+ type,
504
+ limit,
505
+ };
506
+ }
507
+
392
508
  function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] } {
393
509
  const args = [...argv];
394
510
  if (args[0] === "hotels") {
@@ -410,8 +526,18 @@ function stripSubcommands(argv: string[]): { mode: SearchMode; args: string[] }
410
526
  return { mode: "flights", args };
411
527
  }
412
528
 
529
+ if (args[0] === "setup") {
530
+ args.shift();
531
+ return { mode: "setup", args };
532
+ }
533
+
534
+ if (args[0] === "places") {
535
+ args.shift();
536
+ return { mode: "places", args };
537
+ }
538
+
413
539
  throw new CliError(
414
- "Missing subcommand: use `flights` or `hotels`",
540
+ "Missing subcommand: use `setup`, `flights`, `hotels`, or `places`",
415
541
  ExitCode.InvalidInput,
416
542
  );
417
543
  }
@@ -496,6 +622,15 @@ function normalizeMaxPrice(value: string): number {
496
622
  return numeric;
497
623
  }
498
624
 
625
+ function normalizeLimit(value: string, flagName: string): number {
626
+ const numeric = Number.parseInt(value, 10);
627
+ if (!Number.isInteger(numeric) || numeric <= 0) {
628
+ throw new CliError(`${flagName} must be a positive integer`, ExitCode.InvalidInput);
629
+ }
630
+
631
+ return numeric;
632
+ }
633
+
499
634
  function normalizeAdults(value: string): number {
500
635
  const numeric = Number.parseInt(value, 10);
501
636
  if (!Number.isInteger(numeric) || numeric <= 0) {
@@ -514,6 +649,15 @@ function normalizeMinRating(value: string): 3.5 | 4 | 4.5 | 5 {
514
649
  return numeric;
515
650
  }
516
651
 
652
+ function normalizePlaceType(value: string): PlaceType {
653
+ const normalized = value.trim().toLowerCase();
654
+ if (normalized !== "restaurant" && normalized !== "coffee") {
655
+ throw new CliError("--type must be one of: restaurant, coffee", ExitCode.InvalidInput);
656
+ }
657
+
658
+ return normalized;
659
+ }
660
+
517
661
  function normalizeTime(value: string, flagName: string): number {
518
662
  const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
519
663
  if (!match) {
package/src/serpapi.ts CHANGED
@@ -8,6 +8,9 @@ import {
8
8
  FlightSearchResult,
9
9
  HotelOption,
10
10
  HotelQuery,
11
+ PlaceOption,
12
+ PlaceQuery,
13
+ PlaceType,
11
14
  } from "./types";
12
15
 
13
16
  interface SerpApiAirport {
@@ -80,6 +83,23 @@ interface SerpApiHotelsResponse {
80
83
  properties?: SerpApiHotelProperty[];
81
84
  }
82
85
 
86
+ interface SerpApiLocalResult {
87
+ title?: string;
88
+ rating?: number;
89
+ reviews?: number;
90
+ address?: string;
91
+ distance?: string;
92
+ open_state?: string;
93
+ type?: string;
94
+ place_id_search?: string;
95
+ data_id?: string;
96
+ }
97
+
98
+ interface SerpApiPlacesResponse {
99
+ error?: string;
100
+ local_results?: SerpApiLocalResult[];
101
+ }
102
+
83
103
  export async function searchFlights(
84
104
  query: FlightQuery,
85
105
  apiKey: string,
@@ -158,6 +178,25 @@ export async function searchHotels(
158
178
  return hotels;
159
179
  }
160
180
 
181
+ export async function searchPlaces(
182
+ query: PlaceQuery,
183
+ apiKey: string,
184
+ fetchImpl: typeof fetch = fetch,
185
+ ): Promise<PlaceOption[]> {
186
+ const payload = await fetchSerpApiJson<SerpApiPlacesResponse>(
187
+ buildPlaceRequestUrl(query, apiKey),
188
+ fetchImpl,
189
+ );
190
+
191
+ if (typeof payload.error === "string" && payload.error.trim() !== "") {
192
+ throw new CliError(`SerpApi error: ${payload.error}`, ExitCode.ApiFailure);
193
+ }
194
+
195
+ const places = shapePlaceSerpApiResponse(payload, query.type);
196
+ places.sort((a, b) => b.score - a.score);
197
+ return places.slice(0, query.limit);
198
+ }
199
+
161
200
  export function shapeSerpApiResponse(payload: SerpApiFlightsResponse): FlightOption[] {
162
201
  const merged = [...(payload.best_flights ?? []), ...(payload.other_flights ?? [])];
163
202
 
@@ -172,6 +211,15 @@ export function shapeHotelSerpApiResponse(payload: SerpApiHotelsResponse): Hotel
172
211
  .filter((option): option is HotelOption => option !== null);
173
212
  }
174
213
 
214
+ export function shapePlaceSerpApiResponse(
215
+ payload: SerpApiPlacesResponse,
216
+ category: PlaceType,
217
+ ): PlaceOption[] {
218
+ return (payload.local_results ?? [])
219
+ .map((result) => shapeLocalResult(result, category))
220
+ .filter((option): option is PlaceOption => option !== null);
221
+ }
222
+
175
223
  export function filterByDepartureWindow(
176
224
  options: FlightOption[],
177
225
  minMinutes: number,
@@ -336,6 +384,17 @@ function buildHotelRequestUrl(query: HotelQuery, apiKey: string): string {
336
384
  return url.toString();
337
385
  }
338
386
 
387
+ function buildPlaceRequestUrl(query: PlaceQuery, apiKey: string): string {
388
+ const url = new URL("https://serpapi.com/search.json");
389
+ const searchTerm = query.type === "coffee" ? "coffee" : "restaurants";
390
+
391
+ url.searchParams.set("engine", "google_maps");
392
+ url.searchParams.set("type", "search");
393
+ url.searchParams.set("q", `${searchTerm} near ${query.near}`);
394
+ url.searchParams.set("api_key", apiKey);
395
+ return url.toString();
396
+ }
397
+
339
398
  function buildFlightBookingOptionsRequestUrl(
340
399
  query: FlightBookingQuery,
341
400
  token: string,
@@ -383,6 +442,101 @@ function toSerpApiRating(rating: 3.5 | 4 | 4.5 | 5): string {
383
442
  return "10";
384
443
  }
385
444
 
445
+ function shapeLocalResult(result: SerpApiLocalResult, category: PlaceType): PlaceOption | null {
446
+ if (typeof result.title !== "string" || result.title.trim() === "") {
447
+ return null;
448
+ }
449
+
450
+ const rating = Number.isFinite(result.rating) ? (result.rating as number) : undefined;
451
+ const reviews = Number.isFinite(result.reviews) ? (result.reviews as number) : undefined;
452
+ const distanceMeters = parseDistanceMeters(result.distance);
453
+ const link = buildGoogleMapsPlaceLink(result);
454
+ const googleMapsUrl = buildDirectGoogleMapsUrl(result);
455
+
456
+ return {
457
+ name: result.title.trim(),
458
+ category,
459
+ rating,
460
+ reviews,
461
+ address: typeof result.address === "string" ? result.address : undefined,
462
+ distanceMeters,
463
+ openState: typeof result.open_state === "string" ? result.open_state : undefined,
464
+ link,
465
+ googleMapsUrl,
466
+ score: computePlaceScore(rating, reviews, distanceMeters),
467
+ };
468
+ }
469
+
470
+ function computePlaceScore(
471
+ rating: number | undefined,
472
+ reviews: number | undefined,
473
+ distanceMeters: number | undefined,
474
+ ): number {
475
+ const ratingComponent = typeof rating === "number" ? rating / 5 : 0;
476
+ const reviewComponent =
477
+ typeof reviews === "number" && reviews > 0 ? Math.log10(reviews + 1) / 4 : 0;
478
+ const distanceComponent =
479
+ typeof distanceMeters === "number" && distanceMeters >= 0
480
+ ? Math.max(0, 1 - distanceMeters / 10_000)
481
+ : 0;
482
+
483
+ return ratingComponent * 0.6 + reviewComponent * 0.25 + distanceComponent * 0.15;
484
+ }
485
+
486
+ function parseDistanceMeters(value?: string): number | undefined {
487
+ if (typeof value !== "string" || value.trim() === "") {
488
+ return undefined;
489
+ }
490
+
491
+ const normalized = value.trim().toLowerCase().replace(/,/g, "");
492
+ const match = /^(\d+(?:\.\d+)?)\s*(m|meter|meters|km|mi)$/.exec(normalized);
493
+ if (!match) {
494
+ return undefined;
495
+ }
496
+
497
+ const amount = Number(match[1]);
498
+ const unit = match[2];
499
+ if (!Number.isFinite(amount)) {
500
+ return undefined;
501
+ }
502
+
503
+ if (unit === "m" || unit === "meter" || unit === "meters") {
504
+ return amount;
505
+ }
506
+
507
+ if (unit === "km") {
508
+ return amount * 1000;
509
+ }
510
+
511
+ return amount * 1609.34;
512
+ }
513
+
514
+ function buildGoogleMapsPlaceLink(result: SerpApiLocalResult): string | undefined {
515
+ if (typeof result.place_id_search === "string" && result.place_id_search.trim() !== "") {
516
+ return result.place_id_search;
517
+ }
518
+
519
+ return undefined;
520
+ }
521
+
522
+ function buildDirectGoogleMapsUrl(result: SerpApiLocalResult): string | undefined {
523
+ if (typeof result.place_id_search !== "string" || result.place_id_search.trim() === "") {
524
+ return undefined;
525
+ }
526
+
527
+ try {
528
+ const parsed = new URL(result.place_id_search);
529
+ const placeId = parsed.searchParams.get("place_id");
530
+ if (!placeId || placeId.trim() === "") {
531
+ return undefined;
532
+ }
533
+
534
+ return `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(placeId)}`;
535
+ } catch {
536
+ return undefined;
537
+ }
538
+ }
539
+
386
540
  async function fetchSerpApiJson<T>(
387
541
  requestUrl: string,
388
542
  fetchImpl: typeof fetch,
package/src/types.ts CHANGED
@@ -30,6 +30,14 @@ export interface HotelQuery {
30
30
  minRating?: 3.5 | 4 | 4.5 | 5;
31
31
  }
32
32
 
33
+ export type PlaceType = "restaurant" | "coffee";
34
+
35
+ export interface PlaceQuery {
36
+ near: string;
37
+ type: PlaceType;
38
+ limit: number;
39
+ }
40
+
33
41
  export interface ParsedArgsHelp {
34
42
  help: true;
35
43
  outputJson: boolean;
@@ -63,11 +71,27 @@ export interface ParsedArgsFlightBooking {
63
71
  query: FlightBookingQuery;
64
72
  }
65
73
 
74
+ export interface ParsedArgsSetup {
75
+ help: false;
76
+ mode: "setup";
77
+ outputJson: boolean;
78
+ reset: boolean;
79
+ }
80
+
81
+ export interface ParsedArgsPlaces {
82
+ help: false;
83
+ mode: "places";
84
+ outputJson: boolean;
85
+ query: PlaceQuery;
86
+ }
87
+
66
88
  export type ParsedArgs =
67
89
  | ParsedArgsHelp
68
90
  | ParsedArgsFlights
69
91
  | ParsedArgsHotels
70
- | ParsedArgsFlightBooking;
92
+ | ParsedArgsPlaces
93
+ | ParsedArgsFlightBooking
94
+ | ParsedArgsSetup;
71
95
 
72
96
  export interface FlightOption {
73
97
  price: number;
@@ -89,6 +113,19 @@ export interface HotelOption {
89
113
  link?: string;
90
114
  }
91
115
 
116
+ export interface PlaceOption {
117
+ name: string;
118
+ category: PlaceType;
119
+ rating?: number;
120
+ reviews?: number;
121
+ address?: string;
122
+ distanceMeters?: number;
123
+ openState?: string;
124
+ link?: string;
125
+ googleMapsUrl?: string;
126
+ score: number;
127
+ }
128
+
92
129
  export interface FlightSearchResult {
93
130
  options: FlightOption[];
94
131
  googleFlightsUrl?: string;