Cross Platform CLI application with Go and Cobra

Cross Platform CLI application with Go and Cobra

ยท

9 min read

Abstract

This article will cover the process and the involved components of building a Command-Line Interface (CLI)[1] application using the Go programming language. We will cover the required libraries, directory structure, configuration files, testing, and Cross-Platform build process.

Command-line interpreter (CLI)

CLI stands for Command-Line Interface [1]. CLI application receives input from the user, do some computational tasks and produces an output. Compared to a Graphical User Interface (GUI) [2], CLI applications require fewer system resources since interactions with it don't involve graphics.

One of the cool things (at least in my opinion) about CLI applications is that when designed with composition in mind and having a well-defined In/Out interface. These applications can be composed together (similar to function composition) as one solution ๐Ÿค“.

For example, if we have two CLI application A and B, we can create a composed solution of A.B having input of A and output of B.

Cross-Platform

Cross-Platform[5] applications are designed to work on more than one computing platform. For example, we can build the same software but run it on Linux, Windows and Android devices. These applications are also referred to as multi-platform, platform-agnostic or platform-independent.

The idea is very cool. Cause who wants to maintain more software than needed? But it also needs to be considered in the software design. Cross-Platform applications need to consider any Operatic System (OS) specifics. We will see examples of it when dealing with the local filesystem in the sections below ๐Ÿ‘‡.

CLI project setup

We're going to use Cobra [3] CLI framework. Cobra is a very powerful, extendable and delightful framework to work with. You won't regret it, I promise ๐Ÿ˜ถ!

Go version used:

$ go version
go version go1.19 darwin/arm64

Initialise our project:

$ go mod init github.com/Pavel-Durov/cli-demo
go: creating new go.mod: module github.com/Pavel-Durov/cli-demo

Install Cobra and CobraCLI. CobraCLI will create our application and add CLI commands.

$ go get -u github.com/spf13/cobra/cobra
$ go install github.com/spf13/cobra-cli@latest

Now we can use CobraCLI to initialise our CLI app:

$ cobra-cli init

That's it. We have a working app! It should have the following structure:

$ tree 
โ”œโ”€โ”€ cmd
โ”‚   โ””โ”€โ”€ root.go
โ”œโ”€โ”€ go.mod
โ”œโ”€โ”€ go.sum
โ””โ”€โ”€ main.go

We have the main.go file, which is the main entry point of our application. And one single CLI command called root that is the main entry point of Cobra framework. It's also a general convention to have application entry points for GO projects in the cmd directory.

Run our brand-new CLI app:

$ go run ./main.go 
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.
Nothing match to see yet; we get the default message. Let's add some functionality.

For this demo, we're going to build a calculator CLI application. I know, very exciting!

Adding our first command

//file: ./cmd/add.go
package cmd

import (
    "github.com/spf13/cobra"
)

var addCmd = &cobra.Command{
    Use:   "add",
    Short: "Add operator",
    Long:  `Add operator, adds two integers and prints the result.`,
    Run: func(cmd *cobra.Command, args []string) {
        num1, _ := cmd.Flags().GetInt32("n1")
        num2, _ := cmd.Flags().GetInt32("n2")
        cmd.Printf("%d + %d = %d\n", num1, num2, num1+num2)
    },
}

func init() {
    addCmd.Flags().Int32("n1", 0, "--n1 1")
    addCmd.Flags().Int32("n2", 0, "--n1 2")
    addCmd.MarkFlagRequired("n1")
    addCmd.MarkFlagRequired("n2")
}

We could use CobraCLI for that as well. But I decided to go manual here.

Note that we defined the Use property, which means that in order to use add command, we need to specify first add in our CLI parameters.

Wire CLI commands together.

//file: ./cmd/root.go
package cmd

import (
    "os"

    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "[command]",
    Short: "A CLI calculator",
    Long:  `A CLI calculator that can add and subtract two numbers.`,
}

func Execute() {
    err := rootCmd.Execute()
    if err != nil {
        os.Exit(1)
    }
}

