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.