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 --command 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 |
error | ok: false | object | Why the command failed |
error.code | ok: false | string | One of the fourteen 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.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/credentials.json",
"sessions": [
{
"environment": "staging",
"userId": "u_7Kq2",
"email": "you@example.com",
"expiresAt": "2026-09-09T17:58:48.532Z",
"expiresAtLabel": "Sep 9, 2026",
"expired": false
}
],
"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. 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 fourteen values can be exhaustive and stay exhaustive.
| Code | What happened | What to do |
|---|---|---|
not_authenticated | No usable credentials for the target environment | Stop. Quote hint and wait for a person to sign in |
auth_expired | Credentials exist but have lapsed | Stop. Same remedy — a person signs in again |
permission_denied | 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 | 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 | The options or arguments supplied cannot be used | Fix the command line. leed schema --command <name> --json gives the real flags |
validation_failed | Site content or configuration failed validation | Read message and details, correct the files or config named there, re-run |
build_failed | The static site build did not complete | Read details for the template or content fault, fix it, run leed site build again |
upload_failed | 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 | 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 | 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 | The command needs a person at a terminal and cannot proceed | Stop. Do not retry; it fails identically forever |
confirmation_required | The command is destructive and needs --yes to run unattended | Stop and ask your user for consent. Do not supply --yes yourself |
not_found | The requested page, page type or resource does not exist | Look the identifier up (leed site list, leed schema --json), then re-run |
internal_error | 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, 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.
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"
}
}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. Three commands: leed auth reset, leed auth rotate and leed site validate --reset.
{
"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 the raw-content .git/config.",
"hint": "leed auth rotate --yes"
}
}Two further facts surprise people. The same three 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 and the message itself tells you --yes exists. 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 rule is short, and it is the only one to write into a script:
The exit code is 0 on success and non-zero on failure. Branch on
ok, or on zero versus non-zero — never on the specific number.
Three facts make that caution necessary rather than pedantic. leed site notify-cms uses 9 for an HTTP error and 8 for a connection failure. The refusal exit code on the CI-shaped commands is 2. And leed site upload can exit 1 while reporting ok: true, when the upload itself succeeded and the CMS callback afterwards did not — the detail is in warnings, and the exit code stays pessimistic on purpose, because the shell only ever sees the number.
Those numbers are deliberate and are staying: customer scripts already branch on them. The full table, with its ok column, is on CLI troubleshooting and exit codes. For everything you write from here on: parse stdout, read ok, branch on error.code.
Reading one response
flowchart TD
P["Parse stdout as JSON"] --> OK{"ok"}
OK -- "true" --> S["Read data<br/>Iterate warnings"]
S --> DONE(["Done"])
OK -- "false" --> C["Read error.code"]
C --> R["RETRY<br/>network_error<br/>upload_failed"]
C --> F["FIX AND RE-RUN<br/>invalid_arguments · validation_failed<br/>not_a_site_repo · build_failed<br/>not_found · external_command_failed"]
C --> H["STOP · ASK A PERSON<br/>not_authenticated · auth_expired<br/>permission_denied<br/>interactive_input_required<br/>confirmation_required"]
C --> I["REPORT A BUG<br/>internal_error"]
Fourteen codes collapse into four behaviors. 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 --jsonThe manifest is 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. It holds:
commands[]— every leaf, with its dottedname, its argvpath, the literalinvocationstring to run, itsarguments, and itsoptions(inherited globals included, each tagged with thedeclaredOncommand that declares it).commands[].result.successand.error— JSON Schema 2020-12 for the two documents that command can write.errorCodes— the fourteen values above.rootDocuments[]— the documents no command produced. See below.
| Key | What it holds | When you need it |
|---|---|---|
cli | Name of the executable, leed | Building a command line |
version | Version this manifest describes | Pinning, or reporting a bug |
jsonSchemaDialect | The dialect every result schema is written in | Feeding schemas to a validator |
filter | The --command value this manifest was narrowed to, or null | Confirming you got the entry you asked for |
errorCodes | Every value error.code can take | Building an exhaustive branch at runtime |
commands[] | Every command leaf, in argv order | Discovery, and validating an invocation |
commandsWithoutResultSchema | Leaves whose result is null | Checking result !== null before driving a command |
envelopeExemptCommands | Leaves deliberately outside the envelope contract | Knowing what not to parse |
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 whole surface
leed schema --command site.list --json # by dotted name
leed schema --command "site list" --json # by argv pathAn unrecognized filter is an ordinary not_found, and its hint says how many commands there are:
{
"ok": false,
"command": "schema",
"version": "4.106.0",
"error": {
"code": "not_found",
"message": "No such command: nope",
"hint": "Run `leed schema --json` and read commands[].name for the 23 available commands."
}
}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 <name> --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"
}
}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 twenty-two of them, completion-server excepted — 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.