feat(skills): add optional weather skill

Current conditions + multi-day forecasts via the keyless Open-Meteo
APIs (geocoding + forecast). Pure-stdlib script (urllib/json/argparse),
metric/imperial units, text or JSON output, WMO code mapping.
Inspired by the most-installed community weather skill; implemented
first-party from the Open-Meteo docs.
This commit is contained in:
teknium1 2026-07-21 03:16:16 -07:00
parent f4df260f26
commit 8d8842db73
No known key found for this signature in database
6 changed files with 689 additions and 0 deletions

View file

@ -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.

View file

@ -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())

View file

@ -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"] == []

View file

@ -182,6 +182,7 @@ hermes skills uninstall <skill-name>
| [**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

View file

@ -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.

View file

@ -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',
],
},
{