straeto 0.1.4 → 0.1.7

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
@@ -11,7 +11,6 @@
11
11
  <p align="center">
12
12
  <a href="https://www.npmjs.com/package/straeto"><img src="https://img.shields.io/npm/v/straeto" alt="npm version" /></a>
13
13
  <a href="https://github.com/magtastic/straeto-cli/actions/workflows/ci.yml"><img src="https://github.com/magtastic/straeto-cli/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
14
- <a href="https://www.npmjs.com/package/straeto"><img src="https://img.shields.io/npm/dm/straeto" alt="npm downloads" /></a>
15
14
  <a href="https://github.com/magtastic/straeto-cli/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/straeto" alt="license" /></a>
16
15
  </p>
17
16
 
@@ -63,6 +62,15 @@ straeto stop Hlemmur
63
62
  straeto stop 10000802
64
63
  ```
65
64
 
65
+ ### `straeto search [query]`
66
+
67
+ Search for a place by name. Launches interactive mode with autocomplete if no query is provided.
68
+
69
+ ```bash
70
+ straeto search Höfðatorg # search by name
71
+ straeto search # interactive with autocomplete
72
+ ```
73
+
66
74
  ### `straeto alerts`
67
75
 
68
76
  Show active service alerts.
@@ -72,17 +80,40 @@ straeto alerts # alerts in Icelandic (default)
72
80
  straeto alerts -l EN # alerts in English
73
81
  ```
74
82
 
83
+ ### `straeto next <stop>`
84
+
85
+ Show real-time upcoming arrivals at a stop.
86
+
87
+ ```bash
88
+ straeto next Höfðatorg # all upcoming arrivals
89
+ straeto next Höfðatorg -r 14 # only route 14
90
+ straeto next Höfðatorg -d Grandi # filter by direction
91
+ straeto next Höfðatorg -r 14 -n 3 # limit to 3 results
92
+ ```
93
+
75
94
  ### `straeto plan`
76
95
 
77
- Plan a trip between two locations. Launches interactive mode by default with autocomplete search and time selection.
96
+ Plan a trip between two locations. Accepts place names or coordinates. Launches interactive mode by default with autocomplete search and time selection.
78
97
 
79
98
  ```bash
80
99
  straeto plan # interactive mode
81
- straeto plan -f 64.14,-21.90 -t 64.10,-21.94 # coordinate mode
100
+ straeto plan -f Höfðatorg -t Mýrargata # place names
101
+ straeto plan -f 64.14,-21.90 -t 64.10,-21.94 # coordinates
82
102
  straeto plan --at 08:30 # depart at specific time
83
103
  straeto plan --by 17:00 # arrive by specific time
84
104
  ```
85
105
 
