CLI Reference

Every command, flag, environment variable, config key and exit code the Dual CLI accepts.

Everything dual accepts, in one place. For an introduction and a worked first drop, start with the Dual CLI guide.

Commands

Run dual <command> -h for the flags of one command.

dual login

Prompts for the key without echoing it, checks it against the API, then stores it. When stdin is not a terminal the key is read from the pipe, which keeps it out of shell history and the process table:

bash
op read op://vault/dual/api-key | dual login

The organization is read back from the key, so you never type it. Logging in again for the same organization replaces the key in place and makes that organization current.

dual logout

Removes the stored key for --org, or for the current organization. When the last key is removed the credentials file is deleted.

dual whoami

Asks the API which organization the key resolves to, rather than trusting the store, and warns when the two disagree. That happens if a key is revoked or reissued after it was saved. With several organizations stored, it lists them all.

dual switch

With an argument, changes the default organization by name or id. The name needs no quoting. With no argument, lists what is stored and marks the current one. An ambiguous name is refused rather than guessed.

dual init

Four combinations:

bash
dual init # CSV + images/, face and template to create
dual init --folder # assets/0.json + 0.png pairs
dual init --template-id 68b1… # items only, for an existing collection
dual init --folder --template-id 68b1…

Placeholder art is written for every row, so init, validate and launch work before you supply your own images.

dual validate

Reads everything and sends no writes. Reports item and object counts, how many images are referenced against how many will upload, whether the face and template exist or will be created, the batch price against the organization's balance, and any problems. Recipients are checked for form here, before a request is spent on them. Without an API key the balance and fee estimate are skipped and the file checks still run.

Safe to run against production.

dual upload

Uploads each unique referenced image that is not already in the resume log. Rows sharing a filename upload it once. Uploads cost nothing, so they carry no write-ahead entry: a crash costs an orphaned object in the bucket at worst.

dual deploy

Creates the face and template the first time and patches them on every later run, so editing the config and rerunning is the update path. With template.id set it creates nothing and prints what it will use.

dual mint

Batches by default, reading the items: block. Passing --num, --to or --data switches it to a single mint, which needs no config and writes no resume log.

bash
dual mint # the batch
dual mint --template-id 68b1… --num 3 --to 0x11… # one object, three copies

dual launch

Runs validate, upload, deploy and mint in that order and stops at the first step that fails. Accepts --retry-unknown like mint.

dual transfer, dual update, dual action

One object from flags, or a batch from --csv.

bash
dual action burn --csv burns.csv # id
dual update --csv edits.csv # id,data.colour,data.size

CSV headers are dotted paths inside one action payload, so data.colour becomes {"data":{"colour":…}}. Each command keeps its own resume log, .dual-<action>-cache.jsonl.

These actions have no collection to page through, so an item left in flight cannot be resolved automatically. The run summary reports it and --retry-unknown resends it.

dual verify

Reconciles in-flight mint items against the server, then reports done, failed and in-flight counts with the last error for anything unfinished. Mints nothing.

dual report

Turns the resume log into a CSV you can reconcile against the source spreadsheet. Reads only local files and sends nothing, so it is safe to run at any point, including while a batch is still going.

bash
dual report # writes report.csv
dual report --out - # to stdout
dual report --cache .dual-transfer-cache.jsonl --out moves.csv

One line per object, not per item: a row that minted three copies gets three lines, each with its own id and all carrying the same row and name. An item that produced nothing still gets one line, so no source row disappears. Rows the items no longer describe, because the CSV was trimmed after the run, are kept with an empty name. Without a config it still works, reporting everything but the names.

Shared flags

Every command that talks to the API accepts these.

Environment

Credentials

Stored at ~/.config/dual/credentials.json, or ~/Library/Application Support/dual/credentials.json on macOS. File mode 0600, directory 0700, written through a temporary file and renamed so an interrupted write cannot truncate an existing key.

Keyed by organization id, because a key is issued per organization and therefore already identifies one:

json
{
"current": "68b1…",
"organizations": {
"68b1…": { "name": "Acme Festival", "api_key": "…" },
"68c2…": { "name": "Acme Staging", "api_key": "…", "api": "http://localhost:8080" }
}
}

api is stored only when you pass --api at login, so an unset entry never overrides a drop config.

Resolution order

  • Key: --api-key, then $DUAL_API_KEY, then the stored key for --org or the current organization.
  • Host: --api, then the config's api:, then the stored entry, then $DUAL_API, then the built-in default.

