Machine-Readable Output (`--json`)

A script, a CI step or a coding agent runs leed and needs to read the answer without parsing English. Add --json to any command and stdout carries one JSON document instead of formatted text.

Every command answers with the same outer shape, so you write one parser and reuse it everywhere. Check ok. If it is true, read data and iterate warnings. If it is false, branch on error.code.

--json is not gated by your plan and does not change any command’s access rules — it is a rendering mode, and a command you may not run is refused identically with or without it.

Turning it on

The flag is declared on the root program, so it works at any depth:

leed site build --json
leed auth status --json
leed schema site.list --json

For a caller that cannot control argv, LEED_JSON=1 is exactly equivalent — the two produce byte-identical stdout:

LEED_JSON=1 leed site list

Three things about the variable are worth knowing before you export it.

It is an opt-in only. Truthy values are 1, true, yes and on, case- and whitespace-insensitive; anything else leaves JSON mode off. In particular LEED_JSON=0 does not turn it off for a run that also passed --json.

Exporting it in your shell profile has two consequences. leed site build --serve will refuse, because a webserver that runs until a person stops it can never reach the single closing document --json promises. And one command — completion-server, which your shell invokes on every Tab press — deliberately ignores JSON mode entirely, so tab completion keeps working.

What a caller sees

The same successful leed site validate run, twice:

	All changed successfully validated. Please build and visually inspect your site.

The point is the identity, not the choice. Both are projections of the same validated result object: the sentence a person reads and the document a machine parses are built from one value, by one code path, so they cannot drift apart. What the human render calls “All changed successfully validated” is exactly data.validated === true.

stdout is the result, stderr is everything else

One sentence covers both modes: stdout carries the result, stderr carries everything else — logging, progress, an interactive dialog, and the output of any program the CLI drives.

In human mode a success render goes to stdout and a failure render to stderr, which is where the console.error calls it replaced always sent it. Under --json that split does not apply: there is one document, it is the whole contract, and it goes to stdout whether ok is true or false — an ok: false envelope on stderr would leave a parser reading an empty stdout and unable to say why.

Two things you can test:

leed site list > types.txt          # captures the table and nothing else
leed site build --json 2>/dev/null  # leaves the document, discards the build log

Several things moved to reach that rule. Every leed:* log channel now writes to stderr in both modes. leed site validate’s success line moved to stdout, because it is the result. The --serve banner and the device-login dialog are narration and stay on stderr. Subprocesses — Eleventy, wrangler, git — are spawned with their stdout already pointed at file descriptor 2, because a child process holds that descriptor at the OS level where no in-process patch can reach it.

The envelope

FieldPresent whenTypeWhat it holds
okalwaysbooleanDiscriminates the two shapes. Branch on it first
commandalwaysstringDotted identifier of the command that produced this document, e.g. site.list
versionalwaysstringVersion of the CLI that wrote it, from its package.json
dataok: trueobjectThe command’s own payload; its fields are declared by that command’s schema
warningsok: truestring[]Non-fatal problems. [] when there were none, and never absent
nexteither branch, when there is oneobject[]What to run after this, as alternatives in preference order: each has a runnable command, a one-sentence why, and by — agent when it can run unattended, person when it needs a human. On failure it is the remedy; on success it appears only when a follow-up is needed. Absent rather than empty
errorok: falseobjectWhy the command failed
error.codeok: falsestringOne of the fifteen values below. The only field to branch on
error.messageok: falsestringHuman-readable summary. Safe to show a user verbatim
error.hintok: falsestringThe single concrete next step, often a command. Absent when there is no action the caller can take
error.detailsok: falsestringSupporting diagnostic text. Free-form, possibly long, and never to be branched on
error.issuessome invalid_arguments and validation_failed failuresobject[]Field-level reasons — path, message, code — one per offending field, at most ten. Absent rather than empty
error.exitok: falsenumberThe process exit status this failure produces, 1 to 5. See exit codes
error.retryableok: falsebooleanWhether repeating the same call unchanged can succeed — true for every exit 4 failure, and a few others
error.reportednever under --jsonbooleanA human-mode flag saying the text was already narrated. Ignore it

The binary writes the document as one newline-terminated line. Here is a complete one, pretty-printed — the version string is the only one printed anywhere in this category, and yours will differ:

{
  "ok": true,
  "command": "auth.status",
  "version": "4.106.0",
  "data": {
    "credentialsPath": "/home/you/.config/leed/auth.json",
    "sessions": [
      {
        "environment": "staging",
        "companyId": "cmp_4a71",
        "domain": "example.com",
        "isDefault": true,
        "userId": "u_7Kq2",
        "email": "you@example.com",
        "expiresAt": "2026-09-09T17:58:48.532Z",
        "expiresAtLabel": "Sep 9, 2026",
        "expired": false
      }
    ],
    "defaults": { "staging": "cmp_4a71" },
    "site": null
  },
  "warnings": []
}

