Cobra is perhaps the most popular Go-module to build powerful command-line applications (CLIs) in almost no time. This article will look at what Cobra is, its core concepts, and which features it brings to the table for building CLI applications.

https://github.com/spf13/cobra

Through this article, we will build stringer, a fairly simple CLI application. stringerallows its users to reverse and inspect strings. Although the feature set is not that big, it is just enough to use and understand the core concepts of Cobra.

What is Cobra

Cobra (created by Steve Francia aka spf13) is an open-source Go-module to build powerful CLIs in almost no time. Numerous CLIs have been built based on Cobra. Some of the most popular ones are for example:

  • hugo - Perhaps the fastest static site generator (SSG) on earth
  • gh - The official GitHub CLI
  • kubectl - The Kubernetes CLI

Cobra has great features built-in, which we can use to create clean, user-friendly, yet powerful CLIs. Probably the most important features are:

  • nested commands: We can nest commands to build an intuitive user experience (UX) also for more complex scenarios
  • powerful flags: We can create POSIX-conform flags either bound to a particular command or globally
  • customizable help: Although Cobra automatically creates fundamental help messages, we can customize those to address individual needs
  • shell auto-completion: We get shell auto-completion for free. No matter if the users of your CLI are using Bash, Zsh, or even PowerShell

Create a new CLI from scratch with Go and Cobra

Now that we know what we can do with Cobra, it’s time to do some hands-on. As already mentioned, we will build stringer now. Sounds good? Let’s get started by bootstrapping the project:

# create the project folder
mkdir stringer && cd stringer

# initialize the project
go mod init github.com/ThorstenHans/stringer

# create the repository structure
mkdir -p cmd/stringer
mkdir -p pkg/stringer

touch cmd/stringer/root.go
touch pkg/stringer/stringer.go
touch main.go

# add Cobra as a dependency
go get -u github.com/spf13/cobra@latest

# open project in VSCode
code .

Besides creating a basic project layout, the snippet above initializes the project using go mod init and adds cobra@latest as a dependency using go get -u. The final project layout should look like this:

.
├── main.go
├── cmd
│  └── stringer
│    └── root.go
└── pkg
  └── stringer
    └── stringer.go

4 directories, 3 files

With Cobra, we can create new commands using the cobra.Command struct. Commands in cobra are always structured in a tree with a single root command, which acts as the main entry point for the CLI application. The tree of commands is variable on both axes, which means we can nest commands in as many levels as required, and on each level, we can have as many siblings as we want to. First, let’s implement the root command, which we will invoke from our main.go in a few seconds. Add the following code to cmd/stringer/root.go:

package stringer

import (
 "fmt"
 "os"

 "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:  "stringer",
    Short: "stringer - a simple CLI to transform and inspect strings",
    Long: `stringer is a super fancy CLI (kidding)
   
One can use stringer to modify or inspect strings straight from the terminal`,
    Run: func(cmd *cobra.Command, args []string) {

    },
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintf(os.Stderr, "Whoops. There was an error while executing your CLI '%s'", err)
        os.Exit(1)
    }
}

Having the root command in place, we must call it from main.go. Add the following code to main.go:

package main

import "github.com/ThorstenHans/stringer/cmd/stringer"

func main() {
    stringer.Execute()
}

We can give our CLI the first try now. Use go run main.go --help to give it a shot. Without further ado, you should see some fundamental help presented for your first Cobra-based CLI application.

Adding the business-logic

The actual business logic of our CLI does not matter for this article. Let’s throw just a few functions to pkg/stringer/stringer.go, to have something we can interact with.

package stringer

import (
    "strconv"
)

func Reverse(input string) (result string) {
    for _, c := range input {
        result = string(c) + result
    }
    return result
}

func Inspect(input string, digits bool) (count int, kind string) {
    if !digits {
        return len(input), "char"
    }
    return inspectNumbers(input), "digit"
}

func inspectNumbers(input string) (count int) {
    for _, c := range input {
        _, err := strconv.Atoi(string(c))
        if err == nil {
            count++
        }
    }
    return count
}

Adding Sub-Commands with Cobra

As already mentioned, we can add as many sub-commands as required and nest them in various depths. We will add just a few commands to grasp the concept for demonstration purposes. Although it is not needed, I prefer putting every command in a single file to increase readability and ensure the application stays maintainable. Let’s quickly create the necessary files:

touch cmd/stringer/reverse.go
touch cmd/stringer/inspect.go

First, let’s implement the reverse command. This command will allow users to reverse any string provided as an argument.

package stringer

import (
    "fmt"

    "github.com/ThorstenHans/stringer/pkg/stringer"
    "github.com/spf13/cobra"
)

var reverseCmd = &cobra.Command{
    Use:   "reverse",
    Aliases: []string{"rev"},
    Short:  "Reverses a string",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        res := stringer.Reverse(args[0])
        fmt.Println(res)
    },
}

func init() {
    rootCmd.AddCommand(reverseCmd)
}

Although the snippet is quite short, much interesting stuff happens here. Obviously, we add the new reverseCmd as a sub-command to rootCmd using the init function.

But if you take a closer look at the cobra.Command instance created in the snippet, you can spot the usage of some cool Cobra features. First, we want to allow the CLI users to invoke the command using an alias called rev. It’s a slice of string []string, which means we can add as many aliases as we want to a single command.

Second, we use the Args field to ensure users can provide just a single argument when invoking this command. Cobra provides more utility functions than just ExactArgs; consult the package documentation to find more functions for controlling the arguments of a command.

