Use Cobra to Build Go-Powered CLIs

July 18, 2023
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Use Cobra to Build Go-Powered CLIs

Go is a popular open-source programming language that has gained immense popularity. Its simplicity, speed, and reliability make it ideal for building applications such as command-line interface (CLI) tools.

Building a CLI tool involves writing code to interact with command line arguments, input and output streams, and other system resources. Managing all this can become difficult as the functionality of the application grows.

Fortunately, Go has many excellent libraries to help developers build CLI applications quickly and easily. One such library is Cobra, a framework that can help you build scalable, maintainable, and extensible command-line interfaces in Go.

In this tutorial, I will show how you can use Cobra to build CLI applications in Go and highlight some of its key features and benefits. I will do this by walking you through the process of building a CLI that encrypts and decrypts text based on a specified cipher.

Prerequisites

To follow this tutorial, you will need the following:

What you will build

In this tutorial, you will build a CLI tool that can help with generating secret messages using substitution ciphers. While such algorithms are not secure enough to save your passwords, they are certainly enough to share funny jokes with your friends without prying eyes spoiling the fun. To keep things short, your application will only use two algorithms: Caesar’s cipher and Bacon’s cipher.

In addition, your application will be able to directly send the encrypted message as an SMS to a specified phone number via Twilio.

Install Cobra Generator

While it is possible to manually integrate Cobra into an application, it is easier to create the project and commands via the Cobra CLI. Install it using the following command.

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

Once this is completed, you’ll be able to access the CLI by typing cobra-cli in the terminal.

Create the project directory structure

Create a new folder for the application, where you store your Go projects, and navigate into it using the following commands.

mkdir cipher_cli
cd cipher_cli

Next, create a Go module using the following command.

go mod init cipher_cli

After that, create the application using the following command.

cobra-cli init --viper

The --viper flag  automatically sets up Viper. This is a companion to Cobra, intended to provide easy handling of environment variables and config files, and seamlessly connect them to the application's flags. For this article, you will manage your Twilio credentials via environment variables.

Add the commands

As the name implies, CLIs revolve around commands. Commands represent actions and they are the central point of the application. Each interaction that the application supports will be contained in a command.

Every Cobra application has a root command. This command is executed when the CLI name is called. By default, this command prints helpful information about the application but you have the option of performing a custom action. The root command is found in cmd/root.go. Open the file and edit it to match the following.

package cmd

import (
        "fmt"
        "os"

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

var cfgFile string

var rootCmd = &cobra.Command{
        Use:   "cipher_cli",
        Short: "Encrypt and decrypt secret messages in seconds!!!",
        Long: `This application encrypts and decrypts secret messages with ease. 

Try out the Caesar and Bacon Cipher options to generate secret messages and share with your inner circle

cipher_cli encrypt "Welcome to the hallowed chambers" --algorithm=caesar --key=54

cipher_cli encrypt "Welcome to the hallowed chambers" --algorithm=bacon`,
}

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

func init() {
        cobra.OnInitialize(initConfig)

        rootCmd.PersistentFlags().StringP("key", "k", "", "The key to pass for the algorithm")
}

func initConfig() {
        if cfgFile != "" {
                viper.SetConfigFile(cfgFile)
        } else {

                viper.AddConfigPath("$HOME")
                viper.SetConfigType("yaml")
                viper.SetConfigName(".cipher_cli")
        }

        viper.AutomaticEnv()

        if err := viper.ReadInConfig(); err == nil {
                fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
        }
}

To keep things manageable, each command lives in its own file. Every command has two key things: the Command struct initialization, and the init() function.

There are three key fields required by the Command struct:

  1. The Use field is the name associated with the CLI. When typed into the terminal, this will execute the command (or sub-command) as the case may be.
  2. The Short field gives a short description of the command.
  3. The Long gives a more detailed description of the command. You can also include helpful examples of how to run the command here.

The init() function is where you can add flags to your command. You can think of flags as modifiers for your command. For example, depending on what algorithm you pass to the encrypt command, you will get a different behaviour.

A flag can either be local, or persistent. A local flag is only available to the command it is declared in, while a persistent flag is available to all sub commands. The previous code snippet declared a persistent flag named key which is used by the Caesar cipher to determine the number of rotations.

The root command file also includes an initConfig() function. This is where Viper is used to load environment variables into the CLI. This function expects the configuration to be in a file named .cipher_cli.yaml. The last thing to note with regards to configuration is that you can also specify the config file as a command argument.

By default, the application would search the $HOME folder for the config file. Create it in your home folder with the following command, or with your text editor or IDE.

touch ~/.cipher_cli.yaml

Add the following placeholder values to the config file.

TWILIO_PHONE_NUMBER: "<<TWILIO_PHONE_NUMBER>>"
TWILIO_ACCOUNT_SID: "<<TWILIO_ACCOUNT_SID>>"
TWILIO_AUTH_TOKEN: "<<TWILIO_AUTH_TOKEN>>"

The Twilio SMS API will be used to send SMS notifications from the CLI. Before updating the code, you will need your Twilio Auth Token, Account SID, and phone number. To do that:

  • Login to the Twilio Console
  • Copy the details from the Account Info panel
  • In ~/.cipher_cli.yaml, replace <<TWILIO_AUTH_TOKEN>> , <<TWILIO_AUTH_TOKEN>>, and <<TWILIO_PHONE_NUMBER>> respectively, with the copied details

Retrieve you account SID, auth token, and twilio phone number in the Twilio Console

You can run the application using the following command.

go run main.go

For now, this command executes the root command which can be found in cmd/root.go. The long description of the command will be printed on your terminal.

Next, you need two new commands, one for encryption and another for decryption. Add them using the following commands.

cobra-cli add encrypt
cobra-cli add decrypt

This will add two new files for you in the cmd folder. Before modifying the code for the commands, we'll next add the helper functions they depend upon.

Implement the encryption/decryption algorithms

At the root of the project folder, create a new folder named pkg. This will hold all the helper code needed by the commands. Next, in the pkg folder, create a new folder named bacon, and in it create a file named bacon.go. Add the following to the newly created file.

package bacon

import (
        "strings"
        "unicode"
)

var lookup = map[string]string{
        "A": "aaaaa", "B": "aaaab", "C": "aaaba", "D": "aaabb", "E": "aabaa",
        "F": "aabab", "G": "aabba", "H": "aabbb", "I": "abaaa", "J": "abaab",
        "K": "ababa", "L": "ababb", "M": "abbaa", "N": "abbab", "O": "abbba",
        "P": "abbbb", "Q": "baaaa", "R": "baaab", "S": "baaba", "T": "baabb",
        "U": "babaa", "V": "babab", "W": "babba", "X": "babbb", "Y": "bbaaa",
        "Z": "bbaab",
}

var reverseLookup = map[string]string{
        "aaaaa": "A", "aaaab": "B", "aaaba": "C", "aaabb": "D", "aabaa": "E", "aabab": "F", "aabba": "G", "aabbb": "H",
        "abaaa": "I", "abaab": "J", "ababa": "K", "ababb": "L", "abbaa": "M", "abbab": "N", "abbba": "O", "abbbb": "P",
        "baaaa": "Q", "baaab": "R", "baaba": "S", "baabb": "T", "babaa": "U", "babab": "V", "babba": "W", "babbb": "X",
        "bbaaa": "Y", "bbaab": "Z",
}

const chunkSize = 5

func Encrypt(plaintext string) string {
        var ciphertext string
        for _, character := range plaintext {
                if cipher, ok := lookup[strings.ToUpper(string(character))]; ok {
                        if unicode.IsUpper(character) {
                                ciphertext += strings.ToUpper(cipher)
                        } else {
                                ciphertext += cipher
                        }
                } else {
                        ciphertext += string(character)
                }
        }
        return ciphertext
}

func Decrypt(ciphertext string) string {
        var plaintext string
        var chunk string
        for i := 0; i < len(ciphertext)/chunkSize; i++ {
                for j := 0; j < chunkSize; j++ {
                        chunk += string(ciphertext[(i*chunkSize)+j])
                }
                if plainCharacter, ok := reverseLookup[strings.ToLower(chunk)]; ok {
                        if strings.ToLower(chunk) == chunk {
                                plaintext += strings.ToLower(plainCharacter)
                        } else {
                                plaintext += plainCharacter
                        }
                } else {
                        plaintext += chunk
                }
                chunk = ""
        }
        return plaintext
}

The Bacon cipher replaces a character with a sequence of 5 characters. Since each character has a predefined replacement, a map named lookup is used to store the replacement for each character.

In the Encrypt() function, the string to be encrypted is provided as an argument. This function iterates over each character in the provided string and retrieves the associated replacement from the lookup map. The replacements for each character are concatenated into a string and returned as the Bacon encryption of the input string.

The Decrypt() function works in reverse. A reverseLookup map is declared which has the decrypted character for each five character sequence. Given an encrypted sequence, the function iterates through the sequence and generates five character chunks. For each chunk, the function checks for the decrypted value of the chunk in the reverseLookup map. The decrypted values are concatenated and returned just as was done in the Encrypt() function.

Both functions preserve the input case i.e., if the input text is in lower case, then the returned text will also be in lower case.

Next, add the functionality for the Caesar cipher. In the pkg folder, create a new folder named caesar and in it a file named caesar.go. Add the following to the newly created file.

package caesar

import (
        "strings"
        "unicode"
)

const alphabets = "abcdefghijklmnopqrstuvwxyz"

func Encrypt(plaintext string, rotations int) string {
        var ciphertext string

        for _, character := range plaintext {
                index := getCharacterIndex(strings.ToLower(string(character)))
                if index == -1 {
                        ciphertext += string(character)
                } else {
                        cipher := alphabets[getCipherIndex(index, rotations)]
                        if unicode.IsUpper(character) {
                                ciphertext += strings.ToUpper(string(cipher))
                        } else {
                                ciphertext += string(cipher)
                        }
                }
        }
        return ciphertext
}

func Decrypt(ciphertext string, rotations int) string {
        var plaintext string
        for _, character := range ciphertext {
                index := getCharacterIndex(strings.ToLower(string(character)))
                if index == -1 {
                        plaintext += string(character)
                } else {
                        plainCharacter := alphabets[getPlaintextIndex(index, int(rotations))]
                        if unicode.IsUpper(character) {
                                plaintext += strings.ToUpper(string(plainCharacter))
                        } else {
                                plaintext += string(plainCharacter)
                        }
                }
        }
        return plaintext
}

func getCharacterIndex(element string) int {
        return strings.Index(alphabets, element)
}

func getCipherIndex(plainIndex int, key int) int {
        newIndex := plainIndex + key
        if newIndex > 25 {
                return newIndex % 26
        }
        return newIndex
}

func getPlaintextIndex(cipherIndex int, key int) int {
        newIndex := cipherIndex - key
        if newIndex < 0 {
                newIndex = 26 + (newIndex % 26)
        }
        if newIndex > 25 {
                return newIndex % 26
        }
        return newIndex
}

To encrypt or decrypt a Caesarean cipher, you need the text and the number of rotations for each encryption. For each character in the text, the position in the alphabet is calculated. Then using the number of rotations, the position of the replacement text is determined and the character in that position retrieved. This is done for all characters in the input sequence. All the replacement characters are concatenated and returned as the output.

Bear in mind that the number of rotations provided can exceed the number of letters in the alphabet (26) and because of that, you need a function to determine the index of the character to be used for replacement. For encryption, the getCipherIndex() function is used, while the getPlaintextIndex() function is used for decryption.

Add functionality for validation

To ensure that the application functions smoothly, you also need to validate some of the user input. There are so many other things your code can (and should) validate, but to keep things simple, this application will validate the input for the number of rotations and the input for the recipient’s phone number.

In the pkg folder, create a new folder named validation, and in it a new file named validate.go. Add the following to the newly created file.

package validation

import (
        "errors"
        "log"
        "regexp"
        "strconv"
)

func GetRotationsFromKey(key string) (int, error) {
        if key == "" {
                return 0, errors.New("required flag \"key\" not provided")
        }
        parsedKey, err := strconv.ParseInt(key, 10, 32)
        if err != nil {
                return 0, errors.New("invalid key provided")
        }
        return int(parsedKey), nil
}

func ValidatePhoneNumber(phoneNumber string) error {
        e164Pattern := `^\+[1-9]\d{1,14}$`
        match, err := regexp.Match(e164Pattern, []byte(phoneNumber))
        if err != nil {
                log.Fatal(err.Error())
        }
        if !match {
                return errors.New("phone number must be in E.164 format")
        }
        return nil
}

The GetRotationsFromKey() function takes an input string and tries to parse it as an integer. If this is done successfully, the integer is returned. Otherwise, an error is returned. The function also checks if an empty string is provided, if so an error with the appropriate message is returned.

The ValidatePhoneNumber() function takes a string and matches the value against the regular expression for a valid E.164 phone number. In the event that the provided phone number is invalid, an error is returned with an appropriate error message.

Regex validation is used in this article for the sake of simplicity. You could use the Twilio Lookup API for a more robust validation.

Add functionality for sending SMS notifications

Next, add the Twilio SDK to your project using the following command.

go get github.com/twilio/twilio-go 

In the pkg folder, create a new folder named notification, and in it a new file named sms.go. Add the following to the newly created file.

package notification

import (
        "encoding/json"
        "fmt"
        "github.com/spf13/viper"
        "github.com/twilio/twilio-go"
        twilioApi "github.com/twilio/twilio-go/rest/api/v2010"
)

func SendMessage(phoneNumber string, message string) {
        sender := viper.GetString("TWILIO_PHONE_NUMBER")
        accountSid := viper.GetString("TWILIO_ACCOUNT_SID")
        authToken := viper.GetString("TWILIO_AUTH_TOKEN")

        client := twilio.NewRestClientWithParams(twilio.ClientParams{Username: accountSid, Password: authToken})
        params := &twilioApi.CreateMessageParams{}
        params.SetTo(phoneNumber)
        params.SetFrom(sender)
        params.SetBody(message)

        resp, err := client.Api.CreateMessage(params)
        if err != nil {
                fmt.Println("Error sending SMS message: " + err.Error())
        } else {
                response, _ := json.Marshal(*resp)
                fmt.Printf("Response: %s", string(response))
        }
}

Update the commands

With the functionality completed, it’s time to update the encrypt and decrypt commands. Open cmd/decrypt.go and update the code to match the following.

package cmd

import (
        "cipher_cli/pkg/bacon"
        "cipher_cli/pkg/caesar"
        "cipher_cli/pkg/validation"
        "fmt"
        "github.com/spf13/cobra"
        "os"
        "strings"
)

const CAESAR = "caesar"

var decryptCmd = &cobra.Command{
        Use:   "decrypt [string to decode] --algorithm=[algorithm]",
        Short: "Decode a cryptic message",
        Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

cipher_cli decrypt AABAAABBABABAABABBBABBAAA --algorithm=bacon

cipher_cli decrypt fff.jkl.gh --algorithm=caesar --key=87
`,
        Run: func(cmd *cobra.Command, args []string) {
                ciphertext := strings.Join(args, " ")
                var plaintext = ""
                algorithm := cmd.Flags().Lookup("algorithm").Value.String()
                key := cmd.Flags().Lookup("key").Value.String()
                if strings.ToLower(algorithm) == CAESAR {
                        rotations, err := validation.GetRotationsFromKey(key)
                        if err != nil {
                                fmt.Printf("Error: %s", err)
                                os.Exit(1)
                        }
                        plaintext = caesar.Decrypt(ciphertext, rotations)
                } else {
                        plaintext = bacon.Decrypt(ciphertext)
                }
                fmt.Printf("Ciphertext: %s\nPlaintext: %s\n", ciphertext, plaintext)
        },
}

func init() {
        decryptCmd.Flags().StringP("algorithm", "a", "", "The algorithm to use for this action")
        _ = decryptCmd.MarkFlagRequired("algorithm")
        rootCmd.AddCommand(decryptCmd)
}

The structure of the decrypt command is similar to that of the root command. First, you have the declaration of the Command struct. This time, you have some special logic to execute when the command is called.

You do this via the Run field in the struct. The value of this field is a function which takes a Cobra command and the arguments provided by the user as input. In this function, you concatenate the args input to get the input text and depending on the provided value for the algorithm flag, you decrypt the input text accordingly.

In the init() function, you declare a local flag named algorithm which lets the command know which algorithm to use for decryption. This flag is local to the decrypt command. You also mark this flag as required since you cannot decrypt input text without knowing which algorithm to use.

Next, open cmd/encrypt.go and update the code to match the following.

package cmd

import (
        "cipher_cli/pkg/bacon"
        "cipher_cli/pkg/caesar"
        "cipher_cli/pkg/notification"
        "cipher_cli/pkg/validation"
        "fmt"
        "os"
        "strings"

        "github.com/spf13/cobra"
)

var encryptCmd = &cobra.Command{
        Use:   "encrypt",
        Short: "Encrypt a message",
        Long: `Use this command to generate a cryptic version of a message by providing the text you want to encrypt
and the encryption algorithm to be applied. For example:

cipher_cli encrypt "Welcome to the hallowed chambers" --algorithm=caesar --key=54

cipher_cli encrypt "Welcome to the hallowed chambers" --algorithm=bacon
`,
        Run: func(cmd *cobra.Command, args []string) {
                plaintext := strings.Join(args, " ")
                var ciphertext = ""
                algorithm := cmd.Flags().Lookup("algorithm").Value.String()
                key := cmd.Flags().Lookup("key").Value.String()
                recipient := cmd.Flags().Lookup("recipient").Value.String()

                if strings.ToLower(algorithm) == CAESAR {
                        rotations, err := validation.GetRotationsFromKey(key)
                        if err != nil {
                                fmt.Printf("Error: %s", err)
                                os.Exit(1)
                        }
                        ciphertext = caesar.Encrypt(plaintext, rotations)
                } else {
                        ciphertext = bacon.Encrypt(plaintext)
                }
                fmt.Printf("Plaintext: %s\nCiphertext: %s\n", plaintext, ciphertext)
                if recipient != "" {
                        err := validation.ValidatePhoneNumber(recipient)
                        if err == nil {
                                notification.SendMessage(recipient, fmt.Sprintf("From your partner in mischief\n%s", ciphertext))
                        } else {
                                fmt.Printf("Error: %s", err)
                                os.Exit(1)
                        }
                }
        },
}

func init() {
        encryptCmd.Flags().StringP("algorithm", "a", "", "The algorithm to use for this action")
        _ = encryptCmd.MarkFlagRequired("algorithm")
        encryptCmd.Flags().StringP("recipient", "r", "", "Send encrypted messages to a phone number")
        rootCmd.AddCommand(encryptCmd)
}

The encrypt command is similar to the decrypt command in terms of structure. The major difference between them is that this command has the functionality to send encrypted text via SMS to a specified recipient.

To make this work, an extra flag is added to the command (recipient). In the function passed to the Run field of the command struct, a check is added to see if the user has passed any value for this flag. When present, this value is checked and an SMS is appropriately dispatched for valid input.

As you did earlier, you can access the root command via main.go. To access the sub commands, you add the command name along with the arguments and flags. For example, to encrypt a message, use the following command.

go run main.go encrypt "Welcome to the hallowed chambers" --algorithm=caesar --key=54

Install the CLI

At the moment, you have to run the main.go file in order to run your commands. However, there’s one more thing you can do to access your commands without even accessing your code. You can build your application as an executable binary and install it to your $GOPATH/bin folder. Do this by running the following commands.

go build main.go
go install

Now, run your application by typing in the following command.

cipher_cli encrypt "Welcome to the hallowed chambers" --algorithm=bacon --recipient=<<PHONE NUMBER>>

Make sure to replace <<PHONE NUMBER>> with a valid phone number for the recipient. This time, Twilio sends an SMS to your fellow mischief maker — encrypted in plain sight. Well done!

To uninstall your application, all you have to do is delete the binary in $GOPATH/bin: with rm $GOPATH/bin/cipher_cli

There you have it!

Using Cobra, you were able to build an encryption/decryption CLI. You also learnt how to handle arguments and flags in order to add more flexibility to your commands. Finally, you learnt how to build binaries of your application which can be shared amongst your inner circle.

The entire codebase is available on GitHub should you get stuck at any point. I’m excited to see what else you come up with. Until next time, make peace not war ✌🏾

Joseph Udonsak is a software engineer with a passion for solving challenges — be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends.