Skill: eggressive/ns-trains (version 1.1.0)

--- name: ns-trains description: Check Dutch train schedules, departures, disruptions, and plan journeys using the NS API. Perfect for daily commute checks. metadata: {"openclaw":{"emoji":"🚆","requires":{"bins":["node"],"env":["NS_SUBSCRIPTION_KEY"]},"primaryEnv":"NS_SUBSCRIPTION_KEY"}} --- # NS Trains Skill Check Dutch train schedules, departures, disruptions, and plan journeys using the official NS (Nederlandse Spoorwegen) API. ## Setup ### 1. Get an NS subscription key 1. Go to [NS API Portal](https://apiportal.ns.nl/) 2. Create an account and subscribe to the **Ns-App** product (free tier available) 3. Copy your **Primary Key** ### 2. Set Environment Variables ```bash export NS_SUBSCRIPTION_KEY="your-subscription-key-here" # preferred # Back-compat: export NS_API_KEY="$NS_SUBSCRIPTION_KEY" # legacy name still supported # Optional: Configure commute stations for quick shortcuts export NS_HOME_STATION="Utrecht Centraal" export NS_WORK_STATION="Amsterdam Zuid" ``` For security, prefer injecting these env vars via your runtime secret mechanism rather than committing them anywhere. Avoid printing or sharing your subscription key. ## Quick Usage ### 🚆 Commute shortcuts ```bash node {baseDir}/scripts/commute.mjs --to-work # Morning: Home → Work node {baseDir}/scripts/commute.mjs --to-home # Evening: Work → Home ``` ### Plan any journey ```bash node {baseDir}/scripts/journey.mjs --from "Utrecht Centraal" --to "Amsterdam Zuid" ``` ### Check departures from a station ```bash node {baseDir}/scripts/departures.mjs --station "Amsterdam Centraal" ``` ### Check arrivals at a station ```bash node {baseDir}/scripts/arrivals.mjs --station "Rotterdam Centraal" ``` ### Search for stations ```bash node {baseDir}/scripts/stations.mjs amsterdam node {baseDir}/scripts/stations.mjs --search "den haag" ``` ### Check current disruptions ```bash node {baseDir}/scripts/disruptions.mjs node {baseDir}/scripts/disruptions.mjs --from "Utrecht" --to "Amsterdam" ``` ## Natural Language Just ask: - "When is the next train to Amsterdam?" - "Check trains from Utrecht to Rotterdam" - "Any train disruptions today?" - "Plan my commute to work" - "What time does the train arrive?" ## Output Returns journey options with: - Departure/arrival times - Real-time delays - Duration - Transfers - Platform numbers - Disruption warnings - Crowdedness forecast (🟢 low / 🟡 medium / 🔴 high) ## Commands Reference | Command | Description | |---------|-------------| | `commute.mjs [work\|home]` | Quick commute check (requires NS_HOME_STATION & NS_WORK_STATION) | | `journey.mjs --from X --to Y` | Plan a journey between any stations | | `departures.mjs --station X` | List departures from a station | | `arrivals.mjs --station X` | List arrivals at a station | | `stations.mjs [query]` | Search for station names | | `disruptions.mjs` | Check current disruptions | ## API Endpoints Used - [REDACTED_SECRET] - Journey planning - [REDACTED_SECRET] - Arrivals - [REDACTED_SECRET] - Departures - [REDACTED_SECRET] - Disruptions - [REDACTED_SECRET] - Station search ## Reference - NS API Portal: https://apiportal.ns.nl/ - Documentation: https://apiportal.ns.nl/startersguide - Free tier: 5000 requests/day

path: SECURITY.md
content: # Security Notes — ns-trains

This skill calls the official **NS (Nederlandse Spoorwegen) API** to retrieve public travel information (journeys, departures, arrivals, disruptions, station lookup).

## Secrets

- Uses a single secret: `NS_SUBSCRIPTION_KEY` (read from the environment).
- The subscription key is only used to set the HTTP header:
  - `Ocp-Apim-Subscription-Key: <subscription key>`
- The code **does not print** the subscription key and should never log it.

## Network Egress Controls

All HTTP requests go through `scripts/ns-api.mjs` (`nsFetch()`), which enforces:

- **HTTPS only** (refuses non-HTTPS URLs)
- **Host allowlist**: only requests to the NS API gateway are permitted:
  - `gateway.apiportal.ns.nl`

If a URL is not allowlisted, the request is rejected.

## Endpoints Used

Hardcoded endpoints (all under `https://gateway.apiportal.ns.nl/`):

- `/reisinformatie-api/api/v2/stations`
- `/reisinformatie-api/api/v2/arrivals`
- `/reisinformatie-api/api/v2/departures`
- `/reisinformatie-api/api/v3/trips`
- `/reisinformatie-api/api/v3/disruptions`

## Data Handling

- Reads only from environment variables and NS API responses.
- Does not write files, modify system settings, or execute shell commands.
- Outputs results to stdout (times, platforms, disruptions, etc.).

## Operational Guidance

- Treat `NS_SUBSCRIPTION_KEY` as a secret (do not commit, paste, or share).
- Prefer injecting the key via runtime secrets (container env, secret manager) rather than shell profile files.
- If you suspect key exposure, rotate it in the NS API portal.


path: scripts/arrivals.mjs
content: #!/usr/bin/env node
/**
 * NS Station Arrivals
 * Usage: node arrivals.mjs --station "Station Name"
 */

import { nsFetch, requireNsSubscriptionKey } from './ns-api.mjs';

const NS_SUBSCRIPTION_KEY = (() => {
  try { return requireNsSubscriptionKey(); }
  catch (e) { console.error(`❌ ${e.message}`); process.exit(1); }
})();

const STATIONS_URL = 'https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/stations';
const ARRIVALS_URL = 'https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/arrivals';

const args = process.argv.slice(2);
const getArg = (flag) => {
  const idx = args.indexOf(flag);
  return idx !== -1 ? args[idx + 1] : null;
};

const station = getArg('--station') || getArg('-s');
const limit = parseInt(getArg('--limit') || '10');

if (!station) {
  console.log(`
🚉 NS Station Arrivals

Usage: node arrivals.mjs --station "Station Name" [--limit 10]

Examples:
  node arrivals.mjs --station "Amsterdam Zuid"
  node arrivals.mjs -s "Almere Centrum" --limit 5
`);
  process.exit(1);
}

async function getStationCode(name) {
  const res = await nsFetch(`${STATIONS_URL}?q=${encodeURIComponent(name)}`, {
    subscriptionKey: NS_SUBSCRIPTION_KEY,
  });
  const data = await res.json();
  return data.payload?.[0]?.code || null;
}

async function getArrivals() {
  try {
    // Resolve station name to code
    const code = await getStationCode(station);
    if (!code) {
      console.error(`❌ Station not found: ${station}`);
      process.exit(1);
    }

    const res = await nsFetch(`${ARRIVALS_URL}?station=${code}&maxJourneys=${limit}`, {
      subscriptionKey: NS_SUBSCRIPTION_KEY,
    });

    if (!res.ok) {
      console.error(`❌ API Error: ${res.status}`);
      process.exit(1);
    }

    const data = await res.json();
    const arrivals = data.payload?.arrivals || [];

    if (arrivals.length === 0) {
      console.log(`❌ No arrivals found for "${station}"`);
      process.exit(0);
    }

    console.log(`\n🚉 Arrivals at ${station}`);
    console.log('═'.repeat(50));

    arrivals.forEach(arr => {
      const planned = new Date(arr.plannedDateTime);
      const actual = arr.actualDateTime ? new Date(arr.actualDateTime) : planned;
      const delay = Math.round((actual - planned) / 60000);

      const time = planned.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
      const delayStr = delay > 0 ? ` (+${delay})` : '';
      const status = arr.cancelled ? '❌' : (delay > 0 ? '⚠️' : '✅');

      const origin = arr.origin || 'Unknown';
      const platform = arr.actualTrack || arr.plannedTrack || '?';
      const trainType = arr.product?.shortCategoryName || '?';

      console.log(`\n${status} ${time}${delayStr} ← ${origin}`);
      console.log(`   🚆 ${trainType} | 🚏 Platform ${platform}`);

      if (arr.cancelled) console.log(`   ❌ CANCELLED`);
    });

    console.log('\n' + '─'.repeat(50));
    console.log(`⏱️  Updated: ${new Date().toLocaleTimeString('nl-NL')}`);

  } catch (err) {
    console.error(`❌ Error: ${err.message}`);
    process.exit(1);
  }
}

getArrivals();


path: scripts/commute.mjs
content: #!/usr/bin/env node
/**
 * NS Commute Shortcut
 * Usage: node commute.mjs [--to-work | --to-home]
 * 
 * Configure via environment variables:
 *   NS_HOME_STATION - Your home station (e.g., "Almere Centrum")
 *   NS_WORK_STATION - Your work station (e.g., "Amsterdam Zuid")
 */

import { nsFetch, requireNsSubscriptionKey } from './ns-api.mjs';

const NS_SUBSCRIPTION_KEY = (() => {
  try { return requireNsSubscriptionKey(); }
  catch (e) { console.error(`❌ ${e.message}`); process.exit(1); }
})();

const BASE_URL = 'https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips';

// Commute settings from environment
const COMMUTE = {
  home: process.env.NS_HOME_STATION || '',
  work: process.env.NS_WORK_STATION || ''
};

// NS_SUBSCRIPTION_KEY is validated at startup via requireNsSubscriptionKey()

const args = process.argv.slice(2);
const toWork = args.includes('--to-work') || args.includes('-w') || args.includes('work');
const toHome = args.includes('--to-home') || args.includes('-h') || args.includes('home');

if (!toWork && !toHome) {
  console.log(`
🚆 NS Commute Shortcut

Usage: node commute.mjs [direction]

Directions:
  --to-work, -w, work    Home → Work
  --to-home, -h, home    Work → Home

Configuration (environment variables):
  NS_SUBSCRIPTION_KEY        Your NS API subscription key (required)
  NS_HOME_STATION   Your home station (e.g., "Almere Centrum")
  NS_WORK_STATION   Your work station (e.g., "Amsterdam Zuid")

Examples:
  node commute.mjs --to-work
  node commute.mjs home
`);
  process.exit(1);
}

if (!COMMUTE.home || !COMMUTE.work) {
  console.error(`❌ Commute stations not configured

Set these environment variables:
  export NS_HOME_STATION="Your Home Station"
  export NS_WORK_STATION="Your Work Station"

Example:
  export NS_HOME_STATION="Utrecht Centraal"
  export NS_WORK_STATION="Amsterdam Zuid"
`);
  process.exit(1);
}

const from = toWork ? COMMUTE.home : COMMUTE.work;
const to = toWork ? COMMUTE.work : COMMUTE.home;
const direction = toWork ? '🏢 To Work' : '🏠 To Home';

