The package installs as facet-rbxts and the command is facet. Node 20+, ESM.
npx facet-rbxts <command> [options]| Command | What it does |
|---|---|
init | Writes facet.json, creates vela.config.ts if absent, installs build dependencies |
add | Copies components in, with their registry dependencies |
list | Shows every component in the registry |
remove | Deletes copied components |
diff | Shows how a copied component differs from the registry |
doctor | Checks the project matches what components assume |
Global options
| Option | |
|---|---|
--cwd <dir> | Run against another directory. The project root is the nearest package.json at or above it. |
--registry <url|path> | Read from another registry — see Using another registry |
--version, -v | |
--help, -h |
Every command resolves the project root by walking up from --cwd (or the current directory) to the
nearest package.json, and fails with No package.json found above … if there is none.
init
npx facet-rbxts init [-y] [--force] [--no-deps]| Option | |
|---|---|
--yes, -y | Accept every default instead of prompting |
--force | Overwrite an existing facet.json |
--no-deps | Skip every package install |
Prompts for the theme base (zinc / slate / stone / neutral), the mode (dark / light),
the three directories, and an optional import alias — leaving the alias blank means relative
imports, which need no tsconfig paths.
Then, in order:
- Writes
facet.json. - Creates
vela.config.tspre-wired withfacetTheme()only if it does not exist. An existing one is never rewritten — if it does not usefacetTheme, the exact lines to add are printed. - Installs
@facet-ui/themeandvela-rbxts@^0.9.0as dev dependencies. - Runs
add utils, because every component imports~/lib/utils. This is also what installs@facet-ui/react-variants. - Checks
tsconfig.jsonforvela-rbxts/transformerand prints the snippet if it is absent.
Refuses to run if facet.json already exists, unless --force.
add
npx facet-rbxts add <names...> [--overwrite] [--dry-run] [--no-deps] [--yes]| Option | |
|---|---|
--overwrite | Replace files that already exist instead of skipping them |
--dry-run | Resolve and report, write nothing |
--no-deps | Skip the package install |
--yes | Answer the provider-wiring prompt with yes instead of asking |
Resolves the named items plus their registryDependencies, transitively and in dependency order.
For each file: rewrites ~/ imports for where the file will land, then writes it. All writes go
through one transaction that commits or rolls back as a unit.
Files that already exist are skipped, not overwritten, and reported as exists. --overwrite
replaces them wholesale — there is no merge.
The union of the install set’s npm dependencies is then installed, through whichever package
manager your lockfile implies (pnpm-lock.yaml → pnpm, yarn.lock → yarn, bun.lockb/bun.lock →
bun, package-lock.json → npm; then the packageManager field; then npm).
Finally, if vela.config.ts does not use facetTheme and the added components name tokens, it says
so with the token list — rather than letting the next build be the messenger with a Vela diagnostic
on a file you never wrote.
The field states intent; the lockfile states what the project actually installed with. Installing with the wrong one leaves two lockfiles disagreeing.
Wiring a provider
If an added component declares a providers entry —
today that is dialog and its PortalProvider — add offers to wrap
your client entry in it. This is the one file the CLI edits that it did not write, and the bar
for doing so is deliberately high.
PortalProvider has to wrap your app — Lattice reads the portal target from it,and throws when a dialog opens without one
? Add it to src/client/main.client.tsx? (Y/n)Why this and nothing else: every other thing the CLI reports is a build-time failure. A missing
transformer means every class is inert on the next rbxtsc. A missing token is a Vela diagnostic.
Both are loud, and both land in front of the person who just ran the command. A missing
PortalProvider compiles, type-checks, passes CI, ships — and throws the first time a player opens
the dialog, hundreds of lines of package-manager output after the snippet would have been printed.
The parser is used for positions, never for output. @babel/parser answers where the last
import ends and where the render call’s argument starts; the edit itself is two string splices.
Everything outside those two offsets comes out byte for byte identical — no re-quoted strings, no
moved comments, no re-indented JSX on a file you arranged deliberately. A test asserts the exact
bytes.
Anything ambiguous is reported, not guessed:
| What the entry looks like | What happens |
|---|---|
One .render( call, a PlayerGui expression somewhere | Wrapped, after the prompt |
Two files under src/ mount a tree | Snippet, naming both |
Two .render( calls in the one entry | Snippet — which tree to wrap is a design question |
No PlayerGui named anywhere | Snippet — synthesizing the lookup means editing imports too |
| The entry does not parse | Snippet |
| Provider already imported | Nothing, and it says so |
The PlayerGui case is the one worth explaining. Writing
Players.LocalPlayer.WaitForChild("PlayerGui") into the file also means adding Players to an
@rbxts/services import that may or may not exist — a second, riskier edit for a situation that
barely occurs, since a client that mounts React always names its PlayerGui already. So the CLI
reuses the expression that is there and stops when there is none.
The prompt defaults to yes and is the last thing add does, so nothing scrolls past it. --yes
writes without asking.
A non-interactive run without --yes writes nothing and prints the snippet, reported as
“nothing here to ask, and this is not a file to edit unasked”. stdin not being a terminal is not
consent — and it is also not you having declined, which is why the two read differently.
facet doctor checks the same thing on every run, so a provider that gets refactored out later is
reported rather than rediscovered at runtime.
Multiple providers are wired one at a time, and the entry is re-read between them — the second one wraps the file the first just rewrote.
list
npx facet-rbxts list [--registry <url|path>]Every item in the registry with its description, alphabetically, plus which registry was read.
Inside a project it reads that project’s registry field, so a project pinned to a revision or
pointed at a fork is shown what it can actually add. Outside one it falls back to the default —
a missing or unreadable facet.json is an ordinary outcome here, not an error, because listing what
exists is what you run before init.
list was the one command that never opened facet.json, so a pinned project was still shown the
moving registry — offering components an add in that project could not fetch. Found by pinning a
real project to a published revision and watching list report the registry underneath it.
remove
npx facet-rbxts remove <names...> [--force]| Option | |
|---|---|
--force | Delete a file that differs from the registry |
Removes exactly what you name. Dependencies are not pulled in — that would delete utils
because you removed button.
Two refusals:
- A file that differs from the registry, compared after replaying the same
~/rewriteaddapplied on the way in, so a file that was only ever copied reads as untouched.--forceoverrides this. - A component another installed component still imports.
--forcedoes not override this one. The check runs to a fixed point: if one item stays behind because it was modified, anything it imports stops being free to delete.
Directories are left alone, and so is anything you added next to the removed files.
diff
npx facet-rbxts diff [name]With no argument, walks everything the registry knows about that also exists in the project.
Compares each file against what the registry would write there today, replaying the ~/ rewrite
first so your alias and directory choices are not reported as changes. Direction is registry →
project: - is the registry, + is your file.
Facet records nothing at copy time, so this cannot tell your edit from an upstream change — and it says so in its own output rather than implying otherwise. See Updating copied components.
doctor
npx facet-rbxts doctorEight checks, in order. Each is ok, warn, or fail; any fail exits non-zero.
| Check | Fails when |
|---|---|
facet.json | never — reports style, theme, and whether each alias directory exists |
| import alias | facet.json sets an import and tsconfig declares no paths entry that could resolve it |
| transformer | tsconfig.json does not register vela-rbxts/transformer |
| theme | vela.config.ts does not exist (a config that exists but does not use facetTheme is a warning) |
| components | never — lists what is installed; warns when an item is missing some of its files |
| tokens | an installed component names a token the theme does not define |
| packages | a package is missing, or older than the floor the copied files need |
| providers | an installed component declares a provider and the client entry is not wrapped in it |
The providers check is the only one whose failure is not a build failure. A
dialog with no PortalProvider above it compiles, ships, and throws
the first time a player opens it — and nothing in the copied file can prevent that, because the
wiring lives in a file Facet does not own. When it fails it prints the snippet; when it cannot find
a single client entry to look at, it warns rather than failing, and says to check by hand.
The packages check is the one that catches a project set up by an older CLI: the copied files are
current and the versions under them are not. It reads the version actually sitting in
node_modules — walking up, because a project inside a workspace has its dependencies hoisted —
rather than the declared range, since the two differ exactly when someone installed once and never
again.
A registry it cannot reach is a gap in the report, not the end of it: the first four checks stand on their own, and the last three are skipped with a warning saying so.
An item counts as present the moment one of its files does. Deleting a copied file is allowed — it is your file — so a half-installed item is reported, not treated as absent. Its tokens and packages are still needed by whatever is left.
Version comparison is deliberately not semver
The comparison answers one question — is what is installed at least the version this needs? — for
the shapes Facet actually writes (^0.9.0, >=0.8.0, 0.1.1). Anything it cannot read is reported
as unverifiable rather than guessed at, because a doctor that invents a failure is worse than one
that admits a gap.
Environment variables
| Variable | |
|---|---|
FACET_REGISTRY_DIR | A local directory. Wins over everything, including --registry. |
FACET_REGISTRY_URL | A URL. Loses to --registry and to facet.json. |
Exit codes
0 on success. 1 on a user-facing error — an unknown command, an unreachable registry, a failed
doctor. Anything else is a bug and throws with a stack.