warnings

warnings is always present on the success branch, [] when empty, so a consumer can iterate it unconditionally without an existence check.

It exists for a specific situation: a non-fatal step that failed after the result was already determined reports itself here rather than flipping ok to false. leed auth logout is the concrete case — the credentials are deleted from your machine whether or not the CMS could be reached to revoke the session server-side, because the caller’s goal (“no session on this machine”) was met either way. A logout that could not reach the CMS therefore succeeds, with a warning saying the session may remain live on the server until it expires and that re-running from a connected machine will still revoke it.

The failure object

code is the machine-readable reason, drawn from a closed set. message is one sentence you can show a person unaltered. hint is the single concrete next step, usually a command to run — it is absent only when there is genuinely nothing the caller can do. details is free-form supporting text: a build’s error extract, a subprocess’s own output, an import’s progress before it stopped. Never branch on details. issues is its branchable companion, one entry per field a settings write rejected. exit and retryable are stamped on every failure from its code, so the document and the process status always agree. reported is a human-mode rendering flag and never appears under --json.

Error codes

Branch on code, never on message text. The enum is closed, so a switch over these fifteen values can be exhaustive and stay exhaustive.

CodeExitWhat happenedWhat to do
not_authenticated5No usable credentials for the target environmentStop. Quote hint and wait for a person to sign in
auth_expired5Credentials exist but have lapsedStop. Same remedy — a person signs in again
permission_denied5Authenticated, and this account is not allowed to do thisStop and escalate. An administrator has to grant access; retrying and re-authenticating both fail identically
not_a_site_repo3The working directory is not a Leed site checkoutChange to the folder holding leed.config.json, or set ENV_NAME, then re-run
invalid_arguments2The options or arguments supplied cannot be usedFix the command line. leed schema <command> --json gives the real flags
validation_failed3Site content or configuration failed validationRead message, details and any issues, correct the files or config named there, re-run
build_failed3The static site build did not completeRead details for the template or content fault, fix it, run leed site build again
upload_failed3Transferring content or assets to the CMS did not completeRe-run the same command. Progress is tracked, so it resumes rather than restarting
network_error4A request to the CMS or the git host could not be completedRetry with backoff. If it persists, it is connectivity, not the command
external_command_failed3A program the CLI drives — git, or the package manager — is missing, refused, or exited non-zeroRead details. It is always present on this code and carries the remedy
interactive_input_required5The command needs a person at a terminal and cannot proceedStop. Do not retry; it fails identically forever
confirmation_required5The command is destructive and needs --yes to run unattendedStop and ask your user for consent. Do not supply --yes yourself
not_found2The requested page, page type or resource does not existLook the identifier up, then re-run
conflict3The current state refuses this change — a slug a sibling already holds, a record that changed underneath the write, or a rebase that stopped on a merge conflictRe-read the current state and decide. error.retryable says whether retrying can succeed; the CMS’s own narrower code is named in message
internal_error1An unexpected fault in the CLI itselfReport it, keeping the whole document. Nothing in the caller’s environment will fix it

The same list is available at runtime as data.errorCodes from leed schema --json, each with its description and exit status, so a long-lived consumer can read it rather than hard-code it.

Authentication failures are the ones you cannot fix

not_authenticated and auth_expired always carry a hint of the form leed auth login --env <env>. An agent cannot resolve either. Signing in opens a browser, prints a verification code and blocks until somebody approves it — there is no flag past that, by design. The correct behavior is to stop, quote the hint, and wait for a person. CLI authentication explains why the device flow needs one.

The one exception is leed site upload running in CI, whose hint points at setting LEED_API_TOKEN on the build trigger instead.

Two things that look like authentication failures are not. A session check the CMS answered with 429 — the renewal endpoint is rate-limited to 30 requests a minute per token and 120 per IP address — says nothing about the session, so it comes back as network_error: retry with backoff, do not ask for a login. And commands run in parallel no longer sign each other out: they share one renewal under a lock on auth.json, and a token another command has just replaced keeps working for 60 seconds. An auth_expired in the middle of parallel work is a real expiry or revocation, not a race.

external_command_failed carries the answer

This is the code where details is always present. It holds the exact invocation the CLI made plus that program’s own output — git’s message, the package manager’s message, including whatever remedy the program itself printed. Read it rather than guessing. It exists as a separate code precisely so that a fault in your environment is not reported as internal_error, which would blame the CLI for something only you can fix.

Commands that refuse