async function planCommute() {
  const params = new URLSearchParams({
    fromStation: from,
    toStation: to,
    searchForArrival: 'false'
  });

  try {
    const res = await nsFetch(`${BASE_URL}?${params}`, {
      subscriptionKey: NS_SUBSCRIPTION_KEY,
    });

    if (!res.ok) {
      console.error(`❌ API Error: ${res.status}`);
      process.exit(1);
    }

    const data = await res.json();
    const trips = data.trips || [];

    if (trips.length === 0) {
      console.log('❌ No trips found');
      process.exit(0);
    }

    console.log(`\n${direction}: ${from} → ${to}`);
    console.log('═'.repeat(50));

    trips.slice(0, 5).forEach((trip, i) => {
      const legs = trip.legs || [];
      const firstLeg = legs[0];
      const lastLeg = legs[legs.length - 1];
      if (!firstLeg || !lastLeg) return;

      const dep = new Date(firstLeg.origin?.plannedDateTime);
      const arr = new Date(lastLeg.destination?.plannedDateTime);
      const duration = trip.plannedDurationInMinutes || Math.round((arr - dep) / 60000);

      const depTime = dep.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
      const arrTime = arr.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });

      const depDelay = firstLeg.origin?.actualDateTime ?
        Math.round((new Date(firstLeg.origin.actualDateTime) - dep) / 60000) : 0;
      const delayStr = depDelay > 0 ? ` (+${depDelay})` : '';

      const transfers = trip.transfers || 0;
      const platform = firstLeg.origin?.plannedTrack || '?';
      const status = trip.status === 'NORMAL' ? '✅' : '⚠️';

      const crowd = firstLeg.crowdForecast || 'UNKNOWN';
      const crowdEmoji = { 'LOW': '🟢', 'MEDIUM': '🟡', 'HIGH': '🔴' }[crowd] || '⚪';

      console.log(`\n${status} ${depTime}${delayStr} → ${arrTime} (${duration} min)`);
      console.log(`   🔄 ${transfers} transfers | 🚏 Spoor ${platform} | ${crowdEmoji} ${crowd.toLowerCase()}`);

      if (trip.messages?.length > 0) {
        trip.messages.forEach(m => m.text && console.log(`   ⚠️  ${m.text}`));
      }
    });

    console.log('\n' + '─'.repeat(50));
    console.log(`⏱️  ${new Date().toLocaleTimeString('nl-NL')}`);

  } catch (err) {
    console.error(`❌ Error: ${err.message}`);
    process.exit(1);
  }
}

planCommute();


path: scripts/departures.mjs
content: #!/usr/bin/env node
/**
 * NS Station Departures
 * Usage: node departures.mjs --station "Station Name"
 */

import { nsFetch, requireNsSubscriptionKey } from './ns-api.mjs';

const NS_SUBSCRIPTION_KEY = (() => {
  try { return requireNsSubscriptionKey(); }
  catch (e) { console.error(`❌ ${e.message}. Missing subscription key env var; set it and retry.`); process.exit(1); }
})();

const BASE_URL = 'https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/departures';

// Parse arguments
const args = process.argv.slice(2);
const getArg = (flag) => {
  const idx = args.indexOf(flag);
  return idx !== -1 ? args[idx + 1] : null;
};

const station = getArg('--station') || getArg('-s');
const limit = parseInt(getArg('--limit') || '10');

if (!station) {
  console.log(`
🚉 NS Station Departures

Usage: node departures.mjs --station "Station Name" [--limit 10]

Examples:
  node departures.mjs --station "Almere Centrum"
  node departures.mjs --station "Amsterdam Zuid" --limit 5
  node departures.mjs -s "Utrecht Centraal"
`);
  process.exit(1);
}

function looksLikeStationCode(s) {
  return /^[A-Z0-9]{3,6}$/.test(s);
}

async function resolveStationCode(input) {
  if (looksLikeStationCode(input)) return input;

  // Use stations endpoint to resolve display name -> code
  const stationsUrl = `https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/stations?q=${encodeURIComponent(input)}&limit=10`;
  const res = await nsFetch(stationsUrl, { subscriptionKey: NS_SUBSCRIPTION_KEY });

  if (!res.ok) {
    const error = await res.text();
    throw new Error(`Stations lookup failed (${res.status}): ${error}`);
  }

  const data = await res.json();
  const stations = data.payload || [];
  if (stations.length === 0) throw new Error(`Station not found: ${input}`);

  const norm = (s) => (s || '').toString().trim().toLowerCase();
  const wanted = norm(input);

  const exact = stations.find(s => {
    const name = s.namen?.lang || s.namen?.middel || s.code;
    return norm(name) === wanted;
  });

  return (exact || stations[0]).code;
}

