All pages

CLI

Every command of the qubits binary: flags, defaults, output, exit codes and the transaction file workflow. It is a reference for people who manage keys, multisig accounts, vaults, validators and contracts from a shell.

Commands

cargo build --release -p qubits-cli from chain/ writes target/release/qubits. The CLI is the only place vaults are managed and contracts deployed. The browser wallet has no vault screens, and of contracts it knows tokens only.

qubits
├── key      new, show, rotate
├── sign
├── verify
├── address
├── account
├── transfer
├── vault    create, remove, cancel, execute
├── tx       status, show, sign, submit
├── policy   new
├── validators  show, rewards, claim, propose
└── contract    deploy, call, view, show

Outputs on this page are real, from a private single-validator chain with min_fee_per_byte set to 10000000000. Long values are cut with .... qubits help COMMAND prints the same help as --help.

Global options

FlagEnvironmentDefaultMeaning
--rpc URLQUBITS_RPChttp://127.0.0.1:8545The node's HTTP API
-h, --helpPrint help for any command
-V, --versionPrint the version, on qubits itself

--rpc is global: it works before or after the subcommand, and the flag beats the environment. Trailing slashes are trimmed. It is the only environment variable the binary reads. Each HTTP request has a 30 s overall timeout. key new, key show, address, sign, verify, policy new, tx show and tx sign never contact a node.

Exit codes

CodeMeaningExamples
0Success
1The command ran and failed. error: ... goes to standard error, with its causes joined by colonsWrong password, a node refusal, an invalid signature, a failed contract under --wait
2The arguments did not parseA missing flag, a bad address argument, an unknown --scheme or --as value
$ qubits transfer --to qb1... --amount 1 --key alice.json --password-file wrong
error: cannot decrypt key file: wrong password or corrupted file            (exit 1)

$ qubits transfer --to qb1... --amount 99999999999999999999999999 --key alice.json --password-file pw
error: node refused (400): insufficient balance of asset 0: need 99999999999999999999999999, have 984999841579999999999   (exit 1)

$ qubits account qb1xyz
error: invalid value 'qb1xyz' for '<ADDRESS>': invalid address: invalid checksum   (exit 2)

A node refusal reads node refused (STATUS): MESSAGE. The messages are in HTTP API. An unreachable node reads connecting to URL: .... An address inside a contract call string is read by the CLI and not by the argument parser, so a bad one there exits 1.

Results go to standard output. Errors go to standard error, and so do the two notes after key new and policy new, so $(qubits policy new ...) captures just the address.

Keys

Key files are the encrypted JSON format the browser wallet also uses, and they move between the two.

A password comes from a terminal prompt, or from the first line of --password-file. A new password must have at least 12 characters, counted as Unicode characters. The prompt asks for it twice; a password file is read once.

qubits key new

Generates a key and writes it encrypted.

FlagDefaultMeaning
--out FILERequiredWhere to write. An existing file is refused
--scheme SCHEMEml-dsa-65ml-dsa-65, or slh-dsa-sha2-192s for cold storage: hash-only, and signing takes about a second
--password-file FILEPromptPassword source
$ qubits key new --out alice.json --password-file pw
scheme:     ml-dsa-65
public key: 010a89cbc19bf70ae575e6b7c9a89362062a43ce450422f45786d6b4c4326975b94379b2a1d75a...
Saved encrypted key to alice.json. Back it up: it cannot be recovered.

The file is created with mode 0600 and synced to disk. A file that exists is an error, alice.json already exists; refusing to overwrite a key file. The password rule fails with password must be at least 12 characters.

qubits key show

Prints the scheme and the scheme-tagged public key. It needs no password, because the public key sits in the file in the clear.

FlagDefaultMeaning
--key FILERequiredKey file

qubits address

Prints the address a key controls as a single-key account: the hash of a 1-of-1 policy holding that key. It reads only the file.

FlagDefaultMeaning
--key FILERequiredKey file
$ qubits address --key alice.json
qb1gngekccxe2z5cgplmhxh8qd7gvwlm7xjt8vtwq5wlfh4djhdryhswvywcs

An address is the hash of an account's initial policy, so after key rotate this command prints something else and the account keeps its old address. Use --account from then on.

qubits key rotate

