@bettercms-ai/cli
Work on a BetterCMS project on your own machine.
npx @bettercms-ai/cli@latest init --project-id <id>
Copy the command (with your project's id already in it) from the Work on this project locally button in the project top bar. Requires Node 20 or newer.
Commands
| Command | What it does |
|---|---|
init --project-id <id> [--agent <name>] [--repo <folder|GitHub URL>] [--dir <path>|.] [--framework <name>] [--no-launch] [--no-sections] [--push|--no-push] [--authoring <components|fields>] [--forms-email <address>] | Signs you in, fetches the source (or, on a headless project, uses your front end or starts one from a starter), installs dependencies, pre-fills .env, installs the agent skills, wraps each page's sections so they follow the CMS order (not on headless), opens your coding agent on the project. |
status [--project-id <id>] [--json] [--gate] | What is live, what is building, and whether the setup is complete. Defaults to the cached sign-in. |
pull-content [--dir <path>] | Headless: a converted site's first build step (bettercms pull-content && <build>). Writes the published content into the page snapshots its code imports, so a rebuild shows what was published. No key. |
derive [--project-id <id>] [--dir <path>] [--no-build] [--json] | Headless: builds and runs your front end here, renders each page as a visitor sees it, and sends the pages to BetterCMS, which derives their fields and today's copy. Your code is never changed. |
deploy --to <vercel|netlify|cloudflare> [--name <site>] [--dir <out>] [--no-hook] [--json] | Puts this repository on your own host through the host's own CLI and browser sign-in, sets the site's BetterCMS address there, and registers the host's deploy hook so every publish rebuilds it. |
env push [--project-id <id>] [--all] | Sets the project's variables from this repository's .env files. Prints names, never values. |
bridge [--project <id>] [--once] | Keeps this machine available to the dashboard as an executor. |
whoami | The cached sign-in — project, workspace, API, token expiry. Offline. |
logout | Forgets the cached sign-in. Idempotent. |
--version / --help | The installed version / this usage. |
| Flag | Default | What it is |
|---|---|---|
--project-id <id> | init: required · status: the cached sign-in | The project to work on. |
--dir <path> | the project slug | Directory init creates. Must not already exist. |
--api <rung|url> | prod | A rung by name — prod, dev, local — or a full URL. Also BETTERCMS_API_URL. |
--json | off | status only: print the API response verbatim. |
--agent | the one installed, else asked | init only: claude, codex, opencode, cursor or gemini (env BETTERCMS_AGENT); remembered in ~/.bettercms/cli-prefs.json. |
--framework | the project's, else asked | init only, headless with no front end yet: the starter (next, astro, react-ts). |
--no-launch | off | init only: print the next steps instead of opening the coding agent. |
--no-sections | off | init only: leave the page files alone (skip step 7). |
--repo | the connected repository | init only: use this folder or GitHub URL as the site's code. |
--push / --no-push | asked | init only: push BetterCMS's setup commits as it goes (--no-push: never). |
--authoring | asked | init only: how editors build pages, components or fields. |
--forms-email | asked | init only: where the site's forms send submissions ("" keeps the site's own handling). |
--gate | off | status only: print the setup gate (totals per check, the five worst pages, the next fix), and exit 3 while it is incomplete. |
Both --flag value and --flag=value work. Unknown flags are refused, not ignored.
Environments
--api takes a rung by name, so nobody has to remember a hostname:
| Name | API |
|---|---|
prod (default) | https://api.bettercms.ai |
dev | https://dev-api.bettercms.ai |
local | http://localhost:3000 |
npx @bettercms-ai/cli@latest init --project-id <id> --api dev
A full https:// URL still works. A bare word that is not a rung is refused on the command
line, listing the ones that are — it is never sent to DNS as a hostname.
The dev API is on bettercms.ai while the rest of the dev estate is on bettercms.dev
(dashboard app.bettercms.dev, MCP mcp.bettercms.dev). bettercms.dev has a wildcard DNS
record, so dev-api.bettercms.dev resolves and answers an nginx 404. Use --api dev and the
question does not come up.
Each rung caches its own sign-in under its own origin in
~/.bettercms/cli-credentials.json, so switching never presents one rung's token to another —
but the first command against a new rung asks you to approve it.
Sign-in is global, not per directory: init or status for another project replaces the
cached grant, so a bare status afterwards reports that other project. status prints the
project id on its first line for exactly this reason.
init
- Signs you in. An OAuth device code opens your browser at once, with the project
pre-selected. Approve it there. If the browser cannot be opened, the link is printed instead. The grant
is cached in
~/.bettercms/cli-credentials.json, so the next run is silent — unless the cached grant is for a different project, in which case you are asked again. - Reads the project — its slug (the directory name) and workspace.
- Fetches the source. A connected GitHub repo is cloned with
git clone— with no GitHub sign-in: BetterCMS lends the clone its own read access to that one repository (an hour, never written into the clone), and git never prompts. If the clone still cannot work, or there is no connected repo, the latest deployed build is downloaded and unpacked instead. - Installs dependencies with the package manager the lockfile names —
bun.lock/bun.lockb→ bun,pnpm-lock.yaml→ pnpm,yarn.lock→ yarn, otherwise npm. A project with nopackage.jsonskips this step. If the install fails,initwarns with the command to rerun inside the directory and carries on; stopping would leave a folder that blocks the rerun. - Pre-fills
.envfrom.env.examplewhen there is one and no.envyet:BETTERCMS_API_URL/PUBLIC_BCMS_API_URL/BCMS_API_URLare set to the API, andBETTERCMS_WORKSPACE/PUBLIC_BCMS_WORKSPACE/BCMS_WORKSPACEto your workspace slug. Every other line — your keys included — is left exactly as it was. - Sets up your coding agents, like
npm create sanity: runsnpx -y @bettercms-ai/install@latest --global --mcp --api <rung>. In a terminal it asks "Configure BetterCMS MCP and agent skills for these editors?" (Claude Code, Codex CLI, Cursor, VS Code, Zed); without one it uses the editors it finds. Each chosen editor gets the BetterCMS skills (all 24: the compactbettercms,bettercms-migrateandbettercms-abm, plus the separatebettercms-*skills) and the hosted MCP in its user-level config. An API that is neither a named rung nor https gets the skills only. A failure only warns with the command. - Wraps each page's sections so the site's own build renders them in the order the CMS page
lists them, with each section's spacing and style, and draws a section added in the editor:
npx -y @bettercms-ai/convert@latest --sections, with the project's page list from the API. It runs whether or not an agent is installed. First a dry run, whose plan is printed: each page, each section and the slot it gets, and each page it leaves alone with the reason. Then the same run for real, then a check that no class or style token differs fromHEAD, then one local commit of exactly the files it wrote:chore(bettercms): render sections from BetterCMS. It never pushes, never amends, and leaves anything else you have staged alone. If a file it would change has uncommitted changes it does nothing and says which; if the style check or an SDK install fails it puts the files back. A directory that is not a git repository is wrapped and not committed. The wrap only adds<BcmsSections>and one slot tag around each section: no section's markup, props, classes or styles change, and a second run changes nothing. When the repository's@bettercms-aiSDK predates<BcmsSections>, its range is raised inpackage.jsonand the package manager installs it (that change is in the same commit). What it did is recorded with BetterCMS so the setup gate knows which routes were wrapped and which were refused.--no-sectionsskips the step. - Opens Claude Code on the project, BetterCMS connected and a first prompt already sent —
the same prompt as before, now typed for you. Needs
claudeon yourPATH(npm install -g @anthropic-ai/claude-code); without it, or without a terminal, or with--no-launch,initprints the next steps and the command to run instead. Rerunninginitin the same directory reuses the existing checkout and reopens Claude Code rather than failing with "already exists". SetBETTERCMS_AGENT_MODELto name the model Claude Code uses; unset, it is your own default. - Reads the setup gate and prints it when the agent exits, however it exited, on one screen
whatever the size of the site: pages built, created and published; one totals line per counted
check (text, images, links, sections, style, SVGs, numbers, attribute text), each
editable/total; what is still open per check; the five worst routes; every recorded exception with its note; the one fix that closes the most items (Next); the first five items left, each with the tool call that fixes it; and the two commands below. A check nothing has measured yet (a project set up before that check existed) printsUNMEASUREDin place of a share, and anUnmeasuredline names them: neither a failure nor a pass, and one more setup run measures them. While the gate is incompleteinitexits 3 and prints the command that reopens the agent on the same site. After--no-launch, or without a terminal, the gate is printed andinitstill exits 0: it was only asked to fetch the site. A gate that cannot be read (an older API, the network) is said in one line and is not a failure.
Setup incomplete: 23 items left
Pages 18 built, 18 created, 16 published
Text 41210/52008 (79%) characters editable
Images 36/61 (59%) editable
Links 402/410 (98%) editable
Sections 52/61 (85%) stamped and movable
Style UNMEASURED: setup has not counted this on the live build
SVGs 20/44 (45%) editable
Numbers 12/12 (100%) editable
Attrs 70/96 (72%) editable (alt, aria-label, title, placeholder)
Open text 7, svg 6, style 4, image 3, published 2, seo 1
Unmeasured style: not a failure and not a pass. Setup measures them (editor_setup { force: true })
Routes 7 of 18 pass (page, text, images, links, sections, style; worst first)
/services draft text 310/2400 images 0/4 links 12/12 sections 1/9 style ? fails: published, text, image, sections
/blog/post-1 published text 900/2400 images 2/4 links 12/12 sections 3/3 style ? svg 0/3 fails: text, image, svg
…
… and 13 more
Next bind-elements closes 16 of 23: get_conversion_brief {"complete":true}
Left to do:
1. 12% of the visible text on /services is a field's (310 of 2400 characters; the bar is 98%).
fix (bind-elements): get_conversion_brief {"complete":true}
2. 1 of 9 sections on /services are a stamped root the editor can find: the rest are not drawn from the page's blocks.
fix (run-sections-conversion): componentize_sections {"codemod":true,"digest":"<digest from get_componentize_plan>"}
…
… and 18 more
Resume the agent: npx @bettercms-ai/cli@latest init --project-id proj_abc123
Read this again: npx @bettercms-ai/cli@latest status --gate --project-id proj_abc123
The read key
init does not mint an API key: a one-hour sign-in must not leave a key that never expires.
Published content needs none (the SDK reads it by BCMS_PROJECT_ID). When the front end's
config reads one, the coding agent creates it with the create_delivery_key tool, a named,
read-only key listed under Project → Settings → API Keys, and writes it into .env.
A headless project
A headless project's site is built by its own host, so init needs no deployed source: it uses
YOUR front end. Run it in that folder (or pass --repo <folder|GitHub URL>). First init runs
bettercms derive: it builds the site, runs it, renders every page as a visitor sees it (in your
local Chrome, Chromium or Edge, so copy a component fetches in the browser is read too) and sends
those pages to BetterCMS, which derives the pages, fields and today's copy from them, as it does
from a site it builds. Then the agent converts the code in place: each literal becomes a read of
its field, with the markup unchanged. It deploys with bettercms deploy --to <host>, records the
site URL (set_front_end), runs derive again so the setup measures what shipped, and stops only
when get_next_steps reports receipt.complete (its receipt.frontEnd rows and its gate).
npx @bettercms-ai/cli@latest derive # in the front end's folder
BETTERCMS_BROWSER=none npx @bettercms-ai/cli@latest derive # read the HTML as served
init never starts from a starter unless you ask for one: --framework <next|astro|react-ts>, or
"starter" at its question, for a project with no front end at all.
status
$ bettercms status
Project proj_abc123
State idle
Live a1b2c3d since 2026-01-01T00:00:00.000Z
Serving staging — promote pending (production a1b2c3d)
Editor https://bettercms.ai/acme/projects/acme-site/visual
Serving appears only on projects that use the staging/production split. Live reads
nothing deployed yet until the first publish, and State carries the API's own reason when
a build failed: State failed — <reason>.
--json prints the API response verbatim — state, lastLiveSha, liveAt, publishing,
failed, error, slot, productionSha, awaitingPromote, canvas, editorUrl. It is
also non-interactive: if signing in would need a browser it exits 1 with
Sign-in needed for <id>. Run the command once without --json to approve it. rather than
opening one. Run status once without --json on a new machine, then script it.
status needs the same artifact:write grant as deploying, which the default device grant
carries. A narrowed role sees the API's own 403 sentence instead of the status lines.
A Setup line follows when the API reports a setup gate: complete, not judged (nothing is
live, or the project is headless), or incomplete: N items left. status --gate prints the
whole gate, as init does when the agent exits, and exits 3 while it is incomplete, so a script
can wait on it. --json --gate adds the gate to the JSON as setupGate.
deploy
$ bettercms deploy --to vercel
Host Vercel (acme-site)
URL https://acme-site-a1b2c3.vercel.app
Env BCMS_API_URL, BCMS_WORKSPACE, BCMS_PROJECT_ID, PUBLIC_BCMS_API_URL, …
Rebuilds on every publish (deploy hook registered with BetterCMS)
The Sanity model: the host's own CLI (vercel, netlify, wrangler, run with npx) signs
you in through your browser, creates or links the site and deploys this directory. Your host
token stays in that CLI's own store; BetterCMS never sees it. Then deploy:
- sets the site's BetterCMS address on the host —
BCMS_API_URL,BCMS_WORKSPACE,BCMS_PROJECT_ID, unprefixed and under your framework's public prefix (NEXT_PUBLIC_,PUBLIC_,VITE_, …). Published content needs no key, so nothing secret is set; - registers the host's deploy hook with BetterCMS, so every publish rebuilds the site.
| Host | Site | Rebuild on publish |
|---|---|---|
| Vercel | vercel link (creates the project), vercel git connect | Once the project builds from your GitHub repository. |
| Netlify | netlify init in a terminal (creates the site and connects the repository); netlify sites:create without one | Once the site builds from your repository. |
| Cloudflare | A local build, then wrangler deploy --assets <dir>: a Worker with static assets (created on the first deploy) | No: a local upload has no deploy hook. Rerun deploy after publishing, or connect the repository to Workers Builds. |
The hook is added beside BetterCMS's own builds, never instead of them: a project BetterCMS
hosts keeps building there, so the editor keeps working, and switching the production host
stays a dashboard decision. A rerun reuses its hook. Registering one needs the Enterprise
plan; without it the site still deploys and deploy says publishes will not rebuild it.
In CI there is no browser: set the host's own token — VERCEL_TOKEN, NETLIFY_AUTH_TOKEN, or
CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID. Without one, deploy exits 1 and names
it. --json prints { provider, site, url, env, hook } and never opens a browser. Cloudflare
needs --dir when the framework is not Astro, Vite, Nuxt, Gatsby or Create React App.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Done. Also whoami when signed out and logout when there was nothing to clear. |
| 1 | The command failed — the reason is on the last line of stderr. |
| 2 | The command line itself was wrong: unknown command, unknown flag, or a flag missing its value. |
| 3 | init and status --gate only: the setup gate is not complete. What is left is printed above, with the command that resumes. |
| other | The exit code of git or your package manager, which printed its own error. |
Troubleshooting
- "git is required to clone …" — install git, then rerun.
- A private repo asks for credentials — that is git's own prompt; set up an SSH agent or a credential helper and rerun.
- "You approved a different project …" — the consent screen only pre-selects the project; rerun and pick the one in the command.
- "This project has no deployed source yet." — connect a GitHub repo or publish once.
- "status needs --project-id , or run bettercms init first." — nothing is cached on
this machine yet. Pass the id, or run
init. - "Sign-in needed for . Run the command once without --json to approve it." —
--jsonnever opens a browser. Run the same command without--json, approve it, then rerun. - "Could not reach https://api.bettercms.ai: …" — DNS, TLS, a proxy, or the 30 s request
timeout. Check the network, or point
--apisomewhere reachable. - "Unexpected response from the API." — a 2xx body that was not the JSON expected; usually a captive portal or a proxy answering instead of the API.