Back to EDRs
8 mins read

019-guidelines-for-writing-cli

architecture
accepted

Guidelines For Writing Command-Line Interfaces

Context

Command-line interfaces (abbrev. CLIs) are first-class in our world. Most of our users will primarily interact with Amaru via the means of a CLI.

Consistency is key to build good interface; and it is hard to get consistency unless guidelines and conventions are clearly spelled out.

This EDRs defines guidelines to follow when building CLI in the context of the Amaru project to keep the CLIs tidy and maximise the user experience.

Decision

The top-level CLI command structure is as follows:

  • amaru: invoking without options or arguments will always display the short help (as for amaru -h)
  • amaru -V: displays short version information, i.e. only the <MAX>.<MIN>.<DATE> version string
  • amaru --version: displays long version information including git commit etc.
  • amaru --color=<on|off|auto>: control all pretty and color printing either directly or via “stdout is a terminal” — values never and always don’t make sense because this option control exactly one execution, but we may silently accept them anyway
  • amaru shell-completions: shell completions automatically generated by clap
  • amaru dev: a hidden command group not normally used in production (i.e. for development of Amaru or dApps)
  • amaru node: a command group for bootstrapping and running the node as well as resetting after failure; also for future command & control of a running node
  • amaru snapshot: a command group for handling state snapshots
  • amaru transactions: a command group supporting an SPO in their operational duties

Amaru does not offer destructive operations (like removing files or folders), there is no --force flag. Instead, Amaru tells the operator to perform such actions when they are necessary.

The following list enumerates consistency rules for all parts of the CLI.

Command groups are nouns, individual commands are verbs.

Each command line starts with amaru followed by a drill-down into which object is being operated on, followed finally by a command that also sounds like one, i.e. it is a verb in imperative form.

Good example: amaru node run
Bad example: amaru transaction info-action (this should be extended with create)

Use (positional) args for mandatory values, and long options for optional ones.

The upside of arguments is that they can be documented separate from options, and appear first. It’s easy to overwhelm users with options, so having the ability to separate the actually user-required bits is useful.

The downside of arguments is that the user must remember their order, which can be a burden if there are many of them. This should be minimised by providing defaults wherever possible, making those inputs optional.

Option names do not contain “value type” strings.

Each option should have a clear name that describes the value it expects. The type of the value (e.g. FILE, DIR, ADDRESS) should be conveyed through the value_name field in clap and not be part of the option name itself. For example, instead of --chain-dir, we should have --chain with value_name = "DIR". This keeps option names clean and focused on their purpose.

Options shall have associated environment variables as fallback.

Many users enjoy using env vars for configuring their deployments. The difficulty in documenting environment variables is mitigated by the 1:1 correspondence with options and the diligent documentation of those, meaning that clap will document the environment variables for us.

Each such variable name starts with AMARU_. Besides these, industry standards like OpenTelemetry variables are respected. We also respect the RUST_LOG variable as a fallback when neither the --log= option is given nor the AMARU_LOG variable is set.

Options shall have documented default values.

Since options are optional, each associated value must have a default. This default must be documented as part of the CLI, meaning that the default is applied at the level of clap, not at the level of transforming clap arguments to configuration data structures.

Log final options considered for each command.

It can be hard to figure out what exactly gets used as final values: we have defaults, env var fallbacks, and arguments derived from other arguments.

Displaying a summary as an INFO level log event clarifies the execution steps and help user troubleshoot configuration issues down the line.

Note that, we follow the following conventions:

  • One field per arg / option
  • Ordered alphabetically
  • A single _command field confirms the command name
  • An additional log message may follow.
info!(
_command="run",
chain_dir=%chain_dir.to_string_lossy(),
ledger_dir=%ledger_dir.to_string_lossy(),
listen_address=args.listen_address,
max_downstream_peers = args.max_downstream_peers,
max_extra_ledger_snapshots = %args.max_extra_ledger_snapshots,
migrate_chain_db = args.migrate_chain_db,
network=%args.network,
peer_address=%args.peer_address.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
pid_file=%args.pid_file.unwrap_or_default().to_string_lossy(),
"running"
);
Use consistent `value_names` and `env` var names.

value_names (a.k.a. meta-variables) refer to the kind of data expected for the underlying option (filepath, dir, tcp address, etc..). It should be as informative as possible, and identical for options that refer to objects of the same nature (see string constants in amaru/src/lib.rs).

Provide sound defaults whenever possible

It’s easy to get lost in too many options. So as much as possible, commands should run with as little configuration as possible. In particular:

  • Defaults must all be consistent with one-another between commands (!);
  • Defaults may be derived from other options when necessary (we can derive a lot from the —network for example);
Use flags for boolean values

Don’t:

#[arg(
short, // we don’t do short options
long,
value_name = "BOOL", // shouldn’t use option parameter
default_value_t = false
)]
show_validity: bool,

Do instead:

#[arg(long, default_value_t = false)]
show_validity: bool,

It is shorter, more intuitive and easier to document.

Document all options, with at least a top-level short description

We use clap as a command-line builder, which comes with various conventions. One convention being that Rust doc comments on options are translated into command-line descriptions.

Clap provides a short -h and long --help usage helps. In the short version, only the first line of each description is shown. So it’s important to structure comments in this way too, ensuring that the important information comes first; and details about the option comes after a single newline.

Display options in alphabetical order

There’s no “good manual order” for things like options. What one person would think as logical, one other would deem messy. So the only real choice for displaying options is to sort them alphabetically. It follows a principle of least surprise and when done consistently, is easy to spot.

Finally, we abide by the following rules for the behaviour of running programs:

Business output to `stdout`, logging to `stderr`

Any textual output requested by the user is printed to stdout so that Unix pipes and tools can be used to process it. This means that any additional output by amaru (logging etc.) must go to stderr. This is done consistently even for commands that do not emit textual output.

Business output should be structured.

In order to facilitate the processing of structured objects, these should be emitted using JSON format. This rule only applies where this is helpful, a counter-example being a command that lists some header hashes which should of course be printed as plain strings separated by newlines.

In case a stream of structured objects is to be emitted, newline-delimited JSON (ndjson) should be used. This avoid the recipient having to first ingest a whole larger array before being able to emit results, i.e. it allows realtime processing with less memory usage.

Use progress bars for long-running activities.

For slow individual operations in commands that the user expects (and awaits) to finish, we display a progress bar if pretty console outputs are enabled (i.e. subject to the --color setting).

An exception is amaru node run which only emits tracing logs and uses those to indicate progress during slow operations by giving regular progress updates.

Signal initiates shutdown, second signal kills.

The first SIGINT (i.e. ctrl-C) or SIGTERM initiates an orderly shutdown of the process. The second such signal exits the process without further ceremony.

This behaviour can be simplified to just terminating for those invocations that don’t start any components that offer an orderly shutdown ceremony.

We may add SIGHUP or SIGQUIT handling based on user feedback, for example SIGHUP to initiate peer churn or SIGQUIT to log current consensus/ledger/mempool statistics.

Consequences

  • To ensure a consistent value_names and env across all commands, we have factored them into dedicated modules under amaru’s lib.
  • Various command-line changes introduced via amaru#636 to comply with these guidelines.

Discussion points