earthranger-cli
A command-line utility for creating and editing EarthRanger event categories, choices, and v2 event types — posting events, and reading subjects, tracks, patrols and more as agent-friendly JSON — directly against the EarthRanger API, with a cached login session or a bearer token.
You describe what you want in a small YAML spec (no hand-written JSON
Schema); er events apply generates the ER v2 schema envelope and the
shared Choice records, then idempotently creates what's missing and
patches what changed. Nothing is ever deleted — removal means
is_active: false.
Install
earthranger-cli is published on PyPI
and requires Python 3.11 or newer. It provides one command, er.
The recommended install is as an isolated tool, so er lands on your
PATH without touching any project's virtualenv:
uv tool install earthranger-cli # or: pipx install earthranger-cli
er --help
To upgrade later: uv tool upgrade earthranger-cli (or pipx upgrade earthranger-cli).
A plain pip install earthranger-cli into whatever environment you have
active also works. To try it once without installing anything:
uvx --from earthranger-cli er --help
From source (development)
Clone the repo and install it in editable mode with the dev extras
(pytest, ruff); changes to the source take effect immediately:
git clone https://github.com/PADAS/earthranger-cli.git
cd earthranger-cli
uv sync --extra dev # creates .venv with the package installed editable
uv run er --help # or: source .venv/bin/activate && er --help
uv run pytest -q
If you'd rather install editable into an environment you already manage:
uv pip install -e ".[dev]"
See CONTRIBUTING.md for how features move from spec to merged PR.
Releases are cut by pushing a vX.Y.Z tag that matches __version__ in
src/earthranger_cli/__init__.py; the release GitHub Actions workflow
runs the tests, builds with uv build, and publishes to PyPI via Trusted
Publishing.
Authenticate
Sessions live on profiles (gcloud-style): create a profile, log in once, and every command run under that profile reuses its cached session.
er profile add myreserve --server myreserve --username me # becomes the default profile
er auth login # prompts for your password
Working across sites? Save each as a profile; er profile use picks the
default, and a flag or env var overrides it per command or per shell:
er profile add sandbox --server sandbox --username me
er profile add prod --server myreserve --username me # a new profile becomes the default
er profile use sandbox # make sandbox the default (all shells)
er profile set username me2 # edit a property on the selected profile
er --profile sandbox events list categories # one-off override
export ER_PROFILE=prod # pin this shell, regardless of the default
er profile list # marker shows this invocation's selection
er profile current # prints it (exit 1 if none) — prompt-friendly
er profile use # clear the default
Selection order is --profile, then ER_PROFILE, then the default from
er profile use. The selected profile supplies the server and default
username when you don't pass them; explicit --server/--username flags
always win. The default is stored in ~/.config/er-events/config.json
and is shared by every shell, so pin a terminal with ER_PROFILE when you
need it to stay on one site. Whenever er profile use or er profile add
changes the default, they tell you on stderr if the current shell has
ER_PROFILE set and so won't follow it. Re-adding an existing profile (say,
to fix its server) edits it in place and leaves the default alone.
auth login requires a selected profile: it verifies your credentials
and caches the access and refresh tokens (never your password) in
~/.config/er-events/tokens/<profile>.json (0600). Subsequent commands
under that profile just work — expired access tokens are refreshed
automatically and the rotated tokens re-cached. er auth status shows
the selected profile's session; er auth logout deletes it. The session
is bound to the profile's identity: profile set server, setting
username to a different user, or profile remove all clear it, and
logging in with a different --username updates the profile to match.
Profiles pointing at the same server hold independent sessions.
You can always bypass the cache with an explicit credential. A pre-issued OAuth bearer token wins over everything else and needs no username — this is the path for agent sandboxes and CI, where there is no one to type a password:
export ER_SERVER=myreserve
export ER_TOKEN='...' # single-quote it — tokens can contain $, !, & etc.
er events list categories # or: er --token '...' events list categories
To keep a token on a profile instead, er auth login --token '...' verifies
it against the server, records its owner, and stores it in place of a
password session; er auth status then reports static token as <user> (never refreshes) — re-run auth login --token when the token expires.
An explicit password is next in precedence:
export ER_USERNAME=me
export ER_PASSWORD=... # or --password; omit both to be prompted
Docker: tokens often contain shell-special characters, so
-e ER_TOKEN=$TOKENcan corrupt the value. Prefer an env-file (docker run --env-file er.env ...withER_SERVER=...andER_TOKEN=...lines) orexport ER_TOKEN='...'once and forward it by name with a bare-e ER_TOKEN.
If the cached session's refresh token has expired, commands fail with
error: cached session for profile '<name>' expired or invalid — run 'er auth login'. Without a selected profile there is no session cache —
bare --server one-offs authenticate with --password/ER_PASSWORD
each time. (Upgrading from host-keyed caches: run er auth login once
per profile; old tokens/<host>.json files are ignored.)
Walkthrough
-
Write a spec (start from
examples/wildlife_monitoring.yaml):category: value: wildlife_monitoring display: Wildlife Monitoring event_types: - value: animal_sighting display: Animal Sighting fields: - key: species label: Species type: select options: [elephant, lion] - key: count label: Number of animals type: integer min: 0 required: [species]
-
Preview what would change, then apply:
er events apply spec.yaml --dry-run er events apply spec.yaml
-
Post an event against the new type:
er events post --event-type animal_sighting \ --field species=elephant --field count=3 \ --location -1.286,36.817
Add
--dry-runto see the validated event without sending it (a batch from--filelists one line per event). A batch can be a YAML list or a CSV, one event per row:event_type,time,titleandlat/lon(or alocationcolumn holdinglat,lon) are recognised, every other column is a detail field (integers, decimals andtrue/falsebecome numbers and booleans, everything else stays text), and--mappoints a field at a differently named column. The delimiter is detected from the header (comma, semicolon, tab or pipe) and errors name the file's line number. Historical data usually needs only:er events post --file history.csv --event-type animal_sighting \\ --map species=Species_Name --map time=Timestamp --dry-run
Before anything is sent, the event's details are checked against the type's schema as ER renders it: unknown fields, values outside a select's choices, wrong types, out-of-range numbers and missing required fields are all reported, one line each, and nothing is posted. ER's API does not make these checks itself (its form builder does, in the browser), so without this a typo like
--field speices=elephantwould be stored silently.--no-validateskips the check when you mean to post something the schema doesn't describe. -
Edit: change the spec (rename a display, add a field, drop an option), dry-run again, re-apply.
applypatches exactly what differs; dropped options are deactivated, never deleted. -
Absorb server-side edits into your file: if someone changed a value in ER's UI,
er events pull wildlife_monitoring -o spec.yamlrewrites your spec from the live server;apply --dry-runshould then report allunchanged. The few constructs the DSL can't express (location fields, headers, condition operators other thanis_exactly, non-positional section ids, auto-generate schemas, pattern validation, choice-list or nested sub-fields inside collections) are reported and refused unless you pass--skip-unsupported, which drops them and lists what was skipped in a comment at the top of the file.
Commands
| Command | What it does |
|---|---|
events apply SPEC [--dry-run] |
Upsert category, choices, and event types from a spec |
events post --event-type V --field k=v ... [--no-validate] [--dry-run] |
Post one event (--location LAT,LON, --time, --title); details are validated against the type's schema first; --dry-run validates and lists what would be posted without sending |
events post --file events.yaml|history.csv [--event-type V] [--map k=Column ...] [--no-validate] [--dry-run] |
Post a batch from a YAML list or a CSV (one event per row: reserved columns event_type, time, title, lat/lon or location; every other column is a detail field; --map renames); every event is validated before any is sent; exits 1 if any fail; --dry-run previews the batch |
events list categories |
List categories (inactive included) |
events list event-types [--category V] |
List event types |
events show event-type V |
Full v2 event-type JSON + its Choice records |
events pull CATEGORY [--event-type V ...] [-o FILE] [--skip-unsupported] |
Reconstruct a DSL spec from the server (reverse of apply); --event-type limits it to those types, category block kept, so the file still applies |
choices list, choices show FIELD_NAME |
List choice fields with option counts (flagging sets no v2 schema references); print one choice set in display order. Writes stay spec-driven via events apply |
events search [--event-type V[,V...]] [--since/--until | --today | --yesterday | --last 7d] [--where k=v] [--limit N] [-o F] |
Search events; --event-type takes values, display names, ids or *glob* patterns (mixed is fine); the window is in site time; --where species=buffalo filters event_details in the CLI; JSON {records, meta} output |
events export [--since/--until | --today | --last 7d] [--event-type ...] [-o F.csv], observations export --subject-id ID [--since/--until] [-o F.csv] |
The server's own CSV (events: the site's display names and labels); if the account may not export, the matching records as JSON with a note |
now |
Server UTC time, site local time and timezone, and today's --since/--until bounds in site time |
events get EVENT_ID [-o F] |
One event as a one-record {records, meta} document |
events list categories|event-types [--json] [-o F], events show event-type V [--json] [-o F] |
Same as above, opt-in {records, meta} output |
status show, auth whoami |
Server status; the authenticated user |
subjects search|get, tracks get SUBJECT_ID, observations search --subject-id ID |
Read subjects, tracks (v2 GeoJSON), raw observations (one selector required; --since defaults to 24 h before --until, or before now) |
patrols search|get, sources search|get, subject-groups list|get, subject-sources search |
Read patrols (--since/--until, --today, --last 7d in site time), sources, groups, collar↔subject assignments |
spatial-feature-groups list|get ID, spatial-features list|get ID |
Read spatial feature groups and the features in them (geofences, roads, water points, boundaries) |
featuresets list|get ID, regions list |
Read featuresets and their GeoJSON boundaries, operational regions |
auth login [--token T]/status/logout |
Cache (password session or static token), inspect, or clear the selected profile's credential |
profile add/use/set/show/list/remove/current |
Named site profiles; use sets the default and adding a new profile auto-switches to it; set edits the selected profile; --profile NAME / ER_PROFILE override per command or shell |
Every paginated read command answers to both list and search (er subjects list
and er regions search both work); the names above are the ones shown in --help.
Every command that emits the records document (all reads except the two CSV
exports) takes --fields a,b.c and --format json|tsv|csv; every paginated
one takes --count-only and --group-by FIELD|day|week|month (see Windows,
counts, columns below).
Reading data (agent-friendly JSON)
Every read command above emits one JSON document in the same shape as
tusker's er-cli and the Skylight CLI, so agent skills can "write to
-o PATH, then read the file" identically across tools:
{"records": [...], "meta": {"total": 12, "pages": 1, "count_reported": 12}}
recordsis always a flat list — one element forgetcommands and for GeoJSON FeatureCollections.- List commands auto-paginate (following ER's
nextlink, 100 per page by default;--page-sizeoverrides).--limit Ncaps the total and stops requesting pages once it is reached.meta.pagesreports how many pages were fetched;meta.count_reportedis ER's own total when it sends one. -o PATHwrites the document (creating parent directories) and prints a singleDone. N record(s) written to PATH (P page(s)).line to stderr; stdout stays empty. Without-o, the document is pretty-printed to stdout.- Flags are the ER query parameters, spelled either
--updated-sinceor er-cli style--updated_since. Runer <resource> <action> --helpto see the endpoint each command calls, e.g.[GET /api/v1.0/subjects]. - The pre-existing
events list ...andevents show event-typekeep their human output by default; pass--json(or-o) for the contract above.
Windows, counts, columns
- Site time.
--today,--yesterdayand--last 7d(also30m,50h,2w) onevents search,events export,patrols search,observations search|exportandtracks getare computed from the site's clock and timezone, not the caller's;er nowprints both and today's bounds. A bare--since/--untildate is the site's calendar day on every command (a bare--until 2026-08-31means the end of that day). When one of these flags is used,meta.windowrecords the bounds actually sent andmetacarriesserver_utc,site_nowandsite_tz. The site clock is fetched only when something needs it: a time flag, a period--group-by,er now, or a bare date on observations, tracks or the observations export, which must be sent with the site's offset (the events export takes a filter, which das reads in site time itself). Mixing a naive bound with an offset-bearing bound also fetches the site timezone to validate their ordering. Every other read makes no extra request. - Counts.
--count-onlyasks the server for its total in one request (records: [{"count": N}],meta.exact);--group-by prioritycounts per value,--group-by monthper site-local period over the window. - Columns.
--fields id,title,reported_by.namekeeps only those dotted paths (coordinates.1is latitude; a missing path is an empty cell);--format tsvorcsvwrites a table of them instead of JSON. - Details.
events search --where species=buffalokeeps events whoseevent_detailssay so (applied by the CLI, which then does the counting). - Exports.
events exportandobservations exportreturn the server's CSV unchanged; if the account may not export, the matching records come back as JSON with a note. - Bounded walks. Paging stops after 200 pages or on a repeated
nextlink and says so inmeta.truncatedandmeta.note.
How agent skills use it
The pattern is: run one command with -o, then read only the fields you
need from the file. Raw payloads never enter the model's context, stdout
is empty on success, and anything the CLI wants to tell you (a defaulted
time window, a retry) goes to stderr as a note: line.
# Events of a type since a time — the type is its value, display name or id
er events search --event-type geofence_break --updated-since 2026-09-01T00:00:00Z -o /tmp/gf.json
# One subject's track for a window (v2 GeoJSON; one record, a FeatureCollection)
er tracks get <subject_id> --since 2026-06-01T00:00:00Z --until 2026-06-08T00:00:00Z -o /tmp/track.json
# One subject's raw observations for a day — a selector is required, and
# --since defaults to 24 h before --until (or before now) if you omit it
er observations search --subject-id <subject_id> --until 2026-06-02T00:00:00Z -o /tmp/obs.json
# Everything in a small table, capped
er subjects search --limit 500 -o /tmp/subjects.json
# Today's events in site time, three columns, as TSV
er events search --today --fields id,time,event_type --format tsv -o /tmp/today.tsv
# How many active patrols — one request, no records
er patrols search --state open --count-only
# Carcasses per month this quarter, whatever the type is called on this site
er events search --event-type '*carcass*' --since 2026-07-01 --until 2026-09-30 --group-by month
Then, in the skill, jq '.records[] | {id, title, time}' /tmp/gf.json or
the language equivalent.
What a skill can rely on:
- Exit codes.
0with the file written;2for a CLI usage error, with the reason on stderr. Missing observation selectors are rejected before connecting; unknown event-type names are rejected after fetching the server's type listings. Most timestamp flags are passed to the API for validation. API and auth failures exit1; messages handled by the CLI's API error wrapper go to stdout, with the credential to fix named when it was a 401. Capture both output streams when diagnosing failures. - Bounded requests.
observations searchrefuses to run without a selector because ER would otherwise return every observation on the site.--limitstops paging as soon as it is reached. - Retries. Transient failures (429, 5xx, connection or body-read
errors) are retried three times with 0/2/4 s backoff and a
note:on stderr;Retry-Afteris honoured up to 30 s. Writes are never replayed. - Posting is checked.
er events postvalidates details against the type's schema before sending, so a skill that builds an event from model output gets a per-field error instead of a silently stored typo. - Auth in a sandbox is
ER_SERVERplusER_TOKENin the environment, no profile and no login; see the Docker note under Authenticate for passing the token safely.
Spec reference
Field types: string, textarea, integer (advisory — ER stores it as
number on the wire), number (both take
optional min/max), boolean, date, datetime, url, select,
multiselect (both take options).
Sub-forms are type: collection fields: repeating groups of scalar
sub-fields (item_name labels one entry; optional button_text,
item_identifier, columns: 2 with sub-field column: right,
min/max item counts, and a required: list of sub-field keys):
- key: sightings
label: Sightings
type: collection
item_name: sighting
fields:
- {key: species_note, label: Species note, type: string}
- {key: count, label: Count, type: integer}
required: [species_note]
Choice-list or nested-collection sub-fields aren't supported yet and are refused by name.
Every field takes optional active: false (the field is
deprecated/hidden on ER but its historical data remains) and
description. Scalar and choice fields additionally take hint (ER's
placeholder, max 32 chars; not on boolean/date/datetime or
collections), and scalar fields take default
(string/textarea/url/integer/number/boolean only). string
fields take format: url | email | uuid — the builder's "Format
Validation" (url is sent as JSON Schema uri).
Options are {value, display} mappings or bare strings
(lion → value lion, display Lion); option values are free text
(ER stores them as-is). options: [] is valid — it deactivates every
option on that field (an all-inactive choice set pulls back to
exactly this). Category and event-type values must match
[a-z0-9_]+ (they appear in URLs); field keys follow ER's own rule,
[a-zA-Z0-9_-]+.
Select/multiselect fields take an optional choices_field naming the
exact ER Choice set to use, overriding the derived
<event_type>_<field_key> name — needed for stock event types whose
choice sets predate this tool. To share one set across fields, declare
it once at the top level and reference it — the fields then omit
options entirely (inline-declared sharing also works when every
sharing field carries identical options):
choices:
shared_actions:
- {value: stopped, display: Halted}
- {value: warned, display: Warned, icon: warn_icon}
event_types:
- value: t1
fields:
- {key: action, label: Action, type: select, choices_field: shared_actions}
- value: t2
fields:
- {key: response, label: Response, type: multiselect, choices_field: shared_actions}
Options may carry an icon, and their spec order is the dropdown
order: apply writes ordernum from spec position only when the
visible order actually differs (a set whose active options already
appear in spec order keeps its existing numbering — stock 10/20/30
gaps and holes left by deactivated records are left alone). A set with
missing ordernums gets numbered on first apply.
Per event type an optional layout: {label, columns} (default
{label: Details, columns: 1}) controls the form section; with
columns: 2, per-field column: right places a field in the right
column. Multi-section forms use sections: instead of fields:/
layout: — a list of {label?, columns?, active?, condition?, fields: [...]}
mappings, one per form section, in order. active: false hides a
section; condition: {field, operator: is_exactly, value} shows it only
when another (outside) field has the given value — the only condition
operator ER's builder offers that the DSL supports so far; the other
operators are refused by name on pull:
- value: entry_alert
display: Entry Alert
sections:
- label: ""
fields: [...]
- label: ""
columns: 2
fields: [...]
A form-less event type (e.g. an incident collection container) is
declared with an explicit fields: [] and, for collections,
is_collection: true.
Per event type: value, display, fields (required); optional:
required, is_active, icon_id (sent to ER as its writable icon
field — the API's own icon_id is a derived, read-only property),
default_priority (gray|green|amber|red or 0|100|200|300),
default_state (new|active|resolved), readonly (makes the whole
event type read-only in ER — v2 schemas have no per-field read-only),
geometry_type (point|polygon; whether events record a location
point or a drawn polygon), auto_resolve + resolve_time (auto-resolve
events after N hours — auto_resolve: true requires resolve_time,
since ER silently ignores the flag without it), and ordernum (explicit
display rank — a number; ER uses fractional ranks like 0.5 for
insert-between — deliberately not derived from spec position, because a
spec doesn't own every event type in its category). These are sent only when declared: omitted
keys leave the server's values untouched. geometry_type is immutable
once an event type exists — apply refuses a spec that declares a
different value than the server's, since ER's API would accept the
change but events already recorded under the old geometry would be
corrupted.
What apply owns
apply only manages what the spec declares: it never touches event
types or categories that exist on the server but aren't in the spec
(retire one by setting is_active: false in the spec). The exception
is choice options within a spec-managed field — there the spec is
authoritative, and removed options are deactivated on the server.
Why YAML and not JSON?
Both work: spec files are parsed with a YAML parser, and YAML is a
superset of JSON, so a pure-JSON spec file is accepted as-is. YAML is
the documented format because spec files are hand-authored and reviewed
— comments matter, and block style keeps nested fields readable. Watch
YAML's implicit typing (no → false, 1.10 → a float): quote anything
ambiguous.
v2 schemas and choices, briefly
ER v2 event types store a {json, ui} schema envelope. Dropdown fields
don't embed their options; they reference shared Choice records via
$ref: /api/v2.0/schemas/choices.json?field=<name>. This tool derives
<name> as <event_type_value>_<field_key> (compressed with a short
hash suffix when longer than ER's 40-char field limit) and manages those records
for you — creating, re-labelling, deactivating, and reactivating options
to mirror your spec.
Metadata
Release files for earthranger-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 | |
|---|---|---|---|
| earthranger_cli-0.6.0.tar.gz | 329.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| earthranger_cli-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 428.1 kB
Release files / earthranger_cli-0.6.0.tar.gz
| Download URL | earthranger_cli-0.6.0.tar.gz |
|---|---|
| Size | 329.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
089607dff406a1ff8cc4ac22ba02c5390b5bc78c3c80725149541d9658f00aa6
|
|
BLAKE2b-256 checksum How to use checksums |
0a2fad6668167da4ad3fe29b9490a50824123b17d33bd82329e1e4afc652e600
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / earthranger_cli-0.6.0-py3-none-any.whl
| Download URL | earthranger_cli-0.6.0-py3-none-any.whl |
|---|---|
| Size | 98.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c7fd258aac57b4a2db4877d1f4fb78fa36a722ddc815bd0203c1432010e00ca4
|
|
BLAKE2b-256 checksum How to use checksums |
757e648f97e6020741cfdd14bfc839aec427715681d585f6dd0a0a39a5a4c802
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log