Wiliam V. Joaquim

Wiliam V. Joaquim

The power of the CLI with Golang and Cobra CLI

thumbnail

Today we are going to see all the power that a CLI (Command line interface) can bring to development, a CLI can help us perform tasks more effectively and lightly through commands via terminal, without needing an interface. For example, git and Docker, we practically use their CLI all the time, when we execute a git commit -m "commit message" or docker ps -a we are using a CLI. I’m going to leave an article that details what a CLI is.

In this post we will create a boilerplate for Go projects, where with just 1 command via CLI, the entire project structure will be created.

Go e CLI

Well, Go is extremely powerful for building CLI, it is one of the most used languages for this, no wonder it is widely used among DevOps, precisely because it is so powerful and simple.

Just to give an example of the power of Go for CLI builds, you may have already used or at least heard of Docker, Kubernetes, Prometheus, Terraform, but what do they all have in common? They all have a large part of their usability via CLI and are developed in Go 🐿.

Starting a CLI with Go

Go has a package to handle CLI natively. But let’s cover it quickly, the purpose of the post is to use the Cobra CLI package, which will facilitate the construction of our CLI.

Let’s use the flag package

  package main

  import (
    "flag"
    "fmt"
    "time"
  )

  func main() {
    dateFlag := flag.Bool("date", false, "Display the current date")
    flag.Parse()

    if *dateFlag {
      currentTime := time.Now()
      fmt.Println("Current date", currentTime.Format("2006-01-02 15:04:05"))
    }
  }

In this example above, we created a date flag, passing this flag returns the current date, something very simple, running the project with go run main.go --date, we will have the value Current date 2023-11 -15 12:26:14.

  dateFlag := flag.Bool("date", false, "Display the current date")

In the code above, we create a flag, the first argument date is the flag name, false as the default value means that if you run the program without explicitly specifying the --date flag, the value associated with dateFlag will be false. This allows the program to have a specific default behavior if this flag is not provided when the program is executed, while the third argument Display the current date is the detail of what this flag does.

If we run:

  go run main.go -h

We received:

  -date
    Display the current date

We can use the flag with --date or -date, Go already does the automatic check. We can make our entire boilerplate with this approach, but let’s make it a little easier and use the Cobra CLI package.

Cobra CLI

This package is widely used for powerful CLI builds, it is used for example for Kubernetes CLI and GitHub CLI, in addition to offering some cool features such as automatic completion of shell, automatic recognition of flags (the tags) , and you can use -h or -help for example, among other facilities.

Creating the project

Our project will be very simple, we will only have main.go and go.mod and consequently our go.sum, we will start the project with the command:

  go mod init github.com/wiliamvj/boilerplate-cli-go

You can use whatever name you want, by convention we generally create the name of the project as the link to our repository.

ending up like this:

Project structure

Now let’s download the Cobra package with the command:

  go get -u github.com/spf13/cobra@latest

The boilerplate will have a very simple structure, the idea is to create a structure widely used by the Go community, see how it will look:

Project structure

  • cmd: here where we will leave the main.go that starts our app.
  • internal: in this paste where all the code for our application should be located.
    • handler: Here will be the files responsible for receiving our http requests, you may also know them as controllers.
      • routes: Here we will organize our routes.

It’s not the complete framework, we’re just creating the basics for our example.

All of our code will focus on our main.go.

  package main

  import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
  )

  func main() {
    var rootCommand = &cobra.Command{}
    var projectName, projectPath string

    var cmd = &cobra.Command{
      Use:   "create",
      Short: "Create boilerplate for a new project",
      Run: func(cmd *cobra.Command, args []string) {
        // validations
        if projectName == "" {
          fmt.Println("You must supply a project name.")
          return
        }
        if projectPath == "" {
          fmt.Println("You must supply a project path.")
          return
        }
        fmt.Println("Creating project...")
      },
    }

    cmd.Flags().StringVarP(&projectName, "name", "n", "", "Name of the project")
    cmd.Flags().StringVarP(&projectPath, "path", "p", "", "Path where the project will be created")

    rootCommand.AddCommand(cmd)
    rootCommand.Execute()
  }