Sends SetAuth with a 1-of-1 policy on the new key. The address stays the same. For a vault the owner's rotation waits for the delay, and with --recovery it takes effect at once.

FlagDefaultMeaning
--new-key FILERequiredKey file of the new owner key. Only its public key is read
--recoveryOffSign with the vault's recovery key in --key. Requires --account

It also takes the transaction options.

qubits sign

Signs a message off-chain, in the Message domain (qubits/message/v1), so the signature is never valid as a transaction. It prints the scheme-tagged signature as hex.

FlagDefaultMeaning
--key FILERequiredKey file
--message FILEStandard inputFile to sign. - is standard input
--password-file FILEPromptPassword source

qubits verify

Checks a message signature. It prints OK: valid ml-dsa-65 signature, or fails with error: invalid signature and exit 1.

FlagDefaultMeaning
--public-key VALUERequiredScheme-tagged key as hex, or @path to read it from a file
--signature VALUERequiredScheme-tagged signature as hex, or @path
--message FILEStandard inputSigned file. - is standard input

Accounts

qubits account

Prints GET /v1/accounts/{address} as pretty JSON. Takes the address as a positional argument. An account nobody has touched prints "exists": false, and that is still exit 0.

$ qubits account qb1yrrat7595dlqr0xpzyvu5pujm8uk9juz4c6gfc8ws8s8rgnc89rqzl96f6
{
  "address": "qb1yrrat7595dlqr0xpzyvu5pujm8uk9juz4c6gfc8ws8s8rgnc89rqzl96f6",
  "exists": false,
  "nonce": 0,
  "auth": null,
  "vault": null,
  "balances": [],
  "pending": []
}

qubits transfer

Sends tokens. From a vault, the transfer waits for the delay.

FlagDefaultMeaning
--to ADDRESSRequiredRecipient
--amount NRequiredBase units: 1 native token is 1000000000000000000
--asset ID0Asset id; 0 is the native token

It also takes the transaction options.

$ qubits transfer --to qb1yrrat... --amount 5000000000000000000 --key alice.json --password-file pw --wait
transaction 0f38acf9f415c66a62c767cbbf8cb6bba54861d9221e752374b723a0140031c8 submitted (fee 54320000000000)
included in block 1

Transaction options

key rotate, transfer, vault create|remove|cancel|execute and contract call share these flags.

FlagDefaultMeaning
--key FILERequired unless --unsigned-outKey file that signs
--account ADDRESSAddress of --keyAccount that sends. Required without --key. Needed after a rotation, for recovery and for multisig accounts
--unsigned-out FILEOffDon't sign: write a transaction file. Needs no password
--policy FILEPolicy file from policy new, for a multisig account's first transaction, with --unsigned-out
--valid-for SECONDS3,600, or 604,800 with --unsigned-outSets valid_until_ms to now plus this
--waitOffWait for a block. Ignored with --unsigned-out
--password-file FILEPromptPassword source

What a transaction command does

  1. Find the account: --account, or the address of --key.
  2. GET /v1/status and GET /v1/accounts/{address}.
  3. Decrypt the key, after reading the password.
  4. Build the transaction with build_transaction, the code the browser wallet also runs: nonce from the account, fee from the size of a draft with a placeholder signature, and the policy revealed if the account has none yet.
  5. Sign and POST /v1/transactions, then print the id and the fee.
  6. With --wait, wait for a block.

The fee is always the minimum:

fee = min_fee_per_byte * (size + gas_limit / 16, rounded up)
TransactionFeeWeight in bytes
ML-DSA-65 transfer, first from the account, with its public key543200000000005,432
ML-DSA-65 transfer347000000000003,470
SLH-DSA-SHA2-192s transfer, first from the account16443000000000016,443
SLH-DSA-SHA2-192s transfer16385000000000016,385
2-of-3 ML-DSA-65 transfer, first, with the policy12661000000000012,661
2-of-3 ML-DSA-65 transfer678500000000006,785
vault create with a recovery key735200000000007,352
Token transfer at the default --gas 20000001285380000000000128,538
Token transfer with --gas 50000034788000000000034,788

Unused gas comes back, so the last two end up costing the same when the same gas is used.

Errors from the build step name the account:

