CLI.
Eight commands. Small, scriptable, friendly to coding agents. None open a browser. None ask you to log in.
Run dif from the repo root or any descendant. It walks upward until it finds dif/config.yaml. Every command takes --json as a global flag for
machine-readable output. Exit codes are documented per command; 0 means success,
non-zero means a specific kind of failure you can branch on in CI.
Run dif --help for the auto-generated overview.
$dif init
Scaffold a dif/ workspace in the current directory. Safe to re-run: when every
dif-owned file already matches what init would write, it changes nothing and exits 0. It refuses to overwrite a dif-owned file whose content differs unless you pass --force.
Flags
| flag | default | notes |
|---|---|---|
--surface <name> | home | Name of the first surface to create. |
--events <mode> | asks, or cloud | How events are delivered: cloud (dif.sh Cloud delivery) or custom (you export the exposure and track handlers in dif/events/). Without the flag, dif init asks on a terminal and
uses cloud otherwise. |
--key <dif_pk_…> | none | Publishable key from your dif.sh Cloud project. It sets cloud events mode and writes the
key into dif/config.yaml. It cannot be combined with --events custom. |
--force | off | Overwrite dif-owned files whose content differs: structural files, skill files, and .cursor/rules/dif.mdc. |
--agents <list> | all four | Comma-separated: claude (CLAUDE.md + .claude/skills/dif-*), general (AGENTS.md), cursor (.cursor/rules/dif.mdc), copilot (.github/copilot-instructions.md), or none. Omit to scaffold all
four. none can't be combined with the others. --no-agent-files still works as a deprecated alias for --agents none. |
--agents-only | off | Refresh agent files only: the .claude/skills/dif-* skills, .cursor/rules/dif.mdc, and the managed dif blocks. Rewrites dif's own files, the
skill files and .cursor/rules/dif.mdc, without --force. An edit you
made to one of them is overwritten. Never touches dif/. Combine with --agents to pick targets. Needs a dif/config.yaml in the current
directory. Cannot be combined with --surface, --force, --events or --key. |
Example
$dif init ✓ created dif/experiments/{active,concluded} ✓ created dif/surfaces/ ✓ created dif/audiences/ ✓ wrote dif/config.yaml ✓ wrote dif/.gitignore ✓ wrote dif/surfaces/home.md ✓ wrote dif/audiences/locale.ts ✓ wrote dif/audiences/device_type.ts ✓ wrote .claude/skills/ (dif-author-experiment, dif-conclude-experiment, dif-generate-surfaces, dif-triage-experiments) ✓ wrote .cursor/rules/dif.mdc ✓ merged dif guidance into CLAUDE.md ✓ merged dif guidance into AGENTS.md ✓ merged dif guidance into .github/copilot-instructions.md
CLAUDE.md, AGENTS.md, and .github/copilot-instructions.md get a managed dif block; existing content around it
is preserved. .cursor/rules/dif.mdc is dif's own file. A dif block in a legacy .cursorrules from an older dif is refreshed, but a new .cursorrules is
never created. Pass --agents none to skip agent files entirely, or a subset like --agents claude,cursor to scaffold only those. --no-agent-files still
works — it's a deprecated alias for --agents none.
--agents-only is new in dif v0.7.0. After you upgrade dif, run dif init --agents-only to get the new skills and agent guidance. It keeps dif/config.yaml, your surfaces and their learnings, and your audience files as they
are. It overwrites any edit you made under .claude/skills/dif-* or to .cursor/rules/dif.mdc, so commit those first.
Exit codes
| code | meaning |
|---|---|
0 | workspace created, or already initialized with matching files (--json reports "already_initialized": true) |
2 | a dif-owned file exists with different content and --force wasn't passed; also
run from $HOME, an invalid --agents list, or --agents-only with no dif/config.yaml or with --agents none |
1 | an unexpected filesystem or command error |
invarianta fresh dif init followed by dif validate and dif build passes with zero experiments.
$dif connect
Point an existing workspace at dif.sh Cloud. Writes the publishable key into the events: block of dif/config.yaml and forces cloud mode, preserving every
other setting and comment. Doesn't build — dif build regenerates the client with the
new key.
Flags
| flag | default | notes |
|---|---|---|
--key <dif_pk_…> | — | Required. The publishable key from your dif.sh Cloud project. Rejected if it's empty,
malformed, or looks like a secret server key (dif_live_… / dif_test_…). |
--url <url> | https://cloud.dif.sh | Only needed for a self-hosted deployment. |
Example
$dif connect --key dif_pk_live_… ✓ connected dif/config.yaml to dif.sh Cloud next: run dif build to regenerate the client with your key
If the workspace was in mode: custom, connect switches it to cloud and warns that dif/events/{exposure,track}.ts are no longer used. See events for both modes.
Exit codes
| code | meaning |
|---|---|
0 | connected |
1 | missing or malformed --key, or a filesystem error |
2 | no dif.sh workspace found here (run dif init first) |
$dif new
Draft a new experiment file. Reads up to three prior learnings from the surface and embeds them in the brief template as an HTML comment.
Flags
| flag | default | notes |
|---|---|---|
<id> | — | Kebab-case experiment id. Becomes the filename stem. |
--surface <name> | — | Required. Must exist under dif/surfaces/. |
--owner <email> | git config user.email | Falls back to git config. Errors if neither is set. |
--from <id> | — | Clone audience, variants, and exclusion_group from an
existing experiment (active or concluded). Owner, metrics, hypothesis, status, and body are
not copied. |
The draft starts with control and variant_a at 50/50 and today's date.
Example
$dif new checkout-cta-v3 --surface checkout → reading dif/surfaces/checkout.md found 3 prior learnings → drafted dif/experiments/active/checkout-cta-v3.md status: draft, owner: ada@acme.dev
Exit codes
| code | meaning |
|---|---|
0 | drafted |
1 | can't determine owner; pass --owner |
2 | id conflict (already exists) or --from source not found |
3 | surface does not exist |
invariantthe file dif new writes parses cleanly with dif validate.
$dif validate
Run every check against the workspace. Collects all errors and warnings in one pass (no fail-fast), so the report shows everything wrong at once. Writes nothing.
The validator reads:
dif/config.yaml;- every
.mdindif/experiments/active/andconcluded/; - every
.mdindif/surfaces/; - the resolver filenames in
dif/audiences/; .js/.jsx/.ts/.tsxsource fordif("<id>", ...)call sites (skipping.git,node_modules,target,dist,build, anddif/).
Checks, in order:
- Frontmatter parses; required fields present.
(E001) owneris a syntactically valid email.(E003)surfaceresolves to a loaded surface file.(E004)- Variant weights sum to 100.
(E005) - Every audience attribute is declared in
dif/config.yaml.(E006) - Every declared attribute has a resolver in
dif/audiences/.(E008) - Same-surface experiments share an
exclusion_groupor have provably-disjoint audiences.(E007) - Every
dif("<id>", ...)call site maps to an active experiment.(W001, warning)
Every diagnostic code is documented on troubleshooting. --schema-only is reserved for a future fast editor path; today it runs the same full
suite.
Example
$dif validate ✓ all checks passed
$dif validate error E003 dif/experiments/active/bad.md:1:1: `owner` is not a valid email: `not-an-email` help: Use `name@example.com` format. error E008 dif/config.yaml: attribute `plan` has no resolver help: Create `dif/audiences/plan.ts` or run `dif scaffold-audiences`. ✗ 2 error(s), 0 warning(s)
Exit codes
| code | meaning |
|---|---|
0 | no errors (warnings allowed) |
1 | at least one error, or workspace loading failed |
$dif build
Run validate, then compile the runtime artifacts and the agent context file. Aborts
before writing if validation fails.
Flags
| flag | default | notes |
|---|---|---|
--out <dir> | dif/generated | Where to write the TypeScript. Absolute or workspace-relative. Keep it inside the workspace. |
Outputs
<out>/client.ts: registers each active experiment with the SDK by side effect. Deterministic: identical inputs produce byte-identical output.<out>/audiences.ts: theattributes()helper, importing only the resolvers referenced by active experiments. Also deterministic.dif/context.json: the agent context file. Carries a generation timestamp (so it churns on every build) plus a summary of every active experiment, each surface's most recent learning, and project conventions.
Example
$dif build ✓ validated 1 active experiment ✓ client → dif/generated/client.ts ✓ audiences → dif/generated/audiences.ts ✓ context → dif/context.json
Exit codes
| code | meaning |
|---|---|
0 | compiled |
1 | validation, loading, or generation failed |
invariantclient.ts and audiences.ts compile
under tsc --strict and are byte-identical for identical inputs.
$dif qa
Trace one user's assignment chain across every active experiment, and optionally mint a preview link. No exposure events are fired; qa runs entirely against the loaded workspace.
Flags
| flag | default | notes |
|---|---|---|
--user <id> | random | The user id to trace. Omit for a time-seeded synthetic id (so repeated runs sample different buckets). |
--force <exp>=<variant> | — | Force a variant. Repeatable. Bypasses audience and exclusion; emits no exposure. |
--attr <key>=<value> | — | Set an audience attribute. Value parsed as YAML (true, 42, US all keep their type). Repeatable. |
--preview-url <url> | — | Base URL for the preview link emitted when --force is set. |
Example
$dif qa --user u_8131 trace u_8131: • checkout-cta-v2 → variant_a (bucket 7993)
$dif qa --force checkout-cta-v2=variant_a --preview-url https://staging.example.com/checkout trace (synthetic user): • checkout-cta-v2 → variant_a (forced) preview: https://staging.example.com/checkout?_dif=checkout-cta-v2%3Dvariant_a
The preview URL carries a _dif query parameter encoding the forces. The browser SDK
and framework adapters read it and persist the force in a session cookie, so you can share a
screenshot or hand the link to a teammate.
Outcomes
variant (bucket N): bucketed normally; the SDK would fire an exposure for this assignment.variant (forced): picked by--force; bypasses audience.↛ audience miss: user didn't match the audience predicate.↛ exclusion loser (winner: <id>): matched, but a higher-priority experiment in the sameexclusion_groupwon.
Exit codes
| code | meaning |
|---|---|
0 | trace completed |
1 | invalid --force / --attr syntax, malformed YAML, or a workspace
error |
$dif conclude
Conclude an active experiment. Several writes in one transaction:
- Update the frontmatter:
status: active→concluded, setconcluded: <today>. - Replace the
## Decisionsection body. - Move
dif/experiments/active/<id>.md→dif/experiments/concluded/<YYYY-MM>-<id>.md. - Prepend one learning to
dif/surfaces/<surface>.mdunder## Learnings.
Edits are computed before writing; a later filesystem failure rolls back on a best-effort basis.
Flags
| flag | default | notes |
|---|---|---|
<id> | — | Experiment id. Must exist under dif/experiments/active/. |
--decision <text> | $EDITOR | Inline decision text. If omitted, dif opens $EDITOR, then $VISUAL,
then vi. Must be non-empty. |
--skip-learning | off | Don't append to the surface log. Rare; mostly a CI escape hatch. |
Example
$dif conclude checkout-cta-v2 --decision "Shipped variant_a. +2.1% on returning visitors." → moved dif/experiments/concluded/2026-05-checkout-cta-v2.md → drafted ## Decision block → appended to dif/surfaces/home.md "Shipped variant_a. +2.1% on returning visitors."
The first line of the decision becomes the surface log summary, prefixed with the conclusion date
and experiment id. Multi-line decisions stay in the experiment file's ## Decision block; only the first line is duplicated to the surface.
Exit codes
| code | meaning |
|---|---|
0 | concluded |
1 | missing experiment or surface, empty decision, editor failure, or a filesystem error |
invariantafter a successful conclude, dif validate and dif build still pass.
$dif scaffold-audiences
Add the starter audience resolver files to an existing project. Idempotent. Existing files are
kept unless --force is supplied.
Creates dif/audiences/ if needed and writes locale.ts and device_type.ts. It never modifies dif/config.yaml; it prints the
declaration snippet to add under audience_attributes yourself.
$dif scaffold-audiences ✓ wrote dif/audiences/locale.ts ✓ wrote dif/audiences/device_type.ts → add these to dif/config.yaml under audience_attributes: - { name: locale, type: string } - { name: device_type, type: enum, values: [mobile, tablet, desktop] }
Exit codes
| code | meaning |
|---|---|
0 | scaffold completed (including when existing files were kept) |