---
name: eskaler-cli
description: >
  Drive the eskaler operational-checkpoint system from the terminal — check
  today's board, ack/nudge/reassign/complete a task, add people and delivery
  endpoints, edit the roster, manage workflows and task definitions, set the
  escalation chain and holidays, and mint API tokens. Use this whenever a task
  involves eskaler's data or operations, instead of writing curl by hand or
  hitting the D1 database directly. Also use it to DISCOVER what the API can do
  (`eskaler ops list`).
---

# The eskaler CLI

## Install

```bash
curl -fsSL https://eskaler.com/install.sh | sh
```

Installs a single self-contained file to `~/.eskaler/bin/eskaler` (**requires
Node 20+**; the script says so and stops if it is missing). No sudo, no
`node_modules`, nothing else touched. Add `~/.eskaler/bin` to your PATH if the
script says it isn't there. Re-running the same command is an in-place upgrade.

- **`eskaler update`** — upgrade in place. It reads
  `~/.config/eskaler/install.json` first and behaves accordingly: a script
  install self-replaces (atomic rename); an npm install is never overwritten,
  it just prints the npm command.
- **`eskaler --version`** — always include this when reporting a problem.
- Once a day, in a terminal, the CLI may print one line to **stderr** telling
  you a newer version exists. It never auto-updates, never blocks a command, and
  says nothing at all in CI or when its output is piped.
- The CLI sends `X-Eskaler-Cli-Version` on every request. If the API ever
  answers **426 `{"error":"cli_version_unsupported"}`**, your CLI is below the
  supported floor: run `eskaler update`.
- `curl -fsSL https://eskaler.com/install.sh` (without `| sh`) prints the script.
  It is short and boring on purpose — read it.
- Not published to npm yet, and there is no version pinning on this channel
  (only the current release is served). macOS/Linux only; see DEPLOY.md
  "Distributing the CLI".

`eskaler` is a **thin HTTP client for the eskaler REST API**. It contains no
business logic: every command resolves your token, builds one request from the
API's operations manifest, sends it, and prints the answer. That is deliberate —
it means the CLI can do exactly what the API can do, no more and no less, and it
can never quietly disagree with it (a CI gate diffs the two on every commit).

Practical consequence for you: **a command that fails is the API saying no, not
the CLI.** Read the error body; don't work around it locally.

## Setup

### Humans: log in

```bash
eskaler login
```

An **RFC 8628 device-code login**: the CLI prints a URL and an 8-character code,
you approve on **any** device, and it stores the session. It works over SSH, in a
container, and on a box with no display — it only opens a browser for you when
there demonstrably is one. `eskaler logout` deletes the session (and revokes it
server-side); `eskaler whoami` says who you are and **how** that was resolved.

The session is stored **0600** at `${XDG_CONFIG_HOME:-~/.config}/eskaler/credentials.json`.

### CI and agents: an API token

```bash
export ESKALER_TOKEN="…"                 # an API bearer token
export ESKALER_URL="https://eskaler.com" # optional; this is the default
eskaler people list
```

- **Always prefer `ESKALER_TOKEN` to `--token`.** A `--token` flag is visible in
  the process table (`ps auxww`) to every user on the machine, and lands in shell
  history. `--token` / `--url` exist for one-offs against another deployment.
- Mint a token with `eskaler tokens create --name "my-agent"` (that command needs
  a login — see below). The secret is returned **once**; capture it there.

### Credential precedence

```
--token <flag>  →  $ESKALER_TOKEN  →  stored login session  →  none
```

With none of them:

```
eskaler: Not logged in. Run: eskaler login   (or set ESKALER_TOKEN for CI and agents.)
```

`eskaler whoami` tells you which of the four is actually in play — a stale
`ESKALER_TOKEN` quietly outranking the login you just did is the usual surprise.

### Session-gated operations

An API token is **org-scoped, not person-scoped**, so operations that must be
attributed to a human refuse it with a `403`. **`eskaler login` yields a
session**, so after logging in they all work:

| Operations | Why |
|---|---|
| `tasks ack` / `nudge` / `reassign` / `complete` | every action is audited as `dashboard:<email>` |
| `tokens create` / `tokens revoke` | a bearer that can mint bearers is self-replicating |
| `stations pairing create` | a redeemed PIN mints a credential |
| `admin users deactivate` | destroying a colleague's access must be human |

```bash
eskaler login
eskaler tasks ack 019…
```

`eskaler ops list --json | jq '.operations[] | select(.auth=="session") | .name'`
lists them authoritatively at runtime. Everything else accepts an API token.

