@bettercms-ai/cli
v0.3.0
Published
Work on a BetterCMS project locally: sign in, clone the source, install dependencies.
Readme
@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> [--dir <path>] | Signs you in, fetches the source, installs dependencies, pre-fills .env. |
| status [--project-id <id>] [--json] | What is live and what is building. Defaults to the cached sign-in. |
| 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. |
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 devA 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 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; otherwise the latest deployed build is downloaded and unpacked. - 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. - 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. - Prints the next steps.
The one manual step
init does not mint an API key. Create a content:read key under Project → Settings →
API Keys and paste it into .env, then start the dev server.
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://app.bettercms.ai/projects/proj_abc123Serving 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.
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. |
| 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.
