Design System Contracts

Reference

The CLI — @ds-contracts/cli

Every verb is a thin shell over the same engine the reference repository runs — esbuild-bundled, zero required runtime dependencies, Node ≥ 20. Install it globally or run it with npx; the three paths on Get started are built from these verbs.

npm i -g @ds-contracts/cli     # or: npx --yes @ds-contracts/cli@0.4.0 <command>

Usage Generated

packages/cli/src/cli.ts — the shipping usage block, v0.4.0; extracted at build time
ds-contracts 0.4.0 — contracts as the deterministic bridge between design and code

Usage: ds-contracts <command> [options]

Commands:
  onboard <package-or-path>                   PHASE 1 of the code → canvas pipeline: detect the
          [--components a,b,c]                adapter/styling, create or reuse a sandbox (a PATH
          [--workspace <dir>] [--force]       is npm-packed to a tarball so its deps really
                                              install; an npm PACKAGE is extracted from its
                                              INSTALLED copy — a dist-only package is refused by
                                              name, the cwd's own repo is never extracted), seed
                                              contracts from the static pass, DRAFT the capture
                                              config — then STOP at the review gate and print
                                              what a human must decide and why. A target with no
                                              token file gets a SKELETON DTCG (author it before
                                              phase 2's promote; flat leaf names = css var minus
                                              "--"). A directory that already carries a
                                              ds-library.json is ADOPTED. (--force starts a NEW
                                              onboarding over a workspace that already has one
                                              in flight; it does NOT skip the phase-2 review
                                              gate — nothing does.)
  onboard --continue                          PHASE 2: capture → promote → emit → bundle →
          [--channel-key K] [--dry-run]       publish to the standing channel. The designer
          [--bridge <url>]                    clicks "Check for updates" — NO JSON ON A CLIPBOARD.
          [--from capture|promote|emit|       REFUSES an unreviewed capture config by name; there
                bundle|publish]               is no flag that skips the gate (--from resumes a
                                              run over artifacts that already exist; the gate
                                              still runs first, whatever stage you resume from)
  promote --config <ds-library.json>          computed-capture artifacts → promoted contracts +
                                              minted token tree (source-alias pass, provenance
                                              anchors, figmaStatePreviews probe, resolution
                                              guard). Also runs as onboard's promote stage.
  init [--detect] [--force]                   write ds-contracts.config.json here (--detect
                                              prefills adapter/root/tokens + styling hints from
                                              the repo — marked detected, NOT confirmed;
                                              --force overwrites an existing config)
  extract [config] [--reconcile]              code → proposed contracts (react-tsx | cem adapter)
          [--draft-capture-config]            + DRAFT computed-capture config with "__review:*"
                                              markers (the capture runner refuses it unreviewed)
          [--accept-candidates exact|<file>]  + bulk raw-value → token acceptance: unique
                                              exact-value candidates only, every acceptance
                                              ledgered, ambiguity refused by name
  extract --computed --config <capture.json>  real-browser computed-style capture
          [--harness <dir>] [--out <dir>]     (needs playwright-core + Chromium; degrades by name)
  generate <contracts..> --out <dir>          contract → code
          [--target react|html|react-inline|figma-script|<registered>]
          [--tokens f,f] [--icons dir] [--stories] [--emitter <module>]
  figma <contracts..> --out <dir>             contract → Figma sync scripts
          [--tokens f,f] [--icons dir] [--file-key KEY]
  figma bundle <contracts..> --out <file>     contracts + tokens → ONE self-contained
          --tokens <base[,minted]>            CONTRACTS-BUNDLE JSON (the only thing a user
          [--modes <light[,dark]>]            pastes into the plugin — it syncs the tokenSet
          [--name <collection>]               as a named collection and builds the components;
          [--icons dir]                       icon assets referenced by the contracts embed
                                              as SVG text — required when refs exist;
                                              deterministic: same inputs → identical bytes)
  figma push <file> --code <CODE>             send a CONTRACTS-BUNDLE to the plugin bridge
          [--bridge <url>]                    (pairing-code flow, deliver-once, 15-min TTL —
                                              the ad-hoc path: both people, same minute)
  figma claim-channel [--bridge <url>]        mint a STANDING CI↔Figma channel: a write key
                                              (a CI secret — it publishes) + a read key
                                              (sha256 of it — the half the designer pastes
                                              into the plugin; it can never publish)
  figma publish <file> [--channel-key K]      publish a CONTRACTS-BUNDLE to that channel —
          [--bridge <url>] [--dry-run]        CI pushes whenever, the designer checks
          [--repo o/n] [--run-id] [--commit]  whenever, neither waits. GitHub Actions
          [--ref] [--run-url]                 context auto-detects into a provenance
          [--no-provenance]                   SIBLING (never inside the bundle bytes).
                                              Key: --channel-key or DS_CONTRACTS_CHANNEL_KEY,
                                              in memory only, never persisted or logged.
  figma receive --out <contracts-dir>         the dev door: print a pairing code, wait for the
          [--bridge <url>] [--apply]          plugin's proposed contract, land it as a reviewed
                                              local diff (writes NOTHING without --apply). With
                                              --apply it ALSO generates the component code that
                                              contract produces — same as propose-pr, from the
                                              generate section of ds-contracts.config.json; with
                                              no recorded target it says so and writes no code.
  diff [config]                               parity referee: contracts vs code/design
                                              exit 0 clean · 1 drift · 2 error
  propose-pr <file> --repo owner/name         open the contract change + generated code as one PR
          [--token t] [--base b] [--path p] [--title t] [--target t]
          [--code-path d] [--tokens f,f] [--icons d] [--stories]
          [--no-code] [--dry-run]             (targets are never guessed: --target wins, else the
                                              "generate" block of ds-contracts.config.json, else
                                              the contract alone AND the body says so)

--tokens on generate / figma / propose-pr takes any mix of:
  path/to/file.dtcg.json    a DTCG file
  path/to/dir               a DIRECTORY — every *.tokens.json / *.dtcg.json
                            inside it, recursive and sorted
  slot=path/to/file.json    an explicit SLOT: primitives | semantic | light |
                            dark | brand | brand.<name>
Each file is routed to one slot. Unnamed files follow the *.tokens.json layer
convention (brand.<n>.tokens.json, *.light.*, *.dark.*, *semantic*,
*primitives*); anything else is a flat foreign set and lands in primitives.
Two files that define the SAME token differently inside ONE slot are refused
by name — a light tree merged over a dark one emits a dark component whose
own header says light, so the merge that would hide it is not allowed.

On "figma bundle", --tokens is the same FLAG (never positional) but takes
EXACTLY <base[,minted]> — one flat DTCG base file, optionally the minted
tree second; no slots, no directories.

ds-contracts <command> --help shows nothing extra yet — this block is the reference.

The last line is the CLI's own honesty: --help on a verb shows nothing beyond this block — this page and that block are the reference.

onboard — the whole code→canvas pipeline, in two phases

ds-contracts onboard <package-or-path> [--components a,b,c] [--workspace <dir>] then ds-contracts onboard --continue [--channel-key K] [--from <stage>] [--dry-run] [--bridge <url>]. Phase 1 detects the adapter and styling method, creates or reuses a pinned sandbox, seeds contracts from the static pass, drafts the capture config — and stops, printing what a human must decide and why each field matters. Phase 2 runs capture → promote → emit → bundle → publish without stopping, one progress line per stage, and ends with a summary naming what was produced and what was refused.

The stop is the design. A drafted capture config carries a top-level "__unreviewed-draft" marker, and phase 2 refuses it by name before anything else runs — including when you resume a later stage with --from bundle. There is no --yes and no --force past it, because classAllow, varPrefix and the mount recipe do not error when they are wrong; they produce a confident wrong contract.

A directory that already carries a ds-library.json manifest is adopted rather than re-detected — which is also what a second onboard run on the same library does. Capture runs as one sweep for the whole library unless narrowed with --components: the runner's read-boundary frontier receipts are collected across a run, so a narrowed sweep and a full sweep produce different bytes for the same component. A component the capture quarantines ships no contract, drops out of the bundle, is named in the summary, and makes the exit status non-zero — a quarantine is a defect, not a waiver.

promote

ds-contracts promote --config <ds-library.json> [--root <dir>] — computed-capture artifacts → promoted contracts and a minted token tree: the source-alias pass (a minted leaf whose covering combos all name one token, value-verified twice, becomes a DTCG alias to it), provenance-anchor sidecars, a figmaStatePreviews probe against the real referee, and a resolution guard that refuses the whole promotion on a single dangling ref. This was six near-identical copies of a script under examples/*/scripts/ until it became one module; four libraries reproduce their committed artifacts byte-for-byte through it, and two (polaris, astryx) keep their own scripts by name.

init

ds-contracts init [--detect] [--force] — the first command anyone runs. Writes ds-contracts.config.json in the current directory (refuses if one exists; --force overwrites). One config drives every verb: the code/design/tokens/idPrefix/out/diagnose keys are the extraction/diagnose config the extract and diff verbs read, and generate holds code-generation defaults. Every path is relative to the file. This is the exact template init writes Generated:

ds-contracts.config.json as written by ds-contracts init — derived from the shipping source at build time
{
  "$comment": "ds-contracts configuration. `code`/`design`/`tokens`/`idPrefix`/`out`/`diagnose` are the ExtractConfig shape (extract + diff verbs); `generate` holds code-generation defaults. Every path is relative to this file.",
  "code": {
    "adapter": "react-tsx",
    "root": "src/components"
  },
  "design": {
    "source": "design-dump.json"
  },
  "tokens": [
    "tokens/tokens.json"
  ],
  "idPrefix": "ds",
  "out": "ds-contracts/out",
  "generate": {
    "target": "react",
    "out": "src/generated",
    "tokens": [
      "tokens/tokens.json"
    ],
    "icons": "assets/icons",
    "stories": false
  }
}

--detect prefills code.adapter, code.root, the token paths and a styling hint by reading your package.json and source tree. Every detected value is marked detected, NOT confirmed — it is a first guess to correct, not an answer. The styling hint is the one that decides your whole path: co-located CSS Modules means the static pass can extract anatomy directly; runtime or atomic styling means the static pass reads the API surface only and styling truth needs extract --computed.

extract

ds-contracts extract [config] [--reconcile] [--draft-capture-config] [--accept-candidates exact|<file>] — code → schema-valid proposed contracts, over the same code path as the reference repo's extraction. The config resolves positional → --configds-contracts.config.json in the cwd. Components the extractor sees but cannot extract are counted and named — never silently dropped.

What you get depends on how your library is styled. The API surface — props, enum values, defaults, on* events — is always proposed. Anatomy (parts, layout, token bindings) is read from source only by the react-tsx adapter with a co-located <Component>.module.css, and even there it is best-effort; StyleX yields structure without styling; Tailwind, Emotion, styled-components and the cem adapter yield the stub {"root": {}}. Each proposal's description says which it is. A stub anatomy is schema-valid and will emit a canvas set — a correctly named component set with blank interiors — so read the descriptions before you bundle.

extract --computed — the one browser-dependent verb

ds-contracts extract --computed --config <capture.json> [--harness <dir>] [--out <dir>] [--root <dir>] [--component <name>] drives a real Chromium to capture computed styles. It is deliberately a lazy seam: the capture code lives in a separately bundled chunk imported only when the flag is passed, and playwright-core is an optional dependency — so the base install stays browser-free, and when the browser is absent the verb degrades with a named message and exit code 3 instead of an unnamed module crash Generated:

the named degradation, verbatim from the shipping source — every other verb keeps working without a browser
✘ extract --computed needs the real-browser capture harness, which is not available here:
  playwright-core (an optional dependency) or its Chromium binary is missing.
  Install them:  npm i playwright-core && npx playwright-core install chromium
  (or set PLAYWRIGHT_CHROMIUM_PATH to an existing Chromium).
  Every other verb works without a browser — only computed-style capture degrades.

generate

ds-contracts generate <contracts..> --out <dir> — contract → code. Positionals are *.contract.json files or directories; their union is both the generation set and the resolution scope for composition refs (a parent and its referenced children travel together).

FlagWhat it does
--target react (default)the shipping generator — typed TSX + CSS Modules + per-component index + root barrel, prettier-formatted; with --stories, CSF3 Storybook stories. The exact code path the reference repo byte-guards with its golden manifest.
--target html | react-inline | figma-script | <registered>any emitter in the open registry — files are written exactly as the emitter returns them. An unknown target is refused with the list of registered names.
--emitter <module>dynamic-imports a plugin emitter module (path or bare npm specifier) and registerEmitter()s it before generation. The module exports an Emitter as default, emitter, or an emitters array — anything else is refused by name. See writing an emitter.
--tokens f,fcomma-separated DTCG files, a directory (every *.tokens.json / *.dtcg.json inside it, recursive), or slot-named entries slot=file. Each file is routed to one of the five token slots — primitives, semantic, light, dark, brand.<name>. A flat foreign set with no slot named lands in primitives (the pattern the Polaris showcase established); the layered *.tokens.json convention routes itself. Two files that define the same token differently inside one slot are refused by name — the layer that would have been silently overwritten is the whole reason the flag knows about slots (a light tree merged over a dark one emits a dark component whose header says light).
--icons <dir>SVG assets referenced by contract icon names.
--storiesemit Storybook stories (react target).

figma

ds-contracts figma <contracts..> --out <dir> [--tokens f,f] [--icons dir] [--file-key KEY] — emits one Figma Plugin API sync script per contract (the same referee-gated emitter that built the reference repo's entire canvas library). --file-key pins the wrong-file guard: a script anchored to one file refuses to run in another.

figma bundle — the one file a user pastes

ds-contracts figma bundle <contracts..> --out <file> --tokens <base[,minted]> [--modes <light[,dark]>] [--name <collection>] [--icons dir] — packs contracts, a token set and any referenced icon assets into one self-contained CONTRACTS-BUNDLE JSON. This is the recommended artifact for a foreign library, and it is the answer to “what exactly does my designer paste?”: this file, and nothing else. There is no script step.

The bundle's optional tokenSet carries the library's own tokens — flat DTCG base, optional per-mode modes.light/modes.dark, and an optional nested minted tree whose {alias} leaves become Figma-native variable aliases. The plugin syncs it first as a named variable collection with Light/Dark modes, then builds every component set bound to it. Contracts resolve their token refs against base + minted; a ref outside both is refused by name, exactly like a repo contract referencing an unknown repo token. Icon assets referenced by contracts embed as SVG text and --icons is required when such refs exist. Deterministic: the same inputs produce identical bytes.

figma push

ds-contracts figma push <file> --code <CODE> [--bridge <url>] — sends a CONTRACTS-BUNDLE to the plugin bridge under a pairing code. A single contract document is wrapped into a one-contract bundle automatically; anything that is neither a contract (no id) nor a well-formed bundle envelope is refused. The code is the 6-character pairing code from “Receive by code”, folded into “Other ways to receive” in the plugin's Build tab — deliver-once, 15-minute TTL, and it carries no ordering, so an out-of-order paste gets no freshness warning (the standing channel's seq guard covers only figma publish). For the durable door see figma claim-channel / figma publish; the bridge is a dumb pipe that never inspects the payload beyond "is it JSON / is it a well-formed envelope". The bridge URL resolves --bridgeDS_CONTRACTS_BRIDGE_URL → the public default.

ds-contracts figma claim-channel [--bridge <url>] — mints a standing CI↔Figma channel: a write key (a CI secret; it publishes) and a read key that is sha256(writeKey). The read key is the half the designer pastes into the plugin's Changes tab, and it can never publish — so leaked plugin storage cannot inject into the source of truth. Write-with-read-key and read-with-write-key are both refused, and key existence 404s indistinguishably from a bad key (the shape is not a secret; existence is).

ds-contracts figma publish <file> [--channel-key K] [--bridge <url>] [--dry-run] [--repo o/n] [--run-id] [--commit] [--ref] [--run-url] [--no-provenance] — publishes a CONTRACTS-BUNDLE to that channel. CI pushes whenever, the designer checks whenever, neither waits. GitHub Actions context auto-detects into a provenance sibling of the envelope — never inside the bundle bytes, so figma bundle stays byte-deterministic — and the plugin renders it above the change report. Publishes are last-write-wins with a monotonic seq; a delivery older than what the file last applied is named and every actionable row starts unchecked. Reads are non-consuming peeks; there is no timer, because a Figma plugin has no background execution — the plugin checks on open and on a button.

figma receive — the dev door, the other direction

ds-contracts figma receive --out <contracts-dir> [--bridge <url>] [--apply] — the only verb that runs on the developer's machine to take something out of Figma. It prints a 6-character pairing code and waits; the designer types that code into “Send to repo” in the plugin's Send tab, and the proposed contract travels the bridge and lands as a reviewed local diff. It writes nothing without --apply — the default is show-me-first. With --apply it also generates the component code that contract produces, from the generate section of ds-contracts.config.json — the same both-halves rule as propose-pr; with no target recorded it says so and writes no code rather than inventing a framework. Use it when the designer has no GitHub token and you do not want one issued.

Named limits. Deliveries are not signed: anyone holding the write key can publish any provenance, so there is no "verified" badge. The read half of the channel — a headless drift recompute so CI can referee the canvas without a human clicking a tab — is not started. Key discipline matches propose-pr: --channel-key or DS_CONTRACTS_CHANNEL_KEY, in memory only, never persisted or logged, and --dry-run prints the plan and never a key.

diff

ds-contracts diff [config] — the parity referee over surfaces this CLI did not generate: contracts ⟷ code (react-tsx or cem adapter) and, when design.source is configured, contracts ⟷ design. One code path with the reference repo's own diagnose referee. The exit codes are the CI contract:

ExitMeaning
0clean — every checked surface matches the contracts
1drift — findings named on stderr, report JSON written; fail the job
2configuration or input error — fix the config, not the contracts

This is the gate the design-led CI recipe runs after regeneration — a PR that would leave the surfaces disagreeing cannot merge. The protocol page explains why the referee, not a person, holds this authority.

propose-pr

ds-contracts propose-pr <file> --repo owner/name [--token t] [--base b] [--path p] [--title t] [--target t] [--code-path d] [--tokens f,f] [--icons d] [--stories] [--no-code] [--dry-run] — a contract change becomes a reviewable pull request carrying both halves: the contract AND the code it generates. <file> is a proposed contract document, a CONTRACT-PROPOSAL envelope straight out of the plugin's Send tab (the contract inside is unwrapped — committing the envelope would put a non-contract where a contract belongs), or a parity/diagnose diff report (JSON, committed verbatim, no code). The contract lands at --path/<basename> (default contracts/) on a fresh ds-contracts/propose-… branch, opened against --base (default: the repo's default branch, resolved live). Plain GitHub REST via fetch — no gh binary, no SDK.

The code half. Until this landed, the PR contained a document nobody could run and a human had to know to go away and run generate — an invisible second hop. Now the registered emitters run here and their output is committed next to the contract. Targets are never guessed: --target wins; otherwise the generate section of ds-contracts.config.json decides (the block init --detect writes); with neither, the PR carries the contract alone and the body says so. A --target that cannot be honored is a refusal, not a degradation — a named flag with nowhere to write is an error, while a config-derived target degrades loudly and still proposes the contract. --no-code asks for the contract alone on purpose. The react root barrel is deliberately not in the PR: it lists every component in the library and a proposal knows one — the body says that too.

The provenance sentence is printed, not assumed. A component set this tool generated carries a ds_contracts/contractId marker, so canvas → contract → code is a true round trip and re-running the emitters reproduces the component byte for byte. A hand-built set carries no marker: the contract is an inversion of what could be read off the canvas, so the emitted component is a starting point, not a reproduction. The plugin stamps which case it is into the CONTRACT-PROPOSAL envelope and the PR body prints the matching sentence; with no recorded provenance the body says exactly that instead of picking a side. Both ends read one module (core/canvas-code-plan.ts), so the plugin's preview and the PR cannot drift apart.

Token discipline: the fine-grained token (contents:write + pull-requests:write on the target repo) comes from --token, else DS_CONTRACTS_GITHUB_TOKEN, else GITHUB_TOKEN. It lives in one local variable for the duration of the run and is never persisted, logged, or echoed. --dry-run prints the exact five REST steps the live run would take — no network calls, no token required — and that plan output is pinned by an eval.

Exit codes, across the CLI

ExitWhen
0success (including diff clean and propose-pr --dry-run)
1a named refusal (contract violations listed one per line), diff drift, or a runtime error
2usage or configuration error — unknown command/flag, missing required flag, bad config (also: bare ds-contracts with no command)
3extract --computed only — the browser harness is unavailable; the named degradation above

The published package is smoke-tested by the eval suite (cli-smoke), and both journey walkthroughs execute these verbs end-to-end from the same manifest the docs render. Source: packages/cli/.