func init() {
    rootCmd.AddCommand(addCmd) // adding add command to root
}

Since our commands are part of the same package called cmd - the import and configuration are very straightforward.

Rerun our command, this time with -h flag:

$ go run ./main.go  -h
A CLI calculator that can add and subtractwo numbers.

Usage:
  calc [command]

Available Commands:
  add         Add operator
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command

Flags:
  -h, --help   help for calc

Use "calc [command] --help" for more information about a command.

As you can see, cobra did a lot of work for us. It did configure how to parse flags, help messages etc.

Run the actual command with specified parameters:

$ go run ./main.go  add --n1=1 --n2=3
1 + 3 = 4

We have a fully-fledged CLI application that can add two numbers and print the result to the stdout.

Let's add another command! This time we will add substitution.

It will be as simple as:

//file: ./cmd/sub.go
package cmd

import (
    "github.com/spf13/cobra"
)

var subCmd = &cobra.Command{
    Use:   "sub",
    Short: "Sub operator",
    Long:  `Sub operator, subtracts two integers and prints the result.`,
    Run: func(cmd *cobra.Command, args []string) {
        num1, _ := cmd.Flags().GetInt32("n1")
        num2, _ := cmd.Flags().GetInt32("n2")
        cmd.Printf("%d - %d = %d\n", num1, num2, num1-num2)
    },
}

func init() {
    subCmd.Flags().Int32("n1", 0, "--n1 1")
    subCmd.Flags().Int32("n2", 0, "--n1 2")
    subCmd.MarkFlagRequired("n1")
    subCmd.MarkFlagRequired("n2")
}

Same as before, add the new command to the root:

...
rootCmd.AddCommand(subCmd)
...

Give it a go:

$ go run ./main.go  sub --n1=10 --n2=4
10 - 4 = 6

It works exactly as intended! You can imagine how using the same process we can extend our CLI application further.

Adding some tests

I like tests, and I think you should too ๐Ÿ˜Ž. Adding unit tests in GO is very straightforward; however, testing CLI commands like Cobra might be a bit tricky. That's why I wanted to demonstrate how to do it.

Adding test to root CLI command:

// file: cmd/root_test.go
func TestTypeLocal(t *testing.T) {
    buf := new(bytes.Buffer)
    rootCmd.SetOut(buf)
    rootCmd.SetArgs([]string{"sub", "--n1=10", "--n2=4"})

    err := rootCmd.Execute()
    if err != nil {
        fmt.Println(err)
    }
    if buf.String() != "10 - 4 = 6\n" {
        t.Errorf("Expected 10 - 4 = 6, got %s", buf.String())
    }
}

Here, we set a buffer as an out stream for the cobra command and pass CLI arguments (aka flags), then we assert the output result - nothing fancy.

Run the tests:

$ go test ./...
ok      github.com/Pavel-Durov/cli-demo/cmd     0.207s

Adding profile/settings

What do we do if we want to store application configuration across sessions, or maybe we want to have secrets such as API keys defined outside of our application code. Whatever the reason, cobra got your back! Actually Viper[4] got your back. Viper is a configuration management tool for Go applications. Viper and Cobra works great together.

Install Viper:

$ go get github.com/spf13/viper

Configure Viper in our init function, root cmd:

// file: cmd/root.go
func initConfig() {
    home, err := os.UserHomeDir()
    cobra.CheckErr(err)
    viper.AddConfigPath(home)
    viper.SetConfigType("yaml")
    viper.SetConfigName(".calc")
    viper.ReadInConfig()
}

func init() {
    cobra.OnInitialize(initConfig)
    ...
}