106
+ ## Claude Code Plugin
107
+
108
+ Use Strætó directly inside [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — ask about bus routes, stops, alerts, or plan trips in natural language.
109
+
110
+ ```
111
+ /plugin marketplace add magtastic/straeto-cli
112
+ /plugin install straeto@straeto
113
+ ```
114
+
115
+ Then type `/straeto` or just ask about buses in Reykjavík.
116
+
86
117
  ## Development
87
118
 
88
119
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "straeto",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Strætó — Icelandic bus system CLI",
5
5
  "author": "magtastic",
6
6
  "license": "MIT",
package/src/api.ts CHANGED
@@ -8,24 +8,65 @@ const HEADERS = {
8
8
  Origin: "https://www.straeto.is",
9
9
  };
10
10
 
11
+ const MAX_RETRIES = 3;
12
+ const TIMEOUT_MS = 5000;
13
+
11
14
  async function query<T>(
12
15
  gql: string,
13
16
  variables: Record<string, unknown> | undefined,
14
17
  schema: z.ZodType<T>,
15
18
  ): Promise<T> {
16
- const res = await fetch(API_URL, {
17
- method: "POST",
18
- headers: HEADERS,
19
- body: JSON.stringify({ query: gql, variables }),
20
- });
19
+ const start = Date.now();
20
+
21
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
22
+ if (Date.now() - start > TIMEOUT_MS) break;
23
+
24
+ if (attempt > 0) {
25
+ const delay = 200 * 2 ** (attempt - 1);
26
+ const remaining = TIMEOUT_MS - (Date.now() - start);
27
+ if (remaining <= 0) break;
28
+ await new Promise((r) => setTimeout(r, Math.min(delay, remaining)));
29
+ }
21
30
 
22
- if (!res.ok) throw new Error(`API error: ${res.status}`);
31
+ try {
32
+ const res = await fetch(API_URL, {
33
+ method: "POST",
34
+ headers: HEADERS,
35
+ body: JSON.stringify({ query: gql, variables }),
36
+ signal: AbortSignal.timeout(TIMEOUT_MS - (Date.now() - start)),
37
+ });
38
+
39
+ if (!res.ok) throw new Error(`API error: ${res.status}`);
40
+
41
+ const json = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
42
+ if (json.errors && !json.data)
43
+ throw new Error(json.errors.map((error) => error.message).join(", "));
44
+ if (!json.data) throw new Error("No data returned from API");
45
+
46
+ const result = schema.safeParse(json.data);
47
+ if (!result.success) {
48
+ const issues = result.error.issues
49
+ .map((i) => ` ${i.path.join(".")}: ${i.message}`)
50
+ .join("\n");
51
+ const firstPath = result.error.issues[0]?.path ?? [];
52
+ let sample: unknown = json.data;
53
+ for (const key of firstPath) {
54
+ if (sample != null && typeof sample === "object")
55
+ sample = (sample as Record<string | number, unknown>)[key as string | number];
56
+ }
57
+ const raw = JSON.stringify(sample, null, 2);
58
+ const preview = raw && raw.length > 500 ? `${raw.slice(0, 500)}…` : raw;
59
+ throw new Error(
60
+ `Schema validation failed:\n${issues}\n\nValue at "${firstPath.join(".")}":\n${preview}`,
61
+ );
62
+ }
63
+ return result.data;
64
+ } catch (err) {
65
+ if (attempt === MAX_RETRIES - 1 || Date.now() - start > TIMEOUT_MS) throw err;
66
+ }
67
+ }
23
68
 
24
- const json = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
25
- if (json.errors && !json.data)
26
- throw new Error(json.errors.map((error) => error.message).join(", "));
27
- if (!json.data) throw new Error("No data returned from API");
28
- return schema.parse(json.data);
69
+ throw new Error("Strætó is not responding please try again in a moment.");
29
70
  }
30
71
 
31
72
  // --- Schemas ---
@@ -168,14 +209,16 @@ export function findLastStop(bus: BusLocation, stops: Stop[]): string | undefine
168
209
  // --- Response schemas ---
169
210
 
170
211
  const BusLocationResponseSchema = z.object({
171
- BusLocationByRoute: z.object({
172
- lastUpdate: z.string(),
173
- results: z.array(BusLocationSchema),
174
- }),
212
+ BusLocationByRoute: z
213
+ .object({
214
+ lastUpdate: z.string(),
215
+ results: z.array(BusLocationSchema),
216
+ })
217
+ .nullable(),
175
218
  });
176
219
 
177
220
  const StopsResponseSchema = z.object({
178
- GtfsStops: z.object({ results: z.array(StopSchema) }),
221
+ GtfsStops: z.object({ results: z.array(StopSchema) }).nullable(),
179
222
  });
180
223
 
