Skip to main content

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.

er-cli-demo

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=$TOKEN can corrupt the value. Prefer an env-file (docker run --env-file er.env ... with ER_SERVER=... and ER_TOKEN=... lines) or export 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

  1. 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]
    
  2. Preview what would change, then apply:

    er events apply spec.yaml --dry-run
    er events apply spec.yaml
    
  3. 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-run to see the validated event without sending it (a batch from --file lists one line per event). A batch can be a YAML list or a CSV, one event per row: event_type, time, title and lat/lon (or a location column holding lat,lon) are recognised, every other column is a detail field (integers, decimals and true/false become numbers and booleans, everything else stays text), and --map points 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=elephant would be stored silently. --no-validate skips the check when you mean to post something the schema doesn't describe.

  4. Edit: change the spec (rename a display, add a field, drop an option), dry-run again, re-apply. apply patches exactly what differs; dropped options are deactivated, never deleted.

  5. Absorb server-side edits into your file: if someone changed a value in ER's UI, er events pull wildlife_monitoring -o spec.yaml rewrites your spec from the live server; apply --dry-run should then report all unchanged. The few constructs the DSL can't express (location fields, headers, condition operators other than is_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}}
  • records is always a flat list — one element for get commands and for GeoJSON FeatureCollections.
  • List commands auto-paginate (following ER's next link, 100 per page by default; --page-size overrides). --limit N caps the total and stops requesting pages once it is reached. meta.pages reports how many pages were fetched; meta.count_reported is ER's own total when it sends one.
  • -o PATH writes the document (creating parent directories) and prints a single Done. 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-since or er-cli style --updated_since. Run er <resource> <action> --help to see the endpoint each command calls, e.g. [GET /api/v1.0/subjects].
  • The pre-existing events list ... and events show event-type keep their human output by default; pass --json (or -o) for the contract above.

Windows, counts, columns

  • Site time. --today, --yesterday and --last 7d (also 30m, 50h, 2w) on events search, events export, patrols search, observations search|export and tracks get are computed from the site's clock and timezone, not the caller's; er now prints both and today's bounds. A bare --since/--until date is the site's calendar day on every command (a bare --until 2026-08-31 means the end of that day). When one of these flags is used, meta.window records the bounds actually sent and meta carries server_utc, site_now and site_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-only asks the server for its total in one request (records: [{"count": N}], meta.exact); --group-by priority counts per value, --group-by month per site-local period over the window.
  • Columns. --fields id,title,reported_by.name keeps only those dotted paths (coordinates.1 is latitude; a missing path is an empty cell); --format tsv or csv writes a table of them instead of JSON.
  • Details. events search --where species=buffalo keeps events whose event_details say so (applied by the CLI, which then does the counting).
  • Exports. events export and observations export return 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 next link and says so in meta.truncated and meta.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. 0 with the file written; 2 for 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 exit 1; 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 search refuses to run without a selector because ER would otherwise return every observation on the site. --limit stops 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-After is honoured up to 30 s. Writes are never replayed.
  • Posting is checked. er events post validates 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_SERVER plus ER_TOKEN in 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)

Source distribution for earthranger-cli 0.6.0
File Size Uploaded
earthranger_cli-0.6.0.tar.gz 329.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for earthranger-cli 0.6.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page