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 --jsonFor a caller that cannot control argv, LEED_JSON=1 is exactly equivalent — the two produce byte-identical stdout:
LEED_JSON=1 leed site listThree 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:
- Human
- JSON
All changed successfully validated. Please build and visually inspect your site.{"ok":true,"command":"site.validate","version":"4.106.0","data":{"reset":false,"dryRun":false,"validated":true},"warnings":[]}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 logSeveral 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
| Field | Present when | Type | What it holds |
|---|---|---|---|
ok | always | boolean | Discriminates the two shapes. Branch on it first |
command | always | string | Dotted identifier of the command that produced this document, e.g. site.list |
version | always | string | Version of the CLI that wrote it, from its package.json |
data | ok: true | object | The command’s own payload; its fields are declared by that command’s schema |
warnings | ok: true | string[] | Non-fatal problems. [] when there were none, and never absent |
next | either branch, when there is one | object[] | 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 |
error | ok: false | object | Why the command failed |
error.code | ok: false | string | One of the fifteen values below. The only field to branch on |
error.message | ok: false | string | Human-readable summary. Safe to show a user verbatim |
error.hint | ok: false | string | The single concrete next step, often a command. Absent when there is no action the caller can take |
error.details | ok: false | string | Supporting diagnostic text. Free-form, possibly long, and never to be branched on |
error.issues | some invalid_arguments and validation_failed failures | object[] | Field-level reasons — path, message, code — one per offending field, at most ten. Absent rather than empty |
error.exit | ok: false | number | The process exit status this failure produces, 1 to 5. See exit codes |
error.retryable | ok: false | boolean | Whether repeating the same call unchanged can succeed — true for every exit 4 failure, and a few others |
error.reported | never under --json | boolean | A 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.
| Code | Exit | What happened | What to do |
|---|---|---|---|
not_authenticated | 5 | No usable credentials for the target environment | Stop. Quote hint and wait for a person to sign in |
auth_expired | 5 | Credentials exist but have lapsed | Stop. Same remedy — a person signs in again |
permission_denied | 5 | Authenticated, and this account is not allowed to do this | Stop and escalate. An administrator has to grant access; retrying and re-authenticating both fail identically |
not_a_site_repo | 3 | The working directory is not a Leed site checkout | Change to the folder holding leed.config.json, or set ENV_NAME, then re-run |
invalid_arguments | 2 | The options or arguments supplied cannot be used | Fix the command line. leed schema <command> --json gives the real flags |
validation_failed | 3 | Site content or configuration failed validation | Read message, details and any issues, correct the files or config named there, re-run |
build_failed | 3 | The static site build did not complete | Read details for the template or content fault, fix it, run leed site build again |
upload_failed | 3 | Transferring content or assets to the CMS did not complete | Re-run the same command. Progress is tracked, so it resumes rather than restarting |
network_error | 4 | A request to the CMS or the git host could not be completed | Retry with backoff. If it persists, it is connectivity, not the command |
external_command_failed | 3 | A program the CLI drives — git, or the package manager — is missing, refused, or exited non-zero | Read details. It is always present on this code and carries the remedy |
interactive_input_required | 5 | The command needs a person at a terminal and cannot proceed | Stop. Do not retry; it fails identically forever |
confirmation_required | 5 | The command is destructive and needs --yes to run unattended | Stop and ask your user for consent. Do not supply --yes yourself |
not_found | 2 | The requested page, page type or resource does not exist | Look the identifier up, then re-run |
conflict | 3 | The 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 conflict | Re-read the current state and decide. error.retryable says whether retrying can succeed; the CMS’s own narrower code is named in message |
internal_error | 1 | An unexpected fault in the CLI itself | Report 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 loginandleed login— the device flow waits for a browser approval;--emailwaits for a link click in a mail client.leed site build --serve— a webserver that runs until a person stops it.leed completion install, whenSHELLis 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:
| Exit | Class | What to do | Codes |
|---|---|---|---|
0 | Success | Read data | — |
1 | Unclassified | Read the error | internal_error |
2 | Usage | Fix the call | invalid_arguments, not_found |
3 | Precondition unmet | Follow hint | not_a_site_repo, validation_failed, build_failed, conflict, external_command_failed, upload_failed |
4 | Transient | Retry with backoff | network_error |
5 | Needs a person | Stop and ask | not_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" --jsonleed 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 dottedcommandname, the literalinvocationto run, a one-linesummary, and whether itmutates, isidempotent, reaches thenetwork(none,cmsorexternal), and has adryRun.globalOptionsandgroups— the options every command, or every command in a group, inherits, described once.envelope,errorCodesandexitCodes— 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.
| Key | What it holds | When you need it |
|---|---|---|
cli | Name of the executable, leed | Building a command line |
version | Version this index describes | Pinning, or reporting a bug |
jsonSchemaDialect | The dialect every published schema is written in | Feeding schemas to a validator |
globalOptions, groups | Options inherited by every command, or by every command in a group | Building any invocation |
envelope | The success and failure document shapes, once | Writing the one parser |
errorCodes | Every value error.code can take, with its exit status | Building an exhaustive branch at runtime |
exitCodes | Every exit status and what to do about it | Branching before you parse |
commands[] | Every command visible in --help, in argv order | Discovery, and choosing what to run |
rootDocuments[] | help, version and the invocation failure | Matching 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— forleed <anything> --help --json, at any depth. It carries the human help text asdatarather 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, callleed schema <command> --json.version— forleed --version --json. The bare version string still goes to stderr for a person to read.invocation— alwaysok: false, written when argv never resolved to a command: an unknown name, an unparseable global option, or a container such asleed sitegiven no subcommand. It reports whichever container it got as far as, socommandmay besiteorauthrather 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.messageanderror.detailsverbatim. - 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.