February 11, 2020

8 Steps to a Better Console Application

The console application is the workhorse of the software world. It isn't flashy like its graphical cousins the mobile and web apps. Graphical user interfaces compose images, sophisticated widgets, and animations, but the humble command-line interface (CLI) has none of that. Its tools are simple lines of text printed to a terminal.

Despite its simplicity, you can still make usability mistakes when building a console app. Avoid them by following a handful of guidelines - a few of which are highlighted in the The Unix Philosophy. Inspired by this philosophy and my own experience, I've compiled 8 design recommendations for a good CLI.

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.

Provide Help in the Terminal

Help

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.

1
2
3
$ grep
$ grep -h
$ grep --help

If the user makes a mistake, your app should respond with a helpful message indicating how to proceed.

1
2
3
$ grep
Usage: grep [OPTION]... PATTERN [FILE]...
Try 'grep --help' for more information.

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.

1
2
3
4
5
6
7
8
$ grep --version
grep (GNU grep) 3.1
Copyright (C) 2017 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Mike Haertel and others, see <http://git.sv.gnu.org/cgit/grep.git/tree/AUTHORS>.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class Options
{
    [Option('f', "file", HelpText = "The path to the file.")]
    public string FilePath { get; set; }
    
    [Option('d', "delete", SetName = "Delete Operation", HelpText = "Delete the file.")]
    public bool Delete { get; set; }
    
    [Option('c', "create", SetName = "Create Operation", HelpText = "Create the file.")]
    public bool Create { get; set; }
}

Each option has short and long names defined along with help text describing what it does. Help is automatically generated by the library.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ ./ConsoleApp.exe --help
ConsoleApp 1.0.0
Copyright (C) 2020 ConsoleApp

  -f, --file      The path to the file.

  -d, --delete    Delete the file.

  -c, --create    Create the file.

  --help          Display this help screen.

  --version       Display version information.

The delete and create operations can be made mutually exclusive with the SetName property.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ ./ConsoleApp.exe -f gb.txt -d -c
ConsoleApp 1.0.0
Copyright (C) 2020 ConsoleApp

ERROR(S):
Option: 'd, delete' is not compatible with: 'c, create'.
Option: 'c, create' is not compatible with: 'd, delete'.

  -f, --file      The path to the file.

  -d, --delete    Delete the file.

  -c, --create    Create the file.

  --help          Display this help screen.

  --version       Display version information.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[Verb("create", HelpText = "Create the file.")]
public class CreateOptions
{
    [Option('f', "file", Required = true, HelpText = "The path to the file.")]
    public string FilePath { get; set; }
}

[Verb("delete", HelpText = "Delete the file.")]
public class DeleteOptions
{
    [Option('f', "file", Required = true, HelpText = "The path to the file.")]
    public string FilePath { get; set; }
}

The subcommand is the first argument.

1
2
3
4
5
$ ./ConsoleApp.exe create -f gb.txt
File created at gb.txt.

$ ./ConsoleApp.exe delete -f gb.txt
File deleted at gb.txt.

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 >.

Shell Pipelines

Let's say you want to search for all Powerpoint files in a directory and page through the results.

1
$ ls -l | grep .ppt | less

Or find a keyword in a file and output the results to another file.

1
$ find "keyword" < inputfilename > outputfilename

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

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
using System;

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Peter Venkman");
        Console.Error.WriteLine("Printed 1 of 4.");
        Console.WriteLine("Raymond Stantz");
        Console.Error.WriteLine("Printed 2 of 4.");
        Console.WriteLine("Egon Spengler");
        Console.Error.WriteLine("Printed 3 of 4.");
        Console.WriteLine("Winston Zeddemore");
        Console.Error.WriteLine("Printed 4 of 4.");
    }
}

Stderr text will still go to the console by default even if stdout is redirected. Most shells allow mixing the two using special syntax.

1
2
3
4
5
6
7
8
9
$ ./ConsoleApp.exe | grep Peter  # Pipe stdout into grep. Stderr prints to console.
Printed 1 of 4.
Printed 2 of 4.
Printed 3 of 4.
Printed 4 of 4.
Peter Venkman

$ ./ConsoleApp.exe 2>&1 | grep Peter  # Redirect stderr into stdout then pipe into grep.
Peter Venkman

A common need is to redirect stderr into a file.

1
2
3
4
5
6
7
8
$ ./ConsoleApp.exe 2>log.txt | grep Peter  #Send stderr to log file.
Peter Venkman

$ cat log.txt
Printed 1 of 4.
Printed 2 of 4.
Printed 3 of 4.
Printed 4 of 4.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ cat << EOF > gb.txt
> Peter Venkman
> Raymond Stantz
> Egon Spengler
> Winston Zeddemore
> EOF

$ grep Peter gb.txt  # Read from a file.
Peter Venkman

$ cat gb.txt | grep Peter  # Read from stdin.
Peter Venkman