MessageCause
this account does not exist yet; it needs funds to pay feesNo such account on the node
this key does not control the accountThe account has no policy yet and the key's 1-of-1 policy hashes to another address
this key is not part of the account's owner policyThe key isn't in the policy
the account has no recovery policy--recovery on an account without one
the policy needs several signatures; multisig signing is not supported here yet--key on a multisig account: use --unsigned-out

The nonce comes from committed state. A second transaction sent before the first is in a block reuses it, and the node answers bad nonce, expected N. Use --wait between transactions from one account.

Waiting for a block

--wait polls GET /v1/transactions/{id} every 200 ms for up to 60 s. Errors during the poll are ignored, since the node may not know the id yet. It then prints included in block N. After 60 s it fails with transaction ID not included after 60s, and the transaction may still land.

If the transaction left a receipt, the command prints it and turns the outcome into its exit code:

contract qb1...                     only for a deployment
output 2e360f...                    only when not empty
event transfer from qb1...: 44d1... one line per event: topic as text, data as hex
gas used 190562
outcomeStandard errorExit code
done0
revertederror: reverted: REASON1
out_of_gaserror: out of gas; the fee was paid, try a higher --gas1
trappederror: the contract crashed; the fee was paid1
invaliderror: no such function, or the code is not a contract1
$ qubits contract call 'qb1w32tl...md2dv8.transfer(qb14kpawp...edugt, 999999999999999)' --key alice.json --password-file pw --wait
transaction 6bec80d409b44a9792e873d1af3423b5f2c30d618a61d1fa94ed2f4a4257dffa submitted (fee 1285380000000000)
included in block 31
gas used 130952
error: reverted: balance too low                                            (exit 1)

Exit 1 here does not mean nothing happened. The transaction is in a block, its nonce is spent and its fee, less unused gas, is paid.

Two cases exit 0 without the contract having worked:

  • Without --wait, the command ends at submission. The same reverting call exits 0.
  • A vault's contract call only enters the queue, so it has no receipt yet and exits 0 however the call would go. The outcome comes with vault execute --wait, which prints the receipt and exits 1 if the call fails.

Transaction files

A transaction file holds an unsigned or partly signed transaction, so several keys can sign it on separate machines.

pub struct PartialTransaction {
    pub tx: Transaction,
    pub policy: AuthPolicy,        // the policy that must sign
    pub reveal: Option<AuthPolicy>,
    pub signatures: Vec<KeySignature>,
}

The commands that create one refuse to overwrite a file.

$ qubits transfer --to qb14kpawp... --amount 1000000000000000000 \
    --account qb14dux67... --policy team.json --unsigned-out t1.json
wrote t1.json (fee 126610000000000); it needs 2 of 3 signatures: run `qubits tx sign --file t1.json`
{
  "tx": {
    "genesis_hash": "33328cafde7d090f3a30c67008dc841fb112fac0365e73912fd76f9ec8ae0f4d",
    "sender": "qb14dux67t3tsf9s3mzeaxtyw3qkyz7jt0jcsecy8jjkmr3vdsap2wqqnf8k5",
    "nonce": 0,
    "fee": "126610000000000",
    "valid_until_ms": 1794619834297,
    "role": "owner",
    "payload": {
      "transfer": {
        "to": "qb14kpawpp8jhpng89sx69nnws2vw7kw4chdpq8n4gcemqtps3sss0qzedugt",
        "asset": 0,
        "amount": "1000000000000000000"
      }
    }
  },
  "policy": {"threshold": 2, "keys": ["010a89cbc19bf70a...", "01f6cb4f57b73e80...", "01830eaca08514de..."]},
  "reveal": {"threshold": 2, "keys": ["010a89cbc19bf70a...", "01f6cb4f57b73e80...", "01830eaca08514de..."]},
  "signatures": []
}

Writing the file needs the node, for the nonce and the fee. Signing doesn't.

qubits ... --unsigned-out t.json          reads the node; fixes genesis_hash, nonce, fee, valid_until_ms
qubits tx show --file t.json              local
qubits tx sign --file t.json --key K      local, once per signer
qubits tx submit --file t.json --wait     checks the signatures, then sends

--wait here works as described in Waiting for a block.