> A raw dashboard cookie in `ESKALER_COOKIE` is still accepted as an escape
> hatch, and is no longer the way to do this. Use `eskaler login`.

## The contract (this is what makes the CLI agent-safe)

### `--json` — use this from scripts and agents

`--json` prints the API's response body to **stdout, byte-for-byte**. No
envelope, no re-serialization, no added trailing newline. What you parse is
exactly what a REST client would receive:

```bash
eskaler people list --json | jq -r '.[] | .name'
```

- Success → the operation's own JSON body.
- Failure → the error envelope: `{"error": "<code>", …detail}`, e.g.
  `{"error":"validation","issues":[…]}`, `{"error":"referenced","runs":2}`,
  `{"error":"not_found"}`.
- **Switch on `error`, not on the HTTP status.**

The **HTTP status goes to stderr**, never stdout:

```
eskaler: HTTP 409 DELETE /api/roles/chef
```

### Exit codes

| Code | Meaning | What to do |
|---|---|---|
| `0` | the API answered 2xx | continue |
| `1` | **operation error** — the API answered non-2xx, or was unreachable | read the error envelope on stdout |
| `2` | **usage error** — bad argv, unknown command, bad flag value, no credentials. *The request was never sent.* | re-read `--help`; fix the command (or `eskaler login`) |

The 1/2 split is the useful one: `2` means *you* typed it wrong, `1` means the
API said no.

### Flags are typed

Flags are generated from the API's zod schemas, so values arrive as the right
JSON type — `--max-delay-minutes 30` sends the number `30`, not `"30"`.
Booleans are explicit: `--active false`. A wrong type is a usage error (exit 2),
never a silent coercion.

- **Repeatable flags** take several values: `--dates 2026-12-24 2026-12-25`.
- **Nullable fields** accept the literal `null` to CLEAR them:
  `eskaler people update <id> --timezone null`.

### `--body-json` — the escape hatch

Some request bodies are shapes no flag can honestly express (escalation chains
are arrays of objects; the roster bulk body is a bare array). Rather than invent
a lying `--chain <string>` flag, those fields have **no flag** and you pass the
whole body as JSON. `--body-json` is accepted on **every** write operation:

```bash
eskaler org chain replace --body-json '{"chain":[
  {"roleId":"chef","mode":"fix","windowMinutes":30},
  {"roleId":"owner","mode":"notify","windowMinutes":0}
]}'
```

It sets the **entire** body and **cannot be combined with field flags** (that is
a usage error, exit 2) — put every field in the JSON or use none of it.

`--help` on any write command tells you which fields are JSON-only.

## Discovering the full operation set

The CLI has one command per API operation (53 today). Don't guess — ask:

```bash
eskaler ops list                    # name, method, path, auth, summary
eskaler ops list --json | jq '.operations[] | .name'
eskaler people --help               # the people command group
eskaler workflows tasks create --help
```

Command names are mechanical: operation `workflows.tasks.delete` →
`eskaler workflows tasks delete <id> <taskId>`; path params are positionals, in
path order.

## The MCP server (same core, different surface)

eskaler also speaks **MCP** at `https://eskaler.com/mcp` — the same core, the same
auth, the same answers. Which to reach for:

| | this CLI | the MCP server |
|---|---|---|
| Reach | **all 53 operations** | **12 curated tools** (`board_today`, `task_timeline`, `task_act`, `roster_week`, `health_report`, …) |
| Shape | shell commands, `--json` | tools, with schemas your client already sees |
| Use it when | you are scripting, in CI, or need an operation MCP does not curate | you are an agent already in a conversation and want the day's board, one task's story, or to intervene |

**They cannot disagree.** The MCP tools call the same REST route handlers
in-process with your credential (no second implementation), and a conformance gate
drives every fixture through both surfaces and asserts identical `{status, body}`.
If a tool ever answered differently from the CLI, the build would be red.

Connect a client with no credential at all: it discovers OAuth from the `401`,
opens a browser, and you approve it — the token then *is you*, so `task_act`
works. Or configure `Authorization: Bearer <api-token>` (the same token this CLI
uses) for a headless agent; that is org-scoped, so the operator tools 403 exactly
as they do here without `eskaler login`.

The 12 tools are a curated subset, not a cage: `ops_list` returns the live
manifest, so an agent on MCP can still find (and call over REST) any of the 53.
Full detail: `docs/mcp.md`.

## Common workflows

### Check today's board

```bash
eskaler board                       # today, pretty-printed for a terminal
eskaler board --date 2026-03-01
eskaler board --json | jq '.runs[].tasks[] | select(.state != "confirmed") | .name'
```

