straeto 0.1.4 → 0.1.5

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
@@ -63,6 +63,15 @@ straeto stop Hlemmur
63
63
  straeto stop 10000802
64
64
  ```
65
65
 
66
+ ### `straeto search [query]`
67
+
68
+ Search for a place by name. Launches interactive mode with autocomplete if no query is provided.
69
+
70
+ ```bash
71
+ straeto search Höfðatorg # search by name
72
+ straeto search # interactive with autocomplete
73
+ ```
74
+
66
75
  ### `straeto alerts`
67
76
 
68
77
  Show active service alerts.
@@ -72,17 +81,40 @@ straeto alerts # alerts in Icelandic (default)
72
81
  straeto alerts -l EN # alerts in English
73
82
  ```
74
83
 
84
+ ### `straeto next <stop>`
85
+
86
+ Show real-time upcoming arrivals at a stop.
87
+
88
+ ```bash
89
+ straeto next Höfðatorg # all upcoming arrivals
90
+ straeto next Höfðatorg -r 14 # only route 14
91
+ straeto next Höfðatorg -d Grandi # filter by direction
92
+ straeto next Höfðatorg -r 14 -n 3 # limit to 3 results
93
+ ```
94
+
75
95
  ### `straeto plan`
76
96
 
77
- Plan a trip between two locations. Launches interactive mode by default with autocomplete search and time selection.
97
+ Plan a trip between two locations. Accepts place names or coordinates. Launches interactive mode by default with autocomplete search and time selection.
78
98
 
79
99
  ```bash
80
100
  straeto plan # interactive mode
81
- straeto plan -f 64.14,-21.90 -t 64.10,-21.94 # coordinate mode
101
+ straeto plan -f Höfðatorg -t Mýrargata # place names
102
+ straeto plan -f 64.14,-21.90 -t 64.10,-21.94 # coordinates
82
103
  straeto plan --at 08:30 # depart at specific time
83
104
  straeto plan --by 17:00 # arrive by specific time
84
105
  ```
85
106
 
107
+ ## Claude Code Plugin
108
+
109
+ 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.
110
+
111
+ ```
112
+ /plugin marketplace add magtastic/straeto-cli
113
+ /plugin install straeto@straeto
114
+ ```
115
+
116
+ Then type `/straeto` or just ask about buses in Reykjavík.
117
+
86
118
  ## Development
87
119
 
88
120
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "straeto",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Strætó — Icelandic bus system CLI",
5
5
  "author": "magtastic",
6
6
  "license": "MIT",
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;