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
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:
{
"$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
}
}code.adapter—react-tsx(scancode.rootrecursively) orcem(pointcode.manifestat acustom-elements.json).tokens— DTCG files used to refereevar(--x)→ token-path bindings during anatomy extraction; point at your token set, never a guessed hyphen→dot split.design.source— a plugin-dump JSON for the design side ofdiff/extract --reconcile; optional.diagnose.contracts— the directory of adopted contractsdiffreferees against (default<out>/contracts).
--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 → --config → ds-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.
--reconcile— adds the configured design dump and produces the disagreement report: every property classified agree / options-differ / code-only / design-only. The brownfield starting point. Cannot be combined with the two flags below (they run on the code pass).--draft-capture-config— also writes a draft computed-capture config with a"__review:*"marker on every field static source cannot infer (classAllow,varPrefix,mount,fixedProps,stateProps), each with one line of guidance and no guessed value. The draft carries a top-level"__unreviewed-draft"key and the capture runner refuses any config still carrying it — draft is not approved.--accept-candidates exact|<file>— bulk raw-value → token acceptance over the unbound-value report. Only unique exact-value candidates are eligible, every acceptance is ledgered, and ambiguity is refused by name. On a large brownfield library this is the dominant day-one labor.
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:
✘ 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).
| Flag | What 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,f | comma-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. |
--stories | emit 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 --bridge → DS_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:
| Exit | Meaning |
|---|---|
0 | clean — every checked surface matches the contracts |
1 | drift — findings named on stderr, report JSON written; fail the job |
2 | configuration 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
| Exit | When |
|---|---|
0 | success (including diff clean and propose-pr --dry-run) |
1 | a named refusal (contract violations listed one per line), diff drift, or a runtime error |
2 | usage or configuration error — unknown command/flag, missing required flag, bad config (also: bare ds-contracts with no command) |
3 | extract --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/.