The code above is just to start our CLI, we will only have two variables:

  • projectName: it will be the name of our project that we will capture in the input of our CLI.
  • projectPath: will be the path where the boilerplate will be created, we will capture it in the CLI input.
  • &cobra.Command{}: starts the Cobra cli.
  • Run: It receives an anonymous function, it is in this function that we capture the user input entered in the CLI and validate it, our validation is simple, we just check if the projectName and projectPath are not null.
  • cmd.Flags(): Here we create the flags with flags, so you can use -name or -n, both will be accepted, we also include the description of what this flag does.
  • rootCommand.AddCommand(cmd): We add our cmd to the rootCommand created at the beginning of our main.go.
  • rootCommand.Execute(): Finally, we run our CLI.

That’s all we need to get our CLI working, of course without our boilerplate logic, but with that we can use it via the terminal. Lets test!

We can build the project or use it without build

With build:

  go build -o cli .

A file called cli will be created at the root, we will run the binary from our CLI:

  ./cli --help

We will have an output like this:

  Usage:
    [command]

  Available Commands:
    completion  Generate the autocompletion script for the specified shell
    create      Create boilerplate for a new project
    help        Help about any command

  Flags:
    -h, --help   help for this command

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

See that we already have tips on how to use the command we created create Create boilerplate for a new project, if we run:

  ./cli create --help

We will have:

  Create boilerplate for a new project

  Usage:
    create [flags]

  Flags:
    -h, --help          help for create
    -n, --name string   Name of the project
    -p, --path string   Path where the project will be created

Let’s run it now by passing our flags:

  ./cli create -n my-project -p ~/documents

We will have our Creating project... message, indicating that it worked, but nothing happens yet, as we have not implemented the logic.

We can also create subcommands, new flags, new validations, but for now let’s leave it like that, if you want you can create more options, see documentation of the Cobra package.

Creating the boilerplate

With our CLI ready, let’s now go through the boilerplate logic, which is very simple, we will have to create the folders, then we will need to create the files and finally open the files and insert the code, for this we will use the os package a lot of Go, which allows you to access operating system resources.

Let’s first get the main directory and validate if there already exists a folder with the name that will be used to create our project:

  globalPath := filepath.Join(projectPath, projectName)

  if _, err := os.Stat(globalPath); err == nil {
    fmt.Println("Project directory already exists.")
    return
  }

If we pass the projectName as test and the projectPath as /documents, this validates that there is no other folder in documents called test, if there is we return and return a error message.

You can modify and, if there is a folder with the same name, change the name of projectName or delete the folder that already exists, but for now we will just return an error.

  if err := os.Mkdir(globalPath, os.ModePerm); err != nil {
    log.Fatal(err)
  }

In this part, we will create the directory in the path that was entered using our -p flag, if we use:

  ./cli create -n my-project -p ~/documents

Starting Go

A folder called my-project will be created in the documents directory.

  startGo := exec.Command("go", "mod", "init", projectName)
  startGo.Dir = globalPath
  startGo.Stdout = os.Stdout
  startGo.Stderr = os.Stderr
  err := startGo.Run()
  if err != nil {
    log.Fatal(err)
  }

In the code above we execute the command to start the project in Go, it will be created in the root directory we chose, in our example it will run within documents/my-project, this will create the file go.mod and will set the module name to my-projects.

  • exec.Command: Create the command that we will run in the terminal, in this case it will be go mod init my-project.
  • startGo.Dir: Determine where this command will run, in the example it will run in documents/my-project.
  • startGo.Stdout: It will place the command return in the terminal, it will return go: creating new go.mod: module my-project.
  • startGo.Stderr: Redirects the output of a possible error to where the program is being executed.
  • startGo.Run(): Finally, we execute everything.

Creating the folders

Let’s create our folders, they are cmd, internal, handler and routes.

  cmdPath := filepath.Join(globalPath, "cmd")
    if err := os.Mkdir(cmdPath, os.ModePerm); err != nil {
    log.Fatal(err)
  }
  internalPath := filepath.Join(globalPath, "internal")
  if err := os.Mkdir(internalPath, os.ModePerm); err != nil {
    log.Fatal(err)
  }
  handlerPath := filepath.Join(internalPath, "handler")
    if err := os.Mkdir(handlerPath, os.ModePerm); err != nil {
    log.Fatal(err)
  }
  routesPath := filepath.Join(handlerPath, "routes")
    if err := os.Mkdir(routesPath, os.ModePerm); err != nil {
    log.Fatal(err)
  }

