Skip to content

@tabula-css/cli

The tabula command line tool: build, build --check, check:css, scan, migrate, except add, eject, doctor, explain, canary.

Install

bash
npm install --save-dev @tabula-css/cli

Dev 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

bash
npx tabula build
npx tabula build --check

Reads 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.

FlagDefaultMeaning
--checkoffRegenerate 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|conformbRegistry backend: a (design-system API), b (PostCSS probe-sheet walk), or conform (runs both and asserts they agree).
--out <dir>.tabulaOutput directory.
--no-scanoffWith --check, skip the scan gate (drift comparison only).
--no-cssoffWith --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.

bash
npx tabula build --check --backend conform

check:css

bash
npx tabula check:css

Parses 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.

FlagDefaultMeaning
--out <dir>.tabulaWhere 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, varRepeatable. Extra directory name to skip while walking.

See @tabula-css/stylelint-plugin for the five rules this command enforces (TAB-E221TAB-E225), and CSS governance for what the gate does and does not cover.

scan

bash
npx tabula scan

Runs 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.

FlagDefaultMeaning
--strictoffAlso fail when the count of eslint-disable comments naming a tabula/ rule exceeds budgets.maxSuppressions (TAB-W900).
--out <dir>.tabulaWhere 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>

bash
npx tabula migrate logical
npx tabula migrate spacing --write
npx tabula migrate merge
npx tabula migrate dark

Codemods 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.

SubcommandWhat it does
logicalpl-/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.
spacingspace-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.
mergeRewrites 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.
darkReport-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.
FlagDefaultMeaning
--writeoffApply the rewrites. Without it, a unified diff is printed and nothing changes.
--out <dir>.tabulaWhere 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

bash
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.

FlagRequiredMeaning
--nameyesThe exception's token name.
--typeyesDTCG $type (dimension, color, duration, …).
--valueyesThe literal CSS value; dimension/duration values are coerced into { value, unit }.
--familiesyesRepeatable/comma-joined. Family prefixes the exception may mint (w, h, p, …).
--reasonyesWhy no registered token will do.
--owneryesThe team or person accountable for the exception.
--expiresyesYYYY-MM-DD. Named exceptions may be up to 12 months out; --literal escapes must expire within 90 days.
--allowed-inyesRepeatable. Glob(s) the exception's classes may appear in.
--literalnoMarks the escape as lane 2 (the 90-day pressure valve) rather than a named exception.
--chainnoRegister 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.
--applynoWrite 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, --descriptionnoExtra 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)

bash
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

bash
npx tabula eject
npx tabula eject --to tabula-frozen --yes --write-imports

Experimental. 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.

FlagDefaultMeaning
--to <dir>tabula-frozenThe freeze target directory, under the project root.
--yesoff (dry run)Execute. Without it, eject prints the plan and touches nothing.
--forceoffAllow a target that already contains files; otherwise a non-empty target is refused (TAB-E240).
--write-importsoffRewrite 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.
--reportoffAnalysis 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-portoffReport 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=jsonmdWith --report: emit the report document as JSON (top-level experimental: true) instead of Markdown.
--writeoffWith --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

bash
npx tabula doctor

Local 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.

FlagDefaultMeaning
--out <dir>.tabulaWhere 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>

bash
npx tabula explain TAB-E113

Prints 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

bash
npx tabula canary

Generates 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.

FlagDefaultMeaning
--out <dir>.tabulaWhere registry.json is read from — it must exist before the canary can run.

Global flags

FlagMeaning
--format=jsonEmit the machine envelope { tabula, ok, inputsHash, diagnostics } on stdout; nothing else goes to stdout in this mode.
--helpPrint usage.

See also

Released under the MIT License.