019-guidelines-for-writing-cli
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 foramaru -h)amaru -V: displays short version information, i.e. only the<MAX>.<MIN>.<DATE>version stringamaru --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” — valuesneverandalwaysdon’t make sense because this option control exactly one execution, but we may silently accept them anywayamaru shell-completions: shell completions automatically generated byclapamaru 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 nodeamaru snapshot: a command group for handling state snapshotsamaru 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
_commandfield 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_namesandenvacross 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.