Serving the veni CLI
The veni binary that developers install is the Veni Builder toolchain packaged in a tarball. This page is how that tarball gets packaged, served, and onto a machine — and how to add a CLI command. What the bundled toolchain does is a different concern: the scaffold engine is Scaffold, the component registry is Components. To use the CLI, see The veni CLI.
How it's assembled
builder.service.packageCli() builds the tarball on demand — populateVeniDir() stages everything the CLI needs, then it's tar-ed:
The staged .veni/ is the toolchain as cargo — the CLI carries the machinery, it doesn't explain it:
- The CLI scripts —
api/src/assets/*.txtare the CLI's source, copied out and renamed to.ts(veni-cli.txt→cli.ts,veni-add.txt→add.ts,veni-app-root.txt→app-root.ts, …). Bundled and served, never run in the API. This is the part you edit to add a command. - The machinery it ships — the
scaffolder+capabilities.json(Scaffold), thethemesregistry + source (Components), andapp-template/. Packaging just copies them in. - A version stamp —
version.json, soveni --versionandveni doctorcan compare the install against what the Builder serves.
So assembly is packaging, not logic — this page ends at the tarball.
What lands in the bundle
| Endpoint | Serves | Gate |
|---|---|---|
GET /api/cli/install.sh | The installer, with the Builder base + Shell URL — and your token, if you brought one, baked in (via getInstallScript) | none |
GET /api/cli/download | The veni-cli.tar.gz tarball | verifyVeniToken |
The script is ungated: it carries no secret, and demanding a token to fetch it forced everyone to mint a PAT by hand in the Shell UI before they could install anything — the exact copy-paste step the device flow exists to remove. What it downloads is still gated: verifyVeniToken validates the credential (a PAT via the Shell's introspect endpoint, or a session JWT via JWKS) before serving the tarball, and the script earns that token by signing in when the caller didn't bring one.
What the tarball carries — each asset ships as .txt and lands beside the CLI under its runtime name:
| Asset | Installed as | Read by |
|---|---|---|
veni-cli.txt | cli.ts | every veni <cmd> |
veni-login.txt | login.ts | veni login |
veni-enable.txt | enable.ts | veni enable |
veni-add.txt | add.ts | veni add |
veni-use-theme.txt | use-theme.ts | veni theme (and the legacy veni use theme) |
veni-register.txt | register.ts | veni register |
veni-app-root.txt | app-root.ts | imported by every command as ./app-root |
capabilities.json | capabilities.json | veni create, veni enable |
Plus scaffolder.{js,ts}, app-template/, themes/<brand>/{tokens.css,registry,source}, source-package.json, and version.json.
The version stamp
toolchainVersion() is a content hash — a sha256 over the CLI scripts and capabilities.json, truncated to 12 chars. Nothing to bump by hand and nothing that can drift; it answers the only question anyone asks: is the veni on my machine the one this Builder is serving? The stamp is written into the bundle as version.json and served live at /api/cli/version, and veni --version / veni doctor compare the two.
The three endpoints
| Endpoint | Serves | Gated on |
|---|---|---|
GET /api/cli/install.sh | The installer, with the API base, the Shell base, and any token you brought baked in (getInstallScript) | dev mode only |
GET /api/cli/version | { "version": "<toolchain hash>" } | dev mode only |
GET /api/cli/download | The veni-cli.tar.gz tarball | dev mode + a valid Veni token |
Only the download is token-gated (verifyVeniToken — a PAT via the Shell's introspect endpoint, or a session JWT via JWKS). The script and the version are open on purpose:
install.shcarries no secret, and gating it forced everyone to mint a PAT by hand in the Shell UI before they could install anything — the exact copy-paste step the device flow exists to remove. The script earns its own token instead./cli/versionhas to answer when you're signed out or your token expired, which is precisely when you're trying to work out whether the install is the problem.
All three are off unless the API is in dev mode (APP_ENV_BUILDER_ENABLED); the CLI-distribution surface doesn't exist in production.
The baked Shell base comes from APP_ENV_SHELL_PUBLIC_API_URL (falling back to APP_ENV_SHELL_GRPC_URL) — in K8s the gRPC/JWKS URLs are cluster-internal, so anything baked into a script a developer's machine will run needs the public override. The baked API base prefers the configured APP_ENV_API_URL over the request Host, which is client-controlled and spoofable.
APP_ENV_SHELL_PUBLIC_API_URL — set it in Kubernetes
The installer is baked with a Shell URL and runs the device flow against it from a developer's laptop. It falls back to APP_ENV_SHELL_GRPC_URL, which is right locally but cluster-internal in K8s (http://shell-api:3000/api) — a laptop can't resolve that. Set APP_ENV_SHELL_PUBLIC_API_URL to the externally reachable Shell API for any deployed Builder.
Install it
One line, no token needed — the installer runs the same device flow as veni login, so you approve it in the browser and it comes back with a veni_ token. From the shared Dev builder, or a local one on :3010:
curl -fsSL https://veni-builder-api.dev.venizia.ai/api/cli/install.sh | bashcurl -fsSL http://localhost:3010/api/cli/install.sh | bashcurl -fsSL -H "X-Veni-Token: veni_…" http://localhost:3010/api/cli/install.sh | bashWith no token to work from, the script signs you in the same way veni login does — the device flow:
Signing in to https://shell-api.dev.venizia.ai/api ...
Open this URL to approve: https://veni.dev.venizia.ai/cli?code=WDJB-MJHT
Code: WDJB-MJHT
Waiting for approval...
Signed in.
Downloading the veni CLI from https://veni-builder-api.dev.venizia.ai/api...
Installed veni to /home/you/.local/bin/veni
Run: veniIt opens the browser for you (xdg-open / open) and polls every 5s until you approve; the code lives 5 minutes. Approving mints a 90-day personal token named after the machine that asked — veni CLI (you@laptop) — which you can see and revoke under Profile → Personal access tokens. The flow in full →
What the script does (it needs bun, curl, tar):
- Signs you in, unless you brought a token via the
X-Veni-Tokenheader or$VENI_TOKEN— the environment wins over the baked one. - Downloads the tarball with that token and extracts it to
~/.local/share/veni(override withVENI_HOME). - Writes a
venilauncher into~/.local/bin(override withVENI_BIN_DIR). - Writes
~/.local/share/veni/config.json={ base, shell, token }, mode600— soveni createscaffolds from anywhere andveni update/veni loginknow where to call.
Put ~/.local/bin on your PATH, then run veni for the command list, or veni doctor to verify the whole chain.
The device flow
The CLI can't receive a browser redirect — it may be on a headless box over SSH — so it asks the Shell for a pair of codes: a long device_code it keeps and polls with, and a short user_code you carry to the browser. Both the installer and veni login speak it; the endpoints are the Shell's, not the Builder's.
Details that matter when it misbehaves:
- The approval page is
<shell UI>/cli— a public route that still needs you signed in; signed out it sends you through login and back. It shows the machine that asked (host,os, sent by the CLI) so you can tell your own request from someone else's. user_codeavoidsO/0/I/1— it's read off one screen and typed into another.- Flow state lives in the cache with a TTL, not a table — the records are worthless after 5 minutes. A Shell running several replicas therefore needs a shared Redis; with the in-memory fallback a poll can land on a pod that never saw the approval, and the CLI waits out the clock.
- The token is minted at approval time, not at poll time, so it belongs to whoever clicked. It's handed to the CLI exactly once, then cleared from the record.
- Poll too fast and you earn
slow_down— both clients widen their interval by 5s and carry on.
Installing it to build a new app? Continue at Build a satellite — the CLI is one of five prerequisites there, and the page hands off to the walkthrough that runs veni create.
How the commands run
veni <cmd> runs the bundled cli.ts. Before the dispatch it clears a signed-in gate, then a switch (sub) either handles the command inline or shells out to a per-command script:
Two state files drive them:
veni.jsonat the app root — the manifest every command reads and writes:name,theme, the enabledcapabilities,ports, and the registeredshell.veni enableresolves the graph incapabilities.jsonagainst it, then updates it.config.jsonbeside the toolchain (~/.local/share/veni/config.json) — the{ base, shell, token }written at install, so scriptable commands reach the Builder and the Shell from any folder.veni loginrewritesshell+tokenin place; it never setsbase, which is why a login-only install warns thatveni updatehas nothing to call.
The app root, by walk-up
app-root.ts finds the nearest ancestor holding a veni.json (falling back to the nearest one with api/ or ui/), so app-scoped commands — install, dev, add, enable, theme, register, status — work from ui/ or api/ exactly as they do from the app root. create is the deliberate exception: it uses cwd, because a new app folder must land where you stand, not inside the app you happen to be in.
The signed-in gate
Everything but a short exempt list refuses to run without a token. The check sits immediately before switch (sub) and is deliberately local — it asks whether a token was ever saved and never calls the Shell:
const OPEN_COMMANDS = new Set([
'login', 'update', 'uninstall', 'status',
'whoami', 'doctor', 'version', '--version', '-v', // diagnostics must work signed out
]);
if (!OPEN_COMMANDS.has(sub) && path.basename(dir) !== '.veni') {
// VENI_TOKEN in the env, else config.json beside this script must carry a token
}$ veni create calendar
Not signed in — run `veni login` first.
(Or set VENI_TOKEN for one run.)Three decisions worth knowing:
- Local, not live. Scaffolding is offline work — nothing in
create,enable,addorthemefetches anything. Gating on the Shell being reachable would breakveni createon a plane, or whenever the local Shell is down. The trade-off is deliberate: a revoked token still clears the gate, and the commands that actually talk to a server (register,update) are the ones that find out and say so. - The exempt commands are the ones you need in order to get unstuck —
login(how you sign in at all),updateanduninstall(fixing or removing a broken install),status(reading local state), and the diagnosticswhoami,doctorandversion, which have to work precisely when you are signed out. Everything else —create,enable,add,theme,install,dev,register— is gated. - An app-bundled
.veni/is exempt too. The copy that ships inside a scaffolded app has noconfig.jsonby design; without thebasename(dir) !== '.veni'test the gate would lock it dead. The global install lives in~/.local/share/veni, so it's unaffected.
VENI_TOKEN in the environment counts as signed in for that run — the escape hatch for CI and containers, where nobody is going to approve anything in a browser.
What each command actually does:
| Command | At runtime |
|---|---|
login | Device flow against the Shell (/api/auth/cli/device), poll for the minted veni_ token, save it to config.json. |
create | Parse the layer flags, resolve prerequisites from capabilities.json, pick free ports, import the bundled scaffolder, lay the app, apply the brand tokens.css. |
enable <cap> | Read capabilities.json + veni.json, pull prerequisites in order, materialize the capability (scaffold-api grafts a generated api/; enable-database re-applies the DB recipe in place), update veni.json. |
install | bun install in each present layer (api/, ui/). |
dev | spawn bun run dev per present layer, forward their output; SIGINT stops all. |
add <c> | Resolve the theme (from veni.json) and its dependency graph, copy the source into ui/src, prompt on conflicts, add + install npm deps, merge tokens. |
theme <b> | Overwrite ui/src/themes/tokens.css with the brand's tokens and record the brand in veni.json. |
register | Read the app's .env, authenticate, pick the role's endpoint, register with the Shell, point api/.env at it, record the Shell in veni.json. |
status | Print the manifest: capabilities, theme, ports, Shell. |
whoami / doctor | Introspect the saved token against the Shell / probe every dependency in failure order and exit non-zero on any problem. |
version | Compare the bundled version.json against GET /cli/version. |
update | Download the tarball to a staging dir, sanity-check it, then swap it in — keeping config.json. A truncated download never leaves you without a working veni. |
Adding a command
A CLI command is a case in veni-cli.txt. Two shapes:
- Inline — handle it right in the switch (like
create,dev,status,doctor). - Its own script — add
veni-<cmd>.txt, dispatch to it withsync('bun', [path.join(dir, '<cmd>.ts'), ...rest])(likelogin,add,enable,register), and make surepopulateVeniDircopies the new asset into.veni/— otherwise the bundled CLI won't ship it.
Three things to update alongside it, or the command is only half-added:
usage()inveni-cli.txt— bareveniis the manual, so every command lists every flag it accepts.COMMANDS(andOPEN_COMMANDS, if it must work signed out) — an unlisted command misses the sign-in gate and the did-you-mean suggestion.toolchainVersion()inbuilder.service.ts— a new script that isn't in the hash list won't change the served version, so nobody is told toveni update.
Because the .txt assets are the source, editing them changes the next-installed veni; a developer picks it up with veni update (or a re-install).
Common snags
| Symptom | Fix |
|---|---|
Not signed in — run 'veni login' first. | The gate: no config.json token and no VENI_TOKEN. Run veni login, or set VENI_TOKEN for one run. |
install.sh 404s, or /cli/download 403s | The API isn't in dev mode — set APP_ENV_BUILDER_ENABLED=true in a development environment. |
| Installer: "could not start sign-in at <url>" | The baked Shell URL is unreachable from this machine — in K8s set APP_ENV_SHELL_PUBLIC_API_URL on the Builder. Or install with a token: VENI_TOKEN=veni_… curl … | bash. |
| Installer download fails with HTTP 401/403 | /cli/download is still gated — the token the script earned or you passed is invalid or expired. Re-run without X-Veni-Token and approve in the browser. |
| Approval never arrives, the CLI polls to timeout | A multi-replica Shell on the in-memory cache: the poll hit a pod that never saw the approval. Point the Shell at a shared Redis. |
veni: command not found after install | ~/.local/bin isn't on PATH — add it, or re-run with VENI_BIN_DIR pointing at a directory that is. |
A new veni-<cmd>.txt isn't in the installed CLI | populateVeniDir must copy it into .veni/; add it there, then re-package (a fresh install / veni update). |
veni --version reports unknown | version.json is missing — the toolchain didn't come from a Builder. Re-run the installer. |
| Everyone stays on an old CLI after an asset edit | Add the asset to toolchainVersion()'s hash list, otherwise the served version never changes and no one is prompted to update. |
See also
- The veni CLI — every command and flag, from a user's side.
- Scaffold — the engine the CLI bundles.