- Docs
- Developer Kit
- CLI Reference
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:
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:
dual init # CSV + images/, face and template to createdual init --folder # assets/0.json + 0.png pairsdual init --template-id 68b1… # items only, for an existing collectiondual 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.
dual mint # the batchdual 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.
dual action burn --csv burns.csv # iddual 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.
dual report # writes report.csvdual report --out - # to stdoutdual 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:
{"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--orgor the current organization. - Host:
--api, then the config'sapi:, 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:
actions:- mint- name: updateaccess: { type: private }- name: remoteurl: https://hook.example.comconfig: { 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:
mapping:metadata.name: titlemetadata.description: blurbmetadata.image: filecustom.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:
assets/0.json 0.png1.json 1.jpg
Each JSON file is the object's data block, so no mapping is needed.
- Files sort numerically, so
2.jsoncomes before10.json. - A sibling image is found automatically. The lookup tries
.png,.jpg,.jpeg,.gifthen.webp, in that order, and takes the first that exists. The extension must be lower case. metadata.imageinside the JSON overrides the sibling, resolved againstimage_dir.toandnumat 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,
0xfollowed 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:
sendingis written and flushed before the request leaves.- Every mint carries
custom.batch_ref, the batch id and the row. - A 4xx marks the item failed. The event bus rejects those before the transaction, so nothing was charged and a retry is safe.
- 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. - The next run looks up only those rows by
batch_refand 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:
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.