async function getDepartures() {
  const stationCode = await resolveStationCode(station);
  if (stationCode !== station) {
    console.log(`🔎 Resolved "${station}" → ${stationCode}`);
  }

  const params = new URLSearchParams({
    station: stationCode,
    maxJourneys: limit.toString()
  });

  const url = `${BASE_URL}?${params}`;
  
  try {
    const response = await nsFetch(url, {
      subscriptionKey: NS_SUBSCRIPTION_KEY,
    });

    if (!response.ok) {
      const error = await response.text();
      console.error(`❌ API Error (${response.status}): ${error}`);
      process.exit(1);
    }

    const data = await response.json();
    const departures = data.payload?.departures || [];
    
    if (departures.length === 0) {
      console.log(`❌ No departures found for "${station}"`);
      process.exit(0);
    }

    console.log(`\n🚉 Departures from ${station}`);
    console.log('═'.repeat(50));
    
    departures.forEach(dep => {
      const planned = new Date(dep.plannedDateTime);
      const actual = dep.actualDateTime ? new Date(dep.actualDateTime) : planned;
      const delay = Math.round((actual - planned) / 60000);
      
      const time = planned.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
      const delayStr = delay > 0 ? ` (+${delay})` : '';
      const status = dep.cancelled ? '❌' : (delay > 0 ? '⚠️' : '✅');
      
      const direction = dep.direction || 'Unknown';
      const platform = dep.plannedTrack || '?';
      const trainType = dep.product?.shortCategoryName || dep.trainCategory || '?';
      
      console.log(`\n${status} ${time}${delayStr} → ${direction}`);
      console.log(`   🚆 ${trainType} | 🚏 Platform ${platform}`);
      
      if (dep.cancelled) {
        console.log(`   ❌ CANCELLED`);
      }
      
      if (dep.messages && dep.messages.length > 0) {
        dep.messages.forEach(msg => {
          console.log(`   ⚠️  ${msg.message || msg.text || ''}`);
        });
      }
      
      // Route stations
      if (dep.routeStations && dep.routeStations.length > 0) {
        const via = dep.routeStations.slice(0, 3).map(s => s.mediumName).join(' → ');
        console.log(`   📍 Via: ${via}`);
      }
    });
    
    console.log('\n' + '─'.repeat(50));
    console.log(`⏱️  Updated: ${new Date().toLocaleTimeString('nl-NL')}`);
    
  } catch (err) {
    console.error(`❌ Error: ${err.message}`);
    process.exit(1);
  }
}

getDepartures();


path: scripts/disruptions.mjs
content: #!/usr/bin/env node
/**
 * NS Disruptions Checker
 * Usage: node disruptions.mjs [--from "Station" --to "Station"]
 */

import { nsFetch, requireNsSubscriptionKey } from './ns-api.mjs';

const NS_SUBSCRIPTION_KEY = (() => {
  try { return requireNsSubscriptionKey(); }
  catch (e) { console.error(`❌ ${e.message}. Missing subscription key env var; set it and retry.`); process.exit(1); }
})();

const BASE_URL = 'https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/disruptions';

// Parse arguments  
const args = process.argv.slice(2);
const getArg = (flag) => {
  const idx = args.indexOf(flag);
  return idx !== -1 ? args[idx + 1] : null;
};

const fromStation = getArg('--from');
const toStation = getArg('--to');
const showAll = args.includes('--all');

