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. throughfmt.*print*
andos.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.