This is the reference page for two questions: where did that come from and where did that go. It covers the site registry, how a command finds the site it works on, the complete set of files the leed CLI writes on your machine, and every environment variable it reads. Nothing here is something you routinely edit — the value of knowing it is that when a command behaves unexpectedly, one of these things explains it.
sites.json
~/.config/leed/sites.json is the one per-user registry of the sites set up on this machine. leed site init records each site it sets up there, one entry per company and environment, and every later command run inside a checkout refreshes where that checkout is. It replaces the leed.config.json older CLIs kept beside each checkout. Nothing about a site has to sit outside its repository any more: a checkout identifies itself by its own identity.json, and the registry is a record of where each one was last seen.
{
"version": 1,
"sites": [
{
"companyId": "cmp_4a71",
"environment": "production",
"domain": "example.com",
"companyName": "Example",
"paths": ["/home/you/leed/example-com/raw-content"],
"remote": "https://gitlab.com/leed-ai/customers/production/example-com/raw-content.git",
"userId": "usr_1c8e",
"initialized": true,
"huskyVersion": "6.26.0"
}
],
"migratedFrom": ["/home/you/leed/example-com/leed.config.json"]
}| Field | Type | Set by | What reads it |
|---|---|---|---|
companyId | string | leed site init, from the CMS | With environment, identifies the entry; selects the session and keys the on-disk caches |
environment | production, staging or dev | leed site init | Every command run in the checkout: it selects the credentials and the CMS base URL |
domain | string | the CMS, from your company’s public domain | slugify(domain) is the customer slug used for your folder name, your GitLab paths and --base-url’s default |
companyName | string, optional | the siteTitle in the checkout’s src/src.11tydata.json, refreshed by commands run there | The name leed site list shows and --site accepts |
paths | string[] | leed site init, then every command run inside a checkout | Where the site’s checkouts are, most recently used first. --site moves into the first that still exists |
remote | string, optional | the checkout’s git origin, without credentials | Display |
userId | string | the CMS, from the account that set the site up | Embedded in every commit message so the CMS can attribute the change |
initialized | boolean | leed site init, once the checkout exists | Every leed site command: a false here is what produces “Site configuration not initialized” |
huskyVersion | string | leed site init, and every later resync | The drift check that reinstalls the git hooks after a CLI upgrade |
sandbox, name | true, string | sandbox sites only | A sandbox is keyed by its checkout and named by its folder, since every sandbox names the same placeholder company |
migratedFrom | string[] | the one-time import below | The leed.config.json files already imported, which are never read again |
How a command finds its site
A site checkout is the nearest folder holding identity.json: the folder you are in, its raw-content/ when it has one, or the nearest folder above you. So every leed site command works from the site folder, from raw-content/, or from anywhere inside it, wherever you keep it:
leed/
└── example-com/ <- run from here: ./raw-content is the checkout
└── raw-content/ <- or here: identity.json is in this folder
└── src/docs/ <- or anywhere below itMoving or renaming the folder needs no action: the next command run inside it records the new path. Outside every checkout, a leed site command needs --site <name|domain|companyId> — the names leed site list shows — and moves into that site’s checkout before it runs. Without it, it refuses, listing the registered sites and the exact --site to pass, even when only one site is registered:
This is not a site checkout. Pass --site to name the site to work on. Registered sites:
…With no registered site at all, the refusal is “No Leed site found at or above <folder>.”, coded not_a_site_repo, with the hint to run the command inside your checkout, pass --site, or run leed site init. A checkout that is not registered — a bare clone, a sandbox — runs in direct mode: its company and environment come from its own identity.json.
The fields the CLI maintains
huskyVersion is the one drift marker left, and it is the reason hook files reappear in your repository after you upgrade. When you run any leed site command other than init, the CLI compares it against the version of the CLI that is running. When they differ it reinstalls the husky hooks and records the new version. Nothing asks first, and nothing is destroyed that the CLI did not put there. The VS Code files and Claude Code skills older CLIs kept in sync are no longer managed: leed site vscode writes the VS Code files when you ask, and the skills come from the Leed plugin for Claude.
paths and companyName are refreshed every time a command runs inside a checkout, so editing them by hand does nothing lasting.
Upgrading from leed.config.json
Older CLIs kept one leed.config.json per site, in the folder above the checkout, and the installer left one in ~/leed. Each such file the CLI meets — in the folder you are in, any folder above it, ~/leed, or a folder directly inside ~/leed — is imported into sites.json once, with a one-time notice, and its path is recorded in migratedFrom so it is never read again. The file itself is left in place and can be deleted. The vscodeVersion and aiConfiguration keys it carried are not imported, and the skill copies they recorded are removed from the checkout’s .claude/skills/.
How the environment is decided
Every command resolves one of production, staging or dev before it does anything else, and the answer decides which credentials are read and which CMS it talks to. Nothing defaults to production. The CLI never picks an environment, a company or a site you did not name, and the checkout you are standing in counts as naming one.
flowchart TD
A["leed <command>"] --> B{"-e / --env passed?"}
B -- "yes" --> R1["Use it"]
B -- "no" --> S{"--site given?"}
S -- "yes" --> R2["Use that site's environment"]
S -- "no" --> C{"Inside a site checkout?"}
C -- "yes" --> R3["Use the checkout's environment<br/>sites.json, else identity.json"]
C -- "no" --> D{"ENV_NAME set<br/>and valid?"}
D -- "yes" --> R4["Use it"]
D -- "no" --> E{"--company logged in to<br/>exactly one environment?"}
E -- "yes" --> R5["Use that environment"]
E -- "no" --> R6["Refuse: invalid_arguments<br/>rerun with -e <env>"]
That one chain serves every command that takes an environment. On leed auth login, logout and leed asset upload the -e, --env flag is hidden from --help; leed setup, leed site, leed mcp add and the leed settings, page, deployments and contacts commands show it. On leed site, -e only chooses between two registered sites of the same company, since a leed site command always runs in a checkout. CLI authentication covers when you need it.
leed site push, commit and the hidden notify-cms and upload also refuse outright when nothing names an environment — a checkout whose identity.json names none, with ENV_NAME unset:
Could not determine the target environment. Run from inside a Leed site checkout (see leed site list) or set ENV_NAME=dev|staging|production.The order is on purpose. A wrong-environment push is hard to detect after the fact, so the checkout you are standing in wins over an environment variable that might be left over from something else. The practical consequence: inside a site checkout, setting ENV_NAME does not redirect a push, an import or any other command. Pass -e where the command takes it, or work from a checkout for the environment you mean.
Which CMS each environment is
| Environment | CMS base URL | Override |
|---|---|---|
production | https://app.leed.ai | LEED_CMS_URL |
staging | https://staging.app.leed.ai | LEED_CMS_URL |
dev | http://localhost:8787 | LEED_CMS_URL |
LEED_CMS_URL beats all three and applies to every request the CLI makes. An unrecognized environment falls back to the production URL.
Everything the CLI writes to disk
Three groups: your home directory, your project, and your cache.
~
├── .config/leed/auth.json 0600, in a 0700 directory
├── .config/leed/auth.json.lock 0600, present only while a command renews the session
├── .config/leed/sites.json 0600, the site registry
├── .config/leed/sites.json.lock 0600, present only while a command updates the registry
├── .config/leed/preferences.json 0600, offers you have answered once
└── .cache/leed/<env>/<companyId>/
├── leed-validation.json what you validated, and your last build
└── imports/<pageTypeId>.json one resumable import manifest
leed/example-com/
├── example-com.code-workspace only after leed site vscode, or a yes to leed setup's offer
└── raw-content/ the git repository, and the site checkout
├── identity.json from the repository: which company and environment this is
├── .git/config credential-free origin, plus the leed credential helper
├── .husky/ pre-commit, pre-push, commit-msg
├── .vscode/tasks.json 12 tasks, written with the workspace
├── src/ your content and templates
└── .build/ build output; .build/site is the rendered site| Path | Written by | Contents | Permissions | In git? |
|---|---|---|---|---|
${XDG_CONFIG_HOME:-~/.config}/leed/auth.json | leed setup, leed auth login, leed auth rotate, leed site init, every session renewal that rotates the token | one session per environment and company, each with that company’s raw-content token | 0600 in a 0700 directory | outside the repo |
${XDG_CONFIG_HOME:-~/.config}/leed/auth.json.lock | any command renewing the session, for the moment it takes | the holder’s process id, when it took the lock, and a nonce. It makes parallel commands take turns reading and rewriting auth.json; one left behind by a killed process is taken over automatically once its owner is gone, or after 30 seconds | 0600 | outside the repo |
${XDG_CONFIG_HOME:-~/.config}/leed/sites.json | leed site init, and every command run inside a checkout | the site registry described above | 0600 in a 0700 directory | outside the repo |
${XDG_CONFIG_HOME:-~/.config}/leed/sites.json.lock | any command updating the registry | the same kind of lock as auth.json.lock | 0600 | outside the repo |
${XDG_CONFIG_HOME:-~/.config}/leed/preferences.json | leed setup, leed mcp add --docs, leed mcp docs --decline | answers given once: a declined Claude plugin or VS Code workspace offer, and the documentation MCP server answer | 0600 in a 0700 directory | outside the repo |
<CLI install dir>/.lastcheck.json | the automatic update check | the 24-hour throttle timestamp | default | outside the repo |
<folder>/<customer>/<customer>.code-workspace | leed site vscode, or leed setup when you accept its offer | a VS Code workspace opening raw-content/ | default | outside the repo |
raw-content/.git/config | leed site init, and the first leed site command after an upgrade | a credential-free origin URL and the leed __credential helper for the GitLab host — no token | default | not committed |
raw-content/.husky/ | leed site init and every later resync | pre-commit, pre-push, commit-msg and their helpers | default | gitignored |
raw-content/.vscode/tasks.json | leed site vscode, or leed setup when you accept its offer | 12 tasks covering build, validate, commit, push and auth | default | gitignored |
raw-content/.build/ | leed site build | intermediate output, with the rendered site at .build/site | default | gitignored |
${XDG_CACHE_HOME:-~/.cache/leed}/<env>/<companyId>/leed-validation.json | leed site validate, commit and build | which files you validated, your last successful build timestamp, the pending commit set | 0700 directory | outside the repo |
${XDG_CACHE_HOME:-~/.cache/leed}/<env>/<companyId>/imports/<pageTypeId>.json | leed import openapi, markdown and html | one resumable import manifest per page type | 0700 directory | outside the repo |
The cache paths are keyed by environment and company because one person can belong to more than one company on one machine, and a validation record from a staging checkout must never satisfy a production commit. What is inside the repository itself, folder by folder, is mapped in your site repository.
Relocating with XDG
XDG_CONFIG_HOME moves the credentials directory; XDG_CACHE_HOME moves the caches. Both are read at the moment a path is needed, so setting either for a single command works.
Environment variables
Split into three groups, because most of these are not for you.
Ones you might set
| Variable | Used by | Effect | Default |
|---|---|---|---|
ENV_NAME | every command that resolves an environment, leed setup, wrangler config generation | Names the target environment when no -e flag and no site checkout names one. Inside a checkout it never overrides the checkout’s environment | unset |
LEED_CMS_URL | every CMS request | Overrides the base URL for the resolved environment entirely | unset — the table above applies |
LEED_JSON | every command | 1, true, yes or on turns on JSON mode, exactly as --json does. Opt-in only: LEED_JSON=0 does not turn it off for a run that passed --json | unset |
FILE_VALIDATION_MAX_SIZE | leed site validate | Maximum file size in kilobytes. Note the unit — FILE_VALIDATION_MAX_SIZE=2000 means 2 MB, not 2 KB | 600 |
XDG_CONFIG_HOME | credentials, the site registry, preferences | Relocates the config directory, keeping the leed/ segment | ~/.config |
XDG_CACHE_HOME | validation tracking, import manifests | Relocates the caches, dropping the leed/ segment | ~/.cache/leed |
DEBUG | the logger | Merged with whatever --verbose or -n enabled, never replaced. DEBUG=Eleventy* leed site build is the documented way to see Eleventy’s own internals | unset |
NO_COLOR | the palette | Any non-empty value disables color. Beats FORCE_COLOR. Set to the empty string it does nothing | unset |
FORCE_COLOR | the palette | Any non-empty value other than 0 or false forces color on when stdout is not a terminal — which is how a piped or CI run gets color at all. Loses to NO_COLOR, and --json overrides both | unset |
CI | the update check, the build recorder, the leed site resolver | CI=true suppresses the automatic self-update and puts the CLI into direct mode. It is the lever for anyone scripting leed who does not want the binary replaced mid-run | unset |
Ones CI sets for you
These are set on the Cloudflare Builds trigger during provisioning, or injected by the build platform. You do not set them by hand, and setting them locally will make a command behave as though it were running in CI.
| Variable | Used by | Effect | Default |
|---|---|---|---|
LEED_API_TOKEN | leed site upload | The CMS API token a CI build authenticates with. Required in CI — the command refuses immediately with LEED_API_TOKEN not set. Required for CI builds. | unset |
LEED_ENTITLEMENTS | the build’s entitlement resolution | A JSON { tier, mcp } object. In CI it is the only trusted source and the build fails closed without it, so repository contents can never claim a tier they were not sold. Outside CI the build falls back to src/src.11tydata.json | unset |
BUILD_CUSTOMER | the hidden -c, --customer option | The customer slug a CI build is building | unset |
BUILD_SITE_LOCATION | the hidden -l, --location option | The directory holding the content repositories | unset |
WORKERS_CI_BRANCH | leed site build, leed site upload | The branch, used instead of asking git — and the branch is what decides preview versus public output | unset |
WORKERS_CI_COMMIT_SHA | leed site upload | The commit reported to the CMS with the build result | unset |
WORKERS_CI_BUILD_UUID | leed site upload | Correlates the upload with the Cloudflare build that produced it | unset |
Internal variables, for completeness
You will not set these, and nothing in the documented workflow depends on them. They are listed because a developer grepping their environment for an unexplained LEED_- or DNS_-prefixed name deserves an answer.
| Variable | Used by | Effect |
|---|---|---|
BUNDLED | the CLI entry point | Set inside the shipped bundle. Switches asset paths from the development layout to the packaged one, and is what makes the “Running in direct mode (dev)” banner disappear |
SHELL | leed completion install | Detects bash, zsh or fish. With an unrecognized or unset value the command refuses rather than guessing |
CLAUDECODE | leed setup | Set by Claude Code. Inside it, leed setup makes neither the VS Code workspace offer nor the Claude plugin offer |
CUSTOMER_FUNCTIONS_PORT | wrangler configuration generation | Points a locally generated wrangler config at a customer-functions dev server on a non-default port |
DNS_ZONE_NAME | wrangler configuration generation | Names the DNS zone written into a generated wrangler config |
The hidden leed site options
leed site carries three options that are hidden from --help because they exist for Cloudflare Builds rather than for you: -l, --location <path> (bound to BUILD_SITE_LOCATION), -c, --customer <string> (bound to BUILD_CUSTOMER), and --ci (bound to CI). They select between three ways of working out where your files are. In direct mode — any CI signal, or a checkout with an identity.json that is not registered in sites.json, sandboxes included — the checkout is used as it is, its company comes from identity.json, and the customer is derived from the folder above it; CI never reads or writes sites.json. In managed mode, the ordinary case, the checkout’s sites.json entry is loaded. Passing both --customer and --location names the checkout outright, as <location>/<customer>/raw-content. Everything else on the command tree is indexed at CLI command reference.
Passing --customer or --location also suppresses the automatic update check, which is a side effect worth knowing about rather than a feature to rely on.
Cleaning up
Most of what the CLI leaves on your machine is disposable. What each deletion costs:
| Delete this | What happens next |
|---|---|
~/.cache/leed/ (or one company’s subdirectory) | You lose your validation state and your last-build timestamp. The next leed site commit will tell you to validate and then to build. Nothing is lost but the two minutes it takes |
~/.config/leed/auth.json | You are signed out of every environment and company, and the stored raw-content tokens go with it. Run leed auth login, then leed auth rotate in each site before you push or pull |
~/.config/leed/sites.json | The CLI forgets which sites are on this machine. leed site list is empty and --site finds nothing; a checkout you work in runs in direct mode from its own identity.json. Re-running leed setup records it again — an existing checkout is re-pointed, never re-cloned |
~/.config/leed/preferences.json | leed setup and /leed:setup ask their one-time questions again |
raw-content/.build/ | One rebuild |
raw-content/.husky/ | Bare git commit and git push stop being refused. The hooks come back only when the CLI’s version next changes, because the check compares versions, not files |
.vscode/ or the .code-workspace | Nothing, until you run leed site vscode, which writes both again |
Nothing here is unrecoverable from the CLI. If sites.json is lost or damaged, run leed setup --env <environment> again, or leed site init --company <companyId> -e <environment> --dir <the folder above your site folder>; either records the site again without touching your working tree. That path is described on installing the Leed CLI.
A corrupt auth.json is reported, not ignored: a file the CLI cannot parse stops the command with “The credential file … is unreadable.” rather than reading as “not logged in”, so the next login cannot silently overwrite it. Fix or remove it, then sign in again. A sites.json that is not JSON is set aside as sites.json.corrupt-<ms> on the next write rather than overwritten. An unreadable import manifest, by contrast, is still treated as “start over”.
The 600 KB validation ceiling that FILE_VALIDATION_MAX_SIZE overrides is one of several product limits that have nothing to do with your plan — they are collected in limits that are not plan limits, and the rule it enforces is on leed site validate, commit and push. Large files that genuinely belong on your site go through the asset pipeline instead of the repository — see static files and caching. When one of these paths or variables turns out to be the cause of a failure you are looking at, the message is in the catalog on CLI troubleshooting and exit codes.