181
224
  const StopDetailSchema = StopSchema.extend({
@@ -187,15 +230,15 @@ const StopResponseSchema = z.object({
187
230
  });
188
231
 
189
232
  const AlertsResponseSchema = z.object({
190
- Alerts: z.object({ results: z.array(AlertSchema) }),
233
+ Alerts: z.object({ results: z.array(AlertSchema) }).nullable(),
191
234
  });
192
235
 
193
236
  const GeocodeResponseSchema = z.object({
194
- Geocode: z.object({ results: z.array(GeoResultSchema) }),
237
+ Geocode: z.object({ results: z.array(GeoResultSchema) }).nullable(),
195
238
  });
196
239
 
197
240
  const TripPlannerResponseSchema = z.object({
198
- TripPlanner: z.object({ results: z.array(TripItinerarySchema) }),
241
+ TripPlanner: z.object({ results: z.array(TripItinerarySchema) }).nullable(),
199
242
  });
200
243
 
201
244
  // --- Queries ---
@@ -214,7 +257,7 @@ export async function getBusLocations(routes: string[]) {
214
257
  { routes },
215
258
  BusLocationResponseSchema,
216
259
  );
217
- return data.BusLocationByRoute;
260
+ return data.BusLocationByRoute ?? { lastUpdate: "", results: [] };
218
261
  }
219
262
 
220
263
  export async function getStops() {
@@ -223,7 +266,7 @@ export async function getStops() {
223
266
  undefined,
224
267
  StopsResponseSchema,
225
268
  );
226
- return data.GtfsStops.results;
269
+ return data.GtfsStops?.results ?? [];
227
270
  }
228
271
 
229
272
  export async function getStop(id: string) {
@@ -251,7 +294,7 @@ export async function getAlerts(language: string = "IS") {
251
294
  { language },
252
295
  AlertsResponseSchema,
253
296
  );
254
- return data.Alerts.results;
297
+ return data.Alerts?.results ?? [];
255
298
  }
256
299
 
257
300
  export async function geocode(placesQuery: string) {
@@ -264,7 +307,7 @@ export async function geocode(placesQuery: string) {
264
307
  { placesQuery },
265
308
  GeocodeResponseSchema,
266
309
  );
267
- return data.Geocode.results;
310
+ return data.Geocode?.results ?? [];
268
311
  }
269
312
 
270
313
  export async function planTrip(opts: {
@@ -298,5 +341,5 @@ export async function planTrip(opts: {
298
341
  opts,
299
342
  TripPlannerResponseSchema,
300
343
  );
301
- return data.TripPlanner.results;
344
+ return data.TripPlanner?.results ?? [];
302
345
  }
package/src/cli.test.ts CHANGED
@@ -26,6 +26,8 @@ describe("cli --help", () => {
26
26
  expect(stdout).toContain("route");
27
27
  expect(stdout).toContain("stops");
28
28
  expect(stdout).toContain("stop");
29
+ expect(stdout).toContain("search");
30
+ expect(stdout).toContain("next");
29
31
  expect(stdout).toContain("alerts");
30
32
  expect(stdout).toContain("plan");
31
33
  });
@@ -70,6 +72,30 @@ describe("cli route (help/args)", () => {
70
72
  });
71
73
  });
72
74
 
75
+ describe("cli search (help/args)", () => {
76
+ test("shows help for search command", async () => {
77
+ const { stdout, exitCode } = await run("search", "--help");
78
+ expect(exitCode).toBe(0);
79
+ expect(stdout).toContain("--interactive");
80
+ });
81
+ });
82
+
83
+ describe("cli next (help/args)", () => {
84
+ test("shows help for next command", async () => {
85
+ const { stdout, exitCode } = await run("next", "--help");
86
+ expect(exitCode).toBe(0);
87
+ expect(stdout).toContain("--route");
88
+ expect(stdout).toContain("--direction");
89
+ expect(stdout).toContain("--limit");
90
+ });
91
+
92
+ test("fails without stop argument", async () => {
93
+ const { stderr, exitCode } = await run("next");
94
+ expect(exitCode).toBe(1);
95
+ expect(stderr).toContain("missing required argument");
96
+ });
97
+ });
98
+
73
99
  describe("cli plan (help/args)", () => {
74
100
  test("shows help for plan command", async () => {
75
101
  const { stdout, exitCode } = await run("plan", "--help");
package/src/cli.ts CHANGED
@@ -3,9 +3,11 @@ import chalk from "chalk";
3
3
  import { program } from "commander";
4
4
  import pkg from "../package.json";
5
5
  import { alertsCommand } from "./commands/alerts";
6
+ import { nextCommand } from "./commands/next";
6
7
  import { planCommand } from "./commands/plan";
7
8
  import { routeCommand } from "./commands/route";
8
- import { AlertsOpts, PlanOpts, RouteOpts, StopsOpts } from "./commands/schemas";
9
+ import { AlertsOpts, NextOpts, PlanOpts, RouteOpts, StopsOpts } from "./commands/schemas";
10
+ import { searchCommand } from "./commands/search";
9
11
  import { stopCommand } from "./commands/stop";
10
12
  import { stopsCommand } from "./commands/stops";
11
13
  import { writeErr } from "./terminal";
@@ -75,11 +77,39 @@ program
75
77
  }
76
78
  });
77
79
 
80
+ program
81
+ .command("search")
82
+ .description("Search for a place by name")
83
+ .argument("[query...]", "Place name to search for")
84
+ .option("-i, --interactive", "Interactive mode with autocomplete")
85
+ .action(async (parts: string[], rawOpts: { interactive?: boolean }) => {
86
+ try {
87
+ await searchCommand(parts, !!rawOpts.interactive || parts.length === 0);
88
+ } catch (error) {
89
+ handleError(error);
90
+ }
91
+ });
92
+
93
+ program
94
+ .command("next")
95
+ .description("Show upcoming arrivals at a stop")
96
+ .argument("<stop...>", "Stop name or ID")
97
+ .option("-r, --route <route>", "Filter by route number")
98
+ .option("-d, --direction <headsign>", "Filter by direction/headsign")
99
+ .option("-n, --limit <n>", "Limit results")
100
+ .action(async (parts: string[], rawOpts) => {
101
+ try {
102
+ await nextCommand(parts, NextOpts.parse(rawOpts));
103
+ } catch (error) {
104
+ handleError(error);
105
+ }
106
+ });
107
+
78
108
  program
79
109
  .command("plan")
80
110
  .description("Plan a trip between two locations")
81
- .option("-f, --from <coords>", "Origin as lat,lon")
82
- .option("-t, --to <coords>", "Destination as lat,lon")
111
+ .option("-f, --from <place>", "Origin as place name or lat,lon")
112
+ .option("-t, --to <place>", "Destination as place name or lat,lon")
83
113
  .option("-i, --interactive", "Interactive mode — search for places by name")
84
114
  .option("-d, --date <date>", "Date (YYYY-MM-DD, defaults to today)")
85
115
  .option("--at <time>", "Depart at time (HH:MM, defaults to now)")
@@ -0,0 +1,86 @@
1
+ import chalk from "chalk";
2
+ import type { z } from "zod/v4";
3
+ import * as api from "../api";
4
+ import { writeLn } from "../terminal";
5
+ import type { NextOpts } from "./schemas";
6
+
7
+ interface Arrival {
8
+ routeNr: string;
9
+ headsign: string;
10
+ busId: string;
11
+ waitingTime: number;
12
+ stopsAway: number;
13
+ }
14
+
15
+ export async function nextCommand(parts: string[], opts: z.infer<typeof NextOpts>) {
16
+ const input = parts.join(" ");
17
+ const stops = await api.getStops();
18
+ const query = input.toLowerCase();
19
+
20
+ // Find matching stops
21
+ let matches = stops.filter((s) => s.name.toLowerCase() === query);
22
+ if (matches.length === 0) {
23
+ matches = stops.filter((s) => s.name.toLowerCase().includes(query));
24
+ }
25
+ if (matches.length === 0) throw new Error(`No stops matching "${input}".`);
26
+
27
+ // Collect all route numbers serving matched stops
28
+ const stopNames = new Set(matches.map((s) => s.name.toLowerCase()));
29
+ const allRoutes = [...new Set(matches.flatMap((s) => s.routes))];
30
+
31
+ // Filter by route if specified
32
+ const routes = opts.route ? allRoutes.filter((r) => r === opts.route) : allRoutes;
33
+ if (routes.length === 0) throw new Error(`Route ${opts.route} does not serve "${input}".`);
34
+
35
+ // Fetch bus locations for all relevant routes
36
+ const data = await api.getBusLocations(routes);
37
+
38
+ // Find buses heading to this stop
39
+ const arrivals: Arrival[] = [];
40
+ for (const bus of data.results) {
41
+ for (let i = 0; i < bus.nextStops.length; i++) {
42
+ const nextStop = bus.nextStops[i];
43
+ if (!nextStop?.stop) continue;
44
+ if (!stopNames.has(nextStop.stop.name.toLowerCase())) continue;
45
+
46
+ // Filter by direction/headsign if specified
47
+ if (opts.direction) {
48
+ const dir = opts.direction.toLowerCase();
49
+ if (!bus.headsign.toLowerCase().includes(dir)) continue;
50
+ }
51
+
52
+ arrivals.push({
53
+ routeNr: bus.routeNr,
54
+ headsign: bus.headsign,
55
+ busId: bus.busId,
56
+ waitingTime: nextStop.waitingTime,
57
+ stopsAway: i + 1,
58
+ });
59
+ break;
60
+ }
61
+ }
62
+
63
+ // Sort by arrival time
64
+ arrivals.sort((a, b) => a.waitingTime - b.waitingTime);
65
+
66
+ const stopLabel = matches[0]?.name ?? input;
67
+
68
+ if (arrivals.length === 0) {
69
+ writeLn(chalk.dim(`No upcoming arrivals at ${stopLabel}.`));
70
+ return;
71
+ }
72
+
73
+ const limit = opts.limit ? parseInt(opts.limit, 10) : 10;
74
+ const shown = arrivals.slice(0, limit);
75
+
76
+ writeLn(`${chalk.bold.underline(stopLabel)} ${chalk.dim(`— ${arrivals.length} upcoming`)}\n`);
77
+
78
+ for (const a of shown) {
79
+ const route = chalk.bold.yellow(a.routeNr.padEnd(4));
80
+ const headsign = chalk.cyan(a.headsign.padEnd(20));
81
+ const time =
82
+ a.waitingTime <= 1 ? chalk.green.bold("< 1 mín") : chalk.green.bold(`${a.waitingTime} mín`);
83
+ const stops = chalk.dim(`${a.stopsAway} stop${a.stopsAway !== 1 ? "s" : ""} away`);
84
+ writeLn(` ${route}${headsign} ${time} ${stops}`);
85
+ }
86
+ }
@@ -12,6 +12,21 @@ export async function planCommand(opts: z.infer<typeof PlanOpts>) {
12
12
  let from = opts.from;
13
13
  let to = opts.to;
14
14
 
15
+ // Geocode place names to coordinates
16
+ const isCoords = (s: string) => /^-?\d+\.?\d*,-?\d+\.?\d*$/.test(s);
17
+ if (from && !isCoords(from)) {
18
+ const results = await api.geocode(from);
19
+ if (results.length === 0) throw new Error(`Could not find location "${from}"`);
20
+ const loc = results[0];
21
+ if (loc) from = `${loc.lat},${loc.lon}`;
22
+ }
23
+ if (to && !isCoords(to)) {
24
+ const results = await api.geocode(to);
25
+ if (results.length === 0) throw new Error(`Could not find location "${to}"`);
26
+ const loc = results[0];
27
+ if (loc) to = `${loc.lat},${loc.lon}`;
28
+ }
29
+
15
30
  if (opts.interactive || (!from && !to)) {
16
31
  const { promptLocation, promptTimeMode } = await import("../prompt");
17
32
  writeLn("");
@@ -16,6 +16,12 @@ export const AlertsOpts = z.object({
16
16
  lang: z.string(),
17
17
  });
18
18
 
19
+ export const NextOpts = z.object({
20
+ route: z.string().optional(),
21
+ direction: z.string().optional(),
22
+ limit: z.string().optional(),
23
+ });
24
+
19
25
  export const PlanOpts = z.object({
20
26
  from: z.string().optional(),
21
27
  to: z.string().optional(),
@@ -0,0 +1,22 @@
1
+ import * as api from "../api";
2
+ import * as fmt from "../format";
3
+ import { writeLn } from "../terminal";
4
+
5
+ export async function searchCommand(parts: string[], interactive: boolean) {
6
+ if (interactive || parts.length === 0) {
7
+ const { promptLocation } = await import("../prompt");
8
+ writeLn("");
9
+ const result = await promptLocation("Search", api.geocode);
10
+ writeLn(`\n ${result.name} ${result.lat}, ${result.lon}`);
11
+ return;
12
+ }
13
+
14
+ const query = parts.join(" ");
15
+ const results = await api.geocode(query);
16
+ const limit = 10;
17
+ writeLn(fmt.formatGeocode(results.slice(0, limit)));
18
+ if (results.length > limit) {
19
+ const chalk = (await import("chalk")).default;
20
+ writeLn(chalk.dim(`\n … and ${results.length - limit} more`));
21
+ }
22
+ }
package/src/prompt.ts CHANGED
@@ -221,7 +221,7 @@ export async function promptTimeMode(): Promise<TimeMode> {
221
221
 
222
222
  // Need time input
223
223
  await write(
224
- `${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold(opt.label.replace("…", ":"))} `,
224
+ `${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold(opt.label.replace("…", ":"))} ${chalk.dim("(HH:MM) ")}`,
225
225
  );
226
226
  disableRaw();
227
227
 
@@ -229,7 +229,8 @@ export async function promptTimeMode(): Promise<TimeMode> {
229
229
  if (/^\d{1,2}:\d{2}$/.test(timeInput)) {
230
230
  return { time: timeInput, arrivalBy: opt.arrivalBy };
231
231
  }
232
- // Invalid, re-prompt
232
+ // Invalid, show hint and re-prompt
233
+ await write(chalk.yellow(" Please enter time as HH:MM (e.g. 08:30)\n"));
233
234
  enableRaw();
234
235
  await draw();
235
236
  continue;