@tabula-css/cli
The tabula command line tool: build, build --check, check:css, scan, migrate, except add, eject, doctor, explain, canary.
Install
npm install --save-dev @tabula-css/cliDev dependency — tabula is a build-time and CI tool. It bundles @tabula-css/core, @tabula-css/tokens, @tabula-css/registry, @tabula-css/preset, and @tabula-css/stylelint-plugin as dependencies, so installing the CLI alone is enough to run every command below. @typescript-eslint/parser ships as a regular dependency; eslint is an optional peer, needed only by tabula canary.
Overview
tabula is the entry point most projects use. tabula build reads your tokens and tabula.config.json and derives the entire generated .tabula/ artifact set — the step that turns a flat token profile into the closed vocabulary everything else (@tabula-css/merge, the ESLint plugin, the MCP server) reads. The other commands re-check that output for drift, scan source files for classes outside the vocabulary, enforce CSS governance, migrate existing code onto the profile, register scoped exceptions, and explain a diagnostic code offline.
Every command shares one exit-code contract (packages/cli/src/types.ts): 0 the project is clean, 1 the project violates the contract (the tool worked; the styling is wrong), 2 the tool itself could not run (malformed inputs, missing project, internal error). --format=json emits a machine envelope { tabula, ok, inputsHash, diagnostics } on stdout alone, with no human log stapled to the front — the invariant an agent piping tabula build --format=json into a JSON parser depends on.
build
npx tabula build
npx tabula build --checkReads tokens/*.tokens.json and tabula.config.json, derives the full artifact set, and writes it atomically to .tabula/ (nothing is written on a validation failure). Prints the artifact list and the build's inputsHash.
| Flag | Default | Meaning |
|---|---|---|
--check | off | Regenerate in memory and byte-diff against the committed .tabula/; also runs the scan and CSS-governance gates. Writes nothing to disk. Exit 1 on any drift or gate finding. |
--backend a|b|conform | b | Registry backend: a (design-system API), b (PostCSS probe-sheet walk), or conform (runs both and asserts they agree). |
--out <dir> | .tabula | Output directory. |
--no-scan | off | With --check, skip the scan gate (drift comparison only). |
--no-css | off | With --check, skip the CSS governance gate (drift comparison only). |
--css-entry <path> | — | Repeatable. Extra sanctioned entry stylesheet for the CSS governance gate, passed through to check:css. |
--css-ignore <dir> | — | Repeatable. Extra directory name to skip while walking for .css files. |
--check runs drift comparison first, then the scan gate, then the CSS governance gate, in that order — so a scan or CSS finding under --check always means "the source is wrong," never "the registry was stale." A stray file inside .tabula/ that the manifest doesn't name also counts as drift.
npx tabula build --check --backend conformcheck:css
npx tabula check:cssParses every project .css file (walking the whole tree, not just scan's source globs, minus a fixed ignore list) plus the emitted stylesheets in .tabula/, and runs the five CSS governance rules from @tabula-css/stylelint-plugin's kernel — the same rule code the editor plugin runs, so the two cannot disagree. Findings exit 1.
| Flag | Default | Meaning |
|---|---|---|
--out <dir> | .tabula | Where the emitted stylesheets (theme.css, profile.css, source.css) are read from. |
--css-entry <path> | conventional list (src/app.css, src/index.css, app/globals.css, …) | Repeatable. Designates a sanctioned entry stylesheet, exempt from the hand-authored-rules ban only. |
--css-ignore <dir> | node_modules, dist, build, coverage, .git, .next, .turbo, .vitest, var | Repeatable. Extra directory name to skip while walking. |
See @tabula-css/stylelint-plugin for the five rules this command enforces (TAB-E221–TAB-E225), and CSS governance for what the gate does and does not cover.
scan
npx tabula scanRuns Tailwind's own scanner (@tailwindcss/oxide) over the project's source globs — the same extraction the CSS build uses — and reports every candidate that is both unregistered and utility-shaped (a resolving variant chain, a registered family prefix, a banlist hit, or arbitrary-value/property syntax), with file:line. This filter exists because the scanner extracts every word-shaped run in a file, including ordinary prose; reporting all of them would make the gate unusable. Findings exit 1.
| Flag | Default | Meaning |
|---|---|---|
--strict | off | Also fail when the count of eslint-disable comments naming a tabula/ rule exceeds budgets.maxSuppressions (TAB-W900). |
--out <dir> | .tabula | Where registry.json is read from. |
The suppression count is always printed, even without --strict — a budget nobody sees is not a budget.
A variant chain that resolves but whose product isn't declared is reported with TAB-E230 ("outside the registered set, so it emits no CSS") — the v0.2 variant-closure gate. The fix-it names the two ways out: declare the product in variants.products and rebuild, or rewrite the chain into canonical ascending-rank order. Parametric aria-*/data-*/group-*/peer-* variants are unsupported in v0.1 and are not gated.
migrate <what>
npx tabula migrate logical
npx tabula migrate spacing --write
npx tabula migrate merge
npx tabula migrate darkCodemods onto the profile. Dry-run by default — nothing on disk changes unless --write is passed. The governing rule: rewrite only what is provably 1:1; flag everything else with a located, explained TODO comment rather than guessing.
| Subcommand | What it does |
|---|---|
logical | pl-/pr-/ml-/mr-/left-/right-/border-l-/border-r-/text-left/text-right → the logical forms (ps-, pe-, ms-, me-, start-, end-, border-s-, border-e-, align-start, align-end). 1:1, inside class sinks only. Fixes the axis, not the value — tabula scan afterward catches an unregistered target value. Needs no built registry. |
spacing | space-x-*/space-y-* → gap-x-*/gap-y-*, but only when the element provably carries a matching flex direction (flex-row/flex-col) and the target class is registered. Refuses (with a TODO) on grid containers, negative values, space-*-reverse, variant-prefixed classes, or an unprovable axis. Needs a built registry — exits 2 without one. |
merge | Rewrites clsx / classnames / tailwind-merge's twMerge / a foreign cn import to @tabula-css/merge's cn. Imports only; call sites unchanged (the local binding name is preserved via import { cn as clsx } from '@tabula-css/merge'). A sole specifier only — a mixed import is flagged, not split. twMerge is rewritten but always flagged: it merges by name-shape heuristics where cn merges by registry-declared slot ownership, so every call site needs review. |
dark | Report-only, even with --write. Lists every dark:/light:/[data-theme=…]: usage with file:line and why it needs a token axis, not a codemod — the token's other theme literal exists only in the author's design intent and cannot be derived automatically. |
| Flag | Default | Meaning |
|---|---|---|
--write | off | Apply the rewrites. Without it, a unified diff is printed and nothing changes. |
--out <dir> | .tabula | Where registry.json is read from (migrate spacing only). |
Exit codes are subcommand-aware: 0 nothing to do, or --write applied every finding; 1 migration work is outstanding (a dry run with pending rewrites, or any TODO-flagged finding — so migrate dark with any usage is always 1); 2 the tool is broken or misdriven (unknown subcommand, unloadable project, missing registry where required).
except add
npx tabula except add \
--name card-shadow --type dimension --value 347px --families w \
--reason "Figma spec requires this exact width; no token is within 5%." \
--owner @design-systems --expires 2026-12-31 --allowed-in "src/marketing/**"Validates a proposed vocabulary exception by splicing it into the real token document and running the real @tabula-css/tokens validator, then prints the token-file patch. Writes nothing unless --apply is passed.
| Flag | Required | Meaning |
|---|---|---|
--name | yes | The exception's token name. |
--type | yes | DTCG $type (dimension, color, duration, …). |
--value | yes | The literal CSS value; dimension/duration values are coerced into { value, unit }. |
--families | yes | Repeatable/comma-joined. Family prefixes the exception may mint (w, h, p, …). |
--reason | yes | Why no registered token will do. |
--owner | yes | The team or person accountable for the exception. |
--expires | yes | YYYY-MM-DD. Named exceptions may be up to 12 months out; --literal escapes must expire within 90 days. |
--allowed-in | yes | Repeatable. Glob(s) the exception's classes may appear in. |
--literal | no | Marks the escape as lane 2 (the 90-day pressure valve) rather than a named exception. |
--chain | no | Register a one-off variant chain (hover:bg-accent-hover) as an exception instead of minting a value — see below. Takes the paperwork flags, not --type/--value/--families. |
--apply | no | Write the patch to the token file that owns the exception namespace (creating tokens/exceptions.tokens.json if none does). Does not rebuild — .tabula/ is stale until you run tabula build. |
--ticket, --description | no | Extra metadata carried into the patch. |
On success, prints the nearest existing token (TAB-W401) when one is within ~5%/2px of the requested dimension value, nudging toward reuse instead of minting new vocabulary.
Chain exceptions (--chain)
npx tabula except add --chain hover:bg-accent-hover \
--reason "One-off hover state the interaction preset does not cover." \
--owner @design-systems --expires 2026-12-31 --allowed-in "src/marketing/**"The variant-closure counterpart (v0.2): where the value form mints a new class, --chain whitelists a single variant chain over an already-registered utility that the declared-products model (variants.products) would otherwise reject at scan (TAB-E230). It rides the same exception mechanism — writes nothing without --apply, runs the real token validator, and requires --reason, --owner, --expires, and --allowed-in (with the same expiry bounds: 12 months for a named exception, 90 days with --literal). --name defaults to a DTCG-safe slug of the chain; --type, --value, and --families are not used. Parametric variants (group-*/peer-*/aria-*/data-*) are rejected — they are unsupported in v0.1. The chain lands in the registry's chainExceptions, which the reader answers through hasChainClass().
eject
npx tabula eject
npx tabula eject --to tabula-frozen --yes --write-importsExperimental. Eject (the freeze and the --report reverse-map analysis) ships experimental in v0.3.0 — the command works and is tested, but its surface (flags, report format, tier table) may change in a future minor release.
Copies a verified .tabula/ into a project-owned directory that compiles on stock @tailwindcss/cli with zero class changes, and stops the token pipeline flowing to it — a one-way freeze. Dry-run by default: without --yes, eject prints the full plan (every file it would copy, the permission changes, the @import rewrites it found, the expiry warnings, the one-way-door banner, and the cn() policy note) and touches nothing. On execute it writes each copy at 0644 and an EJECTED.md provenance file into the target. See Eject for the full flow and the four hazards it handles.
| Flag | Default | Meaning |
|---|---|---|
--to <dir> | tabula-frozen | The freeze target directory, under the project root. |
--yes | off (dry run) | Execute. Without it, eject prints the plan and touches nothing. |
--force | off | Allow a target that already contains files; otherwise a non-empty target is refused (TAB-E240). |
--write-imports | off | Rewrite project CSS @import lines that point at .tabula/source.css so they point at the frozen copy. Without it, eject only prints the files it found and the exact new import line. |
--report | off | Analysis only, no freeze: classify every class your source actually uses into portability tiers A/B/C/D against vanilla Tailwind v4 and write tabula-eject-report.md. See the report tiers. |
--theme-port | off | Report scenario toggle: classify as if the --tb-* theme namespaces were ported to vanilla ones (--color-*, --spacing-*, …), and include the port instructions in the report. |
--format=json | md | With --report: emit the report document as JSON (top-level experimental: true) instead of Markdown. |
--write | off | With --report: apply ONLY the provably-1:1 tier-B renames through the codemod engine (dry-run diff first, like migrate). Tier C/D classes are never rewritten. |
Preconditions fail closed, in order: a loadable project (TAB-E901); an existing, non-stale, non-hand-edited .tabula/ (the same drift gate doctor uses — TAB-E303 for missing/stale, TAB-E601 for hand-edited or uncovered); and a usable target (TAB-E240, which exits 2 — the tool cannot proceed, the styling is not what is wrong). An exception expired or expiring within 90 days warns (TAB-W402) rather than blocking: after eject there are no rebuilds, so the usual hard-error-on-expiry (TAB-E141) can never fire again. cn() still needs @tabula-css/merge and the copied registry.json; replacing it with tailwind-merge changes rendered output, and EJECTED.md says so in writing.
doctor
npx tabula doctorLocal triage, checked in this order: staleness (current inputs hash vs. the manifest's), hand-edited artifacts (sha256 mismatch, TAB-E601), directory coverage (a stray file, or an artifact missing from the manifest), expiring/expired exceptions (TAB-W401/TAB-E141), @tabula-css/*/Tailwind version skew (TAB-E302), and the escape budget. Exit 1 on any hard failure, 0 with warnings printed otherwise.
| Flag | Default | Meaning |
|---|---|---|
--out <dir> | .tabula | Where the manifest and artifacts are read from. |
doctor is not an authenticated integrity check — a manifest cannot hash itself, so a consistent edit (change an artifact and its recorded hash) passes here. tabula build --check is the authority: it re-derives every artifact from the tokens and config and byte-compares the whole set, manifest included, which is why CI must run --check, not doctor.
explain <TAB-Exxx>
npx tabula explain TAB-E113Prints a diagnostic code's cause, why the rule exists, and its fix, entirely offline from @tabula-css/core's frozen ERROR_CATALOG — the fix floor every other diagnostic falls back to. An unrecognized code gets up to three suggestions within edit distance 2.
canary
npx tabula canaryGenerates a fixture engineered to violate every ESLint rule the strict preset ships, lints it programmatically against the project's own built registry, and fails if any expected rule does not fire (TAB-E201) — catching an unwired plugin, a dropped config, or a registry that failed open, which a normal test suite would not notice. Needs eslint and @typescript-eslint/parser installed; without them it exits 2.
| Flag | Default | Meaning |
|---|---|---|
--out <dir> | .tabula | Where registry.json is read from — it must exist before the canary can run. |
Global flags
| Flag | Meaning |
|---|---|
--format=json | Emit the machine envelope { tabula, ok, inputsHash, diagnostics } on stdout; nothing else goes to stdout in this mode. |
--help | Print usage. |
See also
@tabula-css/registry— the artifactbuildderives and every other command reads.@tabula-css/stylelint-plugin— the rule kernelcheck:cssshares with the editor plugin.@tabula-css/eslint-plugin— thestrictpresetcanarylints its fixture against.- Getting started — the full build → lint → CI walkthrough.
- CSS governance — what
check:csschecks and what it still does not. - Migration — the codemods in more depth, including the shadcn path.
- Eject — the full
tabula ejectflow, the four hazards, and the one-way-door policy.