Despite its apparent simplicity, writing a good console application is more difficult than you would expect. Many developers get the basics wrong and limit the effectiveness of their software as a result. In the 1970s, Doug McIlroy first expressed the Unix Philosophy for building apps with a good CLI. It was later summarized by Peter H. Salus in A Quarter-Century of Unix (1994).
The Unix Philosophy
- Write programs that do one thing and do it well.
- Write programs to work together.
- Write programs to handle text streams, because that is a universal interface.
Inspired by this philosophy and my own experience, I’ve compiled 8 design recommendations for console apps.
Provide Help in the Terminal
A good README or online documentation is fine, but help in the terminal is divine. It’s more accessible and allows users to stay in the terminal. You should support several different ways to diplay help.
|
|
If the user makes a mistake, your app should respond with a helpful message indicating how to proceed.
|
|
There are excellent libraries that make providing help much easier. The one I’m most familiar with is Command Line Parser for .NET. It enforces a consistent syntax and provides help and version information automatically. And speaking of version, you should provide that as well.
|
|
Use a Consistent CLI Syntax
Consistency is important for your app’s command-line interface. Decide up front what kind of syntax the app will need. Will it require subcommands? How will the option flags be structured? Will it accept short names as well as long names?
Platforms like Kubernetes and AWS provide command-line tools for interacting with their services. The syntax is straightforward and consistent.
# Kubernetes
kubectl [command] [TYPE] [NAME] [flags]
# AWS
aws [options] <command> <subcommand> [parameters]
I mentioned using Command Line Parser for providing help automatically. You can also use it to enforce a consistent CLI in .NET apps. It supports subcommands or verbs, short and long names, named and value options, and option groups. The library provides these features via an expressive syntax using attributes. You create one or more option classes to hold the parsed command-line arguments. Their properties are decorated with attributes that tell the runtime how to parse the arguments.
Say we create a (completely redundant) utility for file operations whose features include creating and deleting files. With Command Line Parser, we can enforce the CLI contract using attributes.
|
|
Each option has short and long names defined along with help text describing what it does. Help is automatically generated by the library.
|
|
The delete
and create
operations can be made mutually exclusive with the SetName
property.
|
|
Use Subcommands for Complex Apps
As your command-line tool grows more complex, you may want to use subcommands for segmenting the different functions. Be careful that the subcommands are closely related in purpose to avoid violating item one of the Unix philosophy: Write programs that do one thing and do it well.
Command Line Parser supports subcommands using the Verb
attribute. We can rework the Options
class for our file utility to use subcommands for the create
and delete
operations.
|
|
The subcommand is the first argument.
|
|
Code for Pipes
[Ken Thompson] put pipes into Unix - all in one night. And the next morning we had this orgy of one-liners.
Doug McIlroy from The Unix Oral History Project
Console apps have at their disposal three data streams. They are standard input (stdin) for input data, standard output (stdout) for output data, and standard error (stderr) for error messages. All three streams read or write to the terminal by default, but they can be redirected to read from files or other programs’ output (stdin) or write to files or other program’s input (stdout and stderr). The syntax to do this in Bash or Windows Command shell is a vertical bar or pipe |
. You can use pipes to create data processing pipelines that pass data from app to app. There’s also additional syntax for reading from a file <
and writing to a file >
.
Let’s say you want to search for all Powerpoint files in a directory and page through the results.
|
|
Or find a keyword in a file and output the results to another file.
|
|
Pipes are essential for supporting the Unix philosophy of small focused programs that do one thing well. These programs become more useful if they can be composed into pipelines to do complex operations. Your console app should support this concept by reading from stdin and writing to stdout.
Don’t Cross the Streams
There are two output streams: stdout and stderr, and both serve different purposes. Stdout is for data only. Anything else, errors, warnings, and progress messages, should go to stderr. Don’t mix the two. Ever. If you do, you’ll break the pipeline by making it difficult for the next app in the chain to process the output data.
In .NET, you write a line of text to stdout using Console.WriteLine
which is a wrapper around Console.Out.WriteLine
. For stderr, use Console.Error.WriteLine
.
|
|
Stderr text will still go to the console by default even if stdout is redirected. Most shells allow mixing the two using special syntax.
|
|
A common need is to redirect stderr into a file.
|
|
Read and Write Files with Options and Streams
They all had file arguments; grep had a file argument, and cat had a file argument, and Thompson saw that that wasn’t going to fit with this scheme of things and he went in and changed all those programs in the same night.
Doug McIlroy from The Unix Oral History Project
After Ken Thompson added pipes to Unix, he realized the utilities written for the new operating system had file arguments but didn’t take standard input or use standard output. They couldn’t be chained to create pipelines. He changed those apps to use stdin and stdout in addition to file arguments. You should do the same for your apps that work with files.
Let’s look at grep
as an example. It takes both a file argument and reads from stdin.
|
|
You can do the same for your apps. In .NET, you can check to see if stdin is redirected and respond accordingly.
|
|
This app can either read from stdin or use an argument.
|
|
You can update the app to add an output file argument.
|
|
|
|
Don’t Reinvent the Wheel
When your console app supports stdin/stdout/stderr, then its users can leverage the existing ecosystem of command-line tools. You don’t need to provide paging. Users can pipe into less
. Need to filter? Use grep
. Need file IO? Use the redirection operators: >
, <
, >>
.
The following utilities are available for Unix-like environments running Bash and recent versions Command shell for Windows. They are great for building pipelines that process text.
Utility | Description |
---|---|
COMMAND > out.txt |
Redirect stdout to a file. The file is truncated if it exists. |
COMMAND >> out.txt |
Redirect stdout to a file. Append to the file if it exists. |
COMMAND < in.txt |
Redirect a file’s contents to stdin. |
less /more |
View the contents of a file or stream one page at a time. |
grep |
Search a file or stream for text matching a regular expression and print the results. |
ls |
List the contents of a directory. |
cat |
Reads files or stdin and writes to stdout. |
sed |
Perform text substitution like find and replace on a text stream. |
tr |
Translate, squeeze, and/or delete characters from standard input, writing to standard output. |
sort |
Sorts the text in a stream. |
uniq |
Outputs the unique words in a text stream. |
As an example, the script below reads the source of this blog post and extracts the unique words. All this work is done with existing commands tied together in a pipeline.
|
|
Have a Structured Data Option
All output text should be human-readable by default, preferably simple lists of text delimited by line breaks, however you should consider adding a structured data option to your apps. Output as JSON or XML is useful for many scripts. The data can be parsed and acted upon in an automated way.
Let’s update our Ghostbusters app to include the option to output the characters in JSON format.
|
|
Invoking it with the --json
option gives us an array of objects with an ID, screen name, and actor.
|
|
Next we consume the output with jq - a command-line JSON processor. We can filter the contents to just the actor’s names.
|
|
Summary
- Provide Help in the Terminal
- Use a Consistent CLI Syntax
- Use Subcommands for Complex Apps
- Code for Pipes
- Don’t Cross the Streams
- Read and Write Files with Options and Streams
- Don’t Reinvent the Wheel
- Have a Structured Data Option