Three things follow from fixing the transaction at creation:

  • It goes stale as soon as any other transaction from the account lands. Submitting then fails with bad nonce, expected N, and a new file has to be signed again.
  • Signers can't change the fee. It is computed for the largest signature set the policy allows: the threshold keys with the longest signatures.
  • It expires at valid_until_ms, a week after writing by default.

--valid-for sets another lifetime for commands with transaction options. validators propose and contract deploy always use a week.

qubits tx show

Prints a file. It doesn't contact a node. key indices are positions in policy.keys.

FlagDefaultMeaning
--file FILERequiredTransaction file
$ qubits tx show --file t1.json
from:       qb14dux67t3tsf9s3mzeaxtyw3qkyz7jt0jcsecy8jjkmr3vdsap2wqqnf8k5
nonce:      0
fee:        126610000000000
signed by:  owner key
action:     {"transfer":{"to":"qb14kpawpp8jhpng89sx69nnws2vw7kw4chdpq8n4gcemqtps3sss0qzedugt","asset":0,"amount":"1000000000000000000"}}
signatures: 0 of 2 needed (key indices signed: none)

qubits tx sign

Adds a signature, keeps them sorted by key index, and prints the file as tx show does. It writes a temporary file with the extension .signing and renames it over the original, so a crash can't leave half a file.

FlagDefaultMeaning
--file FILERequiredTransaction file
--key FILERequiredKey file to sign with
--password-file FILEPromptPassword source

A key outside the policy fails with this key is not part of the account's owner policy, and a second signature from one key with signing failed: this key already signed.

qubits tx submit

Checks that enough signatures are there, verifies them against the policy locally, and posts the transaction. Too few fails before anything is sent: signing failed: needs 2 signatures, has 0. A transaction already sent fails with node refused (409): transaction already known.

FlagDefaultMeaning
--file FILERequiredTransaction file
--waitOffWait for a block

qubits tx status

Prints GET /v1/transactions/{id} as pretty JSON. Takes the id as a positional argument. An unknown id fails with node refused (404): unknown transaction.

Multisig

A multisig account is an M-of-N policy, with up to 16 keys and mixed schemes allowed. See Accounts.

qubits policy new

Builds a policy, prints its address to standard output, and writes the policy as JSON. Keep the file: the account's first transaction reveals it.

FlagDefaultMeaning
--threshold MRequiredSignatures required, from 1 to the number of keys
--key FILEKey file of a member; only its public key is read. Repeat for each
--public-key VALUEPublic key of a member, as hex or @path. Repeat for each
--out FILERequiredWhere to write the policy. An existing file is refused

Key files come first in the policy, then public keys, each in the order given. The address hashes the policy, so a different order or threshold is a different address.

$ qubits policy new --threshold 2 --key alice.json --key bob.json --key carol.json --out team.json
qb14dux67t3tsf9s3mzeaxtyw3qkyz7jt0jcsecy8jjkmr3vdsap2wqqnf8k5
Wrote team.json. Keep it: the account's first transaction needs it.

A bad policy exits 1, for example invalid auth policy: threshold must be between 1 and the number of keys.

A full run

qubits policy new --threshold 2 --key alice.json --key bob.json --key carol.json --out team.json
TEAM=qb14dux67t3tsf9s3mzeaxtyw3qkyz7jt0jcsecy8jjkmr3vdsap2wqqnf8k5

# anyone funds the address; the account exists once it holds something
qubits transfer --to $TEAM --amount 10000000000000000000 --key alice.json --wait

# the first transaction carries the policy
qubits transfer --to $BOB --amount 1000000000000000000 \
    --account $TEAM --policy team.json --unsigned-out t1.json

qubits tx sign --file t1.json --key alice.json
qubits tx sign --file t1.json --key carol.json
qubits tx submit --file t1.json --wait

# later ones don't need --policy: the chain has it
qubits transfer --to $BOB --amount 1 --account $TEAM --unsigned-out t2.json

After both signatures, tx show reads signatures: 2 of 2 needed (key indices signed: 0, 2).

Vaults

The rules of vaults are in Vaults. Each command below takes the transaction options. What the owner sends from a vault enters the queue, and --wait reports the queueing transaction's block, not the moment the action takes effect. qubits account shows the queue in pending.

qubits vault create

Sends SetVault with a new policy.

