diff --git a/optional-skills/productivity/weather/SKILL.md b/optional-skills/productivity/weather/SKILL.md new file mode 100644 index 00000000000..f8002f9c966 --- /dev/null +++ b/optional-skills/productivity/weather/SKILL.md @@ -0,0 +1,90 @@ +--- +name: weather +description: Current weather and forecasts via Open-Meteo, no API key. +version: 0.1.0 +author: Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [Weather, Forecast, Utilities] + related_skills: [] +--- + +# Weather + +Look up current conditions and a multi-day forecast for any city using the +free Open-Meteo APIs (geocoding + forecast), which require no API key. The +script is pure Python standard library and prints a compact, chat-friendly +text report. + +## When to Use + +- The user asks about current weather, temperature, wind, or precipitation + for a named place. +- The user asks for a forecast ("what's the weather in Berlin this week?"). +- You need machine-readable weather data (`--format json`) for a follow-up + computation. + +## Prerequisites + +None beyond `python3` (3.8+). The script uses only the standard library +(`urllib`, `json`, `argparse`) — no pip installs, no API key, no config. + +## How to Run + +Run through the `terminal` tool: + +```bash +python3 ~/.hermes/skills/productivity/weather/scripts/weather.py "New York" +``` + +Common variants (same script path): + +```bash +weather.py Berlin --days 7 +weather.py Tokyo --units imperial +weather.py Paris --days 5 --format json +``` + +## Quick Reference + +| Flag | Values | Default | Meaning | +| --- | --- | --- | --- | +| `city` (positional) | one or more words | required | City name; multi-word names work quoted or unquoted | +| `--days` | 1-16 | 3 | Number of forecast days | +| `--units` | `metric`, `imperial` | `metric` | degC/km/h/mm vs degF/mph/inch | +| `--format` | `text`, `json` | `text` | Compact text for chat, or raw JSON | + +## Procedure + +1. Run the script with the city name the user gave. Multi-word names are + joined automatically (`weather.py New York` works). +2. If the user implies a unit preference (US locations often expect + Fahrenheit), pass `--units imperial`. +3. Relay the output. The first line names the resolved location + (city, region, country) — mention it so the user can catch a wrong match. +4. For programmatic needs, use `--format json` and parse the `location` and + `forecast` keys. + +## Pitfalls + +- **Geocoding ambiguity**: the script takes the top geocoding match + (e.g. "Springfield" resolves to Springfield, Missouri). Always echo the + resolved location line back to the user; add a state/country to the query + ("Springfield Illinois") to disambiguate. +- **Rate limits**: Open-Meteo's free tier allows roughly 10,000 calls/day + for non-commercial use. Fine for chat usage; don't loop it in bulk jobs. +- **WMO code coverage**: only the documented WMO weather codes are mapped; + an unexpected code prints as `Unknown (code N)` rather than failing. +- **Failures**: city-not-found and network errors exit 1 with a message on + stderr — check the exit code, not just stdout. + +## Verification + +```bash +python3 ~/.hermes/skills/productivity/weather/scripts/weather.py London --days 1 +``` + +Expect a "Weather for London, England, United Kingdom" header, a "Now:" +line, and one forecast line. diff --git a/optional-skills/productivity/weather/scripts/weather.py b/optional-skills/productivity/weather/scripts/weather.py new file mode 100644 index 00000000000..10457e39715 --- /dev/null +++ b/optional-skills/productivity/weather/scripts/weather.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Current weather and forecasts via Open-Meteo (no API key required). + +Pure standard library: urllib, json, argparse. Geocodes a city name with the +Open-Meteo geocoding API, then fetches current conditions plus an hourly and +daily forecast from the Open-Meteo forecast API. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.parse +import urllib.request + +GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" +FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +TIMEOUT = 10 # seconds + +# WMO weather interpretation codes (WW) as documented at +# https://open-meteo.com/en/docs#weather_variable_documentation +WMO_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 snowfall", + 73: "Moderate snowfall", + 75: "Heavy snowfall", + 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", +} + +COMPASS = [ + "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", +] + + +def wmo_description(code) -> str: + """Map a WMO weather code to a human-readable description.""" + try: + return WMO_CODES.get(int(code), f"Unknown (code {code})") + except (TypeError, ValueError): + return "Unknown" + + +def wind_compass(degrees) -> str: + """Convert wind direction in degrees to a 16-point compass label.""" + try: + idx = int((float(degrees) + 11.25) // 22.5) % 16 + except (TypeError, ValueError): + return "?" + return COMPASS[idx] + + +def fetch_json(url: str) -> dict: + """GET a URL and parse the JSON body (10s timeout).""" + req = urllib.request.Request(url, headers={"User-Agent": "hermes-weather-skill/0.1"}) + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def geocode(city: str) -> dict: + """Resolve a city name to the top geocoding match. Raises LookupError on miss.""" + query = urllib.parse.urlencode({"name": city, "count": 1, "format": "json"}) + data = fetch_json(f"{GEOCODE_URL}?{query}") + results = data.get("results") or [] + if not results: + raise LookupError(f"City not found: {city!r}") + return results[0] + + +def get_forecast(lat: float, lon: float, days: int, units: str) -> dict: + """Fetch current, hourly, and daily forecast data for a coordinate.""" + params = { + "latitude": lat, + "longitude": lon, + "current": ",".join([ + "temperature_2m", "apparent_temperature", "relative_humidity_2m", + "weather_code", "wind_speed_10m", "wind_direction_10m", "precipitation", + ]), + "hourly": "temperature_2m,precipitation_probability,weather_code", + "daily": ",".join([ + "weather_code", "temperature_2m_max", "temperature_2m_min", + "precipitation_sum", "precipitation_probability_max", "wind_speed_10m_max", + ]), + "forecast_days": days, + "timezone": "auto", + } + if units == "imperial": + params.update({ + "temperature_unit": "fahrenheit", + "wind_speed_unit": "mph", + "precipitation_unit": "inch", + }) + return fetch_json(f"{FORECAST_URL}?{urllib.parse.urlencode(params)}") + + +def format_text(place: dict, data: dict, units: str) -> str: + """Render a compact chat-friendly text report.""" + t_unit = "C" if units == "metric" else "F" + w_unit = "km/h" if units == "metric" else "mph" + p_unit = "mm" if units == "metric" else "in" + + name = place.get("name", "?") + region = place.get("admin1") or "" + country = place.get("country") or "" + location = ", ".join(x for x in (name, region, country) if x) + + lines = [f"Weather for {location}"] + + cur = data.get("current", {}) + lines.append( + "Now: {desc}, {temp:.0f}{tu} (feels {feels:.0f}{tu}), " + "humidity {hum:.0f}%, wind {wind:.0f} {wu} {wdir}".format( + desc=wmo_description(cur.get("weather_code")), + temp=cur.get("temperature_2m", float("nan")), + feels=cur.get("apparent_temperature", float("nan")), + hum=cur.get("relative_humidity_2m", float("nan")), + wind=cur.get("wind_speed_10m", float("nan")), + wdir=wind_compass(cur.get("wind_direction_10m")), + tu=f" deg{t_unit}", wu=w_unit, + ) + ) + + daily = data.get("daily", {}) + dates = daily.get("time") or [] + if dates: + lines.append("Forecast:") + for i, date in enumerate(dates): + lines.append( + " {date}: {desc}, {lo:.0f}/{hi:.0f} deg{tu}, " + "precip {psum:.1f} {pu} ({pprob:.0f}%), wind up to {wmax:.0f} {wu}".format( + date=date, + desc=wmo_description(daily["weather_code"][i]), + lo=daily["temperature_2m_min"][i], + hi=daily["temperature_2m_max"][i], + psum=daily["precipitation_sum"][i] or 0.0, + pprob=daily["precipitation_probability_max"][i] or 0.0, + wmax=daily["wind_speed_10m_max"][i], + tu=t_unit, pu=p_unit, wu=w_unit, + ) + ) + return "\n".join(lines) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + prog="weather.py", + description="Current weather and forecast via Open-Meteo (no API key).", + ) + parser.add_argument("city", nargs="+", help="City name (multi-word OK)") + parser.add_argument("--days", type=int, default=3, + help="Forecast days, 1-16 (default: 3)") + parser.add_argument("--units", choices=["metric", "imperial"], default="metric", + help="Unit system (default: metric)") + parser.add_argument("--format", choices=["text", "json"], default="text", + dest="fmt", help="Output format (default: text)") + args = parser.parse_args(argv) + + if not 1 <= args.days <= 16: + parser.error("--days must be between 1 and 16") + + city = " ".join(args.city) + try: + place = geocode(city) + data = get_forecast(place["latitude"], place["longitude"], + args.days, args.units) + except LookupError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + except (urllib.error.URLError, TimeoutError, OSError) as exc: + print(f"Error: network request failed: {exc}", file=sys.stderr) + return 1 + except json.JSONDecodeError: + print("Error: unexpected non-JSON response from Open-Meteo", file=sys.stderr) + return 1 + + if args.fmt == "json": + print(json.dumps({"location": place, "forecast": data}, indent=2)) + else: + print(format_text(place, data, args.units)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/skills/test_weather_skill.py b/tests/skills/test_weather_skill.py new file mode 100644 index 00000000000..88e64e03a1d --- /dev/null +++ b/tests/skills/test_weather_skill.py @@ -0,0 +1,281 @@ +"""Tests for the weather optional skill. + +All network calls are mocked — no live requests are made. +""" + +import io +import json +import sys +from pathlib import Path +from unittest import mock + +import pytest + +TESTS_DIR = Path(__file__).resolve().parent +SKILL_DIR = TESTS_DIR / ".." / ".." / "optional-skills" / "productivity" / "weather" +SKILL_DIR = SKILL_DIR.resolve() +SCRIPTS_DIR = SKILL_DIR / "scripts" +SKILL_MD = SKILL_DIR / "SKILL.md" + +sys.path.insert(0, str(SCRIPTS_DIR)) + +import weather # noqa: E402 + + +# --------------------------------------------------------------------------- +# Canned Open-Meteo responses +# --------------------------------------------------------------------------- + +GEOCODE_HIT = { + "results": [ + { + "name": "Berlin", + "latitude": 52.52437, + "longitude": 13.41053, + "country": "Germany", + "admin1": "Berlin", + } + ] +} + +GEOCODE_MISS = {"generationtime_ms": 0.5} + +FORECAST_METRIC = { + "latitude": 52.52, + "longitude": 13.42, + "current": { + "time": "2026-07-21T12:00", + "temperature_2m": 21.4, + "apparent_temperature": 22.9, + "relative_humidity_2m": 65, + "weather_code": 3, + "wind_speed_10m": 12.3, + "wind_direction_10m": 180, + "precipitation": 0.0, + }, + "daily": { + "time": ["2026-07-21", "2026-07-22"], + "weather_code": [61, 95], + "temperature_2m_max": [24.1, 19.8], + "temperature_2m_min": [15.2, 13.0], + "precipitation_sum": [2.4, 8.1], + "precipitation_probability_max": [55, 90], + "wind_speed_10m_max": [18.0, 32.5], + }, +} + + +def _fake_urlopen_factory(responses): + """Return a fake urlopen yielding canned JSON bodies per URL substring.""" + + def fake_urlopen(req, timeout=None): + url = req.full_url if hasattr(req, "full_url") else req + for key, payload in responses.items(): + if key in url: + body = json.dumps(payload).encode("utf-8") + cm = mock.MagicMock() + cm.__enter__.return_value = io.BytesIO(body) + cm.__exit__.return_value = False + return cm + raise AssertionError(f"Unexpected URL requested: {url}") + + return fake_urlopen + + +# --------------------------------------------------------------------------- +# WMO code mapping +# --------------------------------------------------------------------------- + +class TestWmoMapping: + def test_known_codes(self): + assert weather.wmo_description(0) == "Clear sky" + assert weather.wmo_description(3) == "Overcast" + assert weather.wmo_description(61) == "Slight rain" + assert weather.wmo_description(95) == "Thunderstorm" + assert weather.wmo_description(99) == "Thunderstorm with heavy hail" + + def test_unknown_code(self): + assert "Unknown" in weather.wmo_description(42) + + def test_non_numeric_code(self): + assert weather.wmo_description(None) == "Unknown" + + +# --------------------------------------------------------------------------- +# Geocoding +# --------------------------------------------------------------------------- + +class TestGeocoding: + def test_geocode_hit(self): + fake = _fake_urlopen_factory({"geocoding-api": GEOCODE_HIT}) + with mock.patch.object(weather.urllib.request, "urlopen", fake): + place = weather.geocode("Berlin") + assert place["name"] == "Berlin" + assert place["latitude"] == pytest.approx(52.52437) + + def test_geocode_miss_raises(self): + fake = _fake_urlopen_factory({"geocoding-api": GEOCODE_MISS}) + with mock.patch.object(weather.urllib.request, "urlopen", fake): + with pytest.raises(LookupError): + weather.geocode("Nowhereville12345") + + def test_geocode_miss_exit_code_and_stderr(self, capsys): + fake = _fake_urlopen_factory({"geocoding-api": GEOCODE_MISS}) + with mock.patch.object(weather.urllib.request, "urlopen", fake): + rc = weather.main(["Nowhereville12345"]) + assert rc == 1 + captured = capsys.readouterr() + assert "City not found" in captured.err + + +# --------------------------------------------------------------------------- +# Forecast formatting + unit conversion +# --------------------------------------------------------------------------- + +class TestForecast: + def _run(self, argv): + fake = _fake_urlopen_factory( + {"geocoding-api": GEOCODE_HIT, "/v1/forecast": FORECAST_METRIC} + ) + with mock.patch.object(weather.urllib.request, "urlopen", fake): + return weather.main(argv) + + def test_text_output(self, capsys): + rc = self._run(["Berlin", "--days", "2"]) + assert rc == 0 + out = capsys.readouterr().out + assert "Weather for Berlin, Berlin, Germany" in out + assert "Now: Overcast, 21 degC (feels 23 degC)" in out + assert "wind 12 km/h S" in out + assert "2026-07-21: Slight rain, 15/24 degC" in out + assert "2026-07-22: Thunderstorm" in out + assert "precip 8.1 mm (90%)" in out + + def test_multiword_city_joined(self): + fake = _fake_urlopen_factory( + {"geocoding-api": GEOCODE_HIT, "/v1/forecast": FORECAST_METRIC} + ) + requested = [] + original = fake + + def spy(req, timeout=None): + requested.append(req.full_url) + return original(req, timeout=timeout) + + with mock.patch.object(weather.urllib.request, "urlopen", spy): + rc = weather.main(["New", "York"]) + assert rc == 0 + geo_url = next(u for u in requested if "geocoding-api" in u) + assert "New+York" in geo_url or "New%20York" in geo_url + + def test_imperial_units_requested_and_labeled(self, capsys): + fake_responses = { + "geocoding-api": GEOCODE_HIT, + "/v1/forecast": FORECAST_METRIC, + } + requested = [] + base = _fake_urlopen_factory(fake_responses) + + def spy(req, timeout=None): + requested.append(req.full_url) + return base(req, timeout=timeout) + + with mock.patch.object(weather.urllib.request, "urlopen", spy): + rc = weather.main(["Berlin", "--units", "imperial"]) + assert rc == 0 + forecast_url = next(u for u in requested if "/v1/forecast" in u) + assert "temperature_unit=fahrenheit" in forecast_url + assert "wind_speed_unit=mph" in forecast_url + assert "precipitation_unit=inch" in forecast_url + out = capsys.readouterr().out + assert "degF" in out + assert "mph" in out + + def test_metric_units_not_overridden(self): + requested = [] + base = _fake_urlopen_factory( + {"geocoding-api": GEOCODE_HIT, "/v1/forecast": FORECAST_METRIC} + ) + + def spy(req, timeout=None): + requested.append(req.full_url) + return base(req, timeout=timeout) + + with mock.patch.object(weather.urllib.request, "urlopen", spy): + weather.main(["Berlin"]) + forecast_url = next(u for u in requested if "/v1/forecast" in u) + assert "fahrenheit" not in forecast_url + + def test_json_format(self, capsys): + rc = self._run(["Berlin", "--format", "json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["location"]["name"] == "Berlin" + assert payload["forecast"]["daily"]["weather_code"] == [61, 95] + + def test_days_out_of_range_rejected(self): + with pytest.raises(SystemExit): + weather.main(["Berlin", "--days", "17"]) + + def test_network_error_exit_code(self, capsys): + def boom(req, timeout=None): + raise weather.urllib.error.URLError("connection refused") + + with mock.patch.object(weather.urllib.request, "urlopen", boom): + rc = weather.main(["Berlin"]) + assert rc == 1 + assert "network request failed" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Wind compass helper +# --------------------------------------------------------------------------- + +class TestWindCompass: + @pytest.mark.parametrize( + "deg,label", + [(0, "N"), (90, "E"), (180, "S"), (270, "W"), (359, "N"), (45, "NE")], + ) + def test_compass_points(self, deg, label): + assert weather.wind_compass(deg) == label + + def test_invalid_direction(self): + assert weather.wind_compass(None) == "?" + + +# --------------------------------------------------------------------------- +# SKILL.md frontmatter +# --------------------------------------------------------------------------- + +class TestFrontmatter: + @pytest.fixture(scope="class") + def frontmatter(self): + yaml = pytest.importorskip("yaml") + text = SKILL_MD.read_text(encoding="utf-8") + assert text.startswith("---\n"), "SKILL.md must start with YAML frontmatter" + block = text.split("---", 2)[1] + return yaml.safe_load(block) + + def test_files_exist(self): + assert SKILL_MD.is_file() + assert (SCRIPTS_DIR / "weather.py").is_file() + + def test_required_fields(self, frontmatter): + assert frontmatter["name"] == "weather" + assert frontmatter["version"] == "0.1.0" + assert frontmatter["author"] == "Hermes Agent" + assert frontmatter["license"] == "MIT" + + def test_description_constraints(self, frontmatter): + desc = frontmatter["description"] + assert isinstance(desc, str) + assert len(desc) <= 60 + assert desc.endswith(".") + + def test_platforms(self, frontmatter): + assert frontmatter["platforms"] == ["linux", "macos", "windows"] + + def test_hermes_metadata(self, frontmatter): + hermes = frontmatter["metadata"]["hermes"] + assert hermes["tags"] == ["Weather", "Forecast", "Utilities"] + assert hermes["related_skills"] == [] diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 94416573dc2..abba1420cb9 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -182,6 +182,7 @@ hermes skills uninstall | [**shopify**](/docs/user-guide/skills/optional/productivity/productivity-shopify) | Shopify Admin & Storefront GraphQL APIs via curl. Products, orders, customers, inventory, metafields. | | [**siyuan**](/docs/user-guide/skills/optional/productivity/productivity-siyuan) | SiYuan Note API for searching, reading, creating, and managing blocks and documents in a self-hosted knowledge base via curl. | | [**telephony**](/docs/user-guide/skills/optional/productivity/productivity-telephony) | Give Hermes phone capabilities without core tool changes. Provision and persist a Twilio number, send and receive SMS/MMS, make direct calls, and place AI-driven outbound calls through Bland.ai or Vapi. | +| [**weather**](/docs/user-guide/skills/optional/productivity/productivity-weather) | Current weather and forecasts via Open-Meteo, no API key. | ## research diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-weather.md b/website/docs/user-guide/skills/optional/productivity/productivity-weather.md new file mode 100644 index 00000000000..0e23ef207eb --- /dev/null +++ b/website/docs/user-guide/skills/optional/productivity/productivity-weather.md @@ -0,0 +1,107 @@ +--- +title: "Weather — Current weather and forecasts via Open-Meteo, no API key" +sidebar_label: "Weather" +description: "Current weather and forecasts via Open-Meteo, no API key" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Weather + +Current weather and forecasts via Open-Meteo, no API key. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/productivity/weather` | +| Path | `optional-skills/productivity/weather` | +| Version | `0.1.0` | +| Author | Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `Weather`, `Forecast`, `Utilities` | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Weather + +Look up current conditions and a multi-day forecast for any city using the +free Open-Meteo APIs (geocoding + forecast), which require no API key. The +script is pure Python standard library and prints a compact, chat-friendly +text report. + +## When to Use + +- The user asks about current weather, temperature, wind, or precipitation + for a named place. +- The user asks for a forecast ("what's the weather in Berlin this week?"). +- You need machine-readable weather data (`--format json`) for a follow-up + computation. + +## Prerequisites + +None beyond `python3` (3.8+). The script uses only the standard library +(`urllib`, `json`, `argparse`) — no pip installs, no API key, no config. + +## How to Run + +Run through the `terminal` tool: + +```bash +python3 ~/.hermes/skills/productivity/weather/scripts/weather.py "New York" +``` + +Common variants (same script path): + +```bash +weather.py Berlin --days 7 +weather.py Tokyo --units imperial +weather.py Paris --days 5 --format json +``` + +## Quick Reference + +| Flag | Values | Default | Meaning | +| --- | --- | --- | --- | +| `city` (positional) | one or more words | required | City name; multi-word names work quoted or unquoted | +| `--days` | 1-16 | 3 | Number of forecast days | +| `--units` | `metric`, `imperial` | `metric` | degC/km/h/mm vs degF/mph/inch | +| `--format` | `text`, `json` | `text` | Compact text for chat, or raw JSON | + +## Procedure + +1. Run the script with the city name the user gave. Multi-word names are + joined automatically (`weather.py New York` works). +2. If the user implies a unit preference (US locations often expect + Fahrenheit), pass `--units imperial`. +3. Relay the output. The first line names the resolved location + (city, region, country) — mention it so the user can catch a wrong match. +4. For programmatic needs, use `--format json` and parse the `location` and + `forecast` keys. + +## Pitfalls + +- **Geocoding ambiguity**: the script takes the top geocoding match + (e.g. "Springfield" resolves to Springfield, Missouri). Always echo the + resolved location line back to the user; add a state/country to the query + ("Springfield Illinois") to disambiguate. +- **Rate limits**: Open-Meteo's free tier allows roughly 10,000 calls/day + for non-commercial use. Fine for chat usage; don't loop it in bulk jobs. +- **WMO code coverage**: only the documented WMO weather codes are mapped; + an unexpected code prints as `Unknown (code N)` rather than failing. +- **Failures**: city-not-found and network errors exit 1 with a message on + stderr — check the exit code, not just stdout. + +## Verification + +```bash +python3 ~/.hermes/skills/productivity/weather/scripts/weather.py London --days 1 +``` + +Expect a "Weather for London, England, United Kingdom" header, a "Now:" +line, and one forecast line. diff --git a/website/sidebars.ts b/website/sidebars.ts index 327b2296149..338ea5604ef 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -540,6 +540,7 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/optional/productivity/productivity-shopify', 'user-guide/skills/optional/productivity/productivity-siyuan', 'user-guide/skills/optional/productivity/productivity-telephony', + 'user-guide/skills/optional/productivity/productivity-weather', ], }, {