Last but not least, we use Run to invoke our business logic. In this case, stringer.Reverse returns a simple string. However, if you want to call a function that may potentially return an error, you can also use the RunE field of cobra.Command to propagate the error up the stack.

Having the reverse command in place, we can move on and take care of the inspect command. For demonstration purposes, we will do two iterations. First, let’s start simple, using the concepts we already grasped:

package stringer

import (
    "fmt"

    "github.com/ThorstenHans/stringer/pkg/stringer"
    "github.com/spf13/cobra"
)

var inspectCmd = &cobra.Command{
    Use:   "inspect",
    Aliases: []string{"insp"},
    Short:  "Inspects a string",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {

        i := args[0]
        res, kind := stringer.Inspect(i, false)

        pluralS := "s"
        if res == 1 {
            pluralS = ""
        }
        fmt.Printf("'%s' has a %d %s%s.\n", i, res, kind, pluralS)
    },
}

func init() {
    rootCmd.AddCommand(inspectCmd)
}

The code is pretty similar to the code we used for the reverse command. However, there is a bit of additional code here to generate proper output for the user. At this point, we can give our CLI another try. The following lines demonstrate how to invoke both commands (reverse and inspect):

# Invoke Reverse
go run main.go reverse foo
> oof

go run main.go rev bar
> rab

# Invoke Inspect
go run main.go inspect lorem
> 'lorem' has 5 chars

go run main.go insp FooBar
> 'FooBar' has 6 chars

Adding flags to commands with Cobra

If you are familiar with command-line tools, you will know the concept of flags. By using flags, we’re able to modify the behavior of a command. Cobra has built-in support for flags, and we can use two types of flags:

  • Local flags are assigned to a single command
  • Persistent flags are assigned to a command and all its sub-commands

Now, we will add a local flag to the stringer inspect command. By specifying the —digits flag, users can instruct the command to count all number occurrences in a particular string. To specify the digits flag, let’s update the inspect.go file:

package stringer

import (
    "fmt"

    "github.com/ThorstenHans/stringer/pkg/stringer"
    "github.com/spf13/cobra"
)

var onlyDigits bool
var inspectCmd = &cobra.Command{
    Use:   "inspect",
    Aliases: []string{"insp"},
    Short:  "Inspects a string",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {

        i := args[0]
        res, kind := stringer.Inspect(i, onlyDigits)

        pluralS := "s"
        if res == 1 {
            pluralS = ""
        }
        fmt.Printf("'%s' has %d %s%s.\n", i, res, kind, pluralS)
    },
}

func init() {
    inspectCmd.Flags().BoolVarP(&onlyDigits, "digits", "d", false, "Count only digits")
    rootCmd.AddCommand(inspectCmd)
}

There are three noticeable changes in inspect.go. First, we introduce a new variable called onlyDigits. It has an implicit default value of false. Second, we use inspectCmd.Flags().BoolVarP() to add the digits flag (locally) to our inspectCmd as part of the init() function. Finally, we pass onlyDigits to stringer.Inspect() as second argument to ensure business logic is called correctly.

Having the flag in place, it is time to give our stringer CLI another try:

# inspect a string for digits
go run main.go inspect A1B2C3 --digits
> 'A2B2C3' has 3 digits

go run main.go insp A1B2C3 -d
> 'A2B2C3' has 3 digits

# check command help
go run main.go inspect --help

Inspects a string

Usage:
  stringer inspect [flags]

Aliases:
  inspect, insp

Flags:
  -d, --digits   Count only digits
  -h, --help     help for inspect

The Version Flag

Cobra automatically creates a global flag --version which users can invoke to determine the currently installed version of a particular CLI application. All we need to get this working with stringer is to set the Version field on the root command.

Let’s update cmd/stringer/root.go and set rootCmd.Version to a new local variable. We use a variable - instead of hardcoding the version directly on rootCmd - so we can set the actual version number easily when invoking go build.

var version = "0.0.1"

var rootCmd = &cobra.Command{
    Use:   "stringer",
    Version: version,
    Short:  "stringer - a simple CLI to transform and inspect strings",
    Long: `stringer is a super fancy CLI (kidding)
   
One can use stringer to modify or inspect strings straight from the terminal`,
    Run: func(cmd *cobra.Command, args []string) {

    },
}

We can test the --version flag easily by invoking go run main.go --version and see the default value for version being pretty-printed to the terminal:

# print the version of stringer
go run main.go --version
stringer version 0.0.1

Let’s finally test how to override the version variable at build-time using ldflags:

# build the stringer CLI in version 0.0.2
go build -o ./dist/stringer -ldflags="-X 'github.com/ThorstenHans/stringer/cmd/stringer.version=0.0.2'" main.go

# verify version is being set correctly
./dist/stringer --version
> stringer version 0.0.2

Recap

Building CLI applications with Go and Cobra is the most efficient and productive combination these days. Although other languages like Rust also have strong libraries to build CLIs, the speed you can gain with Cobra and Go is immersive.

Cobra’s feature-set is amazing. Especially when you combine Cobra with Viper (another awesome Go-module created by Steve Francia that addresses common configuration requirements). There is also Cobra Generator, which you can use to auto-generate huge parts of your CLI application to build CLIs even faster.

I found myself also in discussions with colleagues and customers about building CLIs with Node.JS. Although the Node-ecosystem is huge and there are powerful libraries for almost everything, the distribution model of Node.JS is not meant for CLIs. With Go, we can easily cross-compile our application to any platform and distribute CLIs as single binaries that are tiny in actual file size.

Get the source code

As always, you can find the entire source code on GitHub at https://github.com/ThorstenHans/stringer.