async function getDisruptions() {
  const params = new URLSearchParams({
    isActive: 'true'
  });

  const url = `${BASE_URL}?${params}`;
  
  try {
    const response = await nsFetch(url, {
      subscriptionKey: NS_SUBSCRIPTION_KEY,
    });

    if (!response.ok) {
      const error = await response.text();
      console.error(`❌ API Error (${response.status}): ${error}`);
      process.exit(1);
    }

    const data = await response.json();
    let disruptions = data.payload || data || [];
    
    // Flatten if nested
    if (!Array.isArray(disruptions)) {
      disruptions = disruptions.disruptions || [];
    }
    
    if (disruptions.length === 0) {
      console.log('✅ No active disruptions!');
      process.exit(0);
    }
    
    // Filter for route if specified
    if (fromStation || toStation) {
      const searchTerms = [fromStation, toStation].filter(Boolean).map(s => s.toLowerCase());
      disruptions = disruptions.filter(d => {
        const title = (d.title || '').toLowerCase();
        const desc = (d.description || d.timespans?.[0]?.situation?.label || '').toLowerCase();
        const stations = (d.registrationNumbers || []).join(' ').toLowerCase();
        const trajectory = (d.trajectory?.stations || []).map(s => s.name).join(' ').toLowerCase();
        
        const fullText = `${title} ${desc} ${stations} ${trajectory}`;
        return searchTerms.some(term => fullText.includes(term));
      });
      
      if (disruptions.length === 0) {
        console.log(`✅ No disruptions affecting ${fromStation || ''} ${toStation ? '→ ' + toStation : ''}`);
        process.exit(0);
      }
    }
    
    // Limit unless --all
    if (!showAll && disruptions.length > 10) {
      disruptions = disruptions.slice(0, 10);
    }

    console.log(`\n⚠️  Active Disruptions${fromStation ? ` (${fromStation}${toStation ? ' → ' + toStation : ''})` : ''}`);
    console.log('═'.repeat(50));
    
    disruptions.forEach((d, i) => {
      const title = d.title || 'Unknown disruption';
      const type = d.type || 'DISRUPTION';
      const phase = d.phase?.label || d.phase || '';
      
      // Get description from timespans
      let description = '';
      if (d.timespans && d.timespans.length > 0) {
        const ts = d.timespans[0];
        description = ts.situation?.label || ts.cause?.label || '';
        if (ts.alternativeTransport?.label) {
          description += ` | ${ts.alternativeTransport.label}`;
        }
      }
      
      const emoji = type === 'MAINTENANCE' ? '🔧' : '⚠️';
      
      console.log(`\n${emoji} ${title}`);
      if (description) {
        console.log(`   ${description}`);
      }
      if (phase) {
        console.log(`   📍 Phase: ${phase}`);
      }
      
      // Expected duration
      if (d.expectedDuration?.description) {
        console.log(`   ⏰ ${d.expectedDuration.description}`);
      }
      
      // Affected stations
      if (d.trajectory?.stations && d.trajectory.stations.length > 0) {
        const stations = d.trajectory.stations.map(s => s.name).slice(0, 5).join(' ↔ ');
        console.log(`   🚉 ${stations}`);
      }
    });
    
    console.log('\n' + '─'.repeat(50));
    console.log(`⏱️  Checked: ${new Date().toLocaleTimeString('nl-NL')}`);
    if (!showAll && data.payload?.length > 10) {
      console.log(`ℹ️  Showing 10 of ${data.payload.length}. Use --all to see all.`);
    }
    
  } catch (err) {
    console.error(`❌ Error: ${err.message}`);
    process.exit(1);
  }
}

getDisruptions();


path: scripts/journey.mjs
content: #!/usr/bin/env node
/**
 * NS Journey Planner
 * Usage: node journey.mjs --from "Station A" --to "Station B"
 */

import { nsFetch, requireNsSubscriptionKey } from './ns-api.mjs';

const NS_SUBSCRIPTION_KEY = (() => {
  try { return requireNsSubscriptionKey(); }
  catch (e) { console.error(`❌ ${e.message}. Missing subscription key env var; set it and retry.`); process.exit(1); }
})();

const BASE_URL = 'https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips';

// Parse arguments
const args = process.argv.slice(2);
const getArg = (flag) => {
  const idx = args.indexOf(flag);
  return idx !== -1 ? args[idx + 1] : null;
};