Some commands cannot be completed by an unattended caller, and they say so with a well-formed ok: false document rather than by rejecting the flag. There are two classes and the remedies are different.

Structurally interactive

interactive_input_required, refused before the command touches anything:

  • leed auth login and leed login — the device flow waits for a browser approval; --email waits for a link click in a mail client.
  • leed site build --serve — a webserver that runs until a person stops it.
  • leed completion install, when SHELL is unset or names a shell that is not supported, because choosing one needs a question.
{
  "ok": false,
  "command": "auth.login",
  "version": "4.106.0",
  "error": {
    "code": "interactive_input_required",
    "message": "Signing in opens a browser and waits for a person to approve a verification code, which cannot be done on an unattended --json run.",
    "hint": "Ask a person to run `leed auth login --env production` at a terminal, then run this command again",
    "exit": 5,
    "retryable": false
  }
}

Do not retry these. They will fail identically forever. The refusal is a genuine no-op — for --serve it fires ahead of the command’s own setup hook, which would otherwise install the site’s npm dependencies into the checkout before announcing that it refuses to do anything.

Destructive

confirmation_required, needing -y, --yes. leed auth reset, leed auth rotate and leed site validate --reset are the ones you will meet first; leed deployments promote, leed settings oidctrust create and deleting a path row that is not an alias under leed settings path confirm the same way. leed deployments retry and alias deletes are deliberately not gated. A command’s --dry-run, where it has one, is never gated.

{
  "ok": false,
  "command": "auth.rotate",
  "version": "4.106.0",
  "error": {
    "code": "confirmation_required",
    "message": "leed auth rotate invalidates this site's current GitLab tokens and rewrites ~/.bunfig.toml and ~/.config/leed/auth.json.",
    "hint": "leed auth rotate --yes",
    "exit": 5,
    "retryable": false
  }
}

Two further facts surprise people. The same commands refuse in human mode too on any run without an interactive terminal — a pipe, a cron job, a CI step — where the code is interactive_input_required, the message itself tells you --yes exists, and it says so when CI=true rather than a missing terminal is what made the run unattended. And --yes is declared per command, not globally: there is no environment variable that turns confirmation off everywhere. Each command’s own wording is on leed auth and leed site validate, commit and push.

Exit codes are a second signal

The exit status is set by the kind of failure, so a script can act on it before it parses anything:

ExitClassWhat to doCodes
0SuccessRead data—
1UnclassifiedRead the errorinternal_error
2UsageFix the callinvalid_arguments, not_found
3Precondition unmetFollow hintnot_a_site_repo, validation_failed, build_failed, conflict, external_command_failed, upload_failed
4TransientRetry with backoffnetwork_error
5Needs a personStop and asknot_authenticated, auth_expired, permission_denied, confirmation_required, interactive_input_required

The one case where the number and ok disagree is deliberate. A command can report ok: true and still exit non-zero when its main work succeeded and a step after it failed — leed site upload when the CMS callback afterwards did not land, leed site push when the commits went out and the CMS was not notified, a settings publish or a page write that succeeded in part. The cause is in warnings, and the exit status is the failed step’s class, because the shell only ever sees the number.

The same table is published at runtime as data.exitCodes from leed schema --json, and every failure document carries its own error.exit. The full message-by-message table is on CLI troubleshooting and exit codes. For everything you write from here on: parse stdout, read ok, branch on error.code — or on the exit class when you cannot parse.

Reading one response

flowchart TD
  P["Parse stdout as JSON"] --> OK{"ok"}
  OK -- "true" --> S["Read data<br/>Iterate warnings<br/>Run next, if present"]
  S --> DONE(["Done"])
  OK -- "false" --> C["Read error.code"]
  C --> R["RETRY · exit 4<br/>network_error"]
  C --> F["FIX AND RE-RUN · exit 2 or 3<br/>invalid_arguments · not_found<br/>validation_failed · not_a_site_repo<br/>build_failed · upload_failed · conflict<br/>external_command_failed"]
  C --> H["STOP · ASK A PERSON · exit 5<br/>not_authenticated · auth_expired<br/>permission_denied<br/>interactive_input_required<br/>confirmation_required"]
  C --> I["REPORT A BUG · exit 1<br/>internal_error"]

Fifteen codes collapse into four behaviors, and the exit class says which one before you parse anything. That collapse is the whole operational content of this page: a consumer that gets those four branches right handles every failure the CLI can produce, including ones added later.

Discovering the CLI

leed schema --json
leed cli search "publish a form" --json