The board is the operator's view of one org-local date: every run, its tasks,
their state, owner, timings and flags (`UNMANNED`, `UNREACHABLE`, `MISASSIGNED`,
`ESC:<level>`).

### Ack or nudge a stuck task

Get the task instance id from the board, then (these need a login — `eskaler login`):

```bash
TASK=$(eskaler board --json | jq -r '.runs[0].tasks[0].id')

eskaler tasks nudge "$TASK"                       # re-send the question
eskaler tasks ack "$TASK"                         # acknowledge on the owner's behalf
eskaler tasks reassign "$TASK" --person-id p-123  # rebind the role for this run and re-ping
eskaler tasks complete "$TASK" --note "fixed, compressor restarted"
eskaler tasks timeline "$TASK"                    # the full transcript (bearer is fine)
```

A `409` here means the task's phase does not allow the action (e.g. it is
already confirmed) — the message says which.

### Add a person and reach them

```bash
eskaler people create --name "Alice" --timezone Europe/Copenhagen
# → {"id":"p-123","joinCode":"…"}   the join code is shown ONCE

eskaler people endpoints create p-123 --channel telegram --address "tg-12345"

# An sms address must be an E.164 MOBILE number. A landline is REFUSED (400):
# it is a valid phone number that can never receive a checkpoint, and binding
# one used to mean delivering into a void. The check is libphonenumber's, per
# country (src/domain/phone.ts).
eskaler people endpoints create p-123 --channel sms --address "+4571990000"

eskaler people list
eskaler people update p-123 --active false     # the day-to-day "they left" verb
```

### The org's country (dial code + holidays)

```bash
eskaler org update --country DK     # ISO-3166-1 alpha-2; --country null clears it
eskaler org get                     # → {"id":"…","name":"…","country":"DK",…}
```

One setting, two uses: the dashboard renders its dial code as a fixed `+45`
prefix on every phone field (so an operator types `71 99 00 00`, not the country
code), and it defaults the national-holiday import. The API still takes E.164
verbatim — nothing is ever guessed onto a number that has no country code.

### Edit the roster

```bash
eskaler roster list --from 2026-03-01 --to 2026-03-07

# The bulk body is a bare ARRAY, so it is --body-json only:
eskaler roster bulk-upsert --body-json '[
  {"roleId":"chef","date":"2026-03-02","personId":"p-123"},
  {"roleId":"chef","date":"2026-03-03","personId":"p-456"}
]'

eskaler roster delete chef 2026-03-02
```

### Mint an API token

```bash
# Needs a login (a session) — see "Session-gated operations".
eskaler login
eskaler tokens create --name "ci-agent" --json | jq -r '.token'   # shown ONCE
eskaler tokens list
eskaler tokens revoke <id>
```

### Workflows and task definitions

```bash
eskaler workflows create --name "Morning" --timezone Europe/Copenhagen \
  --weekday-mask 1 2 3 4 5

eskaler workflows tasks create <workflowId> \
  --name "Fridge check" --question "Fridge temperature logged?" \
  --time-of-day 09:00 --owner-role-id chef \
  --max-delay-minutes 30 --reminder-timeout-minutes 15 \
  --escalation-timeout-minutes 30

eskaler workflows get <workflowId>
eskaler admin instantiate            # run the instantiator now, don't wait for cron
```

Deletes are safe by construction: an entity that is still referenced returns
`409 {"error":"referenced", …counts}` rather than cascading.

## Gotchas

- **`eskaler people delete` is for mistake cleanup only.** To remove someone from
  day-to-day operation use `eskaler people update <id> --active false`; a person
  with any history cannot be hard-deleted (`409 referenced`).
- **Once-only secrets.** `people create` (join code), `tokens create`,
  `stations create` / `stations token rotate` (device token) return their secret
  exactly once. Capture it in the same command.
- **`holidays replace` REPLACES the whole set** — it is not an append. Read the
  current set first if you mean to add one.
- **Chains**: `notify` may only be the LAST rung, and a `fix` rung needs
  `windowMinutes >= 1`. The API enforces this; the CLI will happily send a bad
  chain and show you the `400`.

## For maintainers

The command tree is **generated** from `src/api/operations.ts` by
`scripts/generate-cli.ts` (`npm run generate:cli`), which emits
`cli/src/commands.generated.ts` and the committed wire manifest
`cli-reference.json`. `npm test` regenerates and `git diff --exit-code`s both, so
an API change without a regeneration fails the build, and the CLI can never lag
the API. `test/conformance/` then drives every fixture through **both** REST and
this CLI and asserts the envelopes are identical. Never hand-edit a generated
file, and never fix a conformance failure by widening the mask.