const fromStation = getArg('--from');
const toStation = getArg('--to');
const dateTime = getArg('--time'); // Optional: ISO datetime

if (!fromStation || !toStation) {
  console.log(`
🚆 NS Journey Planner

Usage: node journey.mjs --from "Station" --to "Station" [--time "2026-01-30T08:00:00"]

Examples:
  node journey.mjs --from "Almere Centrum" --to "Amsterdam Zuid"
  node journey.mjs --from "Amsterdam Zuid" --to "Almere Muziekwijk" --time "2026-01-30T17:30:00"
`);
  process.exit(1);
}

async function planJourney() {
  const params = new URLSearchParams({
    fromStation: fromStation,
    toStation: toStation,
    searchForArrival: 'false',
  });
  
  if (dateTime) {
    params.append('dateTime', dateTime);
  }

  const url = `${BASE_URL}?${params}`;
  
  try {
    const response = await nsFetch(url, {
      subscriptionKey: NS_SUBSCRIPTION_KEY,
    });

    if (!response.ok) {
      const error = await response.text();
      console.error(`❌ API Error (${response.status}): ${error}`);
      process.exit(1);
    }

    const data = await response.json();
    
    if (!data.trips || data.trips.length === 0) {
      console.log('❌ No trips found for this route.');
      process.exit(0);
    }

    console.log(`\n🚆 ${fromStation} → ${toStation}`);
    console.log('═'.repeat(50));
    
    // Show first 5 options
    data.trips.slice(0, 5).forEach((trip, i) => {
      const legs = trip.legs || [];
      const firstLeg = legs[0];
      const lastLeg = legs[legs.length - 1];
      
      if (!firstLeg || !lastLeg) return;
      
      const departure = new Date(firstLeg.origin?.plannedDateTime);
      const arrival = new Date(lastLeg.destination?.plannedDateTime);
      const duration = trip.plannedDurationInMinutes || Math.round((arrival - departure) / 60000);
      
      const depTime = departure.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
      const arrTime = arrival.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
      
      const transfers = trip.transfers || 0;
      const status = trip.status === 'NORMAL' ? '✅' : '⚠️';
      
      // Check for delays
      const depDelay = firstLeg.origin?.actualDateTime ? 
        Math.round((new Date(firstLeg.origin.actualDateTime) - departure) / 60000) : 0;
      const delayStr = depDelay > 0 ? ` (+${depDelay}min)` : '';
      
      // Platform
      const platform = firstLeg.origin?.plannedTrack || '?';
      
      // Crowdedness
      const crowd = firstLeg.crowdForecast || 'UNKNOWN';
      const crowdEmoji = {
        'LOW': '🟢',
        'MEDIUM': '🟡', 
        'HIGH': '🔴',
        'UNKNOWN': '⚪'
      }[crowd] || '⚪';
      
      console.log(`\n${status} Option ${i + 1}: ${depTime}${delayStr} → ${arrTime}`);
      console.log(`   ⏱️  ${duration} min | 🔄 ${transfers} transfers | 🚏 Platform ${platform}`);
      console.log(`   ${crowdEmoji} Crowdedness: ${crowd.toLowerCase()}`);
      
      // Show legs
      if (legs.length > 1) {
        console.log('   Route:');
        legs.forEach(leg => {
          const type = leg.product?.shortCategoryName || leg.product?.categoryName || '?';
          const dir = leg.direction || '';
          console.log(`     • ${type} ${dir ? '→ ' + dir : ''}`);
        });
      }
      
      // Warnings
      if (trip.messages && trip.messages.length > 0) {
        trip.messages.forEach(msg => {
          if (msg.text) {
            console.log(`   ⚠️  ${msg.text}`);
          }
        });
      }
    });
    
    console.log('\n' + '─'.repeat(50));
    console.log(`⏱️  Checked: ${new Date().toLocaleTimeString('nl-NL')}`);
    
  } catch (err) {
    console.error(`❌ Error: ${err.message}`);
    process.exit(1);
  }
}

