What are these things?

Both package is used for command line interface applications to parse arguments. Cobra package parsing arguments based on flags. Cobra support the POSIX compliant parameters. Viper is a configuration solution for Go. With this you can read configuration from multiple resources like:

  • configuration file, supported files: YAML, TOML, JSON, HCL, INI, env file, Java Properties file
  • from environment variables
  • it can also be binded to Cobra flags too

What is explained in this article?

This article is not an official reference, but I have found Cobra and Viper documentation not enough good for me. So after a few tinkering when I used both, I decided to make an article with a sample command.

The command itself is just a dummy thing (print out things). This article focuses on the Cobra and Viper handling instead of making a real functional application.

Make CLI with Cobra

The whole source code of this article is stored in this repository. In this part, only Cobra functions will be used to make a test CLI application. The structure of the commands is represented in the following block.

test-cobra-cli
 +-- file
      +-- list  --path
      +-- create --path
      +-- mv  --path --new-path
      +-- rm  --path
 +-- dir
      +-- list --path
      +-- create --path
      +-- mv  --path --new-path
      +-- rm  --path

Create and application with go mod init command. Cobra definitions are verbose, but they have a CLI that can help with that a bit. To install it use go install github.com/spf13/cobra-cli@latest command. Then install Cobra and Viper packages as well: go get github.com/spf13/cobra, go get github.com/spf13/viper

Execute the cobra-cli init command. If you take a look for the help, it has multiple parameters, you can set authors or license as well. This generates a few file:

$ cobra-cli init -a 'Attila Molnár'
Your Cobra application is ready at
/home/ati/tmp/test_cobra_cli

$ tree .
.
├── cmd
│   └── root.go #### root command
├── go.mod
├── go.sum
├── LICENSE
└── main.go #### It calls the root command in cmd/root.go

2 directories, 5 files

Add some commands

Now I will add a file and a dir subcommand using the following commands:

$ cobra-cli add file
file created at /home/ati/tmp/test_cobra_cli

$ cobra-cli add dir
dir created at /home/ati/tmp/test_cobra_cli

$ tree .
.
├── cmd
│   ├── dir.go
│   ├── file.go
│   └── root.go
├── go.mod
├── go.sum
├── LICENSE
└── main.go

Cobra CLI generates a basic file, it has a lot of parameter. For this article, I will keep number of parameters low for better understanding. During this article, I will cover only the file commands, but directory is very similar just replace the “file” words with “directory”.

So from the generated cmd/file.go file, I have removed things and I just kept the following:

package cmd

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

var fileCmd = &cobra.Command{
    Use:   "file",
    Short: "Perform actions with files",
}

func init() {
    rootCmd.AddCommand(fileCmd)

    fileCmd.PersistentFlags().StringP("path", "p", "", "Path for to the file")
    fileCmd.MarkPersistentFlagRequired("path")
}

What is the difference between persistent flag and normal flag? Persistent flags can be used with any subcommand. You can see in the plan, that the --path flag is there at every subcommand of file. So instead define this flag multiple times, I just do it once at the parent command definition. We can also define short parameter using TypeP functions within Flags() or PersistentFlags(). The path parameter is marked are required.

Let’s see how the help looks now.

$ go run . -h
This is a test application to use cobra CLI

