earthranger-cli
A command-line utility for creating and editing EarthRanger event categories, choices, and v2 event types — and posting events — directly against the EarthRanger API, authenticated with a username and password.
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
uv pip install -e .
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 # prints: export ER_PROFILE=myreserve
export ER_PROFILE=myreserve # select it in this shell (the wrapper below automates this)
er auth login # prompts for your password
Working across sites? Save each as a profile and select one per shell or per command — selection is never global:
er profile add sandbox --server sandbox --username me
er profile add prod --server myreserve --username me # adding auto-switches (via the wrapper)
er profile use sandbox # switch this shell (needs the wrapper below)
er profile set username me2 # edit a property on the selected profile
er --profile sandbox events list categories # one-off override
er profile list # marker shows this shell's selection
er profile current # prints it (exit 1 if none) — prompt-friendly
A selected profile supplies the server and default username when you
don't pass them; explicit --server/--username flags always win.
er profile use and er profile add print an export ER_PROFILE=...
line on stdout (a subprocess can't set its parent shell's env; add's
confirmation goes to stderr), so add this wrapper to your zshrc — it
evals that line in place, which also makes a newly added profile the
shell's selection immediately, and passes every other command through;
the optional prompt segment shows the shell's selection:
er() {
if [[ $1 == profile && ($2 == use || $2 == add) ]]; then
local out
out=$(command er "$@") || { [[ -n $out ]] && print -r -- "$out"; return 1; }
# eval only the switch protocol; anything else (e.g. --help) prints normally
if [[ $out == "unset ER_PROFILE" || ($out == "export ER_PROFILE="* && $out != *$'\n'*) ]]; then
eval "$out"
elif [[ -n $out ]]; then
print -r -- "$out"
fi
else
command er "$@"
fi
}
_er_prompt() { [[ -n $ER_PROFILE ]] && print -n "%F{yellow}(er:$ER_PROFILE)%f "; }
setopt PROMPT_SUBST; PROMPT='$(_er_prompt)'"$PROMPT"
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
-
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 ... |
Post one event (--location LAT,LON, --time, --title) |
events post --file events.yaml |
Post a batch; exits 1 if any fail |
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 [-o FILE] [--skip-unsupported] |
Reconstruct a DSL spec from the server (reverse of apply) |
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 selects per shell and add auto-switches (via the wrapper); set edits the selected profile; --profile NAME per command |
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.
Release files for earthranger-cli 0.1.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.1.0.tar.gz | 184.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| earthranger_cli-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 238.5 kB
Release files / earthranger_cli-0.1.0.tar.gz
| Download URL | earthranger_cli-0.1.0.tar.gz |
|---|---|
| Size | 184.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c7cf42ebd09dcd86fb651e35daaa5529b28fbcbaa2c15c515f220b40931b4996
|
|
BLAKE2b-256 checksum How to use checksums |
8a4623b743ac5e598e67139984f052929c1575e6a5df5f7c19f120b035569e5a
|
| 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 Sep 22, 2026.
Transparency logRelease files / earthranger_cli-0.1.0-py3-none-any.whl
| Download URL | earthranger_cli-0.1.0-py3-none-any.whl |
|---|---|
| Size | 53.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b387bea5b3f73afd745b667681562c3763592b120b4ff8ea80ef6b262862a672
|
|
BLAKE2b-256 checksum How to use checksums |
40b953fdc974bd655e905fa928930119719b947a115db608c239ea4d03108806
|
| 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 Sep 22, 2026.
Transparency log