mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
f4df260f26
commit
8d8842db73
6 changed files with 689 additions and 0 deletions
90
optional-skills/productivity/weather/SKILL.md
Normal file
90
optional-skills/productivity/weather/SKILL.md
Normal 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.
|
||||
209
optional-skills/productivity/weather/scripts/weather.py
Normal file
209
optional-skills/productivity/weather/scripts/weather.py
Normal 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())
|
||||
281
tests/skills/test_weather_skill.py
Normal file
281
tests/skills/test_weather_skill.py
Normal 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"] == []
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue