climate-cli
A multi-provider weather tracker and its agent-first CLI.
A small docker stack — MongoDB, a polling tracker and a read-only web service —
samples several free weather providers on their own refresh policies, stores
every response verbatim (body bytes, status, cache headers, content hash),
derives normalized readings from those stored bytes, and serves the collection
over HTTP with a chart-first dashboard. climate runs that stack and queries
what it collected.
The first-class question is the latest reading: the intended consumer is an agent controlling hardware (home AC) from measured conditions, so every value reports its own age and its provider, and there is a dedicated exit code for "this data is too old to act on".
Design notes worth knowing up front:
- No provider field is ever dropped. Anything without a row in the shared
vocabulary is still stored, as
x_<provider_field_name>. - Raw before parsed. The verbatim response is persisted before any parsing runs, so a parser bug can never lose data; normalized readings are re-derivable from the stored bytes.
- Failures are data. Timeouts, 4xx/5xx and 429s are stored as fetch records too, so a gap in the data is distinguishable from a gap in collection.
- No backfill, ever. A missed tick (stack down, host off, network out) stays an honest gap. Nothing is interpolated or invented.
Quickstart
uv sync
# 1. Compose secrets — gitignored; the only place provider keys ever live.
cp docker/weather.env.example docker/weather.env
$EDITOR docker/weather.env
# 2. Your locations — private data, OUTSIDE the repo.
mkdir -p ~/.config/climate-cli
$EDITOR ~/.config/climate-cli/weather.json
# 3. Run it.
uv run climate stack up
uv run climate stack status
uv run climate weather latest
uv run climate doctor
The dashboard is then at http://127.0.0.1:8095. Take a backup with
uv run climate backup dump.
The location config
~/.config/climate-cli/weather.json (or $XDG_CONFIG_HOME/climate-cli/,
or wherever CLIMATE_WEATHER_CONFIG_PATH points) holds labelled locations and
per-provider settings. It never belongs in this repository — not in the
package, not in tests, not in fixtures, not in the public eidetic memory store.
The values below are placeholders; put your own in:
{
"coordinate_precision": 2,
"locations": {
"home": {"latitude": 0.0, "longitude": 0.0}
},
"providers": {
"open-meteo": {"enabled": true},
"met-no": {"enabled": true},
"openweather": {"enabled": true, "interval_seconds": 600},
"metar": {"enabled": true, "request_params": {"stations": ["XXXX"]}},
"ims": {"enabled": false},
"ims-forecast": {"enabled": false, "request_params": {"cities": ["Your City"]}}
}
}
There is no default location anywhere in the package: with nothing configured, the tracker refuses to start (exit 2) rather than silently watching some place it picked for you.
More than one location. stations (metar), station_ids (ims) and cities
(ims-forecast) each accept either a plain list, which serves every configured
location and is the single-location convenience shown above, or a mapping from
location label to that location's own list:
"metar": {"enabled": true, "request_params": {"stations": {"home": ["XXXX"], "office": ["YYYY"]}}}
Under a mapping, a location with no entry issues no request for that provider,
so one place's station is never stored under another place's label. For
cities the list is ordered: the first name present in the feed wins, which
lets you name your own town first and a larger nearby city as the fallback.
The names never leave the machine; they are kept only with the stored record.
CLI
| Command | What it does |
|---|---|
whoami |
Report this agent's nick, version, backend, and model from culture.yaml. |
learn |
Print a structured self-teaching prompt. |
explain <path> |
Markdown docs for any noun/verb path. |
overview |
Read-only descriptive snapshot of the agent. |
doctor |
Identity invariants plus weather-tracker environment checks. |
cli overview |
Describe the CLI surface itself. |
stack up / down / status / overview |
Run the climate-weather compose project. |
weather latest |
Newest reading per (provider, location). The primary query. |
weather series |
One variable over time. |
weather forecast |
Stored forecasts. |
weather stats |
Due-versus-stored fetch counts per provider. |
weather overview |
Describe the weather noun. |
providers [--limits] |
Adapters: capabilities, auth, quota, freshness, attribution. |
backup dump / list / restore / overview |
Local database backups. |
Markdown is the default rendering — it is what humans and agents both read.
Every command supports --json for code. Bare weather --json returns
overview JSON describing the noun's query verbs, not API data; only the
query sub-verbs — weather latest --json, weather series --json,
weather forecast --json, weather stats --json — return the HTTP API's
payload structure unchanged. Results go to stdout, errors and progress
diagnostics to stderr, never mixed.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success, including "no data yet". |
1 |
User-input error: a bad flag value, a bad path, a missing --yes. |
2 |
Environment error: docker/compose/daemon missing, no compose file, web service unreachable. |
3 |
Stale data — weather latest --max-age exceeded. |
4+ |
Reserved. |
Exit 3 exists because a stopped tracker still returns a well-formed latest.
An agent that acts on measured conditions should ask for it:
climate weather latest --json --max-age 10m || echo "too old to act on"
Providers
Six adapters ship. climate providers --limits prints this table live from the
adapters themselves; the notes below add the licence and freshness context.
Quota figures marked unverified were taken from issue 5 or third-party pages, not from the provider's own documentation — they are configurable metadata to re-check, not facts.
open-meteo
- Auth: none (keyless).
- Licence / attribution: "Weather data by Open-Meteo.com", CC BY 4.0 — https://open-meteo.com/en/license. Free tier is non-commercial.
- Freshness:
interval. Default interval 900 s — the provider'scurrentblock is a 900-second model slice, so polling faster returns the same slice. - Rate limits: 600 calls/min, 10,000 calls/day, 300,000 calls/month, with a variable-count call weight. Unverified (source: issue 5).
- Notes: one request returns current,
minutely_15, hourly and daily blocks; the forecast horizon defaults to 48 h. The only keyless source of radiation model data here.
met-no
- Auth: none, but a mandatory identifying
User-Agentis sent. - Licence / attribution: MET Norway, CC BY 4.0 / NLOD — https://api.met.no/doc/License.
- Freshness:
http_expires. Default interval 1800 s, but the interval is a floor, not the policy: no request is issued before the previous response'sExpires, and the post-expiry request carriesIf-Modified-Since. A304is stored as a fetch record like any other. - Rate limits: no published numeric quota
(https://api.met.no/doc/TermsOfService). The honest obligation is to honour
Expiresand send conditional GETs, not to spend a budget. Verified as "no published figure" — which is not the same as unlimited. - Notes: Locationforecast 2.0 complete; no shortwave-radiation field. Coordinates are capped at 4 decimals by MET Norway's terms — this service never sends more than 2 by default anyway.
openweather
- Auth: required —
CLIMATE_OPENWEATHER_API_KEY(https://openweathermap.org/appid). No key means the provider is reported disabled, with the variable named; nothing else breaks. - Licence / attribution: "Weather data provided by OpenWeather".
- Freshness:
interval. Default interval 600 s (~10 min upstream update cadence). - Rate limits: 60 calls/min, 1,000,000 calls/month. Unverified (source: issue 5).
- Notes: the free
/data/2.5/weathercurrent-conditions call only — no One Call 3.0 (it needs a card on file). Its payload carries no dew point, no solar radiation and no UV.
ims
- Auth: required —
CLIMATE_IMS_API_TOKEN, granted manually by email toims@ims.gov.ilwith no stated turnaround. - Licence / attribution: Israel Meteorological Service — https://ims.gov.il/en/termOfuse.
- Freshness:
station_cadence. Default interval 600 s. - Rate limits: none published, and none discoverable without a token. The
adapter declares a conservative 1,000 calls/day placeholder, explicitly
unverified (
source: "unverified"). - Notes: the only source here of measured solar radiation, humidity and
rain. Station and channel ids are discovered at runtime from
GET /stations, never hard-coded — they differ per station. Observation timestamps are corrected from the API's documented always-UTC+2 quirk (its+03:00label is wrong in summer) to true UTC. Ships disabled; enable it when a token arrives. Not verified live — the adapter is built against the vendor PDF and synthesized fixtures.
metar
- Auth: none (keyless).
- Licence / attribution: aviationweather.gov (NOAA/FAA), U.S. Government Work, public domain — https://aviationweather.gov/data/api/.
- Freshness:
station_cadence. Default interval 1800 s (reports are roughly hourly). - Rate limits: none published for the keyless METAR endpoint. Verified as "no documented cap" from the Data API docs.
- Notes: genuine airport observations, not model output. Off unless you
set
enabled: trueand list ICAO codes inrequest_params.stations— there is no default station. Station coordinates in the feed are deliberately never stored as reading values.
ims-forecast
- Auth: none (keyless).
- Licence / attribution: Israel Meteorological Service — https://ims.gov.il/en/termOfuse.
- Freshness:
interval. Default interval 21600 s (6 h); the feed is issued a few times a day. - Rate limits: none published for the keyless feed.
- Notes: a daily city forecast XML feed (ISO-8859-8), not observations.
Off unless you set
enabled: trueand give an orderedrequest_params.citiescandidate list — the feed lists only about fifteen cities, so name yours first and a larger nearby one as a fallback.
Privacy
Your location is private data and this repository treats it as such.
- Coordinates are rounded before they are sent or stored — 2 decimals (about 1 km) by default, never more than the 4 decimals MET Norway allows. Weather does not vary at address precision.
- The API speaks in labels. Every HTTP response and every dashboard asset
identifies a place by the label you chose (
home,office), never by a coordinate. Stored fetch records carry the label, and request URLs in them carry only the rounded values. - Your config never enters the repo. Locations live in
~/.config/climate-cli/weather.json, bind-mounted read-only into the containers. A repo-wide test (tests/test_repo_hygiene.py) fails the build on any coordinate literal inclimate/,tests/or the docker files. - Keys live in one gitignored file.
docker/weather.env(fromdocker/weather.env.example) and the environment only — never in the config file, never in a log line, never in a stored request URL, never in the public.eidetic/memorystore. Redaction is enforced at both the transport and the storage layer.
Exposing the web service
By default weather-web is published on loopback only
(127.0.0.1:8095) and weather-mongodb publishes no host port at all.
To reach the dashboard from another machine, set the bind address in
docker/weather.env:
CLIMATE_WEB_BIND=0.0.0.0
CLIMATE_WEB_PORT=8095
Warning: the web service has no authentication of any kind. Anyone who can reach the port can read everything you have collected. Loopback stays the default; a public hostname goes through a Cloudflare tunnel behind Cloudflare Access SSO, never the bind address above directly — see
docs/operations/climate-culture-dev.mdfor the setup. The tunnel's ingress is pinned to127.0.0.1:8095, so changingCLIMATE_WEB_PORTorCLIMATE_WEB_BINDmeans re-running that remote-login setup, or the public hostname starts returning502. Remote CLI/doctoraccess to a tunnelled instance needs a Cloudflare Access service token, which is not provisioned yet — don't pointCLIMATE_WEATHER_URLat a public hostname; the CLI cannot complete the SSO login and will just see the redirect. Note also that re-serving third-party weather data beyond personal use may carry licence conditions beyond attribution — see each provider's licence line in the dashboard footer and the provider terms above, which were read in summary only. Re-check before exposing the service beyond your LAN.
The service is read-only over the collection: it has no write route at all.
Debugging MongoDB directly
weather-mongodb publishes no host port by default. For local inspection
with a GUI mongo client, apply the debug override on top of the base
compose file — this only adds a port publish to the existing
weather-mongodb service, never a second mongod process against the same
volume:
docker compose -f docker-compose.yml -f docker-compose.debug.yml up -d weather-mongodb
That reaches it at 127.0.0.1:27020. Bring it back down the same way you
brought up the rest of the stack; there is no separate teardown step.
Development
uv sync
uv run pytest -n auto # the full suite: no network, no docker, no Mongo
uv run pytest -n auto --cov=climate # coverage (gate: 60%)
uv run black --check climate tests
uv run isort --check-only climate tests
uv run flake8 climate tests
uv run bandit -c pyproject.toml -r climate
uv run teken cli doctor . --strict # the agent-first rubric gate CI runs
markdownlint-cli2 "**/*.md" "#node_modules" "#.local" "#.claude/skills" "#.teken"
The runtime package has no third-party dependencies (dependencies = []).
pymongo lives in the weather extra, which only the container image installs
(pip install ".[weather]"); the host CLI reaches the collection through the
web service's HTTP API and never imports it. tests/test_stdlib_only.py proves
this by importing every host-side module in a fresh interpreter with all
third-party imports blocked.
Every PR bumps the version and adds a CHANGELOG.md entry — CI's version-check
job fails otherwise. See CLAUDE.md for the full conventions.
Follow-ups
- A published container image so
climate stack upworks from a PyPI install rather than a repo checkout: issue #6. Today the stack verbs exit2with that hint from a wheel install. - S3 archive of the collection — deferred; the stored shape does not preclude it.
- Compaction of stored-as-is data — revisit once months of data exist.
ecmwf-openandnoaa-gfsdirect-model adapters from issue 5.
License
MIT — see LICENSE.
Release files for climate-cli 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| climate_cli-0.6.0.tar.gz | 3.0 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| climate_cli-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 3.2 MB
Release files / climate_cli-0.6.0.tar.gz
| Download URL | climate_cli-0.6.0.tar.gz |
|---|---|
| Size | 3.0 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
53670ee6251d9e019aa67114b733679739c642710e1c44f0dcf1acc236a48a24
|
|
BLAKE2b-256 checksum How to use checksums |
5e39ed6460eb2512bc733477cfec20c4b2bfeb4950e1fa205c533667c0a2d825
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.18 {"installer":{"name":"uv","version":"0.12.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / climate_cli-0.6.0-py3-none-any.whl
| Download URL | climate_cli-0.6.0-py3-none-any.whl |
|---|---|
| Size | 203.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1096728e43f9effd8fd35b57b8893ceb4bb7ba2bfe93e836c76d34938cbd9fb6
|
|
BLAKE2b-256 checksum How to use checksums |
3f60206c663221c184ab647eb260049752f847d8508cb22ce9cd53a9aa74fa55
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.18 {"installer":{"name":"uv","version":"0.12.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|