@duro-app/cli
v5.43.0
Published
Machine-queryable docs for the Duro design system: components, props, recipes, tokens and rules as CLI output, JSON, or an MCP server.
Maintainers
Readme
@duro-app/cli
Machine-queryable docs for the Duro design system. One lookup verb over a
generated registry: component props and usage guidance, runnable recipes,
design tokens, and the critical rules — as human output, --json, or an MCP
server. Zero runtime dependencies.
npx @duro-app/cli manifest --json # the one-call agent bootstrap
npx @duro-app/cli Button # props, usage, example, related
npx @duro-app/cli Select --part Root
npx @duro-app/cli login-form --source-only > src/LoginForm.tsx
npx @duro-app/cli spacing # token scale + the deep import to copy
npx @duro-app/cli rules # critical rules + what the lint plugin enforces
npx @duro-app/cli "tags that wrap" # free text falls back to a need-search
npx @duro-app/cli list
npx @duro-app/cli hook install # wire the Claude Code session + plan-mode hooks
npx @duro-app/cli skill install # /duro-mockup plus the colors, typography and ui skills
npx @duro-app/cli mockup seed --out docs/mockups/approvals # token-seeded artboard
npx @duro-app/cli mockup check docs/mockups/approvals/*.dc.html
npx @duro-app/cli mockup canvas docs/mockups/approvals # one review pageInstall for the short bin: pnpm add -D @duro-app/cli → duro Button.
Exit codes: 0 ok · 1 not found (with "did you mean") · 2 usage error ·
3 unreadable/incompatible registry.
MCP server
claude mcp add duro-ds -- npx -y -p @duro-app/cli -p @modelcontextprotocol/sdk duro mcpTools: duro_ds_lookup (same fallback-to-search behavior as the CLI),
duro_ds_list, duro_ds_manifest, duro_ds_mockup_check (an artboard's HTML
in, findings out — see below), duro_ds_mockup_canvas (artboards' HTML in, the
review page out). Resources: duro://component/<Name>,
duro://recipe/<name>. Prompt: duro/build-ui primes a session with the
critical rules and the component index. The SDK is an optional peer — plain
CLI use never loads it.
The registry
registry.json is generated from the monorepo sources by
scripts/build-registry.mjs (ts-morph, build-time only) and committed: it
aggregates the 57 *.meta.ts files, per-component prop tables extracted from
the TypeScript source, recipe sources rewritten to publishable imports, the
token scales, and the lint-rule metadata. It is a pure function of the source
tree — sorted keys, no timestamps — so --check (run in prebuild, CI, and
the amont pre-push hook) is a byte comparison. The same script maintains the
generated regions of the repo's CLAUDE.md (--write-docs / --check-docs).
The CLI and @duro-app/ui are released in lockstep; a version mismatch with
a locally installed ui prints a one-line stderr warning.
Authoring relatedTo
Every relatedTo entry declares a kind, and the type makes it required:
relatedTo: [
{
component: 'Menu',
kind: 'contrast',
relationship: 'Menu triggers actions; Select picks a value',
},
{component: 'Field', kind: 'composition', relationship: 'Place Select inside Field.Root'},
]contrast— the two are alternatives; you pick one instead of the other. These become the session-start neighbors table, so write the relationship so it decides in either direction:X for A; Y for B, not "Vertical equivalent" (which of the two is vertical?).composition— one goes inside the other, or is built on it. React composes, so this covers "wraps", "renders" and "built on" too, not just literal nesting. These get their own table: rendered as "Input vs Field" they would read as the opposite advice.
Filing an edge under the wrong kind puts it under a heading that contradicts
it, which is why the field is not optional and not inferred. Write the
relationship to match: a contrast edge decides ("X for A; Y for B"), a
composition edge says what wraps what.
Claude Code session hook
duro hook session-start prints a consult-first preamble, the full catalog,
and two neighbour tables, designed to be injected into agent context by a
Claude Code SessionStart hook — so "check the design system before building
UI" stops being a remembered step an agent can skip.
The catalog answers "what exists". The tables answer the two questions it doesn't, which is where agents actually go wrong:
- PICKING BETWEEN NEIGHBORS — a Menu built out of a Select, a Card where Panel was meant
- COMPOSE — DO NOT HAND-ROLL THE WRAPPER — a hand-rolled label beside an
Input that should have been wrapped in
Field.Root
All three ship in one injection, so none waits on a duro <Component> call
the agent may never make. The tables are the contrast and composition
edges of each component's relatedTo — see above.
Every consuming repo wires it the same way, and the CLI does the wiring:
npx -y @duro-app/cli hook install # wire this repo
npx -y @duro-app/cli hook install --check # CI: exit 1 if it driftedinstall is idempotent, and touches exactly four files:
| File | What install does |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| .claude/hooks/duro-catalog.sh | Writes the generated hook: fetches the catalog, caches it for 7 days (npx resolution dominates session start; the catalog only changes on upgrade), stays silent when offline with no cache. Then runs duro doctor --session, re-run only when a file it reads changes. |
| .claude/hooks/duro-plan.sh | Writes the generated plan-mode nudge. |
| .claude/settings.json | Adds the SessionStart and UserPromptSubmit entries. Existing hooks, permissions and skill overrides are preserved; a hand-renamed duro command is migrated in place rather than duplicated. Unparseable JSON is reported, never clobbered. |
| .gitignore | Ignores .claude/.duro-session.cache* — a glob, because a hook killed mid-npx leaves the staging .tmp behind. |
Do not hand-edit the generated scripts — --check is a byte comparison, so an
edit shows up as drift and the next install overwrites it. Repo-specific
caveats go in .claude/duro-hook.local.md: the hook appends that file after
the catalog, and regeneration leaves it alone. Use it for the things the
generic preamble can't know — local styling conventions, what NOT to convert,
which packages are off-limits:
This repo specifically (see CLAUDE.md): the raw-markup convention licenses
raw div/span MARKUP around Duro components — it is NOT a license to
hand-roll widgets the DS ships.Wire --check into the repo's lint or CI job to catch a stale hook after a
CLI upgrade. The generated script pins @duro-app/cli@^5.0.0 — a floating
pin, so patch releases never read as drift, but a floor: a repo whose
script still pins ^3.4.0 never resolves a 4.x payload and never sees what
the session-start injection gained since. The release refuses a tag whose
major differs from that floor (scripts/check-pin-major.mjs). After a major,
re-run hook install (and skill install, which also refreshes the colors, typography and ui skills) in every consumer: the cache
records the floor it was fetched with, so the next session refetches it on its
own, and a failed refetch says so on stderr.
Plan-mode nudge
The second hook runs on Claude Code's UserPromptSubmit. While the session is
in plan mode (the event's permission_mode is "plan") it adds five lines to
the planner's context: a plan that adds a new screen, or makes a material
choice in layout, hierarchy or interaction, starts by picking a direction —
load the duro-mockup skill and draw 2–4 directions with duro mockup before
ExitPlanMode (fleet decision handoff.fresh-directions-trigger). A fix that
restores an existing design reuses the picked artboard; any other UI change
needs no mockup. So fresh directions come up while planning without anyone
typing /duro-mockup.
Outside plan mode, and on any payload it cannot read, it prints nothing. It
runs on every prompt, so it is plain sh and one grep over the event JSON —
no npx, no node, no network — and every path exits 0: it can never block or
fail a prompt. Existing UserPromptSubmit hooks in settings.json are kept;
the duro entry joins the array.
duro doctor
The design system's stylesheet is layered: reset, then StyleX's
priority1…priority5, so a component's padding-left beats the reset's
padding: 0. An app can break that without touching a component, and the
symptom is always the same — buttons, inputs and page wrappers rendered with
almost no padding while @duro-app/ui's CSS is fine. duro doctor checks the
app's wiring, statically, from its package root (or a repo root: it follows
pnpm/npm workspaces, else looks one directory down):
| Rule | Severity | What breaks |
| -------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| runtime-injection | error | runtimeInjection: true in a vite/babel config. StyleX injects react-strict-dom's element reset into an unlayered <style>, and unlayered beats every layer. A warning in vitest.* (jsdom only). |
| layered-extraction | error | useCSSLayers: false in postcss.config: the app's extracted rules — react-strict-dom's reset included — land unlayered. |
| css-imported | error | The entry (app/root.tsx, src/main.tsx, …) never imports @duro-app/ui/dist/index.css. The JavaScript does not load it; components render unstyled. |
| css-load-order | error | A stylesheet declares the priority* layers before reset. The first declaration fixes layer order for the page, which puts the reset on top. |
| unlayered-reset | warn | * { padding: 0 } (or on button, input, main, …) outside any @layer in the app's own CSS. |
| version-skew | warn | The installed @duro-app/ui is not the version this CLI checks. |
| tokens-compiled | warn, or error | A vite/babel config compiles with StyleX but nothing routes @duro-app/tokens through react-strict-dom/babel-preset (or, in an SSR app, ssr.noExternal misses it). css.create then cannot import @duro-app/tokens/tokens/*.css: StyleX fails with "…ensure the theme file has a .stylex.js or .stylex.ts extension". An error once a css.create file in app//src/ deep-imports the tokens; until then a warning, since that is the import the design system prescribes. noExternal may be inline, a const in the config, the {noExternal} shorthand or a const a sibling module exports; a declared list never counts as routing. When the binding cannot be resolved, a warning names it. |
| media-var | error | Built CSS (build/, dist/) holds an @media whose condition reads a var(--…): a media query cannot read a custom property, so it never matches. The breakpoint consts reached the CSS uncompiled. Reads only output newer than every config and source file (capped scan); with none, the check is reported as skipped (no fresh build) and left out of --json checked. A path it cannot read (anything but a missing one) is a warning that names it and the error code. |
Exit codes: 0 when there are no findings or only warnings, 1 when
there is at least one error, so it gates on errors only. --session always
exits 0. --json prints {ok, findings, checked, skipped, packages}.
Two places run it, and a consuming repo wires both:
- Every agent session — the session hook
runs
doctor --session, which prints nothing when the repo is healthy and an agent-facing block when it is not.hook installwires it. - Every commit that touches the wiring — an amont
row in
amont.conf. Add any custom config file the app has (avite.strict-dom.ts) to the scope:
# stage name scope severity command
pre-commit duro-doctor *.css,package.json,root.tsx,main.tsx,vite.config.ts,vitest.config.ts,postcss.config.js,postcss.config.mjs,babel.config.js block npx -y @duro-app/cli@^5.0.0 doctorMockups that speak the design system
A mockup drawn outside the token system has to be translated when it is
implemented, and the translation drifts — one four-direction canvas used
485 distinct colours, 15 of them in the palette, and the implementation
carried marginTop: 23 and a raw 768px query past every lint rule. Three
commands and one skill make the artboard the contract instead.
npx -y @duro-app/cli mockup seed --out docs/mockups/<screen> --name Main
npx -y @duro-app/cli mockup check docs/mockups/<screen>/*.dc.html # exit 1 on findings
npx -y @duro-app/cli mockup canvas docs/mockups/<screen> # the page to pick from
npx -y @duro-app/cli skill install # /duro-mockup + colors, typography, uiseed writes a .dc.html whose <style> opens with the tokens as resolved custom properties for
every theme (--duro-color-bg, --duro-spacing-md, … — the same names as
@duro-app/tokens/vars.css, generated from the same sources). --theme
light|high-contrast stamps <html data-theme> so the block switches.
--overrides <file.json> seeds an artboard for an app that themes with tokens. The file is the object
ThemeProvider takes as tokens, so an app passes the same file to both:
{
"colors": {"bg": "#020803", "success": "#39ff7a"},
"typography": {
"fontFamily": "'JetBrains Mono', ui-monospace, Menlo, monospace",
"fontFamilyMono": "'JetBrains Mono', ui-monospace, Menlo, monospace"
},
"radii": {"xs": "2px", "sm": "4px", "md": "6px", "lg": "8px"},
"sizes": {"dialogSm": "360px"}
}Every group is optional. The groups are colors, shadows, typography, typeScale, spacing,
layoutSpacing, radii, sizes, borders, duration, easing and effects; each holds a partial map
of that group's token keys (camelCase, as in duro list), and each value is checked by the rule of its
kind (a colour, a font-family list, a non-negative px/rem length, a fluid clamp() length, a weight,
a shadow list, a duration, an easing, a blur). layers, breakpoints, microSpacing, radii.px6 and
radii.full are refused, and sizes.touchTarget (44px), borders.focusRing (2px) and
borders.focusOffset keep their floors. A font list starts with a face the app loads. A file with no
group is the earlier flat map of colour keys ({"bg": "#020803"}) and still works; a file that has a group
and any other top-level key is refused (bg: unknown group (expected colors | shadows | …)). The validation
is @duro-app/tokens/theme, the module ThemeProvider uses, so the CLI refuses what the app would drop.
The overrides are written inside the token block as --duro-<group>-<key> rules (--duro-color-bg,
--duro-typography-font-family, --duro-radius-md, --duro-size-dialog-sm, … for :root and for both
data-theme selectors, so they win whatever the theme), and check accepts them there while still refusing
a raw colour anywhere else. The success line lists the groups that have entries, in the group table's order (with overrides from
theme.json: colors 1, typography 2, radii 4). A missing or non-JSON file, an unknown key or a refused
value exits 2 with <group>.<key>: <reason> on stderr (radii.md: "-1px" must be 0 or a non-negative
px/rem length); nothing is written, and with --json the result (kind, problems) is still printed on
stdout. The artboard is written to <path>.tmp and renamed, so an interrupted run leaves nothing that
blocks the next seed.
check reads an artboard back and refuses, with file:line rule: message:
| Rule | Finding |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| raw-color | A hex / rgb() / hsl() literal anywhere outside the token block, in a rule or an inline style. |
| raw-length | A literal on a spacing, radius, font, shadow or motion property (padding: 23px, font-weight: 600, transition: opacity 150ms). Widths, heights and positions are geometry, not tokens — never reported. |
| raw-breakpoint | A @media / @container width off the breakpoint scale (480 / 640 / 768 / 1024 / 1280). Queries cannot read a variable, so a literal on the scale is the right spelling. |
| unknown-component | data-duro="<Name>" or <Name>.<Part> the registry does not know, with suggestions. |
| no-component-map | No data-duro anywhere. An artboard names the component behind every control or it cannot be implemented from. |
| unannotated-control | An element that draws like a control but names none. Canvas artboards draw controls as div/span, so the tag is not the signal: a class is a control when the union of every rule that mentions it has a pointer cursor, a :hover/:active/:focus state, or a padded rounded fill. A named parent covers only a bare native control inside it. |
When the app's installed @duro-app/ui differs from the CLI, check and
canvas say so under their findings (doctor's version-skew): npx reuses any
cached CLI that satisfies a range, and a checker older than the app reports a
newer component as unknown-component.
canvas <dir> writes <dir>/canvas.html (--out, --title): one
self-contained page with every *.dc.html in the directory, grouped into
lettered directions by file stem (Tree.dc.html and Tree-Phone.dc.html are
direction Tree, Main first), each artboard inlined by srcdoc and scaled
to its frame. It refuses, writing nothing, while any artboard fails check.
Publish it as the Artifact the person picks from, or open it from disk.
skill install writes .claude/skills/duro-mockup/SKILL.md and every file of
the packaged colors, typography and ui skills to .claude/skills/<name>/
(byte-compared by --check, like the hook; a file the package does not ship is
stale, and install removes it only where the directory carries the shipped
NOTICE.md). Install refuses to write into an existing colors, typography
or ui directory without that NOTICE.md (a skill of your own) unless you pass
--force, which also removes the files of the overwritten directory that the
package does not ship. The same three skills are served by the MCP server as guides:
duro_ds_lookup guide:colors, or guide:colors/contrast.md for a reference
file. The skill runs the whole path — seed directions,
check, publish the canvas, implement the picked direction from its
data-duro map, screenshot the built route and put artboard and screenshot
side by side in the PR — with nothing outside the CLI, and it explicitly
overrides the design skill's "lift resolved values" step. Repo notes go in .claude/duro-mockup.local.md.
Its description triggers on a request for a screen, page or mockups, and also
while planning (plan mode) a new screen or a material layout, hierarchy or
interaction change — the moment the plan-mode nudge points
at it.
