sites.json, Files and Environment

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"]
}
FieldTypeSet byWhat reads it
companyIdstringleed site init, from the CMSWith environment, identifies the entry; selects the session and keys the on-disk caches
environmentproduction, staging or devleed site initEvery command run in the checkout: it selects the credentials and the CMS base URL
domainstringthe CMS, from your company’s public domainslugify(domain) is the customer slug used for your folder name, your GitLab paths and --base-url’s default
companyNamestring, optionalthe siteTitle in the checkout’s src/src.11tydata.json, refreshed by commands run thereThe name leed site list shows and --site accepts
pathsstring[]leed site init, then every command run inside a checkoutWhere the site’s checkouts are, most recently used first. --site moves into the first that still exists
remotestring, optionalthe checkout’s git origin, without credentialsDisplay
userIdstringthe CMS, from the account that set the site upEmbedded in every commit message so the CMS can attribute the change
initializedbooleanleed site init, once the checkout existsEvery leed site command: a false here is what produces “Site configuration not initialized”
huskyVersionstringleed site init, and every later resyncThe drift check that reinstalls the git hooks after a CLI upgrade
sandbox, nametrue, stringsandbox sites onlyA sandbox is keyed by its checkout and named by its folder, since every sandbox names the same placeholder company
migratedFromstring[]the one-time import belowThe 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 it

Moving 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 &lt;command&gt;"] --> 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 &lt;env&gt;"]

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

EnvironmentCMS base URLOverride
productionhttps://app.leed.aiLEED_CMS_URL
staginghttps://staging.app.leed.aiLEED_CMS_URL
devhttp://localhost:8787LEED_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
PathWritten byContentsPermissionsIn git?
${XDG_CONFIG_HOME:-~/.config}/leed/auth.jsonleed setup, leed auth login, leed auth rotate, leed site init, every session renewal that rotates the tokenone session per environment and company, each with that company’s raw-content token0600 in a 0700 directoryoutside the repo
${XDG_CONFIG_HOME:-~/.config}/leed/auth.json.lockany command renewing the session, for the moment it takesthe 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 seconds0600outside the repo
${XDG_CONFIG_HOME:-~/.config}/leed/sites.jsonleed site init, and every command run inside a checkoutthe site registry described above0600 in a 0700 directoryoutside the repo
${XDG_CONFIG_HOME:-~/.config}/leed/sites.json.lockany command updating the registrythe same kind of lock as auth.json.lock0600outside the repo
${XDG_CONFIG_HOME:-~/.config}/leed/preferences.jsonleed setup, leed mcp add --docs, leed mcp docs --declineanswers given once: a declined Claude plugin or VS Code workspace offer, and the documentation MCP server answer0600 in a 0700 directoryoutside the repo
<CLI install dir>/.lastcheck.jsonthe automatic update checkthe 24-hour throttle timestampdefaultoutside the repo
<folder>/<customer>/<customer>.code-workspaceleed site vscode, or leed setup when you accept its offera VS Code workspace opening raw-content/defaultoutside the repo
raw-content/.git/configleed site init, and the first leed site command after an upgradea credential-free origin URL and the leed __credential helper for the GitLab host — no tokendefaultnot committed
raw-content/.husky/leed site init and every later resyncpre-commit, pre-push, commit-msg and their helpersdefaultgitignored
raw-content/.vscode/tasks.jsonleed site vscode, or leed setup when you accept its offer12 tasks covering build, validate, commit, push and authdefaultgitignored
raw-content/.build/leed site buildintermediate output, with the rendered site at .build/sitedefaultgitignored
${XDG_CACHE_HOME:-~/.cache/leed}/<env>/<companyId>/leed-validation.jsonleed site validate, commit and buildwhich files you validated, your last successful build timestamp, the pending commit set0700 directoryoutside the repo
${XDG_CACHE_HOME:-~/.cache/leed}/<env>/<companyId>/imports/<pageTypeId>.jsonleed import openapi, markdown and htmlone resumable import manifest per page type0700 directoryoutside 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

VariableUsed byEffectDefault
ENV_NAMEevery command that resolves an environment, leed setup, wrangler config generationNames the target environment when no -e flag and no site checkout names one. Inside a checkout it never overrides the checkout’s environmentunset
LEED_CMS_URLevery CMS requestOverrides the base URL for the resolved environment entirelyunset — the table above applies
LEED_JSONevery command1, 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 --jsonunset
FILE_VALIDATION_MAX_SIZEleed site validateMaximum file size in kilobytes. Note the unit — FILE_VALIDATION_MAX_SIZE=2000 means 2 MB, not 2 KB600
XDG_CONFIG_HOMEcredentials, the site registry, preferencesRelocates the config directory, keeping the leed/ segment~/.config
XDG_CACHE_HOMEvalidation tracking, import manifestsRelocates the caches, dropping the leed/ segment~/.cache/leed
DEBUGthe loggerMerged with whatever --verbose or -n enabled, never replaced. DEBUG=Eleventy* leed site build is the documented way to see Eleventy’s own internalsunset
NO_COLORthe paletteAny non-empty value disables color. Beats FORCE_COLOR. Set to the empty string it does nothingunset
FORCE_COLORthe paletteAny 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 bothunset
CIthe update check, the build recorder, the leed site resolverCI=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-rununset

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.

VariableUsed byEffectDefault
LEED_API_TOKENleed site uploadThe 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_ENTITLEMENTSthe build’s entitlement resolutionA 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.jsonunset
BUILD_CUSTOMERthe hidden -c, --customer optionThe customer slug a CI build is buildingunset
BUILD_SITE_LOCATIONthe hidden -l, --location optionThe directory holding the content repositoriesunset
WORKERS_CI_BRANCHleed site build, leed site uploadThe branch, used instead of asking git — and the branch is what decides preview versus public outputunset
WORKERS_CI_COMMIT_SHAleed site uploadThe commit reported to the CMS with the build resultunset
WORKERS_CI_BUILD_UUIDleed site uploadCorrelates the upload with the Cloudflare build that produced itunset
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.

VariableUsed byEffect
BUNDLEDthe CLI entry pointSet 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
SHELLleed completion installDetects bash, zsh or fish. With an unrecognized or unset value the command refuses rather than guessing
CLAUDECODEleed setupSet by Claude Code. Inside it, leed setup makes neither the VS Code workspace offer nor the Claude plugin offer
CUSTOMER_FUNCTIONS_PORTwrangler configuration generationPoints a locally generated wrangler config at a customer-functions dev server on a non-default port
DNS_ZONE_NAMEwrangler configuration generationNames 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 thisWhat 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.jsonYou 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.jsonThe 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.jsonleed 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-workspaceNothing, 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.

ESC