Homemade CLI Tools

2021-12-13 • edited 2022-01-05 • 5 minutes to read

Go is great! We all know that. I spend most of my coding time on the back-end of the services we build. For many years, before I moved to Go, I used to create my tools as a combinations of various scripting languages - not excluding PHP! Most of it was run by Bash that was executed as a cron job, systemd service or manually as a one-shot executable. I must say, Bash is not as bad as many seem to think. It has its quirks and gotchas but works almost everywhere.

Moving with all of this to Go was a revelation. I’ll share some of my experiences and suggestions for writing CLI tools.

The Basics

We’ll need an entry point. The “runnable” function in Go is called main() and it needs to be defined in a package called main. Although the name of the file is not important, we’ll call it main.go to be consistent.

package main

func main() {
	println("Tea.", "Earl Grey.", "Hot.")
}

Now, when we run this:

go run .

we’ll get:

Tea. Earl Grey. Hot.

We can compile the code into a self-contained executable with:

go build -o tool . 

and run it by:

./tool

Let’s do something more sophisticated.

Commands, Arguments and Flags

Usually, Go’s built-in functions are enough to get us going with even a complicated set of command-line args and parameters, but I’ll skip the core packages for now, and we’ll use the Cobra library. It will allow us to create multiple nested commands within one binary.

First, we need to create a cobra.Command and Execute() it (handling eventual errors on the way):

	c := &cobra.Command{
		Use: "tool",
		Run: func(cmd *cobra.Command, args []string) {
			for _, a := range args {
				fmt.Println(a)
			}
		},
	}
	if err := c.Execute(); err != nil {
		os.Exit(1)
	}

We normally do not need to print the final error, because it will be displayed by Cobra by default.

  • cobra.Command.Use is the one-line usage message.
  • cobra.Command.Run is the actual work function. Most commands will only implement this. We can already pass some args to it.
$ go run . Tea. "Earl Grey." Hot.
Tea.
Earl Grey.
Hot.

The above command does not do much, but it can already display an error when we try to run it with a param (a flag), as we have not yet configured any.

$ go run . -x
Error: unknown shorthand flag: 'x' in -x
Usage:
  tool [flags]

Flags:
  -h, --help   help for tool

By default, Cobra shows the usage of the command when an error occurs. As you can see, we also have a --help flag defined out-of-the-box.

Adding a flag

Before we Execute() we can add a flag to the command with:

	c.Flags().StringVarP(
		&name,    // a pointer to the variable to be set
		"name",   // the name of the flag (use it with `double dash`)
		"n",      // a short name of the flag (to be used with a single `dash`)
		"",       // the default value
		"a name", // a short usage description
	)

This allows us to use:

$ go run . Tea. "Earl Grey." Hot. --name Computer

For more info on all the possible options see: Cobra User Guide .

Reading the Standard Input

You can easily access stdin data from within go code. It is a cool and useful feature. E.g.:

b, err := io.ReadAll(os.Stdin)
if err != nil {
    log.Fatalln(err)
}
fmt.Println(string(b))

Just put it in your Run function and see.

There is one problem though. If you do not pass anything into the stdin your program will hang.

Well, that is not a bug, it’s a feature. It will wait for you to enter some text and end it with ^D.

We can avoid such a situation by checking if any data was provided and I’ve got a function for that:

func inputFileOrStdin(inputFilePath string) (*os.File, func() error, error) {
	if inputFilePath != "" {
		file, err := os.Open(inputFilePath)
		if err != nil {
			return nil, nil, err
		}
		return file, file.Close, nil
	}
	fi, err := os.Stdin.Stat()
	if err != nil {
		return nil, nil, err
	}
	if fi.Size() == 0 && fi.Mode()&os.ModeNamedPipe == 0 {
		return nil, nil, errors.New("no input file provided and stdin is empty")
	}
	log.Println("os.Stdin size:", fi.Size())
	return os.Stdin, func() error { return nil }, nil
}

If the caller provides a path to the function, it will be used to open a file (if it exists) for reading.

If the path is empty, we try to determine if the os.Stdin, which is actually a *os.File, holds any data. To do that we check the size of stdin.

fi, _ := os.Stdin.Stat()
log.Println(fi.Size())

This will work for:

$ go run . <<<"OK"

and you’ll see that the log shows a size of 3.

But when we do:

$ echo "OK" | go run .

we still get a file with size 0. That is why we need to check if the os.ModeNamedPipe bit is set in *os.File’s Mode(). This way we can reliably enough find out if any data was sent through the stdin

I am including a working example as a GitHub Gist . Have fun!

RealLife Example

If you would like to see a more advanced example of a CLI Tool using Cobra in Go, you can check out a recent addition to our Oracle Suite that is used to generate cryptographic key pairs from a simple set of words (a mnemonic phrase).

This article is a part of the Go Advent Calendar! . Be sure to check it out, maybe you’ll find something of interest to you.

software engineeringtoolsgocli

Embedding Types to Reuse Code with Less Noise

comments powered by Disqus