leed schema answers in stages, all built from the live command tree of the binary running it, so it cannot describe a different version of the CLI than the one you have. With no argument it is the index: every command in one line, and everything they share described once. leed cli search finds a command by task when you do not know its name — it matches your words against command names, descriptions, options and positional arguments. The index holds:

  • commands[] — every command, with its dotted command name, the literal invocation to run, a one-line summary, and whether it mutates, is idempotent, reaches the network (none, cms or external), and has a dryRun.
  • globalOptions and groups — the options every command, or every command in a group, inherits, described once.
  • envelope, errorCodes and exitCodes — the shared document shape, the fifteen codes with their exit status, and the five exit classes.
  • rootDocuments[] — the documents no command produced. See below.

leed schema <command> --json returns one command’s document: the same facts, plus its arguments, its own options, the flags it inherits, the JSON Schema of its success data, and the next steps it can name.

KeyWhat it holdsWhen you need it
cliName of the executable, leedBuilding a command line
versionVersion this index describesPinning, or reporting a bug
jsonSchemaDialectThe dialect every published schema is written inFeeding schemas to a validator
globalOptions, groupsOptions inherited by every command, or by every command in a groupBuilding any invocation
envelopeThe success and failure document shapes, onceWriting the one parser
errorCodesEvery value error.code can take, with its exit statusBuilding an exhaustive branch at runtime
exitCodesEvery exit status and what to do about itBranching before you parse
commands[]Every command visible in --help, in argv orderDiscovery, and choosing what to run
rootDocuments[]help, version and the invocation failureMatching a document whose command is not in commands[]

Three ways to narrow it:

leed schema --json                   # the index
leed schema site --json              # the index of one group
leed schema site.list --json         # one command, by dotted name
leed schema site list --json         # the same command, by argv path

--command <name> still works as an alias for the argument. An unrecognized name is an ordinary not_found, and its next is a leed cli search for what you typed:

{
  "ok": false,
  "command": "schema",
  "version": "4.106.0",
  "error": {
    "code": "not_found",
    "message": "No such command: nope",
    "hint": "Find it by task: leed cli search nope",
    "exit": 2,
    "retryable": false
  },
  "next": [{ "command": "leed cli search nope", "why": "…", "by": "agent" }]
}

Documents no command produced

Three documents are written by the CLI itself rather than by a command:

  • help — for leed <anything> --help --json, at any depth. It carries the human help text as data rather than letting it land raw on stdout ahead of a document. It is not the result of the command whose help you asked for; for that command’s options as structured data, call leed schema <command> --json.
  • version — for leed --version --json. The bare version string still goes to stderr for a person to read.
  • invocation — always ok: false, written when argv never resolved to a command: an unknown name, an unparseable global option, or a container such as leed site given no subcommand. It reports whichever container it got as far as, so command may be site or auth rather than a leaf name.
{
  "ok": false,
  "command": "site",
  "version": "4.106.0",
  "error": {
    "code": "invalid_arguments",
    "message": "No subcommand given.",
    "hint": "leed schema --json",
    "exit": 2,
    "retryable": false
  }
}

This is why a consumer should look a received document’s command value up in commands[] first and in rootDocuments[] second.

The one command outside the contract

completion-server ignores JSON mode entirely, is published in the manifest as envelopeExemptCommands, and writes a newline-separated list of completion candidates in the invoking shell’s own format. The exemption exists because the shell hook leed completion install writes reads that stdout as a candidate list for one Tab press — a JSON line there is offered to the user as a single completion, which is how tab completion silently broke for anyone who had exported LEED_JSON. Nothing else invokes it, so there is no consumer for a document from it.

No color, no prompts, no self-update

JSON mode turns off three more things, all for the same reason — none of them belongs on a stream a machine is parsing.

  • No interactive prompt is ever raised. A command that would need one refuses instead, with a code from the table above.
  • Color is force-disabled. The CLI writes no escape sequences of its own. Output from a program it drives can still contain ANSI, and that output can reach error.message and error.details verbatim.
  • The 24-hour self-update check is suppressed, so an update cannot swap the binary out from under a run in progress.

Two color variables work independently of all this, in strict precedence: NO_COLOR beats FORCE_COLOR, which beats TTY detection. For a script, --json is a better lever than CI=true, because it suppresses all three at once and gives you a parseable answer as well.

Every command that accepts --json — all of them but completion-server and the internal credential helper — is indexed at CLI command reference. LEED_JSON, NO_COLOR and FORCE_COLOR sit alongside every other variable the CLI reads on leed.config.json, files and environment. A one-shot build’s payload, and why --serve refuses here, are on leed site build. Driving Leed from an agent over MCP instead is a different surface with a different ceiling — see what the Operator MCP can do. The same contract also ships inside your repository as a Claude Code skill reference, one of the files described in your site repository.

ESC