Building a CLI App in Golang

Photo by Chinmay B on Unsplash

Building a CLI App in Golang

Featured on Hashnode

I recently moved back into a full-time mobile developer role at a company I admire, which means that I don’t write Golang as much anymore. I needed to keep up with Golang, but I needed something more glamorous than writing simple CRUD applications, and here we are!

CLI apps, Command-line applications, are applications that are run from the command line. This is normally done via a terminal depending on the operating system of choice. Golang is a great choice for building command-line applications because it compiles to a single binary with no external dependencies, so you can distribute CLI tools without worrying about dependency management on the target machine.

I had an idea in mind and set out to build a CLI app to help with that. I learned about the Cobra and Charm libraries used to create and glamourize CLI interfaces. In this article, you will too. We will be building a CLI application that will provide terminal command suggestions to our users based on a prompt they enter.

Setup

You need to have Golang installed on your device. You can follow the steps here. You also need an understanding of Golang or the general syntax of programming languages. Let’s move on.

Cobra

Cobra is a Golang library that provides a simple API to create powerful modern CLI interfaces similar to git & go tools. This library provides easy subcommand-based CLIs, nested subcommands, global, local, and cascading flags, and a host of other handy features. You can read more about it here.

We’ll be using the cobra-cli library to help us quickly scaffold a bare-bones command-line application. You can install the library using the command:

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

// to verify that it is installed, run the command
cobra-cli

Once you’ve confirmed that cobra-cli is installed. You can cd into the directory containing your code. Once you’re in the right path, run the command:

go mod init [app-name] //initialize your app
cobra-cli init [root-command-name] //creates root command and necessary scaffolding

Your code file structure should resemble this now:

Let’s take a look at the code. The file root.go contains the root command. The root command is the specific command used to access the features of your CLI application. Cobra provides a straightforward approach to creating commands for your CLI application. The syntax looks like below:

var rootCmd = &cobra.Command{
    Use:   "suggestor",
    Short: "A brief description of your application",
    Long: `A longer description that spans multiple lines and can contain examples of how to use your application. 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.`,
    // Uncomment the following line if your bare application
    // has an action associated with it:
    // Run: func(cmd *cobra.Command, args []string) { },
}

The field, Use, is used to specify the command to start your CLI app. In this case, you’ll access your CLI app by using the suggestor command in your terminal. Short and Long fields will be returned when users use the help flag. Towards the bottom of the root.go file, you’ll find the init function with the following body:

func init() {
    rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

In Golang, init functions are executed when the package is initialized, meaning this function will run even before the main function is executed. The use of init functions has a few gotchas so I suggest they should be used carefully. In the snippet above, we use the init function to add flags to our root function.

The Flags() function is used to define the flags that is defined on a specific command. This time the root command. Boolp specifies that the flag is a boolean flag that takes in a shorthand letter, defined by the "t" parameter that can be used after a single dash. The "toggle" parameter is the full name of the flag and the last two paramters are the default value and help message respectively.

The flag can be accessed on the the root command like this:

suggestor // just the root command
suggestor --toggle true
suggestor -t false

The root.go file also contains the Execute function used to start the CLI applications via the root command.

func Execute() {
    err := rootCmd.Execute()
    if err != nil {
        os.Exit(1)
        // if an error occurs while starting the cli app
        // we stop the app   
    }
}

The file main.go contains the main function just calling the Execute function and starting the app.

package main

import "test/cmd"

func main() {
    cmd.Execute() // calls the execute function in the root.go file
}

This CLI app will be used by external users in the forms specified below;

//Cobra also provides a default help flag that returns flags and other
//commands and the description of your CLI app.
suggestor --help

To test our app in development, you can run go run main.go and add flags and sub-commands like for a normal CLI app. Like so;

go run main.go --help //Run our app with the help flag
💡
You can test your CLI app the way it will be used by external users — via the root command — by building and installing your CLI app using the command go build && go install

Sub-commands

Let’s add a sub-command to make accessing our feature easier. The sub-command will be called get. Users will pass a prompt to this sub-command for us to send to our Gemini endpoint.

var shortHandSuggestion = &cobra.Command{
    Use:     "get",
    Short:   "Short-hand to get cli command suggestions without opening the text input",
    Example: "suggestor get command to sign and create a git commit",
    Args:    cobra.MinimumNArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        //sentence is every thing typed after the 'get' sub-command
        prompt := strings.Join(args, " ")

        // print out the sentence to your terminal
        fmt.Println(prompt)
        return nil
    },
}

The command definition uses the Args field and cobra.MinimumNArgs(1) value to ensure that this subcommand takes at least 1 argument. It also uses the RunE field to run an operation that can return an error. Example field is used to provide a small snippet of how to use this subcommand

We add this command to the root command in the init function, similar to how the flag was added earlier.

func init(){
    rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
    rootCmd.AddCommand(shortHandSuggestion)
}

// this enables us to call the command in the terminal like:
suggestor get command to copy and paste test

Next, we’ll make an API request to our public endpoint, which in turn makes an API request to our Gemini API. We can’t make the API request directly because we can’t bundle API keys with CLI applications, which use the system’s environment instead of their internal environment.
I had to learn this the hard way. I tried for days to bundle my API keys while pushing a release to Homebrew before realizing that it wasn’t possible and going with this approach.

func (c ChatCompletion) GetChatCompletion(prompt string) (*CompletionResponse, error) {

    os := runtime.GOOS

    body := CompletionRequest{
        Prompt: prompt,
        Os:     os,
    }
    res := new(CompletionResponse)
    err := c.client.req(http.MethodPost, "user/cmd_completion", body, res)

    return res, err
}

We’ll create a quick internal client to make an API request to our public endpoint, which is rate-limited, to get suggestions for terminal commands based on the prompts passed to it. We return the response to the terminal like so;

RunE: func(cmd *cobra.Command, args []string) error {
        prompt := strings.Join(args, " ")
        client := chat_client.NewClient()
        resp, err := client.ChatCompletion.GetChatCompletion(prompt)

        if err != nil {
            fmt.Println("Sorry, an error occurred. Please try again")
            return err
        }

        fmt.Printf("Suggestion: %s", resp)
        return nil
    },

You can test your CLI by running your Golang app and adding the sub-command to the end of the run command like so;

go run main.go get command to copy and past text 
// you adding the [get] subcommand and the rest of the args

Your terminal should print out the command like so;

Right now, we’re just printing to the terminal. We can improve the terminal user interface displayed to the user during the API request loading state and even when we’re collecting the prompt from the user. If we get enough likes, I'll do a part 2 where we'll use charm.sh libraries to add a loader and textfield to take input from the user. We can make the terminal interface end up looking like this:

Conclusion

We've seen how easy is it to quickly spin up a CLI application using Golang, Cobra, and Cobra CLI. Due to the limitations of environmental variables, I had to use a "roundabout" method to get prompts from the Gemini API. I hope you enjoyed the article if you did, please give it a like!

Cheers!