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