CLI & Manifests
The GeoLens CLI (geolens) is the fastest way to get data into an instance
from a terminal or CI pipeline. It wraps the Python SDK to publish single files,
scan directories, refresh and inspect datasets, run analysis operations, export
STAC metadata, and apply declarative manifests (geolens.yaml) that
describe a whole catalog.
Install
Section titled “Install”pip install geolens-cli # installs the `geolens` command# or, for an isolated tool install:pipx install geolens-cligeolens-cli requires Python 3.11 or newer.
Verify the install:
geolens --versiongeolens --helpTo run a command without installing anything, uvx fetches the package on
demand:
uvx --from geolens-cli geolens --versionPin the version in automation (uvx --from geolens-cli==<version> geolens ...).
The examples repo
keeps the pinned form of the manifest commands on this page, next to a
working manifest and GitHub Actions workflow.
Authenticate
Section titled “Authenticate”Log in to an instance and store credentials. The CLI talks to the API base
and appends /api itself when you leave it off, so
https://geolens.example.com and https://geolens.example.com/api resolve to
the same stored credential — the examples here spell the suffix out:
geolens login http://localhost:8080/api# prompts for your admin username and password (auto-generated at install; see your .env)By default the bearer token is stored in your operating system keyring. Pass
--no-keyring to fall back to a credentials.toml file instead (useful on
headless hosts without a keyring service):
geolens login https://geolens.example.com/api --no-keyringYou can also store a token or API key non-interactively (handy for scripts):
geolens login https://geolens.example.com/api --token "$JWT"geolens login https://geolens.example.com/api --api-key "$GEOLENS_API_KEY"# pass `-` to read the secret from stdin (keeps it out of argv / shell history):echo "$JWT" | geolens login https://geolens.example.com/api --token ---token and --api-key are mutually exclusive; pass only one.
Check who you are, or clear credentials:
geolens whoami # prints the active user and instancegeolens logout # removes stored credentials for the active instancePublish a single file
Section titled “Publish a single file”geolens publish uploads one local vector or raster file and runs the full
ingest flow (upload -> preview -> commit), then prints the new dataset’s URL:
geolens publish ./city-parks.geojson --name "City Parks"geolens publish ./elevation.tif --name "Elevation" --description "10m DEM"By default the command waits for ingestion to resolve the dataset id. Use
--no-wait to return immediately with a job-search URL instead:
geolens publish ./big-raster.tif --no-waitRefresh a dataset
Section titled “Refresh a dataset”geolens refresh <dataset_id> re-pulls a dataset from its server-stored source
binding — the same manual refresh as the dataset page, for datasets with an
upstream to re-pull (Service, registered PostGIS table, STAC):
geolens refresh <dataset_id> # queue the job and returngeolens refresh <dataset_id> --wait # follow it to a terminal stategeolens refresh <dataset_id> --wait --timeout 600 # bound the waitUnlike publish, refresh does not wait by default. --timeout takes
seconds (finite, greater than 0) and requires --wait; without it, --wait
follows the job however long it queues.
For a protected service, pass --token with no value to get a hidden prompt:
geolens refresh <dataset_id> --tokenAn explicit --token <value> can be visible in argv and shell history. The
token is used for that refresh only — GeoLens never stores it on the dataset,
so each refresh of a protected service needs it again.
Replace a dataset’s data
Section titled “Replace a dataset’s data”geolens replace <dataset-id> <file> uploads a local file over an existing
dataset’s data — the CLI equivalent of the web app’s Re-Upload dialog:
geolens replace <dataset_id> ./city-parks-2026.geojsongeolens replace <dataset_id> ./parcels.gpkg --layer parcels_currentgeolens replace <dataset_id> ./elevation.tif --waitIt runs the same upload, preview, commit flow as publish, pointed at the
dataset’s reupload endpoints: uploads the file, prints the preview (detected
layer, feature count, SRID), and prompts for confirmation before committing.
--layer picks a layer out of a multi-layer file — required when the file
has more than one, since the CLI refuses to commit an unnamed default.
--srid overrides the detected SRID. --wait polls the replace job to a
terminal state and exits non-zero on failure; without it, the command returns
once the job is queued. --yes/-y skips the confirmation prompt, and is
required together with --json, which never prompts.
replace refuses a dataset whose data comes from a service, a STAC item, or
a registered PostGIS table — replacing those with a file would sever the
binding that keeps them refreshable. Use geolens refresh
for those instead.
Inspect a dataset
Section titled “Inspect a dataset”geolens status <dataset_id> prints a dataset’s catalog status alongside its
source origin, freshness, health, and last refresh:
geolens status <dataset_id>geolens --json status <dataset_id> # machine-readable payloadScan a directory
Section titled “Scan a directory”geolens scan walks a directory and reports what would be ingested, a dry
run with no upload. Use it to preview a bulk import:
geolens scan ./gis-exportsgeolens scan ./gis-exports --include-ext .gpkg,.tif --max-depth 2geolens scan ./gis-exports --json # machine-readable outputscan classifies by file extension only. It recognizes GeoJSON, GeoPackage,
FlatGeobuf, KML, KMZ, shapefiles (grouped with their sidecars, and flagged
when .dbf or .shx is missing), and GeoTIFF, and reports everything else
as unsupported — except
orphaned sidecars (a stray .prj, .dbf, .shx, .cpg, .tfw and the like
with no dataset beside them), which it skips without a row. A .json
file counts as GeoJSON only if its first kilobyte looks like one. Read the
report as an inventory of the obvious candidates, not a capability check — its
extension list and the server’s upload allowlist are not the same list, as
Bulk import spells out.
Bulk import
Section titled “Bulk import”There is no bulk-upload command. geolens publish takes exactly one file, and
geolens apply never uploads from your
machine. The supported way to import a directory of local files is a shell loop
over publish:
for f in ./gis-exports/*.geojson ./gis-exports/*.gpkg; do [ -e "$f" ] || continue name=$(basename "$f") geolens publish "$f" --name "${name%.*}" || exit 1doneEach iteration runs the whole ingest flow for that one file — upload, preview,
commit — so 40 files means 40 uploads and 40 ingest jobs queued on the server.
Keep the default --wait and the loop stays serialized instead of flooding the
queue; --tags and --collection require it anyway, since both are applied
after the commit resolves the dataset id.
publish is not idempotent, so re-running the loop publishes a second copy of
every file. Track what succeeded, or delete the earlier datasets first.
Manifests: repeatable catalogs
Section titled “Manifests: repeatable catalogs”For multi-dataset catalogs that you want to version and re-apply, describe your
sources in a geolens.yaml manifest and apply it declaratively.
geolens init # scaffold ./geolens.yaml (errors if it already exists)geolens init --force # overwrite an existing manifestgeolens init catalog.yaml # scaffold at a custom pathgeolens validate geolens.yaml # local schema check, no API callgeolens apply geolens.yaml # validate + apply via the GeoLens APIgeolens apply geolens.yaml --dry-run # preview apply outcomes without writesEach dataset carries a stable key, so re-applying an edited manifest updates
the matching datasets instead of creating duplicates. The server matches each
entry to an existing dataset by key and fingerprints the rest, then answers
create (new key), update (known key, changed entry), or skip (unchanged
entry) per dataset. An unchanged manifest is therefore safe to apply on every
push. --dry-run returns the same verdicts without writing, but the server
evaluates it, so it needs a credential like a real apply.
Apply reconciles the declaration rather than the bytes behind it: a source URL
that serves new content under an unchanged entry still skips.
geolens refresh <dataset_id> is the manual refresh from
the dataset page, for datasets with an upstream to re-pull (Service, registered
PostGIS table, STAC). A source the server downloaded for a manifest is an
ordinary upload once ingested, so refresh refuses it. See Where Your Data Lives.
Manifest schema (v1)
Section titled “Manifest schema (v1)”The top level requires manifest_version: "1", a catalog block, and a
datasets array of 1 to 100 entries. Split a larger catalog across several
manifests.
Print the packaged JSON Schema for geolens.yaml — for editor validation or
CI schema checks — without contacting an API:
geolens schema # print to stdoutgeolens schema -o geolens.schema.json # write to a filecatalog: required title; optional description, organization, and a
contact object (name, email, url).
datasets[]
| Field | Required | Notes |
|---|---|---|
key | Yes | Stable identity for idempotent apply. Must start with a lowercase letter or digit, then a-z 0-9 . _ -; up to 128 chars. |
title | Yes | Human-readable dataset title. |
description | No | Longer description. |
sources | Yes | Exactly one source object (below); the schema caps the array at one entry. |
metadata | No | Tags, CRS, license, bbox (below). |
publication | Yes | Publication intent (below). |
sources[]
| Field | Required | Notes |
|---|---|---|
type | Yes | One of vector, raster_cog. |
uri | Yes | Relative path, https://, or s3:// / gs:// / az:// / abfs:// URI. Must end in an extension the ingest path recognizes: zip, gpkg, geojson, json, csv, xlsx, xls, fgb, kml, or kmz for vector; tif or tiff for raster_cog. A .zip covers a zipped File Geodatabase as well as a Shapefile bundle — the two are told apart by content, not by the manifest. A query string or fragment may follow. Unlike a direct upload, a manifest source cannot be a .parquet file — publish GeoParquet through the UI or geolens publish instead. |
format | No | Driver hint, e.g. geojson, gpkg. |
layer | No | Layer name for multi-layer sources. |
title / description | No | Per-source overrides. |
checksum | No | sha256:<64 lowercase hex characters>. A declared digest of the source bytes, folded into apply’s change-detection fingerprint alongside the rest of the entry — it is not verified against the fetched bytes. Bump it when the file under a stable URI changes, to force a vector entry to reclassify as an update instead of skipping as unchanged. Do not set or change it on a raster_cog source: manifest raster updates aren’t supported, so a changed checksum there makes apply report that entry as an error rather than a skip. |
metadata (all optional): tags (string array), organization, crs
(EPSG:NNNN), license, attribution, bbox ([minx, miny, maxx, maxy] in
WGS84).
publication: required intent. The schema does not fix the values; they
come from the workflow statuses your deployment defines, and the server
validates them at apply time. The default set is draft, ready,
internal, published, which is why geolens validate can accept an intent
your instance then rejects.
Example manifest
Section titled “Example manifest”manifest_version: "1"catalog: title: Regional Open Data description: Public datasets published by the Regional Data Office. organization: Regional Open Data Officedatasets: - key: regional-trails title: Regional trails description: Recreational trail network. sources: - type: vector uri: https://data.example.com/trails.geojson format: geojson metadata: tags: [trails, recreation] crs: EPSG:4326 license: CC-BY-4.0 attribution: Regional Open Data Office bbox: [-78.0, 38.0, -76.5, 39.5] publication: intent: readyRun an analysis
Section titled “Run an analysis”geolens analysis preview computes an operation and writes the resulting
GeoJSON to stdout without creating anything. The result is capped at the
server’s preview limit, and the cap is announced on stderr so stdout stays a
valid GeoJSON document:
geolens analysis preview <dataset_id> --operation buffer --distance 500 > ring.geojsongeolens analysis preview <dataset_id> --operation centroid --compact | jq .geolens analysis materialize runs the operation over the whole dataset and
saves the result as a new dataset:
geolens analysis materialize <dataset_id> \ --operation buffer --distance 500 --title "500 m ring"geolens analysis materialize <dataset_id> \ --operation dissolve --by-field region \ --title "By region" --no-waitBoth accept buffer, centroid, clip, spatial_join, measure,
select_by_location, and intersect; dissolve is materialize-only.
Per-operation flags are --distance (buffer, in metres), --mask-dataset
(required for clip, select_by_location and intersect — the CLI has no
drawn-mask option, so omitting it is a server 422, exit 1), --by-field
(dissolve), and --join-dataset-id / --join-fields (spatial_join).
materialize waits for the job by default. Analysis is queued below uploads, so
that wait has no upper bound — pass --timeout <seconds> to bound it, or
--no-wait to return the job id immediately.
Export STAC metadata
Section titled “Export STAC metadata”Export STAC API 1.0 item metadata for a raster dataset (vector datasets are rejected with a clear message):
geolens export stac <dataset_id> # pretty JSON to stdoutgeolens export stac <dataset_id> -o item.json # write to a filegeolens export stac <dataset_id> --compact # single line, for piping to jqCI integration
Section titled “CI integration”The CLI is built for pipelines: authenticate through the environment, validate
offline, preview with --dry-run on a pull request, apply on a push to main.
--json is a global option and goes before the subcommand (geolens --json apply geolens.yaml). Only geolens scan also accepts --json after the
subcommand; for every other command a trailing --json is a “No such option”
error.
Exit codes
Section titled “Exit codes”Every command returns a non-zero exit code on failure, and the code says what kind of failure it was:
| Code | Meaning | What produces it |
|---|---|---|
0 | Success | The command did what you asked. |
1 | Failed | A request the server answered with a status that is neither auth nor 5xx — a 404 for an unknown dataset, a 400, a 422 the CLI did not catch first. Also a job that finished failed, an apply whose response reported per-dataset errors, and a re-commit of an upload that was already committed. |
2 | Usage error | An unknown flag or a malformed argument, plus the checks below. apply also maps a server 422 here. |
3 | Auth | A 401 or 403 on a request, with the one exception noted below. Also “no instance configured”, but only from publish, whoami, analysis preview and analysis materialize — they check for an instance themselves. status, refresh, export stac, apply and logout treat a missing instance as a usage error and exit 2 instead. whoami also uses 3 for a session it could not refresh. |
4 | Network | The request timed out or the instance was unreachable — DNS, TLS, connection refused. |
5 | Server | A 5xx from the API, or an apply response that was not the JSON it should have been. |
Two collapses are worth knowing before you branch on these. 401 and 403
both exit 3, so a rejected credential and a permission you do not hold look
identical — the distinguishing sentence is on stderr. And every 5xx exits
5, so a 500 and a 503 behind a proxy are the same code.
The exception to 3: a 401 or 403 raised while applying --tags or
--collection after a publish commits becomes a warning line on a dataset that
now exists, and the command exits 1. That is deliberate — re-running would
publish a duplicate, so the failure is not presented as retryable.
That leaves three codes a credential problem can arrive under, so branching on
the code alone is not enough: 3 for an ordinary rejection, 2 when a command
that resolves its own instance finds none configured, and 1 for the
post-commit case above. Read stderr to tell them apart — and treat 1 from
publish as “the dataset exists, the extras did not apply”, never as a signal
to retry the publish.
Several commands exit 2 for a flag combination rather than a typo, and all
but one of them decide it locally, before any request goes out:
publish --tagsor--collectiontogether with--no-wait, since both are applied after the commit resolves the dataset id.refresh --timeoutwithout--wait, and any--timeoutthat is not a finite number greater than zero.analysis materialize --timeoutenforces the same finite bound.export stacagainst a vector dataset: a pre-flightGET /datasets/{id}reads the record type and rejects a non-raster one before the STAC endpoint is touched. This is the one that does make a request — and if that pre-flight404s on an unknown id, the exit is1, not2.analysiswithspatial_joinand no--join-dataset-id. Other bad operation/flag pairings are only caught by the server, which makes them a422and therefore exit1.validateon a manifest that fails the schema, andinitagainst a manifest that already exists without--force.
The examples repo has a ready-made GitHub Actions workflow that splits validate, preview, and apply into three jobs so the write token never reaches a pull request, and a walkthrough of the secrets and environment it expects.
Global options
Section titled “Global options”These apply to every command and go before the subcommand:
| Flag | Effect |
|---|---|
--instance <url> | Override the active instance for this command. |
--json | Machine-readable JSON output. |
-v, --verbose | Debug logging to stderr. |
-q, --quiet | Suppress non-error output. |
--version | Print the CLI version and exit. |