๐ @query-farm/vgi-nl-trains-cli
May 23, 2026 ยท View on GitHub
A live Dutch railway (NS) dashboard in your terminal โ a station list on the left, with departures (top) and arrivals (bottom) for the selected station on the right. Built with Ink (React for the terminal).
It runs Haybarn โ a derived distribution of
DuckDB โ via @haybarn/node-api, loads the vgi extension,
attaches a VGI worker exposing real-time NS (Nederlandse Spoorwegen) data as SQL table
functions, and renders it as an animated split-flap board.
๐ trains ๐ 12:24
โญโโโโโโโโโโโโโโโโโโโโฎ โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ ๐ Stations โ โ ๐ Departures ยท Utrecht Centraal 8 โ
โ ๐ utr โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โโโโโโโโโโโโโโโโโ โ โ Time โ In โ ฮ โ Type โ To โ Trk โ
โ โธ UT Utrecht Cโฆ โ โ โธ 12:24 โ now โ โข โ โ Sprinter โ Amstโฆ โ 1 โ
โ UTLR Utrecht Lโฆ โ โ 12:24 โ 1m โ+13โ โ Intercityโ Nijmโฆ โ 19 โ
โ ... โ โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
โ โ โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ โ โ ๐ Arrivals ยท Utrecht Centraal 8 โ
โฐโโโโโโโโโโโโโโโโโโโโฏ โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
Run it
No install needed โ it talks to a hosted worker by default:
npx @query-farm/vgi-nl-trains-cli # dashboard, pick a station
npx @query-farm/vgi-nl-trains-cli UT # focused on Utrecht Centraal
npx @query-farm/vgi-nl-trains-cli ASD -w 15 # auto-refresh both boards every 15s
Data source
By default the CLI connects to the hosted VGI worker at https://vgi-trains.fly.dev,
so there's nothing to set up. Override the source with environment variables:
| Env var | Effect |
|---|---|
VGI_TRAINS_URL | point at a different HTTP(S) worker, e.g. your own deployment |
VGI_TRAINS_DIR | run a local worker via stdio (path to a vgi-trains-python-fly checkout) |
VGI_TRAINS_LOCATION | a raw DuckDB ATTACH location string (advanced) |
If the worker requires authentication and none is provided, the ATTACH fails and the
CLI shows an "Authentication required" message instead of crashing.
Develop
npm install
npm run dev -- UT --watch # tsx runs source/cli.jsx with no build step
npm run build # bundle source/cli.jsx โ dist/cli.js (esbuild)
Keys
| Context | Keys |
|---|---|
| Station list | type to filter ยท โโ pick station (updates both boards) ยท โ/โ enter boards ยท esc quit |
| A board | โโ select service ยท โ trip detail ยท Tab switch dep โ arr ยท โ back to list ยท q quit |
| Trip detail | esc / โ back |
The selected station drives both panes at once; the focused pane has a cyan border
and shows the โธ row marker.
Options
| Flag | Meaning |
|---|---|
-w, --watch [secs] | auto-refresh the boards (default 30s, min 5) |
-s, --stations [q] | start with the station filter set to q |
-h, --help | help |
Layout & terminal width
The destination column flexes to stdout.columns, so the dashboard fits an 80-column
terminal (destinations truncate) and widens up to ~92 columns. When stdout isn't a TTY
(piped, CI), it skips the interactive layer and prints the two boards stacked as one
static frame โ node dist/cli.js UT | cat stays clean.
Features
- Live dashboard โ station list (left) drives departures + arrivals (right) at once; the layout fills the terminal and reflows on resize.
- Split-flap animation โ cells scramble through glyphs and settle; re-flaps when you
change station and on every
--watchrefresh. Display-width-aware, so emoji in NS data (e.g.Alkmaar ๐ณ๏ธโ๐,Schiphol โ๏ธ) stay column-aligned. - Operator coloring โ non-NS operators (Arriva, Blauwnet, ICE, โฆ) are tinted so they stand out; NS uses the default fg.
- Via stops on the board โ each departure shows its via chain inline when it fits, or
on a dim
โณ via โฆcontinuation line when it doesn't. - Cancelled services โ shown struck through on a red background.
- Trip detail โ
โon a service shows route, planned-vs-actual time, platform change, status, via stops, and disruption messages.
How it works
source/vgi.jsโ the data layer. Opens an in-memory Haybarn database,LOADs thevgiextension, thenATTACHes the worker โ by default the hosted service over HTTPS, or a local worker over stdio whenVGI_TRAINS_DIRis set (Haybarn spawns it as a subprocess). Returns plain JS objects. (Note:station_arrivalshas noviacolumn โ only departures do.)- The
vgiextension ships as a real npm dependency,@haybarn/ext-vgi-h1-5-3(a meta-package that pulls the right platform binary viaoptionalDependencies, like esbuild). Haybarn discovers it automatically, so we justLOAD vgiโ noINSTALL ... FROM community, no network fetch, no path wiring. The-h1-5-3suffix is tied to the DuckDB engine version (@haybarn/node-api1.5.3); bumping the engine means switching to the matching@haybarn/ext-vgi-h1-5-xpackage. source/cli.jsxโ the Ink UI.<Dashboard>owns the navigation state (station filter/selection, pane focus, row selection, detail overlay) and fetches both boards when the selected station settles (debounced ~220ms so holdingโdoesn't spam the API). Animation and the interactive layer auto-disable when stdout isn't a TTY.Appis exported and only auto-runs as the CLI entry, so tests can drive it.
Testing
ink-testing-library drives the UI headlessly โ set process.stdout.isTTY = true and
process.stdout.columns, render(<App args=โฆ />), then stdin.write('\\t') / '\\r' /
arrow escapes ('\\x1b[B') and assert on lastFrame().
License
MIT ยฉ 2026 Query.Farm LLC