FlagDefaultMeaning
--delay-secs NRequiredDelay before owner actions take effect. Must be above 0 and at most 365 days
--recovery-key FILENoneKey file of the recovery key; only its public key is read. Keep this key offline: it can cancel pending actions and replace the owner key at once

The CLI checks the policy before it contacts the node: invalid vault policy: delay must be positive.

qubits vault remove

Sends SetVault with no policy. On a vault this is itself delayed. No flags of its own.

qubits vault cancel

Sends CancelPending.

FlagDefaultMeaning
--id NRequiredPending action id
--recoveryOffSign with the recovery key in --key, not the owner key. Requires --account
qubits vault cancel --id 1 --recovery --account $VAULT --key recovery.json --password-file pw --wait

qubits vault execute

Sends ExecutePending. Anyone can run this once the action has unlocked, and the account that signs pays the fee.

FlagDefaultMeaning
--vault ADDRESSRequiredThe vault
--id NRequiredPending action id

Too early fails with node refused (400): pending action is locked until T ms.

Validators

Changing the set is governance. See Validators and epochs.

qubits validators show

Prints GET /v1/validators as pretty JSON: epoch, epoch start, validators, quorum and the governance address.

qubits validators rewards

Prints GET /v1/rewards as pretty JSON. With an address it prints GET /v1/rewards/{address}: what that address can claim, and whether it still has a seat.

qubits validators claim

Pays a validator's rewards out to its payout address. Any account can send this and pays the fee, and the coins can only go to --payout. It takes the transaction options.

$ qubits validators claim --payout "$BOB" --key alice.json --wait
transaction 7b1a7266cc7253dd5b57d88ab369ca11810bb7b206e1f9f3518d08686df7dc63 submitted (fee 34500000000000)
included in block 3

With nothing to claim it fails with nothing to claim for that address and pays nothing.

qubits validators propose

Writes a transaction file from the governance account that sets a new validator set. The governance account pays the fee, so it must exist and hold funds. Otherwise this fails with governance account: this account does not exist yet; it needs funds to pay fees.

FlagDefaultMeaning
--public-key VALUEKey of the new set, as hex or @path. Repeat for each
--key FILEKey file of a member; only its public key is read. Repeat for each
--payout ADDRESSRequiredWhere a validator's fees go. One for each validator, in the same order
--out FILERequiredWhere to write the transaction file

List the complete new set, not a difference. Key files come first, then public keys, and that order is the leader order. Each validator needs its own payout address. The file is valid for a week.

$ qubits validators propose --key validator.json --key carol.json --payout "$BOB" --payout "$CAROL" --out change.json
wrote change.json (fee 74000000000000); 1 of the 1 current validators must sign it with `qubits tx sign --file change.json`
qubits tx sign --file change.json --key validator.json    # each validator, up to a quorum
qubits tx submit --file change.json --wait

Contracts

How contracts work is in Contracts, and how to write them in Writing contracts.

Call syntax

contract call and contract view read one string:

CONTRACT_ADDRESS.function(arg, arg, ...)

Quote it for the shell. The string splits at the first ., then at the first (, and must end in ). Arguments split on commas outside double quotes, and each is trimmed. An argument's shape says its type, and the encoded arguments are joined with nothing between them, as the contract's Args reader expects them.

WrittenTypeBytes
qb1...Address32
nativeThe native token's address, all zero32
5u12816, little-endian
u64:5u648, little-endian
deadline:600That many seconds from now, as a u64 of milliseconds8, little-endian
"text"UTF-8 with a length in front4 (u32, little-endian), then the text
0xabcdThose bytes as they areAs written

Anything else fails: twelve: not an address, number, "text" or 0x bytes: invalid digit found in string. A malformed call fails with expected contract.function(args), got `transfer(1)` . deadline: is evaluated when the command runs, not when the block does.

qubits contract deploy

Writes a transaction file for a deployment. Only the governance account can deploy, so a quorum of current validators signs it with tx sign and anyone submits it, as with a validator change.