If we create a local YAML file called .calc in our $HOME directory (cause that's what we configured) with the content:

$ cat ~/.calc
username: kimchi

We can now read these values in our application:

username := viper.Get("username")
if username != nil {
    fmt.Println("Hello", username)
}

We don't have to use YAML or the $HOME directory, this setup can be configured in multiple ways.

Note on $HOME directory

Notice how we used os.UserHomeDir() to get the user's home directory. This is important if we want to build a Cross-Platform[6] application. We could've hardcoded the path to the file. But why should we? GO has great platform-agnostic library support - os.UserHomeDir() will return the path to the $HOME directory specific to the machine it's running on without us changing a single line of code!

๐Ÿ‘‰ On Unix (including macOS), it returns the $HOME environment variable

๐Ÿ‘‰ On Windows, it returns %USERPROFILE%

๐Ÿ‘‰ On Plan 9, it returns the $HOME environment variable

Building our CLI application

Go has an incredible build system that comes with everything we need.

We can easily build our application for multiple architectures and operating systems (OS):

Linux target build

$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o out/linux-arm64-calc -ldflags="-extldflags=-static" # linux, arm64 arch
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/linux-amd64-calc -ldflags="-extldflags=-static" # linux, amd64 arch

Mac (aka darwin) target build

$ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o out/darwin-arm64-calc -ldflags="-extldflags=-static" # mac, arm64 arch
$ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o out/darwin-amd64-calc -ldflags="-extldflags=-static" # mac, amd64 arch

Windows target build

$ CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o out/windows-arm64-calc -ldflags="-extldflags=-static" # windows, arm64 arch
$ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o out/windows-amd64-calc -ldflags="-extldflags=-static" # windows, amd64 arch

if we run all these build commands, we'll get these binaries:

$ ls -l ./out/
-rwxr-xr-x  1 ... darwin-amd64-calc
-rwxr-xr-x  1 ... darwin-arm64-calc
-rwxr-xr-x  1 ... linux-amd64-calc
-rwxr-xr-x  1 ... linux-arm64-calc
-rwxr-xr-x  1 ... windows-amd64-calc
-rwxr-xr-x  1 ... windows-arm64-calc

Environment Variables

GOOS

You probably noticed that the only thing that changed between the build targets is the GOOS environment variable. And that's all you need to change with go-build tooling! It is seriously that easy to use!

GOARCH

This is where we specify the CPU architecture we're targeting.

See all the supported GOOS and GOARCH combos:

$ go tool dist list
aix/ppc64
android/386
android/amd64
android/arm
android/arm64
darwin/amd64
....The list goes on

CGO_ENABLED

We also used CGO_ENABLED environment variable. CGO_ENABLED=1 leads to faster and smaller builds - it allows dynamically loading of the host OS's native libraries. However, it relies on a host OS, a dependency we would like to avoid! Otherwise, our code behaviour might differ from machine to machine if our code relies on the host libraries.

Linker flags - ldflags

We used flags called ldflags - ld stands for linker [6] therefore ldflags stands for linker flags. A linker is a program that "links" the pieces of the compiled source code into the binary outcome. We are passing extldflags to our linker. According to the link tool documentation, these flags are passed to the external linker. Long story short, we're using these flags to indicate to the GO build tool to include all the dependencies into the binary and not rely on them provided by the environment it's running in. We set the flag as -static, indicating that the binary should include all its dependencies. If not specified, our binary would be dynamically linked. For the same reasons as with CGO_ENABLED, we would like to avoid it here.

Summary

We've seen how to setup Cobra-based CLI applications from scratch. We touched on Cross-Platform application properties, such as platform-agnostic filesystem paths. We added tests and briefly overviewed Linker and the GO build tooling with different target configurations.

Building GO applications for multiple targets is straightforward and fun once you get the basics.

This write-up was for my own sake of understanding and organising my thoughts as it was about knowledge sharing. I hope it was helpful. If you have questions/objections/observations/complaints, don't hesitate to reach out!

Full source code can be found ๐Ÿ‘‰ here.

References

[1] https://en.wikipedia.org/wiki/Command-line_interface

[2] https://en.wikipedia.org/wiki/Graphical_user_interface

[3] https://cobra.dev/

[4] https://github.com/spf13/viper

[5] https://en.wikipedia.org/wiki/Cross-platform_software

[6] https://pkg.go.dev/cmd/link

ย