This code above creates the necessary folders in sequence, using os.Mkdir, (see the docs), for the handler and folders routes, we need to access the internal folder, as they will be created within internal, for this we use Join to merge the path, resulting in:

  • handlerPath: documents/my-project/internal
  • routesPath: documents/my-project/internal/handler

Creating the files

With the folders created, let’s create the files, for example we will create main.go of course and routes.go, inside the routes folder.

  mainPath := filepath.Join(cmdPath, "main.go")
  mainFile, err := os.Create(mainPath)
  if err != nil {
    log.Fatal(err)
  }
  defer mainFile.Close()

  routesFilePath := filepath.Join(routesPath, "routes.go")
  routesFile, err := os.Create(routesFilePath)
  if err != nil {
    log.Fatal(err)
  }
  defer routesFile.Close()

Above we created the main.go and routes.go files.

  • mainPath: we determine the path, using the mainPath used to create the cmd folder.
  • os.Create(mainPath): we create the file, in the specified directory. (documents/my-project/cmd)
  • routesFilePath: we determine the path, using the routesPath used to create the routes folder.
  • os.Create(routesFilePath): We create the file, in the specified directory. (documents/my-project/internal/handler/routes)
  • defer routesFile.Close(): We close the file, defer, using this GO reserved word, we guarantee that the last thing to happen is to close the file. See more about defer here.

Writing to files

With the folders and files created, now let’s write to the main.go and routes.go files, let’s do something simple, just for example, to organize better, we will separate them into functions that write to each file.

  func WriteMainFile(mainPath string) error {
    packageContent := []byte(`package main

  import "fmt"

  func main() {
    fmt.Println("Hello World!")
  }
  `)

    mainFile, err := os.OpenFile(mainPath, os.O_WRONLY|os.O_APPEND, 0666)
    if err != nil {
      return err
    }
    defer mainFile.Close()

    _, err = mainFile.Write(packageContent)
    if err != nil {
      return err
    }

    return nil
  }

In the function above, we have mainPath as a parameter, which is the file path, we will add a simple code, which will just log a Hello World.

  • packageContent: We create the code that will be written to the file.
  • os.OpenFile: We open the file specified in mainPath.
  • defer mainFile.Close(): We close the file last with defer.
  • mainFile.Write: Finally, we write it to the file, and handle the error if any.

O_WRONLY and O_APPEND, are constants used to define the opening mode of a file, O_WRONLY indicates that the file will be opened only for writing, O_APPEND means that the added content will be added to the end of the file, without overwrite existing content.

  func WriteRoutesFile(routesFilePath string) error {
    packageContent := []byte(`package routes

  // your code here
  `)

    routesFile, err := os.OpenFile(routesFilePath, os.O_WRONLY|os.O_APPEND, 0666)
    if err != nil {
      return err
    }
    defer routesFile.Close()

    _, err = routesFile.Write(packageContent)
    if err != nil {
      return err
    }

    return nil
  }

We do the same for the routes.go file.

Now just call the new functions in the main function, looking like this:

  mainPath := filepath.Join(cmdPath, "main.go")
  mainFile, err := os.Create(mainPath)
  if err != nil {
    log.Fatal(err)
  }
  defer mainFile.Close()
  if err := WriteMainFile(mainPath); err != nil {
    log.Fatal(err)
  }

  routesFilePath := filepath.Join(routesPath, "routes.go")
  routesFile, err := os.Create(routesFilePath)
    if err != nil {
    log.Fatal(err)
  }
  defer routesFile.Close()
  if err := WriteRoutesFile(routesFilePath); err != nil {
    log.Fatal(err)
  }

