@animus-ui/cli
v0.1.86
Published
Standalone Animus extraction CLI — one-shot builds and artifact emission for any build system
Maintainers
Readme
@animus-ui/cli
Standalone Animus extraction CLI — one-shot builds and artifact emission
for any build system. animus build drives the same extraction session the
bundler plugins use and publishes a deterministic, fixed-path artifact set
for CI gates, non-JS orchestrators, and tooling.
If you are on a supported bundler, you want a transform driver instead:
@animus-ui/vite-plugin,
@animus-ui/next-plugin, or
@animus-ui/unplugin (rollup/esbuild/rspack/webpack). The
CLI's artifacts do not transform your sources — see the
standalone extraction contract
for why the transform is mandatory.
Install
npm install --save-dev @animus-ui/cliRequires Node >=22.12.0. Native engine binaries ship for darwin-arm64,
linux-x64-gnu, and linux-arm64-gnu; the CLI exits 3 with remediation text
on unsupported platforms.
Usage
animus build --root . --system ./src/ds.ts --strict
animus watch --root . --system ./src/ds.ts
animus print-config --root . --system ./src/ds.ts
animus prop-order --root . --system ./src/ds.tsanimus prop-order writes only <root>/.animus/prop-order.json. The
animus/prop-order lint rule
reads it, and the command needs no dev server; animus build writes the
same file when the config sets propOrderFile: true.
Configuration lives in animus.config.{json,mjs,js,ts} (probed in that
order; .ts needs Node >= 23.6 native type stripping — older runtimes get
a guided error naming the .mjs/.json remedy). Flags override file
values. --exclude patterns join the config file's exclude, and together
they replace the defaults (dist, .test., .spec.); an explicit
exclude: [] in the config file means no user exclusions (the defaults are
not restored). node_modules, .next and .animus are always excluded.
animus print-config prints the fully resolved configuration as JSON on
stdout with per-key provenance, including extensions and staticCss.
Stream discipline is a contract: stdout carries machine output only; every human-facing line goes to stderr.
animus css audit
animus build --root . --system ./src/ds.ts --verbose
animus css audit --root . --dist distA report of what would make the shipped CSS smaller, ranked by measured
savings. It reads a full manifest (--manifest, default manifest.json in
the artifact directory animus build uses, so --out-dir, cli.outDir,
--config and --root apply as they do there) and the built output
(--dist, default <root>/dist), and writes nothing. The manifest must
come from the build that produced the output: animus build --verbose
with the same settings, or one a host captured. The audit checks this
best-effort: it refuses only on positive evidence of a mismatch and never
proves a match. Where the shipped CSS has Animus's layers (@layer anm-…),
a class a rule inside them names (inside :is(), :where() and other
selector functions too) that the manifest cannot emit fails with exit 2.
The check compares class names only: a manifest from a build that differs
only in a declaration's value or in a global rule that selects no class is
not caught. A CSS file with classes and no Animus layers is audited with a
warning that names it, in the text report and in --json's warnings,
since a class name alone cannot tell another build's classes from the
app's: where no file has the layers, or where the manifest emits classes
outside them (a global registered unlayered). Beside layered output from a
manifest that emits none, such a file is another stylesheet the app ships
and is not compared. Pass the bundler's export conditions to animus build with
--condition (or cli.conditions), as Vite's resolve.conditions, so it
reads the files the bundler does. Each estimate edits the shipped CSS and
recompresses each file on its own (gzip level 9, Brotli quality 11 with a
22-bit window); Brotli ranks them.
- Delivery: whether
stylesheets: 'split'withremoveUnbundledComponents, options of the Vite plugin only, would cost or save bytes for this app, with the preconditions to check. Output whose Animus CSS carries no@layer anm-…block (unlayered or polyfilled) is not estimated. - Slot keepers: the system props that keep a runtime slot, grouped by the rule that keeps them, with the components and sites that keep them.
- Restructures saving at least 0.25% of the CSS's Brotli size: unused variant options a dynamic site keeps, extensions that only change defaults, and base blocks several components repeat. Each has a confidence and the cases it refuses; the rest are listed collapsed. Unused options are always low confidence: a wrapper may still reach them through callers the audit cannot see or read.
The text report goes to stderr; --json prints the report as one JSON
document on stdout. A kept-option finding held back by a site that passes a
value no literal spells carries unproven there: dynamicSites, each site
as file:line; forwards, the sites among them that pass on their
component's own prop unchanged, which its callers write;
reachedByCallers, evidence: the listed options that the callers the
engine matches to that component through its imports pass, as values the
analysis proves (null where a site computes its value or no caller is
matched); notPassedByMatchedCallers, candidates, not a promise: the
listed options no matched caller passes (null where a matched caller's
value cannot be read, as where a spread after the prop may replace it). A
caller the audit cannot match, or a branch before the render, can differ,
so the audit draws no conclusion from them; and
savingMeans: "removed-if-proven-unreached". Its saving is what pruning
would remove if no dynamic site reached those options, which the audit
cannot prove. It is never ranked and has belowFloor: false: no saving
total counts it. It exits 0 unless its input
cannot be read: a missing or partial manifest, one whose classes the
output's CSS never names, or an output file it cannot read, each exit 2
with the remedy.
animus types alias
animus types alias .types-raw --out types \
--project tsconfig.types.json --probes type-probesTurns ordinary compiler declaration output (tsc with declaration and
emitDeclarationOnly) into a packet for consumers to type-check against.
It infers nothing: the compiler's declarations are the contract. Inferred
component declarations spell each system's prop and group registries out
in full, many times per component; the command finds each system
declaration (export declare const ds: SealedSystemInstance<…> from
@animus-ui/system), and replaces every structurally identical registry
subtree elsewhere with an alias in an isolated helper module,
__animus_registry__.d.ts, which reads the type back from that
declaration. Two subtrees match only when their structure, modifiers, literals and key
kinds are equal and every name in them resolves to the same declaration:
the same packet file, or the same physical package directory seen in the
same module mode (an ESM and a CommonJS view never match). Printed text and
assignability are never the test. A subtree the matcher cannot resolve
stays as emitted, and so are, each with a warning: a file whose aliases
would bring an augmentation (a declare module/declare global block, a
script declaration file or a /// <reference lib>) into a public entry
that does not already load it; a file with an import that carries
attributes or a resolution-mode; and an occurrence that names a
declaration of its own file. When an import with attributes or a
resolution-mode sits anywhere an anchor reaches, nothing is aliased in
that run. On the benchmark's 1,000-component app the
declarations shrink from 94.6 MB to 2.1 MB.
Each component declaration (AnimusComponent or AnimusWrappedComponent
from @animus-ui/system, by identity) also takes a local alias,
export declare const Button: ButtonComponent, so a hover shows the name
instead of the expanded type; completion, errors and go-to-definition are
unchanged. The aliases add no export (a file gains export {} where it
lacks one). A file that relies on implicit exports is left as emitted, and
so is a component whose alias name the file mentions anywhere, as an
identifier (however its characters are escaped) or in its text: the name
may already mean something there, such as a global.
One run owns --out at a time, through a .<out>.lock file beside it. A
lock is never taken over: a run killed before it released its lock leaves
it behind, and the next run refuses (exit 2) and names it; delete it once
no run is active. The
packet is written to a staging directory beside --out and promoted only
when every check passes and the declaration output still has the
fingerprint it was built from; on any failure --out is left exactly as it
was and the command exits non-zero. The checks:
- Every packet file type-checks with
skipLibCheck: falseunder the--projectcompiler options, and each alias is the same type as a literal it replaced, once per directory and module flavor. - The probe files in
--probesare checked twice, against the compiler's declarations and against the packet. Each run sees a copy of the package whose--outis that route's declarations, and imports the package by name, so tsc resolves it through the package's realexports. A probe may import other TypeScript files in--probes; any other relative import is refused. Both must report the same diagnostics at the same sites, and neither may load a package file outside its route.
The command refuses an exports entry whose first matching condition
selects a file outside --out before its types condition reaches the
packet, a subpath whose import and require routes select different
packet files (the probes check one), and a types target the packet does
not contain. --out is replaced only when it is missing or holds the
__animus_packet__.json manifest a previous run wrote, checked again just
before the swap. --anchor
<module>#<export> adds a system declared outside the packet, such as a UI
package's components built on another package's ds
(--anchor @acme/styles#ds). Declaration maps follow the rewrite. With
--json every exit prints one JSON document on stdout: its outcome
(promoted, failed, refused or no-typescript), its exitCode, its
reasons (each line the text mode prints, [animus] left off), and on
promotion the report. With --help or --version the document's
outcome is help or version (the version in its version field), and
the usage text stays on stderr. Two exits fall outside this: a run killed
by a signal (SIGTERM during a check) prints nothing, and the launcher
exits 4 with prose alone when the installed package cannot be loaded. A
failure's lines go to stderr in both modes, and without --json the
promotion summary does too. The report's skipped list names system
declarations whose registries were not aliased, such as an empty sealed
system, and its warnings list names files left as emitted and entries
that miss an augmentation; neither changes the exit code. Exit codes: 0
promoted; 1 a check failed or the declarations could not be read; 2 a
usage error (a missing directory, an --out the command did not write or
another run owns, a refused exports route); 3 no TypeScript.
The packet is a type contract only: extraction never reads it, and a TypeScript annotation never decides what CSS ships.
Package recipe
The package build owns freshness: emit, alias, validate and promote run as one failing chain, so an old packet can never stand for a new success.
// package.json
{
"exports": {
".": { "types": "./types/index.d.ts", "import": "./dist/index.js" },
"./tokens": {
"types": "./types/tokens.d.ts",
"import": "./dist/tokens.js",
},
},
"files": ["dist", "types"],
"scripts": {
"build:types": "rm -rf .types-raw && tsc -p tsconfig.types.json && animus types alias .types-raw --out types --project tsconfig.types.json --probes type-probes",
},
}// tsconfig.types.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"declarationMap": true,
"noEmitOnError": true,
// Inherited build state would let a rebuild skip emitting into the
// freshly removed output directory.
"incremental": false,
"composite": false,
"outDir": "./.types-raw",
"rootDir": "./src",
},
"include": ["src"],
}Route every public type subpath to the packet. Each
exportsentry that consumers type-check against gets atypescondition inside--out; runtime and extraction entries stay as they are. A consumer that reaches the package's source through a relative import, apathsalias or an editor redirect bypasses the packet and checks the builders again.Keep an explicit augmentation edge in every type entry. A component's inferred declaration inlines what it read from the module that augments
Theme(orSelectors,Conditions), so tsc drops that import, and a consumer of the entry would check against an unaugmented theme. tsc keeps a type-only re-export, and it has no runtime effect:// src/index.ts export type {} from './theme-augmentation'; export * from './components';The command warns about an entry that does not reach a module augmentation in the packet, or an augmentation of
@animus-ui/systemin a package an anchor names (a UI package's theme lives in its foundation), and leaves a file as emitted when an entry that reaches it would gain an augmentation from an alias.Fail closed.
rm -rf .types-rawandnoEmitOnErrormean a failed compile leaves nothing to alias,&&stops the chain there, and the command promotes only a packet that passed its checks. Make consumers' type checks depend on this script in the task graph (for turbo,"typecheck": { "dependsOn": ["^build:types"] }), and key any build cache on the compiler, its options and every input the producer reads, augmentation files included.Probes are consumer code. Put files in
type-probes/that import the package by name, as a consumer does: accepted uses and deliberate mistakes (a wrong variant, an unknown breakpoint), with no@ts-expect-error. Make the augmentation visible through rejections: without it, Animus props fall back to permissive CSS types and accept any value, so an accepted theme token proves nothing. Include wrong token values the augmented theme rejects (an unknown color token, an unknown breakpoint); a lost augmentation edge then makes those rejections disappear.Ignore the intermediates:
.types-raw/,types/,.types.lock, and the.types.staging-*,.types.check-*and.types.retired-*directories a run leaves only if it is killed. The next run removes the directories; it refuses a leftover lock, which you delete.
What can still go stale: a consumer check that bypasses the build, a
stopped watch, an editor holding unsaved builder edits, or a cache restoring
types/ under a key that misses an input. The packet does not make a clean
source check free: the producer still checks every builder once.
The packet matches the compiler's declaration output, which can check
differently from source. For example, an entry whose emitted declarations
reference import("./index.ts") reaches that module's augmentation, though
its source never imports it, so a consumer of that entry is stricter against
both the declarations and the packet than against source.
--strict
--strict fails the build on every diagnostic that means a configured
input could not be read or resolved — an include or entry that does not
resolve, a configured file that cannot be read, a configured package or
path that cannot be found, registered vocabulary that never reaches the
build. It also fails on a classified unsupported Animus declaration:
a declaration extraction proves comes from @animus-ui/system but cannot
extract, such as .variant(AXIS) passing a whole config through an
identifier, a member-parent .extend(), a default-exported chain, or a
.props() custom prop that loses its transform callback (a mutable
reference, an import from a module outside the analyzed sources or through
export *, or any callback reaching .props() through a const config), and
on a system-configured transform rejected before registration. Inside the
.props() object literal, an inline function and a reference to a const
arrow or function expression, a never-reassigned function declaration, a
const alias of one of these, or a const created by createTransform
imported from @animus-ui/system are supported, declared in the same module
or imported, through aliases and named re-exports, from an analyzed source
module, as is a named transform string such as transform: 'size'. It also fails on a value that
names no token on a strict, populated scale (animus.props.strict-token-miss);
that prop's styling is omitted in every mode, and the warning keeps the
authored value. It also fails on a configured transform that cannot be evaluated
during extraction (it exhausts its budget or reads the host environment) where
the value has no runtime path, in a style block, variant, state or global style
(animus.transform.static-evaluation-unavailable); the raw value applies in
every mode, while the same value in JSX stays on its runtime path without a
diagnostic. Each classified diagnostic carries
a stable animus.* code, its file and binding, the reason and a supported
rewrite. Genuine degradation stays a warning in every mode (per-property
skips, name collisions, unsupported-value fallbacks, pruning, valid dynamic
values, raw values on unscaled or strict: false props), and so does a declaration whose
Animus origin cannot be proven. Without --strict (or with strict: false
in the config file) all of these print as warnings and the
build exits 0.
Exit codes
| Code | Meaning |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Success — artifact set published and self-consistent |
| 1 | Extraction failure (error diagnostics, --strict escalation, zero files, structural emptiness), or a system module that throws while loading (fatal in every mode) |
| 2 | Config/usage error (unknown key or command, unresolvable system path, --out-dir equal to the root, lock conflict) |
| 3 | Engine/environment failure (native engine load, consistency-check failure) |
| 4 | The @animus-ui/cli install could not be loaded (broken or partial install — reinstall) |
Both animus build and animus watch exit 130 on SIGINT and 143 on
SIGTERM, releasing the advisory lock and keeping last-good artifacts;
animus watch also exits 3 under --fail-on-degraded when any root
cannot be watched. animus watch drains the in-flight cycle first; a
second signal during that drain abandons the drain and exits at once,
still releasing the lock and removing the session tree. Watch readiness is
an explicit stderr event: wait for the watch ready line before starting
dependent steps — it is emitted after edits observed during the first
analysis have been analyzed and published, not merely after the first pass.
Silent-empty success is impossible: system-load failure, zero discovered files, and structural emptiness are fatal in every mode.
Artifacts
animus build publishes to --out-dir (default <root>/.animus/ —
.gitignore it) at fixed paths: styles.css, system-props.js,
manifest.json, font-preloads.json (the font faces' preloads, for
renderFontPreloads; present only when a face preloads, as the faces'
preload flags or the config's fontPreloads entries select them), and
commit.json — written last, carrying a content hash
per payload, so one read verifies set completeness and freshness. Bytes are
deterministic and identity-free; lock.json is a single-writer advisory
lock that fails loud. The full contract, including the consistency-check
recipe for non-JS orchestrators, is in the
standalone extraction contract.
With stylesheets: 'split' in the config, the build also publishes
styles.css as consecutive slices, styles.<slice>.<hash>.css for the
foundation, components and utilities, and stylesheets.json, their names in
the order a document links them. Their bytes in order are styles.css's, so
linking them in that order changes nothing in the cascade, and a slice an
edit leaves alone keeps its name and its cached copy. commit.json lists
them with the rest.
License
MIT
