Download any NASA Earth observation dataset (HDF / NetCDF / GeoTIFF) end-to-end. Wraps the official earthaccess library to authenticate with NASA Earthdata Login and bulk-download granules from MODIS, VIIRS, GPM, Sentinel, SMAP, ASTER, etc. Supports BBox + temporal window filtering and QA sidecars. Default credentials are loaded from ~/.geoskill/secrets.json (NASA Earthdata bearer token). 0.2.0: alias map (description → short_name), multi-word search, --dry-run, proper exit codes (5 on 0 results), accurate --max-files, fixed granule display.
---
name: nasa-dataset-download
description: 'Download any NASA Earth observation dataset (HDF / NetCDF / GeoTIFF) end-to-end. Wraps the official earthaccess library to authenticate with NASA Earthdata Login and bulk-download granules from MODIS, VIIRS, GPM, Sentinel, SMAP, ASTER, etc. Supports BBox + temporal window filtering and QA sidecars. Default credentials are loaded from ~/.geoskill/secrets.json (NASA Earthdata bearer token). 0.2.0: alias map (description → short_name), multi-word search, --dry-run, proper exit codes (5 on 0 results), accurate --max-files, fixed granule display.'
---
# nasa-dataset-download
End-to-end download of any NASA Earth observation dataset. Built on top
of [`earthaccess`](https://github.com/nsidc/earthaccess) (NSIDC's
official Python SDK) so it handles **MODIS**, **VIIRS**, **GPM**,
**Sentinel-1**, **SMAP**, **ASTER**, **MERRA-2**, and thousands of
other NASA datasets through a uniform CLI.
## Quick start
```bash
# 0. one-time: put your Earthdata bearer token in ~/.geoskill/secrets.json
# (or set $EARTHDATA_TOKEN in your env)
python scripts/nasa_dataset_download.py login
# [OK] EARTHDATA_USERNAME source=user_secrets available=True
# [OK] EARTHDATA_TOKEN source=user_secrets available=True
# Attempting live login with earthaccess.login()...
# OK: earthaccess.authenticated=True
# 1. browse the 52K offline catalog for inspiration
python scripts/nasa_dataset_download.py known MOD11A1
# 2. list available granules
python scripts/nasa_dataset_download.py search MOD11A1 \
--version 061 --temporal-start 2024-06-01 --temporal-end 2024-06-01 \
--bbox 115 39 117 41
# 3. download 2 granules (defaults to ./output/<short_name>/)
python scripts/nasa_dataset_download.py download MOD11A1 \
--version 061 --temporal-start 2024-06-01 --temporal-end 2024-06-01 \
--bbox 115 39 117 41 --max-files 2
# 4. print URLs only (no download)
python scripts/nasa_dataset_download.py urls MOD11A1 \
--version 061 --temporal-start 2024-06-01 --temporal-end 2024-06-01 \
--bbox 115 39 117 41 --out urls.json
```
## Subcommands
| Subcommand | Network | Purpose |
|---|---|---|
| `login` | Yes | Authenticate with Earthdata; show credential state |
| `known` | No | Browse the bundled 52K offline catalog **+ alias map** (multi-word AND search) |
| `search` | Yes | List granules for a dataset in BBox + time window. Description-style names ("land surface temperature") print an alias hint suggesting canonical short_name(s). |
| `download` | Yes | Bulk-download granules to a local directory. `--dry-run` previews; `--max-files` actually limits downloads. |
| `urls` | Yes | Print granule URLs (no download); also writes a structured JSON sidecar via `--out`. |
## Alias map (description → short_name)
`data/aliases.json` (50 entries) maps natural-language descriptions to
canonical short_names. Lets users search by what they know, not by what
they've memorized:
```bash
# user types a description, skill suggests the right short_name
python scripts/nasa_dataset_download.py known "land surface temperature"
# -> alias match: 'land surface temperature' -> MOD11A1, MOD11A2, MOD11B, MOD11_L2, ...
# and 10 catalog records like MOD11A1 | MODIS/Terra LST/Emissivity Daily L3
# search with description also gets the hint
python scripts/nasa_dataset_download.py search "land surface temperature" --temporal-start 2024-06-01 --bbox 115 39 117 41
# stderr: hint: 'land surface temperature' matched the alias map; try the canonical short_name(s): MOD11A1, ...
# disable hinting per-invocation
python scripts/nasa_dataset_download.py search "MOD11A1" --no-alias-resolve
```
Other entries: `MODIS LST`, `surface reflectance`, `land cover`,
`vegetation index`, `NDVI`, `snow cover`, `sea ice`, `precipitation`,
`IMERG`, `GPM`, `rainfall`, `soil moisture`, `SMAP`, `SST`, `ocean color`,
`active fire`, `evapotranspiration`, `Sentinel-1`, `Landsat`, `DEM`, `ASTER`,
`LAI`, `GPP`, `ET`.
## Common options (per subcommand)
- `--format {text,json}` — output format
- `--qa PATH` — write a JSON run-summary sidecar to `PATH` (mirrors Phase 5 convention)
- `--bbox W S E N` — geographic bounding box in WGS84
- `--temporal-start YYYY-MM-DD` / `--temporal-end YYYY-MM-DD` — date range (inclusive)
- `--count N` — max results
- `--max-files N` (download only) — **actually limits downloads** (was buggy in v0.1.0)
- `--dry-run` (download only) — print what would be downloaded; no files written
- `--no-alias-resolve` (search only) — skip the alias-map hint when `short_name` looks like a description
## 0-result handling
Per geoskill-core §2.2, search returns **exit 5** when 0 granules match
(v0.1.0 silently exited 0). The error output now lists possible causes
and suggests a fix:
```
$ python scripts/nasa_dataset_download.py search NOTAREAL --temporal-start 2024-06-01 --temporal-end 2024-06-01
no granules found. Possible causes:
- 'NOTAREAL' may be a description, not a short_name
- the date 2024-06-01 may be out of range for this dataset
- try `nasa-dataset-download known "NOTAREAL"` to find a similar short_name
- or check spelling: e.g. MOD11A1 (not MOD11A-1), GPM_3IMERGHH (not IMERG)
$ echo $?
5
```
## Credentials
This skill authenticates with **NASA Earthdata Login** (covers all
EOSDIS data centers: LAADS, GES DISC, LP DAAC, ASF, NSIDC, CMR,
AppEEARS, Worldview). Resolution order:
1. `EARTHDATA_USERNAME` / `EARTHDATA_PASSWORD` / `EARTHDATA_TOKEN` env vars
2. `~/.geoskill/secrets.json` (user-level, **not** vendored)
3. `~/.netrc` entries (`machine urs.earthdata.nasa.gov`)
4. Skill defaults
**Bearer token is preferred** for CMR / LP DAAC earthdata cloud / GES DISC
since it works with both modern (CMR-driven) and legacy endpoints.
```json
// C:\Users\<you>\.geoskill\secrets.json
{
"EARTHDATA_USERNAME": "ruiduobao",
"EARTHDATA_PASSWORD": "Ruiduobao123",
"EARTHDATA_TOKEN": "eyJ0eXAiOiJKV1Q..."
}
```
Generate a bearer token at <https://urs.earthdata.nasa.gov/profile>.
## Endpoints used
| Service | URL | Auth | Datasets |
|---|---|---|---|
| CMR | `https://cmr.earthdata.nasa.gov/search/` | optional | All NASA |
| LP DAAC earthdata cloud | `https://data.lpdaac.earthdatacloud.nasa.gov/` | **bearer** | MODIS Land / SRTM / ASTER |
| GES DISC | `https://data.gesdisc.earthdata.nasa.gov/data/` | **bearer** | GPM / MERRA-2 / TRMM |
| LAADS archive (legacy) | `https://ladsweb.modaps.eosdis.nasa.gov/archive/allData/` | basic | MODIS L1 / atmosphere |
## Validation: 10 real downloads
Verified on 2026-07-27 (Beijing BBox, 2024-06-01) — all 10 datasets
downloaded successfully via Earthdata bearer token:
| # | Dataset | Provider | Size | Format |
|---|---|---|---|---|
| 1 | MOD11A1 v061 | LP DAAC | 3.95 MB | HDF4 |
| 2 | MOD09GA v061 | LP DAAC | 173.6 MB | HDF4 |
| 3 | GPM_3IMERGHH v07 | GES DISC | 16.1 MB | HDF5 |
| 4 | MOD11A2 v061 | LP DAAC | 13.6 MB | HDF4 |
| 5 | MOD10A1 v61 | NSIDC | 17.0 MB | HDF4 |
| 6 | MOD13Q1 v061 | LP DAAC | 235.2 MB | HDF4 |
| 7 | MOD09A1 v061 | LP DAAC | 130.4 MB | HDF4 |
| 8 | GPM_3IMERGDF v07 | GES DISC | 31.95 MB | HDF5 |
| 9 | MOD14A1 v061 | LP DAAC | 0.9 MB | HDF4 |
| 10 | MOD21A1D v061 | LP DAAC | 2.6 MB | HDF4 |
**Total: 16 files, 625 MB, 148 seconds** via VPN 7897.
## Tests
23 tests (all PASSED on 2026-07-27):
- 6 catalog (offline, no network) — `test_catalog_exists`, `test_load_catalog`, `test_search_catalog_*`, `test_search_catalog_multiword*`
- 4 alias (offline) — `test_alias_map_loaded`, `test_resolve_alias_*`
- 1 granule_info (offline) — `test_granule_info_keys`
- 6 CLI smoke (no network) — `test_cli_help`, `test_cli_version`, `test_cli_login_shows_creds`, `test_cli_known_search`, `test_cli_known_qa_sidecar`, `test_cli_known_multikeyword`
- 3 CLI live (no network — pure error/hint paths) — `test_cli_search_no_match_exit5`, `test_cli_download_dry_run`
- 3 live (skipif no token) — `test_live_login`, `test_live_search_mod11a1`, `test_live_search_gpm`, `test_live_search_mod09ga`
Plus a **20-question end-to-end regression** (`.user-test/regression_test.py`)
that runs every realistic user question and asserts the expected outcome
(20/20 PASS in ~70s wall clock).
```bash
cd nasa-dataset-download
python -m pytest --tb=short
# ============================= 23 passed in ~24s =============================
python .user-test/regression_test.py
# 20/20 questions PASSED
```
## Why a new skill
The existing `modis-lst-download`, `gpm-download`, and `download-dem`
each target one product line. `nasa-dataset-catalog` searches 52K
datasets but doesn't handle bulk download well. This skill fills
both gaps:
- **Bulk download** any of the 52K NASA datasets in one command
- Uses the **official `earthaccess` SDK** (NSIDC's maintained library)
- Defaults to `~/.geoskill/secrets.json` for credentials (single place
for all 40+ skills)
## Exit codes
Per geoskill-core §2.2: 0=success, 2=arg, 3=missing dep, 4=network,
5=no match, 6=data validation, 7=processing, 130=interrupt.
## Limitations
- AWS S3 cloud-hosted datasets (`earthaccess.search_data(cloud_hosted=True)`)
not exposed as a separate subcommand yet — use the Python API for those
- GES DISC older URLs (`gpm1.gesdisc.eosdis.nasa.gov/opendap/`) not
supported; use the new `data.gesdisc.earthdata.nasa.gov/data/` path
- Granule listing is hard-capped at `--count`; raise if you need more
- No retry / resume / multipart — if download fails midway, re-run
(skips already-downloaded files via earthaccess internal cache)
- Alias map (`data/aliases.json`) has 50 entries; the offline catalog has
52K. For unmapped descriptions, `known` falls back to multi-word AND
search across the offline catalog and may surface false positives
(e.g. "Sentinel-1" hits ESA Greenland records that mention sentinel in
the title). When in doubt, the alias hint suggests canonical short_names.
## Versioning
- **0.2.0** (2026-07-27) — Phase 7.7. Alias map (50 entries) + multi-word
AND search; fixed `search` output (`g.title` / `g.size_in_mb` /
`g.get_umm()` were broken in earthaccess 0.18); `--max-files` now
actually limits downloads (was reporting-only in v0.1.0); `--dry-run`
on download; 0-result search returns exit 5 with hint; suppressed
FutureWarning noise; JSON output now includes granule_id, size, day/night,
production_dt, temporal range, data_links, cloud_hosted flag. 23 tests +
20-question regression. 0 fail, 0 skip-with-fail.
- 0.1.0 (2026-07-27) — Phase 7.6 initial release. 5 subcommands, 13 tests,
10 real-download integration tests (PASS 10/10, 625 MB across 6
different NASA data centers).
## License
MIT. `earthaccess` is Apache-2.0. Bundled `data/nasa_catalog.json`
from <https://github.com/opengeos/NASA-Earth-Data> (MIT).
don't have the plugin yet? install it then click "run inline in claude" again.
reorganized original content into explicit intent, inputs (with credentials/endpoints/setup), procedure (5 steps with edge cases), decision points (6 explicit if-else branches for descriptions/no-match/network/auth/dry-run/spatial), and output contract with concrete JSON schema and file paths; added rate-limit and token-expiry edge cases throughout.
end-to-end download of any NASA Earth observation dataset. wraps the official earthaccess SDK from NSIDC to handle MODIS, VIIRS, GPM, Sentinel-1, SMAP, ASTER, MERRA-2, and thousands of NASA datasets through a single CLI. use this when you need to bulk-download granules across geographic areas and time windows from any EOSDIS data center (LP DAAC, GES DISC, LAADS, NSIDC, ASF, CMR).
this skill downloads NASA Earth observation data at scale. you provide a dataset short_name (or human-readable description), a bounding box, a date range, and a file count limit. the skill queries the CMR (Common Metadata Repository), filters granules by your constraints, and downloads them locally with optional dry-run and QA metadata. it handles authentication via Earthdata Login, resolves alias descriptions to canonical dataset names, and exits with proper error codes when no granules match or network issues occur.
required parameters:
DATASET: dataset short_name (e.g. MOD11A1, GPM_3IMERGHH) or human description (e.g. "land surface temperature"). descriptions trigger alias-map lookup; if no alias match, multi-word AND search across the offline catalog is attempted.optional parameters (all subcommands):
--format {text,json} , output format; default=text--qa PATH , write JSON run-summary sidecar to PATH (mirrors Phase 5 convention)--bbox W S E N , bounding box in WGS84 decimal degrees (west, south, east, north); default=global--temporal-start YYYY-MM-DD , inclusive start date; no default (searches all available)--temporal-end YYYY-MM-DD , inclusive end date; defaults to today if start is set--count N , max results per query; default=10--version VERSION , dataset version (e.g. 061 for MOD11A1); optional--no-alias-resolve , skip alias-map hint when dataset name looks like a descriptionoptional parameters (download subcommand only):
--max-files N , actually limit downloads to N granules; default=1--dry-run , print what would be downloaded without writing filesexternal connections:
https://cmr.earthdata.nasa.gov/search/ , dataset catalog (optional auth, all NASA data)https://data.lpdaac.earthdatacloud.nasa.gov/ , MODIS Land, SRTM, ASTER (requires bearer token)https://data.gesdisc.earthdata.nasa.gov/data/ , GPM, MERRA-2, TRMM (requires bearer token)https://ladsweb.modaps.eosdis.nasa.gov/archive/allData/ , MODIS L1, atmosphere (basic auth or token)credentials (resolution order):
EARTHDATA_USERNAME, EARTHDATA_PASSWORD, EARTHDATA_TOKEN~/.geoskill/secrets.json (JSON file with keys EARTHDATA_USERNAME, EARTHDATA_PASSWORD, EARTHDATA_TOKEN)~/.netrc entries (machine urs.earthdata.nasa.gov)setup guidance:
https://urs.earthdata.nasa.gov/profile (recommended for cloud-hosted datasets)~/.geoskill/secrets.json:{
"EARTHDATA_USERNAME": "your_username",
"EARTHDATA_PASSWORD": "your_password",
"EARTHDATA_TOKEN": "eyJ0eXAiOiJKV1Q..."
}
export EARTHDATA_TOKEN=eyJ0eXAiOiJKV1Q...bundled data:
data/nasa_catalog.json , 52K+ offline dataset records (MIT license from opengeos/NASA-Earth-Data)data/aliases.json , 50-entry alias map (description to short_name); entries: MODIS LST, surface reflectance, land cover, vegetation index, NDVI, snow cover, sea ice, precipitation, IMERG, GPM, rainfall, soil moisture, SMAP, SST, ocean color, active fire, evapotranspiration, Sentinel-1, Landsat, DEM, ASTER, LAI, GPP, ET, and othersinput: none (reads from env, ~/.geoskill/secrets.json, or ~/.netrc)
action: run python scripts/nasa_dataset_download.py login
output: console output showing credential source and earthaccess auth state (authenticated=True or False); if auth fails, error message with next steps
edge case: if token is expired, earthaccess.login() fails; regenerate token at https://urs.earthdata.nasa.gov/profile and retry
input: optional QUERY string (e.g. "land surface temperature" or MOD11A1)
action: run python scripts/nasa_dataset_download.py known [QUERY] (or known with no args to list all 52K)
output: text or JSON list of matching catalog records (short_name, description, temporal_range)
edge case: multi-word queries use AND logic (all words must match in title/description); may surface false positives; alias matches are printed as hints
input: DATASET, optional --bbox, --temporal-start, --temporal-end, --version, --count, --format
action: run python scripts/nasa_dataset_download.py search <DATASET> [OPTIONS]
output: list of matching granules with granule_id, size_in_mb, acquisition date, and data_links; JSON sidecar if --qa PATH is set
edge case: if DATASET is a description, alias lookup is attempted and a hint is printed to stderr suggesting canonical short_names; if 0 granules match, exit code 5 is returned with diagnostic message
input: DATASET, --bbox, --temporal-start, --temporal-end, --version, --max-files, --dry-run
action: run python scripts/nasa_dataset_download.py download <DATASET> --dry-run [OPTIONS]
output: console list of granule URLs and sizes that would be downloaded (no files written); JSON sidecar if --qa PATH is set
edge case: none (safe to run multiple times)
input: DATASET, --bbox, --temporal-start, --temporal-end, --version, --max-files, --format
action: run python scripts/nasa_dataset_download.py download <DATASET> [OPTIONS] (omit --dry-run)
output: files written to ./output/<short_name>/ (default) or custom path; JSON sidecar if --qa PATH is set; console log of downloaded file paths and total size
edge case: if a file already exists locally (per earthaccess internal cache), it is skipped; if network timeout occurs mid-download, re-run command to resume; if --max-files is set, download stops after N granules
input: DATASET, --bbox, --temporal-start, --temporal-end, --version, --count, --out PATH
action: run python scripts/nasa_dataset_download.py urls <DATASET> [OPTIONS] --out urls.json
output: JSON array of granule URLs written to PATH; structured metadata (granule_id, size, cloud_hosted flag, data_links) included
edge case: none (no files downloaded, pure metadata extraction)
if DATASET name is a human-readable description (not a canonical short_name):
data/aliases.json (50 hardcoded entries like "land surface temperature" -> ["MOD11A1", "MOD11A2", "MOD11B", ...])--no-alias-resolve flag to skip alias-map step entirelyif 0 granules match the search criteria:
known "<DATASET>" to find similar short_names; check spelling against canonical names (e.g. MOD11A1 not MOD11A-1)if network timeout or connection error occurs:
if Earthdata credentials are missing or expired:
https://urs.earthdata.nasa.gov/profile and store in ~/.geoskill/secrets.json or env varif --max-files N is set (download only):
if --dry-run is set (download only):
if --bbox or temporal range filters are omitted:
if --format json is set:
success case (search / urls subcommands):
subcommand: "search" or "urls"dataset: canonical short_namequery_bbox: [W, S, E, N] or nullquery_temporal_start: ISO 8601 date or nullquery_temporal_end: ISO 8601 date or nullresult_count: integer (granules found)total_size_mb: float (sum of all granule sizes)execution_time_seconds: floatexit_code: 0success case (download subcommand):
./output/<short_name>/ (default) or custom path specified in commandsubcommand: "download"dataset: canonical short_namequery_bbox: [W, S, E, N] or nullquery_temporal_start: ISO 8601 date or nullquery_temporal_end: ISO 8601 date or nullfiles_downloaded: integerfiles_skipped: integer (already cached locally)total_size_mb: floatlocal_output_dir: absolute pathexecution_time_seconds: floatexit_code: 0failure case (exit code 4 - network):
exit_code: 4 and error_message keyfailure case (exit code 5 - no match):
exit_code: 5, result_count: 0, and suggestions keyfailure case (exit code 3 - auth):
exit_code: 3 and error_message keyfailure case (exit code 2 - arg):
exit_code: 2 and error_message keythe skill worked if any of the following is true:
login subcommand: console output shows [OK] EARTHDATA_TOKEN source=user_secrets available=True and earthaccess.authenticated=True
known subcommand: console lists 52K+ dataset records or a filtered subset with short_name, description, and temporal range; if a description is provided, alias-matched records are marked
search subcommand: console lists 1+ granules with granule_id, size_in_mb, acquisition_date, and download URLs; exit code is 0; if --qa was set, sidecar JSON exists and contains result_count > 0
download subcommand: files appear in ./output/<short_name>/ with expected names and sizes; console log shows "downloaded:
urls subcommand: JSON file written to --out path contains structured array with granule_id, size_in_mb, and data_links keys; exit code is 0
dry-run (download): console lists granule URLs and sizes without writing any files; exit code is 0
error handling: when 0 granules match, exit code is 5 (not 0 or 1); stderr contains "no granules found" message with suggestions; when auth fails, exit code is 3 and stderr points to setup instructions; when network timeout occurs, exit code is 4