Facebook Linkedin Twitter
Posted Mon Dec 12, 2022 •  Reading time: 6 minutes

Taming Cobras: Making the most of Cobra CLIs

Cobra by @spf13 is the most popular go CLI framework. It allows to easily build composable CLI tools with subcommands, automatic help messages, auto-completion and basically any feature you ever need. It is used by most big go projects, including docker, kubernetes, helm, and many more.

This post will go into the details of how to use cobra for more sophisticated projects and setups. We will also take a look at testing cobra commands. All of the findings and patterns were developed when working on the Ory open source projects, namely the unified Ory CLI.

And lastly I want to give you a few handy helpers that make it easier to work with cobra. You can find them at github.com/zepatrik/cobrax.

Cobra Basics

The official Cobra documentation says:

Cobra doesn’t require any special constructors. Simply create your commands.

According to the documentation you would end up with a setup similar to this (probably split across files and packages):

package main

import "github.com/spf13/cobra"

var RootCmd = &cobra.Command{
	Use:   "my-app",
	Short: "Short description",
	Long:  "A longer description of what this app does and how to use it.",
	Run: func(cmd *cobra.Command, args []string) {
		// Do stuff here
	},
}

var ChildCmd = &cobra.Command{
	Use:   "child",
	Short: "A child command",
	Run: func(cmd *cobra.Command, args []string) {
		// Do other stuff here
	},
}

func init() {
	RootCmd.AddCommand(ChildCmd)
}

func main() {
	if err := RootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

The main characteristics of this pattern are:

  • initialization is done through init() functions and therefore not controllable by importing packages
  • children register themselves at their parent
  • errors are handled inside the Run functions (i.e. through fmt.*print* and os.Exit)

OK sounds good, let’s get hacking right? - It’s not as easy. Let’s look into two problems and how to avoid them.

Problem 1: Composable Commands

If you follow the recommended pattern, it will work for simple cases. Once you want to compose commands and helper utilities across projects and even packages, you will run into the first problems. Especially because of the uncontrollable initialization you have little choice of what command is registered where.

Therefore, I highly recommend using a pattern of RegisterCommandsRecursive instead:

package main

import (
	"github.com/spf13/cobra"
	"github.com/user/project/child"
)

var rootCmd = &cobra.Command{}

func main() {
    child.RegisterCommandsRecursive(rootCmd)
    rootCmd.Execute()
}
package child

import "github.com/spf13/cobra"

var (
	SubpackageRoot = &cobra.Command{}
	grandchildCmd  = &cobra.Command{}
)

func RegisterCommandsRecursive(parent *cobra.Command) {
	parent.AddCommand(SubpackageRoot)
	SubpackageRoot.AddCommand(grandchildCmd)
}

This will allow you to import and register subtrees of the whole command tree. Child commands that need specific parent commands because of persistent flags and hooks should not be exposed on their own.

Problem 2: Testing

You will hit the next major head-scratching problem once you try to test your commands. Sure, the main functionality will be imported by the Run functions and should be tested individually, but you should still test the integration of all of that. Further, it would be great if your commands can be usable as test-dependencies of other packages and projects. You don’t want to expose all of the internal functions called by the commands, but the command as an interface is always exposed by definition.

Idea: Test the Run functions

This sounds like a good idea, but you will have to do a lot of setup steps to get the Run functions to work. Your setup also has to be consistent with how Cobra and potentially your code package set up the commands. This includes, but is not limited to:

  • registering the same (persistent) flags
  • ensuring (persistent) pre-run hooks are called
  • copying init() functionality

Further, you don’t want your tests to run into any os.Exit calls, and you want to capture STDOUT and STDERR to do assertions, as well as control STDIN.

Solution

Fortunately, Cobra itself can help us there, we just have to use it properly. Instead of directly using os.Std*, you should resort to the (cobra.Command).InOrStdin, (cobra.Command).OutOrStdout and (cobra.Command).ErrOrStderr functions. That allows you to set readers/writers for STD* in tests, but falls back to the os.Std* files if none are set.

The other problem, avoiding os.Exit, is also fairly simple to work around. Instead of using it in the Run function, use the RunE function and simply return errors. RunE is identical to Run except that it returns an error. Error messages can still be printed by the command, and you can use custom errors to control the output and set the exit code inside the main function. Any error returned by any child RunE will eventually be returned by rootCmd.Execute(). There is a set of helpers and errors for exactly this use-case in my cobra helper library. Have a look at the full integration example.

This is an except of that example:

package main

import (
	"bufio"
	"fmt"
	"github.com/spf13/cobra"
	"github.com/zepatrik/cobrax"
)

var rootCmd = &cobra.Command{
    Use:   "greetme",
    Short: "This is a friendly program to greet you.",
    RunE: func(cmd *cobra.Command, args []string) error {
        // Use `cmd.ErrOrStderr()` instead of `os.Stderr`.
        fmt.Fprintln(cmd.ErrOrStderr(), "Hello, what is your name?")
        // Use `cmd.InOrStdin()` instead of `os.Stdin`.
        name, err := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
        if err != nil {
            // Print a custom error message.
            fmt.Fprintf(cmd.ErrOrStderr(), "Error reading name: %v", err)
            // Wrap the error with one that carries the correct exit code.
            return cobrax.WithExitCode(
                // Silence the usage output and return an error that will not be printed.
                cobrax.FailSilently(cmd), 2)
        }
        fmt.Fprintf(cmd.ErrOrStderr(), "Hello %s\n", name)
        return nil
    },
}

func main() {
	cobrax.ExecuteRootCommand(rootCmd)
}

Now we can test our commands through standard go test invocations. Other packages and even projects can import our commands and run them, for example as a test server, as part of their go test. We don’t have to maintain and document any additional interface, as the imported commands behave exactly like the public CLI. No more need for packages like CockroachDB’s testserver.

Now all of this sounds great, there is just one small thing to consider. Remember how the Cobra docs state

Cobra doesn’t require any special constructors.

This is kind of true, but Cobra and especially the spf13/pflag package, that is used for flag parsing, follow a devil-may-care approach. This means you can’t run a command multiple times, as internal state changes irrecoverably. There is however a simple solution: use functions to create new commands, instead of global initialization. This also helps to initialize your commands cleanly yourself. It ives us a gread place where to put flag definitions, and allows to directly register subcommands. Additionally, you can even parameterize these functions to allow for more flexibility when importing the commands. The above mentioned full integration example also follows this pattern.

Bonus: Templating Command Descriptions

When we start to import and compose commands, we notice that some of our help texts and descriptions need to be more dynamic. Instead of passing the parent path or other information down to all the children, we can use a simple helper included in the cobrax package to enable go templates for the otherwise static cmd.Short, cmd.Long, and cmd.Example. The template data are the command itself, and the most useful fields are {{ .Root.Name }} and {{ .CommandPath }}.

We use this extensively in the Ory projects to build better example strings.

Conclusion

Cobra itself is a nice and feature rich framework to build go CLIs. Such a project does not arise from the ground up perfectly, but is continuously developed. I see how it makes sense for small projects to avoid complexity around setup by using init functions and don’t care about error handling. But for larger projects you will sooner than latter run into the problems described above. There are ways to solve these problems, but they are not always easy to figure out. So take my findings as a baseline to not sit hours and hours debugging your CLI commands. As I did not want to make this post unreadable because of too many code samples, I prepared a standalone repository that follows all patterns described and demonstrates all the benefits. Please go ahead and browse through it.

Additionally, I assembled a small library with code that helps us at Ory develop our CLI commands since years.