$ cat gb.txt | grep Peter > Peter.txt  # Output results to a file.

$ cat Peter.txt
Peter Venkman

You can do the same for your apps. In .NET, you can check to see if stdin is redirected and respond accordingly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System;
using System.IO;

class Program
{
    static void Main(string[] args)
    {
        Stream fileContents;

        if (Console.IsInputRedirected)
        {
            fileContents = Console.OpenStandardInput(); 
        }
        else if (args.Length > 0 && File.Exists(args[0]))
        {
            fileContents = File.OpenRead(args[0]);
        }
        else
        {
            throw new ArgumentException("No file provided.");
        }
        
        Stream outputStream = Console.OpenStandardOutput();
        fileContents.CopyTo(outputStream);
        outputStream.Flush();
    }
}

This app can either read from stdin or use an argument.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ./ConsoleApp.exe < gb.txt  # Read from stdin
Peter Venkman
Raymond Stantz
Egon Spengler
Winston Zeddemore

$ ./ConsoleApp.exe gb.txt  # Read file argument
Peter Venkman
Raymond Stantz
Egon Spengler
Winston Zeddemore

You can update the app to add an output file argument.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using System;
using System.IO;

class Program
{
    static void Main(string[] args)
    {
        // Get the input stream
        Stream fileContents;

        if (Console.IsInputRedirected)
        {
            fileContents = Console.OpenStandardInput(); 
        }
        else if (args.Length > 0 && File.Exists(args[0]))
        {
            fileContents = File.OpenRead(args[0]);
        }
        else
        {
            throw new ArgumentException("No file provided.");
        }
        
        // Get the output stream
        Stream outputStream;

        if (Console.IsOutputRedirected)
        {
            outputStream = Console.OpenStandardOutput();
        }
        else if (args.Length > 1)
        {
            outputStream = File.OpenWrite(args[1]);
        }
        else
        {
            throw new ArgumentException("No output file provided.");
        }
        
        // Copy from input to output
        fileContents.CopyTo(outputStream);
        outputStream.Flush();
    }
}
1
2
3
4
5
6
7
$ ./ConsoleApp.exe gb.txt gb2.txt  # Use both file arguments

$ cat gb2.txt
Peter Venkman
Raymond Stantz
Egon Spengler
Winston Zeddemore

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.

1
sed 's/\s/\n/g' < 2020-01-26-8-steps-to-a-better-console-application.markdown | tr '[:upper:]' '[:lower:]' | grep "^\w*$" | grep [^0-9*] | sort | uniq > unique-words.txt

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Program
{
    static void Main(string[] args)
    {
        if (args.Any(arg => arg.ToLower() == "--json"))
        {
            List<Actor> actors = new List<Actor>
            {
                new Actor(1, "Peter Venkman", "Bill Murray"),
                new Actor(1, "Raymond Stantz", "Dan Aykroyd"),
                new Actor(1, "Egon Spengler", "Harold Ramis"),
                new Actor(1, "Winston Zeddemore", "Ernie Hudson")
            };

            string output = JsonConvert.SerializeObject(actors, Formatting.Indented);
            
            Console.Write(output);
        }
        else
        {
            Console.WriteLine("Peter Venkman");
            Console.Error.WriteLine("Printed 1 of 4.");
            Console.WriteLine("Raymond Stantz");
            Console.Error.WriteLine("Printed 2 of 4.");
            Console.WriteLine("Egon Spengler");
            Console.Error.WriteLine("Printed 3 of 4.");
            Console.WriteLine("Winston Zeddemore");
            Console.Error.WriteLine("Printed 4 of 4.");
        }
    }
}

Invoking it with the --json option gives us an array of objects with an ID, screen name, and actor.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ ./ConsoleApp.exe --json
[
  {
    "ID": 1,
    "ScreenName": "Peter Venkman",
    "Name": "Bill Murray"
  },
  {
    "ID": 1,
    "ScreenName": "Raymond Stantz",
    "Name": "Dan Aykroyd"
  },
  {
    "ID": 1,
    "ScreenName": "Egon Spengler",
    "Name": "Harold Ramis"
  },
  {
    "ID": 1,
    "ScreenName": "Winston Zeddemore",
    "Name": "Ernie Hudson"
  }
]

Next we consume the output with jq - a command-line JSON processor. We can filter the contents to just the actor's names.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ ./ConsoleApp.exe --json | jq '.[] | {actor: .Name}'
{
  "actor": "Bill Murray"
}
{
  "actor": "Dan Aykroyd"
}
{
  "actor": "Harold Ramis"
}
{
  "actor": "Ernie Hudson"
}

Summary

  1. Provide Help in the Terminal
  2. Use a Consistent CLI Syntax
  3. Use Subcommands for Complex Apps
  4. Code for Pipes
  5. Don't Cross the Streams
  6. Read and Write Files with Options and Streams
  7. Don't Reinvent the Wheel
  8. Have a Structured Data Option

References

© Joe Buschmann 2020