CLI

Scripting

Use the CLI in scripts and CI: JSON output, exit codes, error handling, and unattended authentication.

Updated Jul 27, 2026

Every command that returns data has a machine-readable mode (--json) and a conventional exit code. (login and logout have no --json — they print a fixed status line.) What it does not have is a machine-readable error format, retries, or paging — so a script has to check the exit status itself and pace its own calls. This page covers what a non-interactive caller can rely on.

Authenticate unattended

Set ZILFU_TOKEN in the environment and drop the --token flag entirely:

export ZILFU_TOKEN="$(cat /run/secrets/zilfu_token)"

zilfu spaces list --json

--token <token> works too, but it lands in shell history and is visible in ps output to anyone on the machine. Use the environment variable, or zilfu login --token <token> once on a persistent machine to write the stored config — see Get started for the precedence rules and where credentials are stored.

An empty ZILFU_TOKEN is used as-is rather than falling back to the stored config, because only null/undefined values fall through. ZILFU_TOKEN="" in a CI environment produces No API token found. even when ~/.config/zilfu/config.json holds a perfectly good token. Unset the variable instead of blanking it.

zilfu health is the only API call that needs no token (zilfu logout needs none either — it just deletes the local file).

JSON output

--json writes pretty-printed JSON (two-space indent, one trailing newline) to stdout and nothing else. It never emits NDJSON, and it never wraps the payload.

Command --json emits
zilfu whoami --json Object — id, name, email
zilfu health --json Object — {"status": "ok"}
zilfu spaces list --json Bare array of space objects
zilfu accounts list --space <id> --json Bare array of account objects
zilfu posts list --space <id> --json Bare array of post resources

The list commands emit a bare array. The API's response envelope is unwrapped before printing, so the pagination fields on posts list (links, meta) are discarded — --json gives you no page count, no total, and no next-page URL. spaces and accounts are not paginated server-side, so nothing is lost there.

--json has to come after the leaf subcommand (zilfu posts list --json, never zilfu --json posts list). See Commands.

--json and the table are different shapes for posts list. The table is a four-column projection with a human status label; --json is the full post resource, where status is the numeric code the API stores. A script reading --json has to map the codes itself — the table's scheduled is "status": 0. The mapping is in Commands.

Exit codes

Code Meaning
0 The command succeeded, including when it returned no results
1 Every failure — API error, bad argument, missing token, unreachable host
130 Ctrl-C at the interactive token prompt of zilfu login

There is no exit-code granularity. 401, 403, 404, 422, 429, 5xx, an invalid --space, a missing token and a host that never answered all exit 1. To tell them apart, parse the Error <status>: prefix on stderr. A network-level failure — DNS, connection refused, TLS — reports status 0, so Error 0: means no HTTP response happened at all.

Error output

Errors always go to stderr as plain text, one line, with a status prefix:

Error 401: Unauthenticated.

No read-only command can currently return a 422 — nothing the CLI sends is validated. If one ever does, validation failures add one indented line per field message:

Error 422: The per page field must be at least 1.
  - per_page: The per page field must be at least 1.

Errors raised before any request is made carry no status prefix — Invalid space id: abc, or the three-line No API token found. block.

--json never applies to errors. There is no JSON error envelope, no error code field, and stdout stays empty on failure.

Usage text is printed to stdout, not stderr, when an argument is missing or wrong. zilfu posts list --json > out.json with --space omitted writes a usage block into out.json and exits 1 (ANSI-coloured on a normal shell; plain when CI, NO_COLOR, or TERM=dumb is set). Always check the exit code before parsing stdout:

if ! zilfu posts list --space 42 --json > posts.json; then
  echo "zilfu failed" >&2
  exit 1
fi

Empty results are not errors

An empty list prints No results. to stdout and exits 0; with --json it prints []. An empty record prints (empty). None of these are failures — a script that treats "no output" as an error will misfire on a space with nothing in it.

count=$(zilfu posts list --space 42 --json | jq 'length')

Filter and page with jq

The CLI has no --status, --account, --from, --to, --page or --per-page flags, even though the API supports status, account_id, from, to, per_page, and page. Pipe --json into jq instead:

# Scheduled posts only (status 0), as id + scheduled time
zilfu posts list --space 42 --json \
  | jq -r '.[] | select(.status == 0) | "\(.id)\t\(.scheduled_at)"'

# Accounts that need reconnecting
zilfu accounts list --space 42 --json \
  | jq -r '.[] | select(.is_active == false) | .handler'

# The space id for a space you know by name
zilfu spaces list --json | jq -r '.[] | select(.name == "Acme") | .id'

Filtering downstream does not work around paging. zilfu posts list returns only the API's first page — 15 posts — and there is no flag to ask for more, so jq is filtering 15 rows, not your whole history. For anything larger, call the API directly with per_page and the filter parameters.

Timeouts, retries, and rate limits

The CLI sets no request timeout and never retries. A hung server ties up the command until Node's own fetch timeout fires (about five minutes), then fails with Error 0: fetch failed — wrap invocations in your own timeout if a stuck job would block a pipeline:

timeout 30 zilfu spaces list --json

A 429 surfaces as Error 429: Too Many Attempts. and nothing more. The underlying error object carries no response headers, so Retry-After, X-RateLimit-Remaining and X-RateLimit-Reset are unreachable from the CLI. Pace your own calls: the ceiling is 120 requests per minute per token, detailed in Rate limits. If you need header-driven backoff, call the API directly.

Do not parse the table

Table rows have trailing whitespace stripped per line, so a row whose last cells are empty simply ends early — the column count varies from row to row:

id  status     scheduled_at                 published_at
--  ---------  ---------------------------  ---------------------------
10  scheduled  2026-08-01T09:00:00.000000Z
11  published                               2026-07-01T09:00:00.000000Z
12  draft

Row 10 has three fields, row 11 has three fields carrying different data, and row 12 has two. awk '{print $3}' returns a scheduled time on one row and a published time on the next. Nulls render as empty strings, and the table is a lossy projection anyway. Use --json for anything a program reads.

One more pipeline hazard: closing the pipe early can kill the process. zilfu spaces list | head -1 may abort with a Node EPIPE stack trace on stderr and exit 1 even though the command worked. Buffer to a file first, or tolerate the non-zero status.