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 --command 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
errorok: falseobjectWhy the command failed
error.codeok: falsestringOne of the fourteen 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.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/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.

CodeWhat happenedWhat to do
not_authenticatedNo usable credentials for the target environmentStop. Quote hint and wait for a person to sign in
auth_expiredCredentials exist but have lapsedStop. Same remedy — a person signs in again
permission_deniedAuthenticated, 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_repoThe working directory is not a Leed site checkoutChange to the folder holding leed.config.json, or set ENV_NAME, then re-run
invalid_argumentsThe options or arguments supplied cannot be usedFix the command line. leed schema --command <name> --json gives the real flags
validation_failedSite content or configuration failed validationRead message and details, correct the files or config named there, re-run
build_failedThe static site build did not completeRead details for the template or content fault, fix it, run leed site build again
upload_failedTransferring content or assets to the CMS did not completeRe-run the same command. Progress is tracked, so it resumes rather than restarting
network_errorA 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_failedA 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_requiredThe command needs a person at a terminal and cannot proceedStop. Do not retry; it fails identically forever
confirmation_requiredThe command is destructive and needs --yes to run unattendedStop and ask your user for consent. Do not supply --yes yourself
not_foundThe requested page, page type or resource does not existLook the identifier up (leed site list, leed schema --json), then re-run
internal_errorAn 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, 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 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"
  }
}

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 --json

The 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 dotted name, its argv path, the literal invocation string to run, its arguments, and its options (inherited globals included, each tagged with the declaredOn command that declares it).
  • commands[].result.success and .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.
KeyWhat it holdsWhen you need it
cliName of the executable, leedBuilding a command line
versionVersion this manifest describesPinning, or reporting a bug
jsonSchemaDialectThe dialect every result schema is written inFeeding schemas to a validator
filterThe --command value this manifest was narrowed to, or nullConfirming you got the entry you asked for
errorCodesEvery value error.code can takeBuilding an exhaustive branch at runtime
commands[]Every command leaf, in argv orderDiscovery, and validating an invocation
commandsWithoutResultSchemaLeaves whose result is nullChecking result !== null before driving a command
envelopeExemptCommandsLeaves deliberately outside the envelope contractKnowing what not to parse
rootDocuments[]help, version and the invocation failureMatching 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 path

An 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 — 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 <name> --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"
  }
}

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 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.

ESC