The config outranks the store for the host because it declares where that drop lives. The store fills in for commands run without one.

Config file

Every key. All are optional unless noted. Unknown keys are an error, so a typo fails at load instead of becoming a silent default. Relative paths resolve against the config file's directory.

Top level

face

Each view:

The go-template renderer runs with missingkey=error, so every field a template names must exist on every object.

template

actions takes a bare name or a full mapping with name, alias, url, config and access:

yaml
actions:
- mint
- name: update
access: { type: private }
- name: remote
url: https://hook.example.com
config: { retries: 2 }

factory takes max_supply, start_time, end_time (RFC 3339, validated locally) and whitelist.

object takes metadata, custom and system. Item rows merge on top.

template.id together with a face: block that has no id is rejected. A face is only attached when a template is created, so the face would be created and then referenced by nothing.

items

Item sources

Both produce the same thing and converge on one field: whatever fills an item's image is uploaded and replaced with the asset object the API expects, an id and a URL, never the filename.

CSV

mapping points a dotted object path at a column:

yaml
mapping:
metadata.name: title
metadata.description: blurb
metadata.image: file
custom.seat: seat

Without a mapping, the headers are the paths (metadata.name,custom.rarity).

Two columns are always read directly and never written into the object:

  • to, the recipient. Blank means the API key's own wallet.
  • num, copies for that row. Blank means 1.

A blank cell leaves the template's default in place. A duplicate column, a mapping naming a column that does not exist, or a file with no data rows is an error.

Folder

Numbered pairs:

text
assets/
0.json 0.png
1.json 1.jpg

Each JSON file is the object's data block, so no mapping is needed.

  • Files sort numerically, so 2.json comes before 10.json.
  • A sibling image is found automatically. The lookup tries .png, .jpg, .jpeg, .gif then .webp, in that order, and takes the first that exists. The extension must be lower case.
  • metadata.image inside the JSON overrides the sibling, resolved against image_dir.
  • to and num at the top level are lifted out, exactly as the CSV columns.

Value typing

CSV cells are strings, so they are converted to what the API expects.

Fields whose type the API fixes are converted exactly, because guessing would turn a name like 90210 into a number and fail validation. Folder items are already JSON and are used as they stand.

Recipients

to accepts:

  • An Ethereum address, 0x followed by 40 hex digits. Always works.
  • An email that already has a wallet in this organization. Minting resolves a recipient and never creates one, so an email that has never signed up fails at mint time.

Use an address for anyone new. validate rejects a malformed value locally and counts rows that rely on an email, but whether a wallet exists is only knowable server side.

The resume log

.dual-cache.jsonl beside the config, gitignored. An append-only log: each change is one synced line, replayed on load, last write winning. A torn final line from a crash is discarded. Damage anywhere earlier is fatal, because trusting it could re-mint paid objects.

It records the batch id, the face and template ids, uploaded assets by file, and each item's state.

The rules that stop a paid action happening twice:

  1. sending is written and flushed before the request leaves.
  2. Every mint carries custom.batch_ref, the batch id and the row.
  3. A 4xx marks the item failed. The event bus rejects those before the transaction, so nothing was charged and a retry is safe.
  4. A network fault or a 5xx leaves it sending. The response was lost but the action may have committed, so it is never resent blindly.
  5. The next run looks up only those rows by batch_ref and settles them.

Automatic resending stops after three attempts once the reconciler has confirmed the action is not on the server, because an error that repeats every run is deterministic and retrying it forever never terminates. dual verify shows the last error, and --retry-unknown forces a resend.

Deleting the log starts a genuinely new drop, but it also forgets the face, template and uploaded assets, so the next run creates them again.

Concurrency

Uploads run in parallel. Actions never do, whatever --concurrency says.

/ebus/execute reads the caller's current nonce, signs, then compare-and-increments it. The nonce is keyed on the account address, so every action from one API key shares a single sequence. Two requests in flight together read the same value, and the loser is rejected:

text
http 400: invalid nonce

Spacing requests out with --rate does not fix this. It only makes the collision intermittent, so a batch mostly works and drops an item now and then. Actions are therefore serialised, and --rate remains the throughput control.

A lost race is a 400, so nothing was charged and the item is marked failed and retried on the next run.

Limits

A 429 is retried after its Retry-After. The gateway rejects it before it reaches a handler, so repeating it is safe. A 5xx or network fault is not retried for an action, because it may follow a commit.

Exit codes