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. It 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, the mode, 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, and the exact lines to add are printed instead. - Installs
@facet-ui/themeandvela-rbxts@^0.9.0as dev dependencies. - Runs
add utils, since every component imports~/lib/utils. This also installs@facet-ui/react-variants. - Checks
tsconfig.jsonforvela-rbxts/transformerand prints the snippet if 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,
rewriting each file’s ~/ imports for where it will land. 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 selects pnpm, yarn.lock yarn, bun.lockb or
bun.lock bun, and package-lock.json npm. Failing that it reads the packageManager field, then
falls back to npm.
Finally, if vela.config.ts does not use facetTheme and the added components name tokens, it says
so with the token list. Otherwise the next build reports it as 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,
add offers to wrap your client entry in it. Today that is only
dialog. This is the one file the CLI edits that it did not write,
and the bar 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: everything else the CLI reports fails at build time. A missing
PortalProvider compiles, type-checks, passes CI, ships — and throws the first time a player opens
the dialog.
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 is two string splices, so
everything outside those offsets comes out byte for byte identical.
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 |
Writing Players.LocalPlayer.WaitForChild("PlayerGui") would mean adding Players to an
@rbxts/services import that may not exist. 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. --yes writes without asking. A non-interactive run without --yes writes nothing and prints the snippet. Stdin not being a terminal is not consent, and it is not the same as declining.
facet doctor checks the same thing on every run, so a provider 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 pinned project is shown what it
can actually add. Outside one it falls back to the default — a missing facet.json is an
ordinary outcome here, since listing what exists is what you run before init.
list was the one command that never opened facet.json, so a pinned project was shown the moving registry — offering components its own add could not fetch.
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, so a file that was only ever copied reads as untouched.--forceoverrides this. - A component another installed component still imports.
--forcedoes not override this. 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 today, replaying the ~/ rewrite first so
your alias and directory choices are not reported as changes. Direction is registry → project.
Facet records nothing at copy time, so this cannot tell your edit from an upstream change, and says so in its own output. 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 compiles, ships, and throws the first time a player opens it. Nothing in the copied
file can prevent that. When it fails it prints the snippet. When it cannot find a single client
entry it warns rather than failing.
The packages check catches a project set up by an older CLI, where the copied files are current and
the versions under them are not. It reads the version actually in node_modules, walking up for a
workspace, rather than the declared range. 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 alone, and the last three are skipped with a warning.
An item counts as present the moment one of its files does. Deleting a copied file is allowed, so a half-installed item is reported rather than 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 for the shapes Facet writes. Is what is installed at least the version this needs? Anything it cannot read is reported as unverifiable rather than guessed at.
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.