planJourney();


path: scripts/ns-api.mjs
content: // Shared NS API helpers
// Security: enforce allowlisted host + HTTPS and never log subscription keys.

const NS_API_HOST_ALLOWLIST = new Set([
  'gateway.apiportal.ns.nl',
]);

export function requireNsSubscriptionKey() {
  // Prefer the newer env var name, but support legacy NS_API_KEY for backward compatibility.
  const key = process.env.NS_SUBSCRIPTION_KEY || process.env.NS_API_KEY;
  if (!key) {
    throw new Error('NS_SUBSCRIPTION_KEY not set (or legacy NS_API_KEY missing)');
  }
  return key;
}

function assertAllowlistedUrl(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'https:') {
    throw new Error(`Refusing non-HTTPS request: ${u.protocol}`);
  }
  if (!NS_API_HOST_ALLOWLIST.has(u.hostname)) {
    throw new Error(`Refusing request to non-allowlisted host: ${u.hostname}`);
  }
  return u;
}

export async function nsFetch(url, { subscriptionKey, headers = {}, ...opts } = {}) {
  const u = assertAllowlistedUrl(url);

  const res = await fetch(u, {
    ...opts,
    headers: {
      'Ocp-Apim-Subscription-Key': subscriptionKey,
      'Accept': 'application/json',
      ...headers,
    },
  });

  return res;
}


path: scripts/stations.mjs
content: #!/usr/bin/env node
/**
 * NS Station Lookup
 * Usage: node stations.mjs --search "query"
 */

import { nsFetch, requireNsSubscriptionKey } from './ns-api.mjs';

const NS_SUBSCRIPTION_KEY = (() => {
  try { return requireNsSubscriptionKey(); }
  catch (e) { console.error(`❌ ${e.message}`); process.exit(1); }
})();

const BASE_URL = 'https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/stations';

const args = process.argv.slice(2);
const getArg = (flag) => {
  const idx = args.indexOf(flag);
  return idx !== -1 ? args[idx + 1] : null;
};

const query = getArg('--search') || getArg('-s') || args[0];
const limit = parseInt(getArg('--limit') || '10');

if (!query) {
  console.log(`
🚉 NS Station Lookup

Usage: node stations.mjs --search "query" [--limit 10]

Examples:
  node stations.mjs --search "amsterdam"
  node stations.mjs -s "almere"
  node stations.mjs utrecht
`);
  process.exit(1);
}

async function searchStations() {
  try {
    const res = await nsFetch(`${BASE_URL}?q=${encodeURIComponent(query)}&limit=${limit}`, {
      subscriptionKey: NS_SUBSCRIPTION_KEY,
    });

    if (!res.ok) {
      console.error(`❌ API Error: ${res.status}`);
      process.exit(1);
    }

    const data = await res.json();
    const stations = data.payload || [];

    if (stations.length === 0) {
      console.log(`❌ No stations found for "${query}"`);
      process.exit(0);
    }

    console.log(`\n🚉 Stations matching "${query}"`);
    console.log('═'.repeat(50));

    stations.forEach(s => {
      const name = s.namen?.lang || s.namen?.middel || s.code;
      const code = s.code;
      const type = s.stationType?.replace(/_/g, ' ').toLowerCase() || 'station';
      const country = s.land || 'NL';

      console.log(`\n📍 ${name}`);
      console.log(`   Code: ${code} | Type: ${type} | ${country}`);
      
      if (s.sporen?.length > 0) {
        const tracks = s.sporen.map(sp => sp.spoorNummer).join(', ');
        console.log(`   Tracks: ${tracks}`);
      }
    });

    console.log('\n' + '─'.repeat(50));
    console.log(`Found ${stations.length} station(s)`);

  } catch (err) {
    console.error(`❌ Error: ${err.message}`);
    process.exit(1);
  }
}

searchStations();


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: clean (LOW)