Usage:
  test-cobra-cli [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  dir         Perform actions with files
  file        Perform actions with directories
  help        Help about any command

Flags:
  -h, --help   help for test-cobra-cli

Use "test-cobra-cli [command] --help" for more information about a command.

Add more subcommand

Next step is to add the list/delete/move/create subcommands for file and directory too. It can be done very similarly like we did before but we have to specify the parent command where this subcommand belongs. The default is the rootCmd. But where is the problem? Let’s see and example!

$ cobra-cli add -p fileCmd ls
ls created at /home/ati/tmp/test_cobra_cli
$ cobra-cli add -p dirCmd ls
ls created at /home/ati/tmp/test_cobra_cli

So where is the problem? They both created the cmd/ls.go file with lsCmd command. And the second one override the first one! How it can be solved? Delete this file and try it from another way!

❯ cobra-cli add -p fileCmd fileList   #### generates: cmd/fileList.go
fileList created at /home/ati/tmp/test_cobra_cli

❯ cobra-cli add -p dirCmd dirList    #### generates: cmd/dirList.go
dirList created at /home/ati/tmp/test_cobra_cli

Rest of the subcommands can also be generated on this way. The generated file names are different, but at what cost? Let see the help of the file subcommand now.

$ go run . file -h
A longer description that spans multiple lines and likely contains examples
and usage of using your command. 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.

Usage:
  test-cobra-cli file [flags]
  test-cobra-cli file [command]

Available Commands:
  fileCreate  A brief description of your command
  fileDelete  A brief description of your command
  fileList    A brief description of your command
  fileMove    A brief description of your command

Flags:
  -h, --help   help for file

Use "test-cobra-cli file [command] --help" for more information about a command.

It is not comfortable to repeat ourselves when we type command like test-cobra-cli file fileList command. The key is to open a file (e.g.: cmd/fileList.go) and modify the Use field. The subcommand name is get from there. On this way, we will have comfortable subcommands for file and directory too, but they are in separated files with separate command structure.

package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

var fileListCmd = &cobra.Command{
    Use:   "ls",
    Short: "List files in directory",
    RunE: func(cmd *cobra.Command, args []string) error {
        path, _ := cmd.Flags().GetString("path")
        fmt.Println("List files in", path)

        return nil
    },
}

func init() {
    fileCmd.AddCommand(fileListCmd)
}

Take a look for the help and we will see more comfortable commands.

$ go run . file -h
Perform actions with files

Usage:
  test-cobra-cli file [command]

Available Commands:
  create      Create new file
  ls          List files in directory
  mv          Move file
  rm          Dekele file

Flags:
  -h, --help          help for file
  -p, --path string   Path for to the file

Use "test-cobra-cli file [command] --help" for more information about a command.

$ go run . dir -h
Do things with directory

Usage:
  test-cobra-cli dir [command]

Available Commands:
  create      Create new directory
  ls          List directories
  mv          Move directory
  rm          Remove a directory

Flags:
  -h, --help          help for dir
  -p, --path string   Path to directory

Use "test-cobra-cli dir [command] --help" for more information about a command.

Read parameters

Before explanation, let’s see an example from cmd/fileMove.go file.

package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

var fileMoveCmd = &cobra.Command{
    Use:   "mv",
    Short: "Move file",
    RunE: func(cmd *cobra.Command, args []string) error {
        path, _ := cmd.Flags().GetString("path")
        newPath, _ := cmd.Flags().GetString("new-path")
        fmt.Println("Rename file", path, newPath)

        return nil
    },
}

func init() {
    fileCmd.AddCommand(fileMoveCmd)

    fileMoveCmd.Flags().String("new-path", "", "New name of the file")
    fileMoveCmd.MarkFlagRequired("new-path")
}

What are different then the basic generated one?

  • I used RunE instead of Run. Using RunE, does the same, but it returns with an error. So if the command would fail, it would be reflected in the return code of the command. I like using this as default because from scripting purpose it gives some ease.
  • I added a flag here called new-path. This is not persistent, so this flag only assigned for this subcommand.

Reading parameter can be done with cmd.Flags()GetType("paramater-name") command. If we run the command, it will just works.

$ go run . file mv -p test.txt --new-path new_test.txt
Rename file test.txt new_test.txt

Create a configuration

Using Viper, we can manager our configuration. On this exercise, I create a serve subcommand which accept parameters: server-port, server-hostname and server-cors-policy.

I really like those applications when I have freedom to provide configuration on multiple way. I create a sample for that:

  • Specify options via parameter: --server-port, --server-hostname, --server-cors-policy
  • Specify options via environment variables: TESTCLI__SERVER_PORT, TESTCLI__SERVER__HOSTNAME, TESTCLI__SERVER__CORS_POLICY
    • It has a prefix for better environment variable handling
    • Sections are separated by __ (double underscore)
  • Read it from configuration file

This is the content of cmd/serve.go file:

package cmd

import (
    "fmt"
    "os"
    "strings"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

// serveCmd represents the serve command
var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start server",
    RunE: func(cmd *cobra.Command, args []string) error {
        fmt.Println("serve called")

        // Use viper.GetType instead of cmd.GetType
        hostname := viper.Get("server.hostname")
        port := viper.GetInt("server.port")
        cors := viper.Get("server.cors_policy")

        fmt.Println("Server listen on", hostname, port)
        fmt.Println("CORS policy is", cors)

        return nil
    },
}

func init() {
    // Run this command during initialization, within this Viper reads configuration
    cobra.OnInitialize(initConfig)

    // Default is not set here but in the initConfig function
    rootCmd.AddCommand(serveCmd)
    serveCmd.Flags().StringVar(&cfgFile, "config", "", "config file (default is $(pwd)/settings.yaml)")

    // Setup flags for the parameters
    serveCmd.Flags().String("server-hostname", "", "Hostname for the server (env: TESTCLI__SERVER__HOSTNAME)")
    viper.BindPFlag("server.hostname", serveCmd.Flags().Lookup("server-hostname"))

    serveCmd.Flags().Int("server-port", 0, "Port number for the server (env: TESTCLI__SERVER__PORT)")
    viper.BindPFlag("server.port", serveCmd.Flags().Lookup("server-port"))

    serveCmd.Flags().String("server-cors-policy", "permissive", "Port number for the server (env: TESTCLI__SERVER__CORS_POLICY)")
    viper.BindPFlag("server.cors_policy", serveCmd.Flags().Lookup("server-cors-policy"))
}

// initConfig reads in config file and ENV variables if set.
func initConfig() {
    if cfgFile != "" {
        // Use config file from the flag.
        viper.SetConfigFile(cfgFile)
    } else {
        // Find current working directory.
        cwd, err := os.Getwd()
        cobra.CheckErr(err)

        viper.AddConfigPath(cwd)
        viper.SetConfigType("yaml")
        viper.SetConfigName("settings")
    }

    // Give prefix for environment variables and read them automatically
    viper.SetEnvPrefix("TESTCLI_")
    viper.SetEnvKeyReplacer(strings.NewReplacer(".", "__"))
    viper.AutomaticEnv() // read in environment variables that match

    // If a config file is found, read it in.
    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
    }
}

Let’s try to understand what we see

So, what is new here? We use Viper to get the value of parameters instead of Cobra. You can use Cobra, but Viper does not update values of Cobra flags. Instead, Cobra updates the configuration in Viper from value of flags. With viper.BindPFlag, Viper can be bind for a Cobra flag. Configuration value in Viper, is updated with the value of flag (if provided).

The next part is initConfig function that is run when Cobra initialize. This makes that before the flags would be parsed, Viper read and parse the provided configuration files. Viper generates the environment variables with the following patter: prefix + '_' + parameter and make it upper case. The ‘.’ characters are also replaced with ‘__’ (double underscore).

With and example server.hostname parameter can be specified with TESTCLI__SERVER__HOSTNAME environment variable. Or server.cors_policy can be set with TESTCLI__SERVER__CORS_POLICY environment variable.

They can also set via configuration file like:

server:
  hostname: localhost
  port: 5000

Precedence is the following for this setup:

  1. Value read from Cobra flag
  2. Value read from environment variable
  3. Value is read from configuration file

Let see some example from command line.

$ go run . serve
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 5000
CORS policy is permissive

$ go run . serve --server-cors-policy strict
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 5000
CORS policy is strict

$ TESTCLI__SERVER__CORS_POLICY=strict go run . serve
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 5000
CORS policy is strict

$ TESTCLI__SERVER__CORS_POLICY=strict TESTCLI__SERVER__PORT=6969 go run . serve
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 6969
CORS policy is strict

$ TESTCLI__SERVER__CORS_POLICY=strict TESTCLI__SERVER__PORT=6969 go run . serve --server-port 420
Using config file: /home/ati/tmp/test_cobra_cli/settings.yaml
serve called
Server listen on localhost 420
CORS policy is strict

Final words

Cobra and Viper seems verbose at the first time and kong might be a better solution. But after I spent some time to learn Cobra and Viper, it has very nice configuration options. For smaller CLI kong is better for me, but for bigger applications, Cobra+Viper seems fine for me.