Argument or flagDefaultMeaning
CODE (positional)RequiredThe compiled .wasm
--arg VALUEOne argument of init, in the syntax above. Repeat for each, in order. There is no comma splitting here
--gas N5,000,000Gas limit. What isn't used goes back
--out FILERequiredWhere to write the transaction file
$ qubits contract deploy contracts/out/token.wasm --arg qb1gngekc... --arg 1000000000000 \
    --arg '"Qubits Dollar"' --arg '"qUSD"' --arg u64:6 --out token.json
wrote token.json (fee 3226920000000000); 1 of the 1 current validators must sign it with `qubits tx sign --file token.json`
if nothing else is sent from that account first, the contract will be at qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8

$ qubits tx sign --file token.json --key validator.json --password-file pw
$ qubits tx submit --file token.json --wait
transaction e14a2d855790a82c85705934677ca6350586ad458f3411eb41cde6113924c4df submitted (fee 3226920000000000)
included in block 27
contract qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8
gas used 682141

The address is derived from the deployer and its nonce, which is why the command can say it in advance, and why the same deployment on two chains lands on one address.

qubits contract call

Sends a contract call in a transaction. From a vault, the call waits for the delay.

Argument or flagDefaultMeaning
CALL (positional)RequiredThe call string
--amount N0Native tokens to send along, in base units
--gas N2,000,000Gas limit, paid in the fee. What isn't used goes back

It also takes the transaction options.

$ qubits contract call 'qb1w32tl...md2dv8.transfer(qb14kpawp...edugt, 2500000)' --key alice.json --password-file pw --wait
transaction 00291aa26b43b39be15ff24b2e18e7dfdd0308eb6c5ff85b5f2308325b1f31a9 submitted (fee 1285380000000000)
included in block 29
event transfer from qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8: 44d19b63...a0252600000000000000000000000000
gas used 190562

Unlike the failures in Waiting for a block, some calls never reach a block, because the node checks them first: no contract at that address, init runs once, at deployment and gas limit is above 5000000. Those transactions pay nothing.

qubits contract view

Reads from a contract, for free, with POST /v1/contracts/{address}/view. Nothing is kept. It runs as nobody with nothing attached: the CLI doesn't expose caller or amount. A result other than done prints the receipt and exits 1.

Argument or flagDefaultMeaning
CALL (positional)RequiredThe call string
--as SHAPEhexHow to print the output
--asOutput must bePrints
hexAnythingHex
u12816 bytesA decimal number
u128sA positive multiple of 16 bytesDecimal numbers separated by spaces, as a pool's reserves come back
u648 bytesA decimal number
address32 bytesqb1...
textAnythingUTF-8, replacing what isn't

A size that doesn't fit fails: the output is 13 bytes, not that shape.

$ qubits contract view 'qb1w32tl...md2dv8.name()' --as text
Qubits Dollar
$ qubits contract view 'qb1w32tl...md2dv8.decimals()' --as u64
6
$ qubits contract view 'qb1w32tl...md2dv8.total_supply()' --as u128
1000000000000
$ qubits contract view 'qb1w32tl...md2dv8.total_supply()'
0010a5d4e80000000000000000000000
$ qubits contract view 'qb1w32tl...md2dv8.owner()' --as address
qb1gngekccxe2z5cgplmhxh8qd7gvwlm7xjt8vtwq5wlfh4djhdryhswvywcs

qubits contract show

Prints GET /v1/contracts/{address} as pretty JSON. Takes the address as a positional argument. If none is there it fails with node refused (404): no contract at that address.

{
  "address": "qb1w32tl3w33tcc8pn8g4xwpedcpyvrhvzs040cwqs992y7ph2nfu0smd2dv8",
  "code": "003b52fed25b748080c8d446d4eac5cbe5272b9b14b405625e9cd58581a60c75",
  "code_size": 6677,
  "balance": "0"
}

Source

  • chain/crates/qubits-cli/src/main.rs: commands, flags, transaction files, exit codes
  • chain/crates/qubits-cli/src/chain.rs: the HTTP client and wait_included
  • chain/crates/qubits-cli/src/contract.rs: call syntax, output shapes, receipts
  • chain/crates/qubits-types/src/client.rs: build_transaction, PartialTransaction, required_fee
  • chain/crates/qubits-crypto/src/keystore.rs: key files and MIN_PASSWORD_LEN
  • chain/crates/qubits-cli/tests/chain.rs, chain/crates/qubits-cli/tests/cli.rs: the commands run end to end