Final code

  package main

  import (
    "fmt"
    "log"
    "os"
    "os/exec"
    "path/filepath"

    "github.com/spf13/cobra"
  )

  func main() {
    var rootCommand = &cobra.Command{}
    var projectName, projectPath string

    var cmd = &cobra.Command{
      Use:   "create",
      Short: "Create boilerplate for a new project",
      Run: func(cmd *cobra.Command, args []string) {
        if projectName == "" {
          fmt.Println("You must supply a project name.")
          return
        }
        if projectPath == "" {
          fmt.Println("You must supply a project path.")
          return
        }
        fmt.Println("Creating project...")

        globalPath := filepath.Join(projectPath, projectName)

        if _, err := os.Stat(globalPath); err == nil {
          fmt.Println("Project directory already exists.")
          return
        }
        if err := os.Mkdir(globalPath, os.ModePerm); err != nil {
          log.Fatal(err)
        }

        startGo := exec.Command("go", "mod", "init", projectName)
        startGo.Dir = globalPath
        startGo.Stdout = os.Stdout
        startGo.Stderr = os.Stderr
        err := startGo.Run()
        if err != nil {
          log.Fatal(err)
        }

        cmdPath := filepath.Join(globalPath, "cmd")
        if err := os.Mkdir(cmdPath, os.ModePerm); err != nil {
          log.Fatal(err)
        }
        internalPath := filepath.Join(globalPath, "internal")
        if err := os.Mkdir(internalPath, os.ModePerm); err != nil {
          log.Fatal(err)
        }
        handlerPath := filepath.Join(internalPath, "handler")
        if err := os.Mkdir(handlerPath, os.ModePerm); err != nil {
          log.Fatal(err)
        }
        routesPath := filepath.Join(handlerPath, "routes")
        fmt.Println(routesPath)
        if err := os.Mkdir(routesPath, os.ModePerm); err != nil {
          log.Fatal(err)
        }

        mainPath := filepath.Join(cmdPath, "main.go")
        mainFile, err := os.Create(mainPath)
        if err != nil {
          log.Fatal(err)
        }
        defer mainFile.Close()
        if err := WriteMainFile(mainPath); err != nil {
          log.Fatal(err)
        }

        routesFilePath := filepath.Join(routesPath, "routes.go")
        routesFile, err := os.Create(routesFilePath)
        if err != nil {
          log.Fatal(err)
        }
        defer routesFile.Close()
        if err := WriteRoutesFile(routesFilePath); err != nil {
          log.Fatal(err)
        }
      },
    }

    cmd.Flags().StringVarP(&projectName, "name", "n", "", "Name of the project")
    cmd.Flags().StringVarP(&projectPath, "path", "p", "", "Path where the project will be created")

    rootCommand.AddCommand(cmd)
    rootCommand.Execute()
  }

  func WriteMainFile(mainPath string) error {
    packageContent := []byte(`package main

  import "fmt"

  func main() {
    fmt.Println("Hello World!")
  }
  `)

    mainFile, err := os.OpenFile(mainPath, os.O_WRONLY|os.O_APPEND, 0666)
    if err != nil {
      return err
    }
    defer mainFile.Close()

    _, err = mainFile.Write(packageContent)
    if err != nil {
      return err
    }

    return nil
  }

  func WriteRoutesFile(routesFilePath string) error {
    packageContent := []byte(`package routes

    // your code here
  `)

    routesFile, err := os.OpenFile(routesFilePath, os.O_WRONLY|os.O_APPEND, 0666)
    if err != nil {
      return err
    }
    defer routesFile.Close()

    _, err = routesFile.Write(packageContent)
    if err != nil {
      return err
    }

    return nil
  }

Testing the CLI

Well, with everything ready, let’s test it! To do this, we will compile our code with the good old go build.

  go build -o cli .

Running the CLI:

  ./cli create -n my-project -p ~/documents

Let’s get the answer:

  Creating project...
  go: creating new go.mod: module my-project

Accessing our project and opening it in Visual Studio Code with:

  cd /documents/my-project && code .

We will have our boilerplate created:

Final Project

If we run the project created via CLI, we can see that everything works.

  go run cmd/main.go

  output:
    Hello World!

With this we finish creating our CLI that creates a boilerplate.

Final considerations

Our boilerplate could be even more automated, we could, for example, install a package like the Go Chi package, creating standard endpoints, all using the CLI, you can even create your own framework, have you ever thought that with just one command your initial project is already configured?

With knowledge in CLI creation, you have great power in your hands!

repository of the project