#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const DEFAULT_KEY_PATH = path.join(process.env.HOME || '', '.openclaw/workspace/.clawdbot/credentials/google-maps/apikey.txt'); const FALLBACK_KEY_PATHS = [ DEFAULT_KEY_PATH, path.join(process.env.HOME || '', '.openclaw/credentials/google-maps/apikey.txt'), ]; function parseArgs(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i++) { const t = argv[i]; if (t.startsWith('--')) { const k = t.slice(2); const n = argv[i + 1]; if (!n || n.startsWith('--')) out[k] = true; else { out[k] = n; i++; } } else out._.push(t); } return out; } function usage() { console.log(`Google Maps Traffic CLI Commands: eta --from "Origin" --to "Destination" [--departAt now|ISO] leave-by --from "Origin" --to "Destination" --arriveBy ISO Optional flags: --keyPath API key file path (default: ${DEFAULT_KEY_PATH}) --timeZone Display timezone (default: America/Chicago) --avoidTolls Avoid toll roads when possible --avoidHighways Avoid highways when possible --avoidFerries Avoid ferries when possible Notes: - Requires Google Maps APIs (Routes API + Geocoding API) enabled for the key. `); } function must(opts, keys) { const miss = keys.filter((k) => !opts[k]); if (miss.length) throw new Error(`Missing: ${miss.map((k) => '--' + k).join(', ')}`); } function readApiKey(opts) { if (process.env.GOOGLE_MAPS_API_KEY) return process.env.GOOGLE_MAPS_API_KEY.trim(); const candidates = opts.keyPath ? [opts.keyPath] : FALLBACK_KEY_PATHS; for (const p of candidates) { if (!fs.existsSync(p)) continue; const key = fs.readFileSync(p, 'utf8').trim(); if (!key) throw new Error(`API key file is empty: ${p}`); return key; } throw new Error(`API key file not found. Checked: ${candidates.join(', ')}`); } async function geocode(address, key) { const u = new URL('https://maps.googleapis.com/maps/api/geocode/json'); u.searchParams.set('address', address); u.searchParams.set('key', key); const r = await fetch(u); const j = await r.json(); if (j.status !== 'OK' || !j.results?.length) { throw new Error(`Geocoding failed for "${address}": ${j.status}${j.error_message ? ` (${j.error_message})` : ''}`); } const loc = j.results[0].geometry.location; return { lat: loc.lat, lng: loc.lng, formatted: j.results[0].formatted_address }; } function parseGoogleDuration(s) { // e.g. "1534s" const m = /^(-?\d+)s$/.exec(String(s || '')); if (!m) return null; return Number(m[1]); } function fmtMinutes(sec) { return `${Math.round(sec / 60)} min`; } function routeModifiersFromOpts(opts = {}) { const modifiers = {}; if (opts.avoidTolls) modifiers.avoidTolls = true; if (opts.avoidHighways) modifiers.avoidHighways = true; if (opts.avoidFerries) modifiers.avoidFerries = true; return Object.keys(modifiers).length ? modifiers : undefined; } async function computeRoute({ from, to, departAt, key, modifiers }) { const [o, d] = await Promise.all([geocode(from, key), geocode(to, key)]); const body = { origin: { location: { latLng: { latitude: o.lat, longitude: o.lng } } }, destination: { location: { latLng: { latitude: d.lat, longitude: d.lng } } }, travelMode: 'DRIVE', routingPreference: 'TRAFFIC_AWARE', computeAlternativeRoutes: false, languageCode: 'en-US', units: 'IMPERIAL', }; if (modifiers) body.routeModifiers = modifiers; if (departAt && departAt !== 'now') body.departureTime = new Date(departAt).toISOString(); const res = await fetch('https://routes.googleapis.com/directions/v2:computeRoutes', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': key, 'X-Goog-FieldMask': 'routes.duration,routes.distanceMeters,routes.staticDuration', }, body: JSON.stringify(body), }); if (!res.ok) { const txt = await res.text(); throw new Error(`Routes API error ${res.status}: ${txt}`); } const data = await res.json(); if (!data.routes?.length) throw new Error('No route found'); const r = data.routes[0]; const durationSec = parseGoogleDuration(r.duration); const staticSec = parseGoogleDuration(r.staticDuration); return { origin: o.formatted, destination: d.formatted, durationSec, staticSec, distanceMiles: r.distanceMeters ? r.distanceMeters / 1609.344 : null, }; } function localIso(ts, tz = 'America/Chicago') { return new Date(ts).toLocaleString('en-US', { timeZone: tz, hour12: true }); } async function cmdEta(opts) { must(opts, ['from', 'to']); const key = readApiKey(opts); const tz = opts.timeZone || 'America/Chicago'; const departTs = opts.departAt && opts.departAt !== 'now' ? new Date(opts.departAt).getTime() : Date.now(); const modifiers = routeModifiersFromOpts(opts); const route = await computeRoute({ from: opts.from, to: opts.to, departAt: opts.departAt || 'now', key, modifiers }); const arriveTs = departTs + (route.durationSec || 0) * 1000; console.log(JSON.stringify({ from: route.origin, to: route.destination, departureLocal: localIso(departTs, tz), arrivalLocal: localIso(arriveTs, tz), eta: fmtMinutes(route.durationSec), trafficDelay: route.staticSec && route.durationSec ? fmtMinutes(Math.max(0, route.durationSec - route.staticSec)) : null, distanceMiles: route.distanceMiles ? Number(route.distanceMiles.toFixed(1)) : null, avoidTolls: !!opts.avoidTolls, avoidHighways: !!opts.avoidHighways, avoidFerries: !!opts.avoidFerries, timeZone: tz, }, null, 2)); } async function cmdLeaveBy(opts) { must(opts, ['from', 'to', 'arriveBy']); const key = readApiKey(opts); const tz = opts.timeZone || 'America/Chicago'; const arriveTs = new Date(opts.arriveBy).getTime(); if (!Number.isFinite(arriveTs)) throw new Error('Invalid --arriveBy ISO datetime'); const modifiers = routeModifiersFromOpts(opts); // two-pass estimate let departGuess = arriveTs - 45 * 60 * 1000; for (let i = 0; i < 2; i++) { const route = await computeRoute({ from: opts.from, to: opts.to, departAt: new Date(departGuess).toISOString(), key, modifiers }); departGuess = arriveTs - (route.durationSec || 0) * 1000; } const finalRoute = await computeRoute({ from: opts.from, to: opts.to, departAt: new Date(departGuess).toISOString(), key, modifiers }); console.log(JSON.stringify({ from: finalRoute.origin, to: finalRoute.destination, leaveByLocal: localIso(departGuess, tz), targetArrivalLocal: localIso(arriveTs, tz), eta: fmtMinutes(finalRoute.durationSec), trafficDelay: finalRoute.staticSec && finalRoute.durationSec ? fmtMinutes(Math.max(0, finalRoute.durationSec - finalRoute.staticSec)) : null, distanceMiles: finalRoute.distanceMiles ? Number(finalRoute.distanceMiles.toFixed(1)) : null, avoidTolls: !!opts.avoidTolls, avoidHighways: !!opts.avoidHighways, avoidFerries: !!opts.avoidFerries, timeZone: tz, }, null, 2)); } (async function main() { try { const args = parseArgs(process.argv.slice(2)); const cmd = args._[0]; if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return usage(); if (cmd === 'eta') return await cmdEta(args); if (cmd === 'leave-by') return await cmdLeaveBy(args); throw new Error(`Unknown command: ${cmd}`); } catch (e) { console.error(`ERROR: ${e.message}`); process.exit(1); } })();