# cli v0.1

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.

dif init [--surface <name>] [--events <cloud|custom>] [--key <dif_pk_…>] [--force] [--agents <claude,general,cursor,copilot,none>] [--agents-only] [--json]

Flags

flagdefaultnotes
--surface <name>homeName of the first surface to create.
--events <mode>asks, or cloudHow 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_…>nonePublishable 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.
--forceoffOverwrite dif-owned files whose content differs: structural files, skill files, and .cursor/rules/dif.mdc.
--agents <list>all fourComma-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-onlyoffRefresh 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

codemeaning
0workspace created, or already initialized with matching files (--json reports "already_initialized": true)
2a 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
1an 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.

dif connect --key <dif_pk_…> [--url <url>] [--json]

Flags

flagdefaultnotes
--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.shOnly 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

codemeaning
0connected
1missing or malformed --key, or a filesystem error
2no 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.

dif new <id> --surface <name> [--owner <email>] [--from <experiment-id>] [--json]

Flags

flagdefaultnotes
<id>—Kebab-case experiment id. Becomes the filename stem.
--surface <name>—Required. Must exist under dif/surfaces/.
--owner <email>git config user.emailFalls 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

codemeaning
0drafted
1can't determine owner; pass --owner
2id conflict (already exists) or --from source not found
3surface 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.

dif validate [--schema-only] [--json]

The validator reads:

  • dif/config.yaml;
  • every .md in dif/experiments/active/ and concluded/;
  • every .md in dif/surfaces/;
  • the resolver filenames in dif/audiences/;
  • .js / .jsx / .ts / .tsx source for dif("<id>", ...) call sites (skipping .git, node_modules, target, dist, build, and dif/).

Checks, in order:

  1. Frontmatter parses; required fields present. (E001)
  2. owner is a syntactically valid email. (E003)
  3. surface resolves to a loaded surface file. (E004)
  4. Variant weights sum to 100. (E005)
  5. Every audience attribute is declared in dif/config.yaml. (E006)
  6. Every declared attribute has a resolver in dif/audiences/. (E008)
  7. Same-surface experiments share an exclusion_group or have provably-disjoint audiences. (E007)
  8. 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

codemeaning
0no errors (warnings allowed)
1at 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.

dif build [--out <dir>] [--json]

Flags

flagdefaultnotes
--out <dir>dif/generatedWhere 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: the attributes() 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

codemeaning
0compiled
1validation, 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.

dif qa [--user <id>] [--force <exp>=<variant>]... [--attr <key>=<value>]... [--preview-url <url>] [--json]

Flags

flagdefaultnotes
--user <id>randomThe 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 same exclusion_group won.

Exit codes

codemeaning
0trace completed
1invalid --force / --attr syntax, malformed YAML, or a workspace error

$dif conclude

Conclude an active experiment. Several writes in one transaction:

  1. Update the frontmatter: status: active → concluded, set concluded: <today>.
  2. Replace the ## Decision section body.
  3. Move dif/experiments/active/<id>.md → dif/experiments/concluded/<YYYY-MM>-<id>.md.
  4. Prepend one learning to dif/surfaces/<surface>.md under ## Learnings.

Edits are computed before writing; a later filesystem failure rolls back on a best-effort basis.

dif conclude <id> [--decision <text>] [--skip-learning] [--json]

Flags

flagdefaultnotes
<id>—Experiment id. Must exist under dif/experiments/active/.
--decision <text>$EDITORInline decision text. If omitted, dif opens $EDITOR, then $VISUAL, then vi. Must be non-empty.
--skip-learningoffDon'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

codemeaning
0concluded
1missing 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.

dif scaffold-audiences [--force] [--json]

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

codemeaning
0scaffold completed (including when existing files were kept)