straeto 0.1.3 → 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 +34 -2
- package/package.json +1 -1
- package/src/cli.test.ts +26 -0
- package/src/cli.ts +33 -3
- package/src/commands/next.ts +86 -0
- package/src/commands/plan.ts +15 -0
- package/src/commands/schemas.ts +6 -0
- package/src/commands/search.ts +22 -0
- package/src/prompt.ts +3 -2
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
|
|
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
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 <
|
|
82
|
-
.option("-t, --to <
|
|
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
|
+
}
|
package/src/commands/plan.ts
CHANGED
|
@@ -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("");
|
package/src/commands/schemas.ts
CHANGED
|
@@ -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;
|