diff --git a/cli-manifest.json b/cli-manifest.json index b708dd7..f7e6c10 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -14594,6 +14594,210 @@ "modulePath": "oeis/sequence.js", "sourceFile": "oeis/sequence.js" }, + { + "site": "open-meteo", + "name": "air-quality", + "description": "Hourly Open-Meteo air quality forecast for a city or lat,lon", + "access": "read", + "domain": "open-meteo.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "location", + "type": "str", + "required": true, + "positional": true, + "help": "City name or \"lat,lon\"" + }, + { + "name": "hours", + "type": "int", + "default": 24, + "required": false, + "help": "Forecast hours (1-168)" + } + ], + "columns": [ + "rank", + "location", + "region", + "country", + "time", + "usAqi", + "europeanAqi", + "pm10", + "pm25", + "nitrogenDioxide", + "ozone", + "uvIndex" + ], + "type": "js", + "modulePath": "open-meteo/air-quality.js", + "sourceFile": "open-meteo/air-quality.js" + }, + { + "site": "open-meteo", + "name": "current", + "description": "Current weather from Open-Meteo for a city or lat,lon", + "access": "read", + "domain": "open-meteo.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "location", + "type": "str", + "required": true, + "positional": true, + "help": "City name or \"lat,lon\"" + }, + { + "name": "units", + "type": "str", + "default": "metric", + "required": false, + "help": "metric or imperial units", + "choices": [ + "metric", + "imperial" + ] + } + ], + "columns": [ + "location", + "region", + "country", + "latitude", + "longitude", + "timezone", + "time", + "temperature", + "apparentTemperature", + "humidity", + "precipitation", + "cloudCover", + "windSpeed", + "windDirection", + "windGusts", + "isDay", + "weather" + ], + "type": "js", + "modulePath": "open-meteo/current.js", + "sourceFile": "open-meteo/current.js" + }, + { + "site": "open-meteo", + "name": "forecast", + "description": "Daily Open-Meteo forecast for a city or lat,lon", + "access": "read", + "domain": "open-meteo.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "location", + "type": "str", + "required": true, + "positional": true, + "help": "City name or \"lat,lon\"" + }, + { + "name": "days", + "type": "int", + "default": 7, + "required": false, + "help": "Forecast days (1-16)" + }, + { + "name": "units", + "type": "str", + "default": "metric", + "required": false, + "help": "metric or imperial units", + "choices": [ + "metric", + "imperial" + ] + } + ], + "columns": [ + "rank", + "location", + "region", + "country", + "latitude", + "longitude", + "timezone", + "date", + "weather", + "tempMin", + "tempMax", + "apparentTempMin", + "apparentTempMax", + "precipitation", + "precipitationProbability", + "windSpeedMax", + "windGustsMax" + ], + "type": "js", + "modulePath": "open-meteo/forecast.js", + "sourceFile": "open-meteo/forecast.js" + }, + { + "site": "open-meteo", + "name": "hourly", + "description": "Hourly Open-Meteo forecast for a city or lat,lon", + "access": "read", + "domain": "open-meteo.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "location", + "type": "str", + "required": true, + "positional": true, + "help": "City name or \"lat,lon\"" + }, + { + "name": "hours", + "type": "int", + "default": 24, + "required": false, + "help": "Forecast hours (1-168)" + }, + { + "name": "units", + "type": "str", + "default": "metric", + "required": false, + "help": "metric or imperial units", + "choices": [ + "metric", + "imperial" + ] + } + ], + "columns": [ + "rank", + "location", + "region", + "country", + "time", + "weather", + "temperature", + "humidity", + "precipitationProbability", + "precipitation", + "windSpeed", + "windGusts" + ], + "type": "js", + "modulePath": "open-meteo/hourly.js", + "sourceFile": "open-meteo/hourly.js" + }, { "site": "openalex", "name": "search", diff --git a/clis/open-meteo/air-quality.js b/clis/open-meteo/air-quality.js new file mode 100644 index 0000000..c4d408d --- /dev/null +++ b/clis/open-meteo/air-quality.js @@ -0,0 +1,66 @@ +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { + AIR_QUALITY_BASE, + fetchJson, + locationFields, + requireHours, + resolveLocation, +} from './utils.js'; + +cli({ + site: 'open-meteo', + name: 'air-quality', + access: 'read', + description: 'Hourly Open-Meteo air quality forecast for a city or lat,lon', + domain: 'open-meteo.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'location', positional: true, required: true, help: 'City name or "lat,lon"' }, + { name: 'hours', type: 'int', default: 24, help: 'Forecast hours (1-168)' }, + ], + columns: [ + 'rank', 'location', 'region', 'country', 'time', + 'usAqi', 'europeanAqi', 'pm10', 'pm25', + 'nitrogenDioxide', 'ozone', 'uvIndex', + ], + func: async (args) => { + const location = await resolveLocation(args.location); + const hours = requireHours(args.hours); + const url = new URL(AIR_QUALITY_BASE); + url.searchParams.set('latitude', String(location.latitude)); + url.searchParams.set('longitude', String(location.longitude)); + url.searchParams.set('timezone', 'auto'); + url.searchParams.set('forecast_days', String(Math.ceil(hours / 24))); + url.searchParams.set('hourly', [ + 'us_aqi', + 'european_aqi', + 'pm10', + 'pm2_5', + 'nitrogen_dioxide', + 'ozone', + 'uv_index', + ].join(',')); + + const body = await fetchJson(url, 'open-meteo air-quality'); + const hourly = body?.hourly ?? {}; + const times = Array.isArray(hourly.time) ? hourly.time : []; + const rows = times.slice(0, hours).map((time, i) => ({ + rank: i + 1, + ...locationFields(location), + time, + usAqi: hourly.us_aqi?.[i] ?? null, + europeanAqi: hourly.european_aqi?.[i] ?? null, + pm10: hourly.pm10?.[i] ?? null, + pm25: hourly.pm2_5?.[i] ?? null, + nitrogenDioxide: hourly.nitrogen_dioxide?.[i] ?? null, + ozone: hourly.ozone?.[i] ?? null, + uvIndex: hourly.uv_index?.[i] ?? null, + })); + if (!rows.length) { + throw new EmptyResultError('open-meteo air-quality', 'Open-Meteo returned no air quality rows.'); + } + return rows.map(({ latitude, longitude, ...row }) => row); + }, +}); diff --git a/clis/open-meteo/current.js b/clis/open-meteo/current.js new file mode 100644 index 0000000..67466fb --- /dev/null +++ b/clis/open-meteo/current.js @@ -0,0 +1,69 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { + FORECAST_BASE, + applyUnitParams, + describeWeatherCode, + fetchJson, + locationFields, + requireUnits, + resolveLocation, +} from './utils.js'; + +cli({ + site: 'open-meteo', + name: 'current', + access: 'read', + description: 'Current weather from Open-Meteo for a city or lat,lon', + domain: 'open-meteo.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'location', positional: true, required: true, help: 'City name or "lat,lon"' }, + { name: 'units', default: 'metric', choices: ['metric', 'imperial'], help: 'metric or imperial units' }, + ], + columns: [ + 'location', 'region', 'country', 'latitude', 'longitude', 'timezone', + 'time', 'temperature', 'apparentTemperature', 'humidity', + 'precipitation', 'cloudCover', 'windSpeed', 'windDirection', + 'windGusts', 'isDay', 'weather', + ], + func: async (args) => { + const location = await resolveLocation(args.location); + const units = requireUnits(args.units); + const url = new URL(FORECAST_BASE); + url.searchParams.set('latitude', String(location.latitude)); + url.searchParams.set('longitude', String(location.longitude)); + url.searchParams.set('timezone', 'auto'); + url.searchParams.set('current', [ + 'temperature_2m', + 'apparent_temperature', + 'relative_humidity_2m', + 'precipitation', + 'weather_code', + 'cloud_cover', + 'wind_speed_10m', + 'wind_direction_10m', + 'wind_gusts_10m', + 'is_day', + ].join(',')); + applyUnitParams(url, units); + + const body = await fetchJson(url, 'open-meteo current'); + const cur = body?.current ?? {}; + return [{ + ...locationFields(location), + timezone: body?.timezone ?? null, + time: cur.time ?? null, + temperature: cur.temperature_2m ?? null, + apparentTemperature: cur.apparent_temperature ?? null, + humidity: cur.relative_humidity_2m ?? null, + precipitation: cur.precipitation ?? null, + cloudCover: cur.cloud_cover ?? null, + windSpeed: cur.wind_speed_10m ?? null, + windDirection: cur.wind_direction_10m ?? null, + windGusts: cur.wind_gusts_10m ?? null, + isDay: cur.is_day == null ? null : Boolean(cur.is_day), + weather: describeWeatherCode(cur.weather_code), + }]; + }, +}); diff --git a/clis/open-meteo/forecast.js b/clis/open-meteo/forecast.js new file mode 100644 index 0000000..aa684bc --- /dev/null +++ b/clis/open-meteo/forecast.js @@ -0,0 +1,73 @@ +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { + FORECAST_BASE, + applyUnitParams, + describeWeatherCode, + fetchJson, + locationFields, + requireDays, + requireUnits, + resolveLocation, +} from './utils.js'; + +cli({ + site: 'open-meteo', + name: 'forecast', + access: 'read', + description: 'Daily Open-Meteo forecast for a city or lat,lon', + domain: 'open-meteo.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'location', positional: true, required: true, help: 'City name or "lat,lon"' }, + { name: 'days', type: 'int', default: 7, help: 'Forecast days (1-16)' }, + { name: 'units', default: 'metric', choices: ['metric', 'imperial'], help: 'metric or imperial units' }, + ], + columns: [ + 'rank', 'location', 'region', 'country', 'latitude', 'longitude', + 'timezone', 'date', 'weather', 'tempMin', 'tempMax', + 'apparentTempMin', 'apparentTempMax', 'precipitation', + 'precipitationProbability', 'windSpeedMax', 'windGustsMax', + ], + func: async (args) => { + const location = await resolveLocation(args.location); + const days = requireDays(args.days); + const units = requireUnits(args.units); + const url = new URL(FORECAST_BASE); + url.searchParams.set('latitude', String(location.latitude)); + url.searchParams.set('longitude', String(location.longitude)); + url.searchParams.set('timezone', 'auto'); + url.searchParams.set('forecast_days', String(days)); + url.searchParams.set('daily', [ + 'weather_code', + 'temperature_2m_min', + 'temperature_2m_max', + 'apparent_temperature_min', + 'apparent_temperature_max', + 'precipitation_sum', + 'precipitation_probability_max', + 'wind_speed_10m_max', + 'wind_gusts_10m_max', + ].join(',')); + applyUnitParams(url, units); + + const body = await fetchJson(url, 'open-meteo forecast'); + const daily = body?.daily ?? {}; + const dates = Array.isArray(daily.time) ? daily.time : []; + return dates.map((date, i) => ({ + rank: i + 1, + ...locationFields(location), + timezone: body?.timezone ?? null, + date, + weather: describeWeatherCode(daily.weather_code?.[i]), + tempMin: daily.temperature_2m_min?.[i] ?? null, + tempMax: daily.temperature_2m_max?.[i] ?? null, + apparentTempMin: daily.apparent_temperature_min?.[i] ?? null, + apparentTempMax: daily.apparent_temperature_max?.[i] ?? null, + precipitation: daily.precipitation_sum?.[i] ?? null, + precipitationProbability: daily.precipitation_probability_max?.[i] ?? null, + windSpeedMax: daily.wind_speed_10m_max?.[i] ?? null, + windGustsMax: daily.wind_gusts_10m_max?.[i] ?? null, + })); + }, +}); diff --git a/clis/open-meteo/hourly.js b/clis/open-meteo/hourly.js new file mode 100644 index 0000000..43a25e3 --- /dev/null +++ b/clis/open-meteo/hourly.js @@ -0,0 +1,72 @@ +import { EmptyResultError } from '@agentrhq/webcmd/errors'; +import { cli, Strategy } from '@agentrhq/webcmd/registry'; +import { + FORECAST_BASE, + applyUnitParams, + describeWeatherCode, + fetchJson, + locationFields, + requireHours, + requireUnits, + resolveLocation, +} from './utils.js'; + +cli({ + site: 'open-meteo', + name: 'hourly', + access: 'read', + description: 'Hourly Open-Meteo forecast for a city or lat,lon', + domain: 'open-meteo.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'location', positional: true, required: true, help: 'City name or "lat,lon"' }, + { name: 'hours', type: 'int', default: 24, help: 'Forecast hours (1-168)' }, + { name: 'units', default: 'metric', choices: ['metric', 'imperial'], help: 'metric or imperial units' }, + ], + columns: [ + 'rank', 'location', 'region', 'country', 'time', 'weather', + 'temperature', 'humidity', 'precipitationProbability', + 'precipitation', 'windSpeed', 'windGusts', + ], + func: async (args) => { + const location = await resolveLocation(args.location); + const hours = requireHours(args.hours); + const units = requireUnits(args.units); + const url = new URL(FORECAST_BASE); + url.searchParams.set('latitude', String(location.latitude)); + url.searchParams.set('longitude', String(location.longitude)); + url.searchParams.set('timezone', 'auto'); + url.searchParams.set('forecast_days', String(Math.ceil(hours / 24))); + url.searchParams.set('hourly', [ + 'temperature_2m', + 'relative_humidity_2m', + 'precipitation_probability', + 'precipitation', + 'weather_code', + 'wind_speed_10m', + 'wind_gusts_10m', + ].join(',')); + applyUnitParams(url, units); + + const body = await fetchJson(url, 'open-meteo hourly'); + const hourly = body?.hourly ?? {}; + const times = Array.isArray(hourly.time) ? hourly.time : []; + const rows = times.slice(0, hours).map((time, i) => ({ + rank: i + 1, + ...locationFields(location), + time, + weather: describeWeatherCode(hourly.weather_code?.[i]), + temperature: hourly.temperature_2m?.[i] ?? null, + humidity: hourly.relative_humidity_2m?.[i] ?? null, + precipitationProbability: hourly.precipitation_probability?.[i] ?? null, + precipitation: hourly.precipitation?.[i] ?? null, + windSpeed: hourly.wind_speed_10m?.[i] ?? null, + windGusts: hourly.wind_gusts_10m?.[i] ?? null, + })); + if (!rows.length) { + throw new EmptyResultError('open-meteo hourly', 'Open-Meteo returned no hourly forecast rows.'); + } + return rows.map(({ latitude, longitude, ...row }) => row); + }, +}); diff --git a/clis/open-meteo/open-meteo.test.js b/clis/open-meteo/open-meteo.test.js new file mode 100644 index 0000000..7965d3a --- /dev/null +++ b/clis/open-meteo/open-meteo.test.js @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@agentrhq/webcmd/registry'; +import { ArgumentError, EmptyResultError } from '@agentrhq/webcmd/errors'; +import './air-quality.js'; +import './current.js'; +import './forecast.js'; +import './hourly.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const geocodeBody = { + results: [{ + name: 'Seattle', + admin1: 'Washington', + country: 'United States', + latitude: 47.6062, + longitude: -122.3321, + }], +}; + +describe('open-meteo current', () => { + const cmd = getRegistry().get('open-meteo/current'); + + it('geocodes city names and shapes current weather', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify(geocodeBody), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + timezone: 'America/Los_Angeles', + current: { + time: '2026-07-03T10:00', + temperature_2m: 18.2, + apparent_temperature: 17.5, + relative_humidity_2m: 62, + precipitation: 0, + weather_code: 2, + cloud_cover: 35, + wind_speed_10m: 9.4, + wind_direction_10m: 220, + wind_gusts_10m: 18.1, + is_day: 1, + }, + }), { status: 200 }))); + + const rows = await cmd.func({ location: 'Seattle' }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + location: 'Seattle', + region: 'Washington', + country: 'United States', + timezone: 'America/Los_Angeles', + temperature: 18.2, + humidity: 62, + isDay: true, + weather: 'Partly cloudy', + }); + }); + + it('rejects empty location', async () => { + await expect(cmd.func({ location: '' })).rejects.toBeInstanceOf(ArgumentError); + }); +}); + +describe('open-meteo forecast', () => { + const cmd = getRegistry().get('open-meteo/forecast'); + + it('skips geocoding for lat,lon and passes imperial units', async () => { + const calls = []; + vi.stubGlobal('fetch', vi.fn((url) => { + calls.push(String(url)); + return Promise.resolve(new Response(JSON.stringify({ + timezone: 'America/New_York', + daily: { + time: ['2026-07-03'], + weather_code: [61], + temperature_2m_min: [70], + temperature_2m_max: [82], + apparent_temperature_min: [71], + apparent_temperature_max: [85], + precipitation_sum: [0.12], + precipitation_probability_max: [40], + wind_speed_10m_max: [12], + wind_gusts_10m_max: [20], + }, + }), { status: 200 })); + })); + + const rows = await cmd.func({ location: '40.7128,-74.0060', days: 1, units: 'imperial' }); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('temperature_unit=fahrenheit'); + expect(rows[0]).toMatchObject({ + rank: 1, + location: '40.7128,-74.0060', + weather: 'Slight rain', + tempMax: 82, + precipitationProbability: 40, + }); + }); + + it('promotes missing geocode results to EmptyResultError', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ results: [] }), { status: 200 }))); + await expect(cmd.func({ location: 'No Such Place' })).rejects.toBeInstanceOf(EmptyResultError); + }); + + it('rejects --days out of range', async () => { + await expect(cmd.func({ location: 'Seattle', days: 17 })).rejects.toBeInstanceOf(ArgumentError); + }); +}); + +describe('open-meteo hourly', () => { + const cmd = getRegistry().get('open-meteo/hourly'); + + it('returns hourly forecast rows', async () => { + vi.stubGlobal('fetch', vi.fn((url) => { + expect(String(url)).toContain('forecast_days=1'); + expect(String(url)).toContain('hourly=temperature_2m'); + return Promise.resolve(new Response(JSON.stringify({ + timezone: 'America/New_York', + hourly: { + time: ['2026-07-03T00:00', '2026-07-03T01:00'], + temperature_2m: [70, 71], + relative_humidity_2m: [60, 61], + precipitation_probability: [10, 20], + precipitation: [0, 0.01], + weather_code: [0, 2], + wind_speed_10m: [8, 9], + wind_gusts_10m: [14, 15], + }, + }), { status: 200 })); + })); + + const rows = await cmd.func({ location: '40.7128,-74.0060', hours: 2, units: 'imperial' }); + expect(rows).toEqual([ + { + rank: 1, + location: '40.7128,-74.0060', + region: null, + country: null, + time: '2026-07-03T00:00', + weather: 'Clear sky', + temperature: 70, + humidity: 60, + precipitationProbability: 10, + precipitation: 0, + windSpeed: 8, + windGusts: 14, + }, + expect.objectContaining({ rank: 2, weather: 'Partly cloudy', temperature: 71 }), + ]); + }); + + it('rejects --hours out of range', async () => { + await expect(cmd.func({ location: 'Seattle', hours: 169 })).rejects.toBeInstanceOf(ArgumentError); + }); +}); + +describe('open-meteo air-quality', () => { + const cmd = getRegistry().get('open-meteo/air-quality'); + + it('geocodes and maps air quality rows', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify(geocodeBody), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + timezone: 'America/Los_Angeles', + hourly: { + time: ['2026-07-03T00:00'], + us_aqi: [12], + european_aqi: [8], + pm10: [5.1], + pm2_5: [3.1], + nitrogen_dioxide: [7.2], + ozone: [55.5], + uv_index: [0], + }, + }), { status: 200 }))); + + const rows = await cmd.func({ location: 'Seattle', hours: 1 }); + expect(String(fetch.mock.calls[1][0])).toContain('air-quality'); + expect(rows).toEqual([{ + rank: 1, + location: 'Seattle', + region: 'Washington', + country: 'United States', + time: '2026-07-03T00:00', + usAqi: 12, + europeanAqi: 8, + pm10: 5.1, + pm25: 3.1, + nitrogenDioxide: 7.2, + ozone: 55.5, + uvIndex: 0, + }]); + }); + + it('promotes empty hourly air quality responses to EmptyResultError', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + hourly: { time: [] }, + }), { status: 200 })))); + await expect(cmd.func({ location: '40.7128,-74.0060', hours: 1 })).rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/clis/open-meteo/utils.js b/clis/open-meteo/utils.js new file mode 100644 index 0000000..a8eafce --- /dev/null +++ b/clis/open-meteo/utils.js @@ -0,0 +1,146 @@ +// Open-Meteo shared helpers — free weather forecast + geocoding, no API key. +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors'; + +export const GEOCODING_BASE = 'https://geocoding-api.open-meteo.com/v1/search'; +export const FORECAST_BASE = 'https://api.open-meteo.com/v1/forecast'; +export const AIR_QUALITY_BASE = 'https://air-quality-api.open-meteo.com/v1/air-quality'; +const UA = 'webcmd-open-meteo/1.0'; + +const WEATHER_CODES = { + 0: 'Clear sky', + 1: 'Mainly clear', + 2: 'Partly cloudy', + 3: 'Overcast', + 45: 'Fog', + 48: 'Depositing rime fog', + 51: 'Light drizzle', + 53: 'Moderate drizzle', + 55: 'Dense drizzle', + 56: 'Light freezing drizzle', + 57: 'Dense freezing drizzle', + 61: 'Slight rain', + 63: 'Moderate rain', + 65: 'Heavy rain', + 66: 'Light freezing rain', + 67: 'Heavy freezing rain', + 71: 'Slight snow fall', + 73: 'Moderate snow fall', + 75: 'Heavy snow fall', + 77: 'Snow grains', + 80: 'Slight rain showers', + 81: 'Moderate rain showers', + 82: 'Violent rain showers', + 85: 'Slight snow showers', + 86: 'Heavy snow showers', + 95: 'Thunderstorm', + 96: 'Thunderstorm with slight hail', + 99: 'Thunderstorm with heavy hail', +}; + +export function requireLocation(value) { + const s = String(value ?? '').trim(); + if (!s) throw new ArgumentError('open-meteo location is required'); + return s; +} + +export function requireDays(value, def = 7) { + const n = value == null || value === '' ? def : Number(value); + if (!Number.isInteger(n) || n < 1 || n > 16) { + throw new ArgumentError('--days must be an integer between 1 and 16'); + } + return n; +} + +export function requireHours(value, def = 24, max = 168) { + const n = value == null || value === '' ? def : Number(value); + if (!Number.isInteger(n) || n < 1 || n > max) { + throw new ArgumentError(`--hours must be an integer between 1 and ${max}`); + } + return n; +} + +export function requireUnits(value) { + const units = String(value ?? 'metric').trim().toLowerCase(); + if (units !== 'metric' && units !== 'imperial') { + throw new ArgumentError('--units must be one of: metric, imperial'); + } + return units; +} + +export function describeWeatherCode(code) { + const n = Number(code); + return WEATHER_CODES[n] ?? (Number.isFinite(n) ? `Weather code ${n}` : null); +} + +function parseLatLon(location) { + const match = /^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/.exec(location); + if (!match) return null; + const latitude = Number(match[1]); + const longitude = Number(match[2]); + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + throw new ArgumentError('lat,lon must be valid WGS84 coordinates'); + } + return { name: location, latitude, longitude, country: null, admin1: null }; +} + +export async function fetchJson(url, label) { + let resp; + try { + resp = await fetch(url, { headers: { 'User-Agent': UA, accept: 'application/json' } }); + } catch (err) { + throw new CommandExecutionError(`${label} request failed: ${err.message}`); + } + if (resp.status === 429) { + throw new CommandExecutionError(`${label} rate-limited (HTTP 429); retry later.`); + } + if (!resp.ok) { + throw new CommandExecutionError(`${label} returned HTTP ${resp.status}.`); + } + try { + return await resp.json(); + } catch (err) { + throw new CommandExecutionError(`${label} returned non-JSON body: ${err.message}`); + } +} + +export async function resolveLocation(rawLocation) { + const location = requireLocation(rawLocation); + const coords = parseLatLon(location); + if (coords) return coords; + + const url = new URL(GEOCODING_BASE); + url.searchParams.set('name', location); + url.searchParams.set('count', '1'); + url.searchParams.set('language', 'en'); + url.searchParams.set('format', 'json'); + const body = await fetchJson(url, 'open-meteo geocoding'); + const first = Array.isArray(body?.results) ? body.results[0] : null; + if (!first) { + throw new EmptyResultError('open-meteo geocoding', `No Open-Meteo location match for "${location}".`); + } + return { + name: first.name ?? location, + latitude: Number(first.latitude), + longitude: Number(first.longitude), + country: first.country ?? null, + admin1: first.admin1 ?? null, + }; +} + +export function applyUnitParams(url, units) { + if (units === 'imperial') { + url.searchParams.set('temperature_unit', 'fahrenheit'); + url.searchParams.set('wind_speed_unit', 'mph'); + url.searchParams.set('precipitation_unit', 'inch'); + } +} + +export function locationFields(location) { + return { + location: location.name, + region: location.admin1, + country: location.country, + latitude: location.latitude, + longitude: location.longitude, + }; +}